feat: DRY SDK integration — shared bridge, Desktop cutover, dead infra deletion

Major architectural changes:

1. SHARED KMP BRIDGE (core/data/radio/):
   - RadioClientAccessor: Platform-agnostic interface for SDK RadioClient
   - SdkRadioController: Shared RadioController impl (replaces per-platform copies)
   - SdkStateBridge: Shared SDK→repository bridge (event dispatch, node sync)
   - SdkPacketHandler: Thin SDK-backed PacketHandler for MQTT/XModem/History

2. DESKTOP SDK CUTOVER:
   - DesktopRadioClientProvider: TCP + Serial transport support
   - Removed DirectRadioControllerImpl (old desktop radio path)
   - Desktop now shares the same SDK bridge code as Android

3. DEAD INFRASTRUCTURE DELETION (~5,100 LOC removed):
   - PacketHandlerImpl, MeshDataHandlerImpl, MeshRouterImpl
   - CommandSenderImpl, MeshActionHandlerImpl, MeshConfigFlowManagerImpl
   - MeshConnectionManagerImpl, AdminPacketHandlerImpl
   - ServiceBroadcasts (Android intent-based pub/sub)
   - NodeRepositoryImpl (Room-backed, replaced by SdkNodeRepositoryImpl)
   - 8 trivial UseCases (SetLocale, SetTheme, ToggleAnalytics, etc.)
   - All associated test files for deleted impls
   - Deleted interfaces: AdminPacketHandler, CommandSender, MeshActionHandler,
     MeshConfigFlowManager, MeshConnectionManager, MeshRouter, ServiceBroadcasts

4. NEW FEATURES:
   - NodeMetadataEntity + Room migration 38→39 (persistent favorites/notes)
   - AppMetadataRepository (clean access to node metadata)
   - MessagePersistenceHandler (focused rememberDataPacket for StoreForward)

All three targets compile clean: :app:compileGoogleDebugKotlin,
:desktop:compileKotlin, :core:data:jvmTest passes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-05 06:41:37 -05:00
parent 2c438b9a3f
commit 74ba959b24
77 changed files with 2190 additions and 7294 deletions

View File

@@ -28,6 +28,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koin.core.annotation.Single
import org.meshtastic.core.data.radio.RadioClientAccessor
import org.meshtastic.core.model.InterfaceId
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.service.SdkClientLifecycle
@@ -45,15 +46,15 @@ import org.meshtastic.sdk.transport.tcp.TcpTransport
* This is the SDK integration point for the POC. The [RadioClient] is exposed as a [StateFlow] so ViewModels and the
* service can react to connection changes with `flatMapLatest`.
*/
@Single(binds = [SdkClientLifecycle::class])
@Single(binds = [SdkClientLifecycle::class, RadioClientAccessor::class])
class RadioClientProvider(
private val context: Context,
private val radioPrefs: RadioPrefs,
) : SdkClientLifecycle {
) : SdkClientLifecycle, RadioClientAccessor {
private val _client = MutableStateFlow<RadioClient?>(null)
/** Active [RadioClient], or `null` when disconnected or between connections. */
val client: StateFlow<RadioClient?> = _client.asStateFlow()
override val client: StateFlow<RadioClient?> = _client.asStateFlow()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val mutex = Mutex()
@@ -121,7 +122,7 @@ class RadioClientProvider(
}
/** Fire-and-forget version of [rebuildAndConnect] for non-suspending call sites. */
fun rebuildAndConnectAsync() {
override fun rebuildAndConnectAsync() {
scope.launch {
runCatching { rebuildAndConnect() }.onFailure { e -> Logger.e(e) { "RadioClientProvider: connect failed" } }
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.radio
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.meshtastic.sdk.RadioClient
import org.meshtastic.sdk.TransportIdentity
import org.meshtastic.sdk.testing.FakeRadioTransport
import org.meshtastic.sdk.testing.InMemoryStorageProvider
import kotlin.coroutines.CoroutineContext
/**
* Test-only RadioClient setup using FakeRadioTransport.
* Provides deterministic handshake and packet injection for integration tests.
*/
class TestRadioClientProvider(
val nodeNum: Int = 1,
coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Default,
) {
val transport = FakeRadioTransport(
identity = TransportIdentity("fake:test-radio-provider"),
autoHandshake = true,
nodeNum = nodeNum,
)
val client: RadioClient = RadioClient.Builder()
.transport(transport)
.storage(InMemoryStorageProvider())
.autoSyncTimeOnConnect(false)
.coroutineContext(coroutineContext)
.build()
suspend fun connect() {
client.connect()
}
suspend fun disconnect() {
client.disconnect()
}
}

View File

@@ -0,0 +1,115 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.radio
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.io.bytestring.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.proto.Data
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NodeInfo
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.User
import org.meshtastic.sdk.ConnectionState
import org.meshtastic.sdk.Frame
import org.meshtastic.sdk.NodeChange
import org.meshtastic.sdk.NodeId
import org.meshtastic.sdk.TransportState
import org.meshtastic.sdk.decodeAsNodeInfo
import org.meshtastic.sdk.testing.FakeRadioTransport
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
@OptIn(ExperimentalCoroutinesApi::class)
class TestRadioClientProviderTest {
@Test
fun connectInjectNodeAndDisconnect() = runTest {
val provider = TestRadioClientProvider(coroutineContext = backgroundScope.coroutineContext)
provider.connect()
assertEquals(ConnectionState.Connected, provider.client.connection.value)
val nodeInfo = NodeInfo(
num = 0x1234,
user = User(
id = "!00001234",
long_name = "Test Node",
short_name = "TN",
),
)
val packetAwaiter = backgroundScope.async {
provider.client.packets.first { packet ->
packet.from == nodeInfo.num && packet.decodeAsNodeInfo()?.num == nodeInfo.num
}
}
runCurrent()
provider.transport.injectPacket(
MeshPacket(
from = nodeInfo.num,
to = provider.nodeNum,
decoded = Data(
portnum = PortNum.NODEINFO_APP,
payload = NodeInfo.ADAPTER.encode(nodeInfo).toByteString(),
),
),
)
runCurrent()
runCurrent()
assertEquals(nodeInfo.num, packetAwaiter.await().decodeAsNodeInfo()?.num)
val nodeAwaiter = backgroundScope.async {
provider.client.nodes.first { change ->
change is NodeChange.Added && change.node.num == nodeInfo.num
}
}
runCurrent()
provider.transport.injectNodeInfo(nodeInfo)
runCurrent()
runCurrent()
val added = assertIs<NodeChange.Added>(nodeAwaiter.await())
assertEquals(nodeInfo.num, added.node.num)
assertEquals(nodeInfo.user?.long_name, provider.client.nodeSnapshot()[NodeId(nodeInfo.num)]?.user?.long_name)
provider.disconnect()
assertEquals(ConnectionState.Disconnected, provider.client.connection.value)
assertEquals(TransportState.Disconnected, provider.transport.state.value)
}
private fun FakeRadioTransport.injectNodeInfo(nodeInfo: NodeInfo) {
val proto = FromRadio.ADAPTER.encode(FromRadio(node_info = nodeInfo))
val frame = ByteArray(4 + proto.size).apply {
this[0] = 0x94.toByte()
this[1] = 0xC3.toByte()
this[2] = (proto.size shr 8).toByte()
this[3] = (proto.size and 0xFF).toByte()
proto.copyInto(this, destinationOffset = 4)
}
injectFrame(Frame(ByteString(frame)))
}
}

View File

@@ -44,6 +44,9 @@ kotlin {
implementation(projects.core.proto)
implementation(projects.core.takserver)
// Meshtastic SDK — shared RadioController and StateBridge implementations
api(libs.sdk.core)
implementation(libs.jetbrains.lifecycle.runtime)
implementation(libs.androidx.paging.common)
implementation(libs.kotlinx.serialization.json)
@@ -69,6 +72,7 @@ kotlin {
commonTest.dependencies {
implementation(projects.core.testing)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.sdk.testing)
}
}
}

View File

@@ -1,43 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.datasource
import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
interface NodeInfoWriteDataSource {
suspend fun upsert(node: NodeEntity)
suspend fun installConfig(mi: MyNodeEntity, nodes: List<NodeEntity>)
suspend fun clearNodeDB(preserveFavorites: Boolean)
suspend fun clearMyNodeInfo()
suspend fun deleteNode(num: Int)
suspend fun deleteNodes(nodeNums: List<Int>)
suspend fun deleteMetadata(num: Int)
suspend fun upsert(metadata: MetadataEntity)
suspend fun setNodeNotes(num: Int, notes: String)
suspend fun backfillDenormalizedNames()
}

View File

@@ -1,72 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.datasource
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.core.database.DatabaseProvider
import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.di.CoroutineDispatchers
@Single
class SwitchingNodeInfoWriteDataSource(
private val dbManager: DatabaseProvider,
private val dispatchers: CoroutineDispatchers,
) : NodeInfoWriteDataSource {
override suspend fun upsert(node: NodeEntity) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().upsert(node) } }
}
override suspend fun installConfig(mi: MyNodeEntity, nodes: List<NodeEntity>) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().installConfig(mi, nodes) } }
}
override suspend fun clearNodeDB(preserveFavorites: Boolean) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().clearNodeInfo(preserveFavorites) } }
}
override suspend fun clearMyNodeInfo() {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().clearMyNodeInfo() } }
}
override suspend fun deleteNode(num: Int) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().deleteNode(num) } }
}
override suspend fun deleteNodes(nodeNums: List<Int>) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().deleteNodes(nodeNums) } }
}
override suspend fun deleteMetadata(num: Int) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().deleteMetadata(num) } }
}
override suspend fun upsert(metadata: MetadataEntity) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().upsert(metadata) } }
}
override suspend fun setNodeNotes(num: Int, notes: String) {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().setNodeNotes(num, notes) } }
}
override suspend fun backfillDenormalizedNames() {
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().backfillDenormalizedNames() } }
}
}

View File

@@ -1,85 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.AdminPacketHandler
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.SessionManager
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.MeshPacket
/**
* Implementation of [AdminPacketHandler] that processes admin messages, including session passkeys, device/module
* configuration, and metadata.
*/
@Single
class AdminPacketHandlerImpl(
private val nodeManager: NodeManager,
private val configHandler: Lazy<MeshConfigHandler>,
private val configFlowManager: Lazy<MeshConfigFlowManager>,
private val sessionManager: SessionManager,
) : AdminPacketHandler {
override fun handleAdminMessage(packet: MeshPacket, myNodeNum: Int) {
val payload = packet.decoded?.payload ?: return
val u = AdminMessage.ADAPTER.decode(payload)
Logger.d { "Admin message from=${packet.from} fields=${u.summarize()}" }
// Firmware embeds the session_passkey in every admin response. A missing (default-empty)
// field must not reset stored state, so only record refreshes when bytes arrived.
val incomingPasskey = u.session_passkey
if (incomingPasskey.size > 0) {
sessionManager.recordSession(packet.from, incomingPasskey)
}
val fromNum = packet.from
u.get_module_config_response?.let {
if (fromNum == myNodeNum) {
configHandler.value.handleModuleConfig(it)
} else {
it.statusmessage?.node_status?.let { nodeManager.updateNodeStatus(fromNum, it) }
}
}
if (fromNum == myNodeNum) {
u.get_config_response?.let { configHandler.value.handleDeviceConfig(it) }
u.get_channel_response?.let { configHandler.value.handleChannel(it) }
}
u.get_device_metadata_response?.let {
if (fromNum == myNodeNum) {
configFlowManager.value.handleLocalMetadata(it)
} else {
nodeManager.insertMetadata(fromNum, it)
}
}
}
}
/** Returns a short summary of the non-null admin message fields for logging. */
private fun AdminMessage.summarize(): String = buildList {
get_config_response?.let { add("get_config_response") }
get_module_config_response?.let { add("get_module_config_response") }
get_channel_response?.let { add("get_channel_response") }
get_device_metadata_response?.let { add("get_device_metadata_response") }
if (session_passkey.size > 0) add("session_passkey")
}
.joinToString()
.ifEmpty { "empty" }

View File

@@ -1,466 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.isWithinSizeLimit
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.SessionManager
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.AirQualityMetrics
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Constants
import org.meshtastic.proto.Data
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.HostMetrics
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Neighbor
import org.meshtastic.proto.NeighborInfo
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.PowerMetrics
import org.meshtastic.proto.Telemetry
import kotlin.math.absoluteValue
import kotlin.random.Random
import kotlin.time.Duration.Companion.hours
import org.meshtastic.proto.Position as ProtoPosition
@Suppress("TooManyFunctions", "CyclomaticComplexMethod", "LongParameterList")
@Single
class CommandSenderImpl(
private val packetHandler: PacketHandler,
private val nodeManager: NodeManager,
private val radioConfigRepository: RadioConfigRepository,
private val tracerouteHandler: TracerouteHandler,
private val neighborInfoHandler: NeighborInfoHandler,
private val sessionManager: SessionManager,
@Named("ServiceScope") private val scope: CoroutineScope,
) : CommandSender {
private val currentPacketId = atomic(Random(nowMillis).nextLong().absoluteValue)
private val localConfig = MutableStateFlow(LocalConfig())
private val channelSet = MutableStateFlow(ChannelSet())
init {
radioConfigRepository.localConfigFlow.onEach { localConfig.value = it }.launchIn(scope)
radioConfigRepository.channelSetFlow.onEach { channelSet.value = it }.launchIn(scope)
}
override fun getCachedLocalConfig(): LocalConfig = localConfig.value
override fun getCachedChannelSet(): ChannelSet = channelSet.value
override fun getCurrentPacketId(): Long = currentPacketId.value
override fun generatePacketId(): Int {
val numPacketIds = ((1L shl PACKET_ID_SHIFT_BITS) - 1)
val next = currentPacketId.incrementAndGet() and PACKET_ID_MASK
return ((next % numPacketIds) + 1L).toInt()
}
private fun computeHopLimit(): Int = (localConfig.value.lora?.hop_limit ?: 0).takeIf { it > 0 } ?: DEFAULT_HOP_LIMIT
/**
* Resolves the correct channel index for sending a packet to [toNum].
*
* PKI encryption ([DataPacket.PKC_CHANNEL_INDEX]) is only used for **admin** packets, where end-to-end encryption
* is appropriate. Protocol-level requests (traceroute, telemetry, position, nodeinfo, neighborinfo) must NOT use
* PKI because relay nodes need to read and/or modify the inner payload (e.g. traceroute appends each hop's node
* number). These requests fall back to the node's heard-on channel.
*/
private fun getAdminChannelIndex(toNum: Int): Int {
val myNum = nodeManager.myNodeNum.value ?: return 0
val myNode = nodeManager.nodeDBbyNodeNum[myNum]
val destNode = nodeManager.nodeDBbyNodeNum[toNum]
return when {
myNum == toNum -> 0
myNode?.hasPKC == true && destNode?.hasPKC == true -> DataPacket.PKC_CHANNEL_INDEX
else ->
channelSet.value.settings
.indexOfFirst { it.name.equals(ADMIN_CHANNEL_NAME, ignoreCase = true) }
.coerceAtLeast(0)
}
}
/**
* Returns the heard-on channel for a non-admin request to [toNum]. Does NOT use PKI — protocol-level requests need
* clear inner payloads.
*/
private fun getChannelIndex(toNum: Int): Int = nodeManager.nodeDBbyNodeNum[toNum]?.channel ?: 0
override fun sendData(p: DataPacket) {
if (p.id == 0) p.id = generatePacketId()
val bytes = p.bytes ?: ByteString.EMPTY
require(p.dataType != 0) { "Port numbers must be non-zero!" }
// Use Wire extension for accurate size validation
val data =
Data(
portnum = PortNum.fromValue(p.dataType) ?: PortNum.UNKNOWN_APP,
payload = bytes,
reply_id = p.replyId ?: 0,
emoji = p.emoji,
)
if (!Data.ADAPTER.isWithinSizeLimit(data, Constants.DATA_PAYLOAD_LEN.value)) {
val actualSize = Data.ADAPTER.encodedSize(data)
p.status = MessageStatus.ERROR
error("Message too long: $actualSize bytes")
} else {
p.status = MessageStatus.QUEUED
}
sendNow(p)
}
private fun sendNow(p: DataPacket) {
val meshPacket =
buildMeshPacket(
to = resolveNodeNum(p.to ?: DataPacket.ID_BROADCAST),
id = p.id,
wantAck = p.wantAck,
hopLimit = if (p.hopLimit > 0) p.hopLimit else computeHopLimit(),
channel = p.channel,
decoded =
Data(
portnum = PortNum.fromValue(p.dataType) ?: PortNum.UNKNOWN_APP,
payload = p.bytes ?: ByteString.EMPTY,
reply_id = p.replyId ?: 0,
emoji = p.emoji,
),
)
p.time = nowMillis
packetHandler.sendToRadio(meshPacket)
}
override fun sendAdmin(destNum: Int, requestId: Int, wantResponse: Boolean, initFn: () -> AdminMessage) {
val adminMsg = initFn().copy(session_passkey = sessionManager.getPasskey(destNum))
val packet =
buildAdminPacket(to = destNum, id = requestId, wantResponse = wantResponse, adminMessage = adminMsg)
packetHandler.sendToRadio(packet)
}
override suspend fun sendAdminAwait(
destNum: Int,
requestId: Int,
wantResponse: Boolean,
initFn: () -> AdminMessage,
): Boolean {
val adminMsg = initFn().copy(session_passkey = sessionManager.getPasskey(destNum))
val packet =
buildAdminPacket(to = destNum, id = requestId, wantResponse = wantResponse, adminMessage = adminMsg)
return packetHandler.sendToRadioAndAwait(packet)
}
override fun sendPosition(pos: ProtoPosition, destNum: Int?, wantResponse: Boolean) {
val myNum = nodeManager.myNodeNum.value ?: return
val idNum = destNum ?: myNum
Logger.d { "Sending our position/time to=$idNum $pos" }
if (localConfig.value.position?.fixed_position != true) {
nodeManager.handleReceivedPosition(myNum, myNum, pos, nowMillis)
}
packetHandler.sendToRadio(
buildMeshPacket(
to = idNum,
channel = if (destNum == null) 0 else getChannelIndex(destNum),
priority = MeshPacket.Priority.BACKGROUND,
decoded =
Data(
portnum = PortNum.POSITION_APP,
payload = pos.encode().toByteString(),
want_response = wantResponse,
),
),
)
}
override fun requestPosition(destNum: Int, currentPosition: Position) {
val meshPosition =
ProtoPosition(
latitude_i = Position.degI(currentPosition.latitude),
longitude_i = Position.degI(currentPosition.longitude),
altitude = currentPosition.altitude,
time = (nowMillis / 1000L).toInt(),
)
packetHandler.sendToRadio(
buildMeshPacket(
to = destNum,
channel = getChannelIndex(destNum),
priority = MeshPacket.Priority.BACKGROUND,
decoded =
Data(
portnum = PortNum.POSITION_APP,
payload = meshPosition.encode().toByteString(),
want_response = true,
),
),
)
}
override fun setFixedPosition(destNum: Int, pos: Position) {
val meshPos =
ProtoPosition(
latitude_i = Position.degI(pos.latitude),
longitude_i = Position.degI(pos.longitude),
altitude = pos.altitude,
)
sendAdmin(destNum) {
if (pos != Position(0.0, 0.0, 0)) {
AdminMessage(set_fixed_position = meshPos)
} else {
AdminMessage(remove_fixed_position = true)
}
}
nodeManager.handleReceivedPosition(destNum, nodeManager.myNodeNum.value ?: 0, meshPos, nowMillis)
}
override fun requestUserInfo(destNum: Int) {
val myNum = nodeManager.myNodeNum.value ?: return
val myNode = nodeManager.nodeDBbyNodeNum[myNum] ?: return
packetHandler.sendToRadio(
buildMeshPacket(
to = destNum,
channel = getChannelIndex(destNum),
decoded =
Data(
portnum = PortNum.NODEINFO_APP,
want_response = true,
payload = myNode.user.encode().toByteString(),
),
),
)
}
override fun requestTraceroute(requestId: Int, destNum: Int) {
tracerouteHandler.recordStartTime(requestId)
packetHandler.sendToRadio(
buildMeshPacket(
to = destNum,
wantAck = true,
id = requestId,
channel = getChannelIndex(destNum),
decoded = Data(portnum = PortNum.TRACEROUTE_APP, want_response = true, dest = destNum),
),
)
}
override fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {
val type = TelemetryType.entries.getOrNull(typeValue) ?: TelemetryType.DEVICE
val portNum: PortNum
val payloadBytes: ByteString
if (type == TelemetryType.PAX) {
portNum = PortNum.PAXCOUNTER_APP
payloadBytes = Paxcount().encode().toByteString()
} else {
portNum = PortNum.TELEMETRY_APP
payloadBytes =
Telemetry(
device_metrics = if (type == TelemetryType.DEVICE) DeviceMetrics() else null,
environment_metrics = if (type == TelemetryType.ENVIRONMENT) EnvironmentMetrics() else null,
air_quality_metrics = if (type == TelemetryType.AIR_QUALITY) AirQualityMetrics() else null,
power_metrics = if (type == TelemetryType.POWER) PowerMetrics() else null,
local_stats = if (type == TelemetryType.LOCAL_STATS) LocalStats() else null,
host_metrics = if (type == TelemetryType.HOST) HostMetrics() else null,
)
.encode()
.toByteString()
}
packetHandler.sendToRadio(
buildMeshPacket(
to = destNum,
id = requestId,
channel = getChannelIndex(destNum),
decoded = Data(portnum = portNum, payload = payloadBytes, want_response = true, dest = destNum),
),
)
}
override fun requestNeighborInfo(requestId: Int, destNum: Int) {
neighborInfoHandler.recordStartTime(requestId)
val myNum = nodeManager.myNodeNum.value ?: 0
if (destNum == myNum) {
val neighborInfoToSend =
neighborInfoHandler.lastNeighborInfo
?: run {
val oneHour = 1.hours.inWholeMinutes.toInt()
Logger.d { "No stored neighbor info from connected radio, sending dummy data" }
NeighborInfo(
node_id = myNum,
last_sent_by_id = myNum,
node_broadcast_interval_secs = oneHour,
neighbors =
listOf(
Neighbor(
node_id = 0, // Dummy node ID that can be intercepted
snr = 0f,
last_rx_time = (nowMillis / 1000L).toInt(),
node_broadcast_interval_secs = oneHour,
),
),
)
}
// Send the neighbor info from our connected radio to ourselves (simulated)
packetHandler.sendToRadio(
buildMeshPacket(
to = destNum,
wantAck = true,
id = requestId,
channel = getChannelIndex(destNum),
decoded =
Data(
portnum = PortNum.NEIGHBORINFO_APP,
payload = neighborInfoToSend.encode().toByteString(),
want_response = true,
),
),
)
} else {
// Send request to remote
packetHandler.sendToRadio(
buildMeshPacket(
to = destNum,
wantAck = true,
id = requestId,
channel = getChannelIndex(destNum),
decoded = Data(portnum = PortNum.NEIGHBORINFO_APP, want_response = true, dest = destNum),
),
)
}
}
fun resolveNodeNum(toId: String): Int = when (toId) {
DataPacket.ID_BROADCAST -> DataPacket.NODENUM_BROADCAST
else -> {
val numericNum =
if (toId.startsWith(NODE_ID_PREFIX)) {
toId.substring(NODE_ID_START_INDEX).toLongOrNull(HEX_RADIX)?.toInt()
} else {
null
}
numericNum
?: nodeManager.nodeDBbyID[toId]?.num
?: throw IllegalArgumentException("Unknown node ID $toId")
}
}
private fun buildMeshPacket(
to: Int,
wantAck: Boolean = false,
id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one
hopLimit: Int = 0,
channel: Int = 0,
priority: MeshPacket.Priority = MeshPacket.Priority.UNSET,
decoded: Data,
): MeshPacket {
val actualHopLimit = if (hopLimit > 0) hopLimit else computeHopLimit()
var pkiEncrypted = false
var publicKey: ByteString = ByteString.EMPTY
var actualChannel = channel
if (channel == DataPacket.PKC_CHANNEL_INDEX) {
pkiEncrypted = true
val destNode = nodeManager.nodeDBbyNodeNum[to]
// Resolve the public key using the same fallback as Node.hasPKC:
// standalone publicKey (populated after Room round-trip) first, then
// the embedded user.public_key (always available in-memory).
publicKey = destNode?.let { it.publicKey ?: it.user.public_key } ?: ByteString.EMPTY
if (publicKey.size == 0) {
Logger.w { "buildMeshPacket: no public key for node ${to.toUInt()}, PKI encryption will fail" }
}
actualChannel = 0
}
return MeshPacket(
from = nodeManager.myNodeNum.value ?: 0,
to = to,
id = id,
want_ack = wantAck,
hop_limit = actualHopLimit,
hop_start = actualHopLimit,
priority = priority,
pki_encrypted = pkiEncrypted,
public_key = publicKey,
channel = actualChannel,
decoded = decoded,
)
}
private fun buildAdminPacket(
to: Int,
id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one
wantResponse: Boolean = false,
adminMessage: AdminMessage,
): MeshPacket =
buildMeshPacket(
to = to,
id = id,
wantAck = true,
channel = getAdminChannelIndex(to),
priority = MeshPacket.Priority.RELIABLE,
decoded =
Data(
want_response = wantResponse,
portnum = PortNum.ADMIN_APP,
payload = adminMessage.encode().toByteString(),
),
)
companion object {
private const val PACKET_ID_MASK = 0xffffffffL
private const val PACKET_ID_SHIFT_BITS = 32
private const val ADMIN_CHANNEL_NAME = "admin"
private const val NODE_ID_PREFIX = "!"
private const val NODE_ID_START_INDEX = 1
private const val HEX_RADIX = 16
private const val DEFAULT_HOP_LIMIT = 3
}
}

View File

@@ -1,54 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.ToRadio
/**
* Centralized heartbeat sender for the data layer.
*
* Consolidates heartbeat nonce management into a single monotonically increasing counter, preventing the firmware's
* per-connection duplicate-write filter (byte-level memcmp) from silently dropping consecutive heartbeats.
*
* This is distinct from [org.meshtastic.core.network.transport.HeartbeatSender], which operates at the transport layer
* with raw byte encoding. This class works at the protobuf/data layer through [PacketHandler].
*/
@Single
class DataLayerHeartbeatSender(private val packetHandler: PacketHandler) {
private val nonce = atomic(0)
/**
* Enqueues a heartbeat with a unique nonce.
*
* @param tag descriptive label for log messages (e.g. "pre-handshake", "inter-stage")
*/
@Suppress("TooGenericExceptionCaught")
fun sendHeartbeat(tag: String = "handshake") {
try {
val n = nonce.incrementAndGet()
packetHandler.sendToRadio(ToRadio(heartbeat = Heartbeat(nonce = n)))
Logger.d { "[$tag] Heartbeat enqueued (nonce=$n)" }
} catch (e: Exception) {
Logger.w(e) { "[$tag] Failed to enqueue heartbeat; proceeding" }
}
}
}

View File

@@ -1,401 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ignoreExceptionSuspend
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.Reaction
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.OTAMode
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.User
@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod")
@Single
class MeshActionHandlerImpl(
private val nodeManager: NodeManager,
private val commandSender: CommandSender,
private val packetRepository: Lazy<PacketRepository>,
private val serviceBroadcasts: ServiceBroadcasts,
private val dataHandler: Lazy<MeshDataHandler>,
private val analytics: PlatformAnalytics,
private val meshPrefs: MeshPrefs,
private val uiPrefs: UiPrefs,
private val databaseManager: DatabaseManager,
private val notificationManager: NotificationManager,
private val radioConfigRepository: RadioConfigRepository,
@Named("ServiceScope") private val scope: CoroutineScope,
) : MeshActionHandler {
companion object {
private const val DEFAULT_REBOOT_DELAY = 5
private const val EMOJI_INDICATOR = 1
}
override suspend fun onServiceAction(action: ServiceAction) {
Logger.d { "ServiceAction dispatched: ${action::class.simpleName}" }
ignoreExceptionSuspend {
val myNodeNum = nodeManager.myNodeNum.value
if (myNodeNum == null) {
Logger.w { "MeshActionHandlerImpl: myNodeNum is null, skipping ServiceAction!" }
if (action is ServiceAction.SendContact) {
action.result.complete(false)
}
return@ignoreExceptionSuspend
}
when (action) {
is ServiceAction.Favorite -> handleFavorite(action, myNodeNum)
is ServiceAction.Ignore -> handleIgnore(action, myNodeNum)
is ServiceAction.Mute -> handleMute(action, myNodeNum)
is ServiceAction.Reaction -> handleReaction(action, myNodeNum)
is ServiceAction.ImportContact -> handleImportContact(action, myNodeNum)
is ServiceAction.SendContact -> {
val accepted =
safeCatching {
commandSender.sendAdminAwait(myNodeNum) { AdminMessage(add_contact = action.contact) }
}
.getOrDefault(false)
action.result.complete(accepted)
}
is ServiceAction.GetDeviceMetadata -> {
commandSender.sendAdmin(action.destNum, wantResponse = true) {
AdminMessage(get_device_metadata_request = true)
}
}
}
}
}
private fun handleFavorite(action: ServiceAction.Favorite, myNodeNum: Int) {
val node = action.node
commandSender.sendAdmin(myNodeNum) {
if (node.isFavorite) {
AdminMessage(remove_favorite_node = node.num)
} else {
AdminMessage(set_favorite_node = node.num)
}
}
nodeManager.updateNode(node.num) { it.copy(isFavorite = !node.isFavorite) }
}
private fun handleIgnore(action: ServiceAction.Ignore, myNodeNum: Int) {
val node = action.node
val newIgnoredStatus = !node.isIgnored
commandSender.sendAdmin(myNodeNum) {
if (newIgnoredStatus) {
AdminMessage(set_ignored_node = node.num)
} else {
AdminMessage(remove_ignored_node = node.num)
}
}
nodeManager.updateNode(node.num) { it.copy(isIgnored = newIgnoredStatus) }
scope.handledLaunch { packetRepository.value.updateFilteredBySender(node.user.id, newIgnoredStatus) }
}
private fun handleMute(action: ServiceAction.Mute, myNodeNum: Int) {
val node = action.node
commandSender.sendAdmin(myNodeNum) { AdminMessage(toggle_muted_node = node.num) }
nodeManager.updateNode(node.num) { it.copy(isMuted = !node.isMuted) }
}
private fun handleReaction(action: ServiceAction.Reaction, myNodeNum: Int) {
val channel = action.contactKey[0].digitToInt()
val destId = action.contactKey.substring(1)
val dataPacket =
DataPacket(
to = destId,
dataType = PortNum.TEXT_MESSAGE_APP.value,
bytes = action.emoji.encodeToByteArray().toByteString(),
channel = channel,
replyId = action.replyId,
wantAck = true,
emoji = EMOJI_INDICATOR,
)
.apply { from = nodeManager.getMyId().takeIf { it.isNotEmpty() } ?: DataPacket.ID_LOCAL }
commandSender.sendData(dataPacket)
rememberReaction(action, dataPacket.id, myNodeNum)
}
private fun handleImportContact(action: ServiceAction.ImportContact, myNodeNum: Int) {
val verifiedContact = action.contact.copy(manually_verified = true)
commandSender.sendAdmin(myNodeNum) { AdminMessage(add_contact = verifiedContact) }
nodeManager.handleReceivedUser(
verifiedContact.node_num,
verifiedContact.user ?: User(),
manuallyVerified = true,
)
}
private fun rememberReaction(action: ServiceAction.Reaction, packetId: Int, myNodeNum: Int) {
scope.handledLaunch {
val user = nodeManager.nodeDBbyNodeNum[myNodeNum]?.user ?: User(id = nodeManager.getMyId())
val reaction =
Reaction(
replyId = action.replyId,
user = user,
emoji = action.emoji,
timestamp = nowMillis,
snr = 0f,
rssi = 0,
hopsAway = 0,
packetId = packetId,
status = MessageStatus.QUEUED,
to = action.contactKey.substring(1),
channel = action.contactKey[0].digitToInt(),
)
packetRepository.value.insertReaction(reaction, myNodeNum)
}
}
override fun handleSetOwner(u: MeshUser, myNodeNum: Int) {
Logger.d { "Setting owner: longName=${u.longName}, shortName=${u.shortName}" }
val newUser = User(id = u.id, long_name = u.longName, short_name = u.shortName, is_licensed = u.isLicensed)
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_owner = newUser) }
nodeManager.handleReceivedUser(myNodeNum, newUser)
}
override fun handleSend(p: DataPacket, myNodeNum: Int) {
commandSender.sendData(p)
serviceBroadcasts.broadcastMessageStatus(p.id, p.status ?: MessageStatus.UNKNOWN)
dataHandler.value.rememberDataPacket(p, myNodeNum, false)
val bytes = p.bytes ?: ByteString.EMPTY
analytics.track("data_send", DataPair("num_bytes", bytes.size), DataPair("type", p.dataType))
}
override fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int) {
if (destNum != myNodeNum) {
val provideLocation = uiPrefs.shouldProvideNodeLocation(myNodeNum).value
val currentPosition =
when {
provideLocation && position.isValid() -> position
provideLocation ->
nodeManager.nodeDBbyNodeNum[myNodeNum]?.position?.let { Position(it) }?.takeIf { it.isValid() }
?: Position(0.0, 0.0, 0)
else -> Position(0.0, 0.0, 0)
}
commandSender.requestPosition(destNum, currentPosition)
}
}
override fun handleRemoveByNodenum(nodeNum: Int, requestId: Int, myNodeNum: Int) {
nodeManager.removeByNodenum(nodeNum)
commandSender.sendAdmin(myNodeNum, requestId) { AdminMessage(remove_by_nodenum = nodeNum) }
}
override fun handleSetRemoteOwner(id: Int, destNum: Int, payload: ByteArray) {
val u = User.ADAPTER.decode(payload)
commandSender.sendAdmin(destNum, id) { AdminMessage(set_owner = u) }
nodeManager.handleReceivedUser(destNum, u)
}
override fun handleGetRemoteOwner(id: Int, destNum: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_owner_request = true) }
}
override fun handleSetConfig(payload: ByteArray, myNodeNum: Int) {
val c = Config.ADAPTER.decode(payload)
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_config = c) }
// Optimistically persist the config locally so CommandSender picks up
// the new values (e.g. hop_limit) immediately instead of waiting for
// the next want_config handshake.
scope.handledLaunch { radioConfigRepository.setLocalConfig(c) }
}
override fun handleSetRemoteConfig(id: Int, destNum: Int, payload: ByteArray) {
val c = Config.ADAPTER.decode(payload)
commandSender.sendAdmin(destNum, id) { AdminMessage(set_config = c) }
// When targeting the local node, optimistically persist the config so the
// UI reflects changes immediately (matching handleSetConfig behaviour).
if (destNum == nodeManager.myNodeNum.value) {
scope.handledLaunch { radioConfigRepository.setLocalConfig(c) }
}
}
override fun handleGetRemoteConfig(id: Int, destNum: Int, config: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) {
if (config == AdminMessage.ConfigType.SESSIONKEY_CONFIG.value) {
AdminMessage(get_device_metadata_request = true)
} else {
AdminMessage(get_config_request = AdminMessage.ConfigType.fromValue(config))
}
}
}
override fun handleSetModuleConfig(id: Int, destNum: Int, payload: ByteArray) {
val c = ModuleConfig.ADAPTER.decode(payload)
commandSender.sendAdmin(destNum, id) { AdminMessage(set_module_config = c) }
c.statusmessage?.let { sm -> nodeManager.updateNodeStatus(destNum, sm.node_status) }
// Optimistically persist module config locally so the UI reflects the
// new values immediately instead of waiting for the next want_config handshake.
if (destNum == nodeManager.myNodeNum.value) {
scope.handledLaunch { radioConfigRepository.setLocalModuleConfig(c) }
}
}
override fun handleGetModuleConfig(id: Int, destNum: Int, config: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) {
AdminMessage(get_module_config_request = AdminMessage.ModuleConfigType.fromValue(config))
}
}
override fun handleSetRingtone(destNum: Int, ringtone: String) {
commandSender.sendAdmin(destNum) { AdminMessage(set_ringtone_message = ringtone) }
}
override fun handleGetRingtone(id: Int, destNum: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_ringtone_request = true) }
}
override fun handleSetCannedMessages(destNum: Int, messages: String) {
commandSender.sendAdmin(destNum) { AdminMessage(set_canned_message_module_messages = messages) }
}
override fun handleGetCannedMessages(id: Int, destNum: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) {
AdminMessage(get_canned_message_module_messages_request = true)
}
}
override fun handleSetChannel(payload: ByteArray?, myNodeNum: Int) {
if (payload != null) {
val c = Channel.ADAPTER.decode(payload)
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_channel = c) }
// Optimistically persist the channel settings locally so the UI
// reflects changes immediately instead of waiting for the next
// want_config handshake.
scope.handledLaunch { radioConfigRepository.updateChannelSettings(c) }
}
}
override fun handleSetRemoteChannel(id: Int, destNum: Int, payload: ByteArray?) {
if (payload != null) {
val c = Channel.ADAPTER.decode(payload)
commandSender.sendAdmin(destNum, id) { AdminMessage(set_channel = c) }
// When targeting the local node, optimistically persist the channel so
// the UI reflects changes immediately (matching handleSetChannel behaviour).
if (destNum == nodeManager.myNodeNum.value) {
scope.handledLaunch { radioConfigRepository.updateChannelSettings(c) }
}
}
}
override fun handleGetRemoteChannel(id: Int, destNum: Int, index: Int) {
commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_channel_request = index + 1) }
}
override fun handleRequestNeighborInfo(requestId: Int, destNum: Int) {
commandSender.requestNeighborInfo(requestId, destNum)
}
override fun handleBeginEditSettings(destNum: Int) {
commandSender.sendAdmin(destNum) { AdminMessage(begin_edit_settings = true) }
}
override fun handleCommitEditSettings(destNum: Int) {
commandSender.sendAdmin(destNum) { AdminMessage(commit_edit_settings = true) }
}
override fun handleRebootToDfu(destNum: Int) {
commandSender.sendAdmin(destNum) { AdminMessage(enter_dfu_mode_request = true) }
}
override fun handleRequestTelemetry(requestId: Int, destNum: Int, type: Int) {
commandSender.requestTelemetry(requestId, destNum, type)
}
override fun handleRequestShutdown(requestId: Int, destNum: Int) {
commandSender.sendAdmin(destNum, requestId) { AdminMessage(shutdown_seconds = DEFAULT_REBOOT_DELAY) }
}
override fun handleRequestReboot(requestId: Int, destNum: Int) {
Logger.i { "Reboot requested for node $destNum" }
commandSender.sendAdmin(destNum, requestId) { AdminMessage(reboot_seconds = DEFAULT_REBOOT_DELAY) }
}
override fun handleRequestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {
val otaMode = OTAMode.fromValue(mode) ?: OTAMode.NO_REBOOT_OTA
val otaEvent =
AdminMessage.OTAEvent(reboot_ota_mode = otaMode, ota_hash = hash?.toByteString() ?: ByteString.EMPTY)
commandSender.sendAdmin(destNum, requestId) { AdminMessage(ota_request = otaEvent) }
}
override fun handleRequestFactoryReset(requestId: Int, destNum: Int) {
Logger.i { "Factory reset requested for node $destNum" }
commandSender.sendAdmin(destNum, requestId) { AdminMessage(factory_reset_device = 1) }
}
override fun handleRequestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) {
commandSender.sendAdmin(destNum, requestId) { AdminMessage(nodedb_reset = preserveFavorites) }
}
override fun handleGetDeviceConnectionStatus(requestId: Int, destNum: Int) {
commandSender.sendAdmin(destNum, requestId, wantResponse = true) {
AdminMessage(get_device_connection_status_request = true)
}
}
override fun handleUpdateLastAddress(deviceAddr: String?) {
val currentAddr = meshPrefs.deviceAddress.value
if (deviceAddr != currentAddr) {
Logger.i { "Device address changed, switching database and clearing node DB" }
meshPrefs.setDeviceAddress(deviceAddr)
scope.handledLaunch {
nodeManager.clear()
databaseManager.switchActiveDatabase(deviceAddr)
notificationManager.cancelAll()
nodeManager.loadCachedNodeDB()
}
}
}
}

View File

@@ -1,308 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.repository.HandshakeConstants
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NotificationPrefs
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.FileInfo
import org.meshtastic.proto.FirmwareEdition
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.NodeInfo
import org.meshtastic.core.model.MyNodeInfo as SharedMyNodeInfo
import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo
@Suppress("LongParameterList", "TooManyFunctions")
@Single
class MeshConfigFlowManagerImpl(
private val nodeManager: NodeManager,
private val connectionManager: Lazy<MeshConnectionManager>,
private val nodeRepository: NodeRepository,
private val radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
private val serviceBroadcasts: ServiceBroadcasts,
private val analytics: PlatformAnalytics,
private val heartbeatSender: DataLayerHeartbeatSender,
private val notificationPrefs: NotificationPrefs,
@Named("ServiceScope") private val scope: CoroutineScope,
) : MeshConfigFlowManager {
private val wantConfigDelay = 100L
/** Monotonically increasing generation so async clears from a stale handshake are discarded. */
private val handshakeGeneration = atomic(0L)
/**
* Type-safe handshake state machine. Each state carries exactly the data that is valid during that phase,
* eliminating the possibility of accessing stale or uninitialized fields.
*
* Guards [handleConfigComplete] so that duplicate or out-of-order `config_complete_id` signals from the firmware
* cannot trigger the wrong stage handler or drive the state machine backward.
*/
private sealed class HandshakeState {
/** No handshake in progress. */
data object Idle : HandshakeState()
/**
* Stage 1: receiving device config, module config, channels, and metadata.
*
* [rawMyNodeInfo] arrives first (my_info packet); [metadata] may arrive shortly after. Both are consumed
* together by [buildMyNodeInfo] at Stage 1 completion.
*/
data class ReceivingConfig(val rawMyNodeInfo: ProtoMyNodeInfo, val metadata: DeviceMetadata? = null) :
HandshakeState()
/**
* Stage 2: receiving node-info packets from the firmware.
*
* [myNodeInfo] was committed at the Stage 1→2 transition. [nodes] accumulates [NodeInfo] packets until
* `config_complete_id` arrives.
*/
data class ReceivingNodeInfo(val myNodeInfo: SharedMyNodeInfo, val nodes: List<NodeInfo> = emptyList()) :
HandshakeState()
/** Both stages finished. The app is fully connected. */
data class Complete(val myNodeInfo: SharedMyNodeInfo) : HandshakeState()
}
private var handshakeState: HandshakeState = HandshakeState.Idle
override val newNodeCount: Int
get() = (handshakeState as? HandshakeState.ReceivingNodeInfo)?.nodes?.size ?: 0
override fun handleConfigComplete(configCompleteId: Int) {
val state = handshakeState
when (configCompleteId) {
HandshakeConstants.CONFIG_NONCE -> {
if (state !is HandshakeState.ReceivingConfig) {
Logger.w { "Ignoring Stage 1 config_complete in state=$state" }
return
}
handleConfigOnlyComplete(state)
}
HandshakeConstants.NODE_INFO_NONCE -> {
if (state !is HandshakeState.ReceivingNodeInfo) {
Logger.w { "Ignoring Stage 2 config_complete in state=$state" }
return
}
handleNodeInfoComplete(state)
}
else -> Logger.w { "Config complete id mismatch: $configCompleteId" }
}
}
private fun handleConfigOnlyComplete(state: HandshakeState.ReceivingConfig) {
Logger.i { "Config-only complete (Stage 1)" }
val finalizedInfo = buildMyNodeInfo(state.rawMyNodeInfo, state.metadata)
if (finalizedInfo == null) {
Logger.w { "Stage 1 failed: could not build MyNodeInfo, retrying Stage 1" }
handshakeState = HandshakeState.Idle
scope.handledLaunch {
delay(wantConfigDelay)
connectionManager.value.startConfigOnly()
}
return
}
// Warn if firmware is below the absolute minimum supported version.
// The UI layer already enforces this via FirmwareVersionCheck, so we just log here
// for diagnostics rather than hard-disconnecting.
finalizedInfo.firmwareVersion?.let { fwVersion ->
if (DeviceVersion(fwVersion) < DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION)) {
Logger.w {
"Firmware $fwVersion is below minimum ${DeviceVersion.ABS_MIN_FW_VERSION}" +
"protocol incompatibilities may occur"
}
}
}
handshakeState = HandshakeState.ReceivingNodeInfo(myNodeInfo = finalizedInfo)
Logger.i { "myNodeInfo committed (nodeNum=${finalizedInfo.myNodeNum})" }
connectionManager.value.onRadioConfigLoaded()
scope.handledLaunch {
delay(wantConfigDelay)
heartbeatSender.sendHeartbeat("inter-stage")
delay(wantConfigDelay)
Logger.i { "Requesting NodeInfo (Stage 2)" }
connectionManager.value.startNodeInfoOnly()
}
}
private fun handleNodeInfoComplete(state: HandshakeState.ReceivingNodeInfo) {
Logger.i { "NodeInfo complete (Stage 2)" }
val info = state.myNodeInfo
// Transition state immediately (synchronously) to prevent duplicate handling.
// The async work below (DB writes, broadcasts) proceeds without the guard.
// Because nodes is now immutable, no snapshot is needed — state.nodes IS the snapshot.
// Any stall-guard retry that re-enters handleNodeInfo will see Complete state and be ignored.
handshakeState = HandshakeState.Complete(myNodeInfo = info)
val entities =
state.nodes.mapNotNull { nodeInfo ->
nodeManager.installNodeInfo(nodeInfo, withBroadcast = false)
nodeManager.nodeDBbyNodeNum[nodeInfo.num]
?: run {
Logger.w { "Node ${nodeInfo.num} missing from DB after installNodeInfo; skipping" }
null
}
}
scope.handledLaunch {
nodeRepository.installConfig(info, entities)
analytics.setDeviceAttributes(info.firmwareVersion ?: "unknown", info.model ?: "unknown")
nodeManager.setNodeDbReady(true)
nodeManager.setAllowNodeDbWrites(true)
serviceRepository.setConnectionState(ConnectionState.Connected)
serviceBroadcasts.broadcastConnection()
connectionManager.value.onNodeDbReady()
}
}
override fun handleMyInfo(myInfo: ProtoMyNodeInfo) {
Logger.i { "MyNodeInfo received: ${myInfo.my_node_num}" }
// Transition to Stage 1, discarding any stale data from a prior interrupted handshake.
handshakeState = HandshakeState.ReceivingConfig(rawMyNodeInfo = myInfo)
nodeManager.setMyNodeNum(myInfo.my_node_num)
nodeManager.setFirmwareEdition(myInfo.firmware_edition)
applyEventFirmwareNotificationDefaults(myInfo.firmware_edition)
// Bump the generation so that a pending clear from a prior (interrupted) handshake
// will see a stale snapshot and skip its writes, preventing it from wiping config
// that was saved by this (newer) handshake's incoming packets.
val gen = handshakeGeneration.incrementAndGet()
// Clear persisted radio config so the new handshake starts from a clean slate.
// DataStore serializes its own writes, so the clear will precede subsequent
// setLocalConfig / updateChannelSettings calls dispatched by later packets in this
// session (handleFromRadio processes packets sequentially, so later dispatches always
// occur after this one returns).
scope.handledLaunch {
if (handshakeGeneration.value != gen) return@handledLaunch // Stale handshake; skip.
radioConfigRepository.clearChannelSet()
radioConfigRepository.clearLocalConfig()
radioConfigRepository.clearLocalModuleConfig()
radioConfigRepository.clearDeviceUIConfig()
radioConfigRepository.clearFileManifest()
}
}
override fun handleLocalMetadata(metadata: DeviceMetadata) {
Logger.i { "Local Metadata received: ${metadata.firmware_version}" }
val state = handshakeState
if (state is HandshakeState.ReceivingConfig) {
handshakeState = state.copy(metadata = metadata)
// Persist the metadata immediately — buildMyNodeInfo() reads it at Stage 1 complete,
// but the DB write does not need to wait until then.
if (metadata != DeviceMetadata()) {
scope.handledLaunch { nodeRepository.insertMetadata(state.rawMyNodeInfo.my_node_num, metadata) }
}
} else {
Logger.w { "Ignoring metadata outside Stage 1 (state=$state)" }
}
}
override fun handleNodeInfo(info: NodeInfo) {
val state = handshakeState
if (state is HandshakeState.ReceivingNodeInfo) {
handshakeState = state.copy(nodes = state.nodes + info)
} else {
Logger.w { "Ignoring NodeInfo outside Stage 2 (state=$state)" }
}
}
override fun handleFileInfo(info: FileInfo) {
Logger.d { "FileInfo received: ${info.file_name} (${info.size_bytes} bytes)" }
scope.handledLaunch { radioConfigRepository.addFileInfo(info) }
}
override fun triggerWantConfig() {
connectionManager.value.startConfigOnly()
}
/**
* Builds a [SharedMyNodeInfo] from the raw proto and optional firmware metadata. Pure function — no side effects.
* Returns null only if construction throws.
*/
private fun buildMyNodeInfo(raw: ProtoMyNodeInfo, metadata: DeviceMetadata?): SharedMyNodeInfo? = try {
with(raw) {
SharedMyNodeInfo(
myNodeNum = my_node_num,
hasGPS = false,
model =
when (val hwModel = metadata?.hw_model) {
null,
HardwareModel.UNSET,
-> null
else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase()
},
firmwareVersion = metadata?.firmware_version?.takeIf { it.isNotBlank() },
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 0L,
messageTimeoutMsec = 300000,
minAppVersion = min_app_version,
maxChannels = 8,
hasWifi = metadata?.hasWifi == true,
channelUtilization = 0f,
airUtilTx = 0f,
deviceId = device_id.utf8(),
pioEnv = pio_env.ifEmpty { null },
)
}
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
Logger.e(ex) { "Failed to build MyNodeInfo" }
null
}
private fun applyEventFirmwareNotificationDefaults(edition: FirmwareEdition) {
if (edition != FirmwareEdition.VANILLA) {
if (!notificationPrefs.nodeEventsAutoDisabledForEvent.value) {
notificationPrefs.setNodeEventsEnabled(false)
notificationPrefs.setNodeEventsAutoDisabledForEvent(true)
}
} else {
if (notificationPrefs.nodeEventsAutoDisabledForEvent.value) {
notificationPrefs.setNodeEventsEnabled(true)
notificationPrefs.setNodeEventsAutoDisabledForEvent(false)
}
}
}
}

View File

@@ -1,438 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.HandshakeConstants
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.MeshWorkerManager
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.SessionManager
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.proto.Config
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.ToRadio
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.DurationUnit
@Suppress("LongParameterList", "TooManyFunctions")
@Single
class MeshConnectionManagerImpl(
private val radioInterfaceService: RadioInterfaceService,
private val serviceRepository: ServiceRepository,
private val serviceBroadcasts: ServiceBroadcasts,
private val serviceNotifications: MeshServiceNotifications,
private val uiPrefs: UiPrefs,
private val packetHandler: PacketHandler,
private val nodeRepository: NodeRepository,
private val locationManager: MeshLocationManager,
private val mqttManager: MqttManager,
private val historyManager: HistoryManager,
private val radioConfigRepository: RadioConfigRepository,
private val radioController: RadioController,
private val sessionManager: SessionManager,
private val nodeManager: NodeManager,
private val analytics: PlatformAnalytics,
private val packetRepository: PacketRepository,
private val workerManager: MeshWorkerManager,
private val appWidgetUpdater: AppWidgetUpdater,
private val heartbeatSender: DataLayerHeartbeatSender,
@Named("ServiceScope") private val scope: CoroutineScope,
) : MeshConnectionManager {
/**
* Serializes [onConnectionChanged] to prevent TOCTOU races when multiple coroutines emit state transitions
* concurrently (e.g. flow collector vs. sleep-timeout coroutine).
*/
private val connectionMutex = Mutex()
private var preHandshakeJob: Job? = null
private var sleepTimeout: Job? = null
private var locationRequestsJob: Job? = null
private var handshakeTimeout: Job? = null
private var connectTimeMsec = 0L
private var connectionRestored = false
init {
// Bridge transport-level state into the canonical app-level state.
// This is the ONLY consumer of RadioInterfaceService.connectionState — it applies
// light-sleep policy and handshake awareness before writing to ServiceRepository.
radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(scope)
// Ensure notification title and content stay in sync with state changes
serviceRepository.connectionState.onEach { updateStatusNotification() }.launchIn(scope)
scope.launch {
try {
appWidgetUpdater.updateAll()
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.e(e) { "Failed to kickstart LocalStatsWidget" }
}
}
nodeRepository.myNodeInfo
.onEach { myNodeEntity ->
locationRequestsJob?.cancel()
if (myNodeEntity != null) {
locationRequestsJob =
uiPrefs
.shouldProvideNodeLocation(myNodeEntity.myNodeNum)
.onEach { shouldProvide ->
if (shouldProvide) {
locationManager.start(scope) { pos ->
scope.handledLaunch {
val packet = DataPacket(
bytes = okio.ByteString.of(*org.meshtastic.proto.Position.ADAPTER.encode(pos)),
dataType = org.meshtastic.proto.PortNum.POSITION_APP.value,
)
radioController.sendMessage(packet)
}
}
} else {
locationManager.stop()
}
}
.launchIn(scope)
}
}
.launchIn(scope)
}
/**
* Bridges a transport-level [ConnectionState] into the canonical app-level state.
*
* Applies light-sleep policy (power-saving / router role) to decide whether a [ConnectionState.DeviceSleep] event
* should be surfaced as sleep or as a full disconnect, then delegates to [onConnectionChanged] for the actual state
* transition.
*/
private suspend fun onRadioConnectionState(newState: ConnectionState) {
val localConfig = radioConfigRepository.localConfigFlow.first()
val isRouter = localConfig.device?.role == Config.DeviceConfig.Role.ROUTER
val lsEnabled = localConfig.power?.is_power_saving == true || isRouter
val effectiveState =
when (newState) {
is ConnectionState.Connected -> ConnectionState.Connected
is ConnectionState.DeviceSleep ->
if (lsEnabled) ConnectionState.DeviceSleep else ConnectionState.Disconnected
is ConnectionState.Connecting -> ConnectionState.Connecting
is ConnectionState.Disconnected -> ConnectionState.Disconnected
}
onConnectionChanged(effectiveState)
}
private suspend fun onConnectionChanged(c: ConnectionState) = connectionMutex.withLock {
val current = serviceRepository.connectionState.value
if (current == c) return@withLock
// If the transport reports 'Connected', but we are already in the middle of a handshake (Connecting)
if (c is ConnectionState.Connected && current is ConnectionState.Connecting) {
Logger.d { "Ignoring redundant transport connection signal while handshake is in progress" }
return@withLock
}
Logger.i { "onConnectionChanged: $current -> $c" }
sleepTimeout?.cancel()
sleepTimeout = null
preHandshakeJob?.cancel()
preHandshakeJob = null
handshakeTimeout?.cancel()
handshakeTimeout = null
when (c) {
is ConnectionState.Connecting -> serviceRepository.setConnectionState(ConnectionState.Connecting)
is ConnectionState.Connected -> handleConnected()
is ConnectionState.DeviceSleep -> handleDeviceSleep()
is ConnectionState.Disconnected -> handleDisconnected()
}
}
private fun handleConnected() {
// Track whether this connection was restored from device sleep (vs. a fresh connect),
// matching Apple's "connectionRestored" attribute for cross-platform DataDog parity.
connectionRestored = serviceRepository.connectionState.value is ConnectionState.DeviceSleep
// The service state remains 'Connecting' until config is fully loaded
if (serviceRepository.connectionState.value != ConnectionState.Connected) {
serviceRepository.setConnectionState(ConnectionState.Connecting)
}
serviceBroadcasts.broadcastConnection()
connectTimeMsec = nowMillis
// Send a wake-up heartbeat before the config request. The firmware may be in a
// power-saving state where the NimBLE callback context needs warming up. The 100ms
// delay ensures the heartbeat BLE write is enqueued before the want_config_id
// (sendToRadio is fire-and-forget through async coroutine launches).
preHandshakeJob =
scope.handledLaunch {
heartbeatSender.sendHeartbeat("pre-handshake")
delay(PRE_HANDSHAKE_SETTLE_MS)
Logger.i { "Starting mesh handshake (Stage 1)" }
startConfigOnly()
}
}
private fun startHandshakeStallGuard(stage: Int, timeout: Duration, action: () -> Unit) {
handshakeTimeout?.cancel()
handshakeTimeout =
scope.handledLaunch {
delay(timeout)
if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
// Attempt one retry. Note: the firmware silently drops identical consecutive
// writes (per-connection dedup). If the first want_config_id was received and
// the stall is on our side, the retry will be dropped and the reconnect below
// will trigger instead — which is the right recovery in that case.
Logger.w {
"Handshake stall detected at Stage $stage — retrying, then reconnecting if still stalled"
}
action()
delay(HANDSHAKE_RETRY_TIMEOUT)
if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
Logger.e { "Handshake still stalled after retry, forcing reconnect" }
onConnectionChanged(ConnectionState.Disconnected)
}
}
}
}
private fun tearDownConnection() {
packetHandler.stopPacketQueue()
sessionManager.clearAll() // Prevent stale per-node passkeys on reconnect.
locationManager.stop()
mqttManager.stop()
}
private fun handleDeviceSleep() {
serviceRepository.setConnectionState(ConnectionState.DeviceSleep)
tearDownConnection()
if (connectTimeMsec != 0L) {
val now = nowMillis
val duration = now - connectTimeMsec
connectTimeMsec = 0L
analytics.track(
EVENT_CONNECTED_SECONDS,
DataPair(EVENT_CONNECTED_SECONDS, duration.milliseconds.toDouble(DurationUnit.SECONDS)),
)
}
sleepTimeout =
scope.handledLaunch {
try {
val localConfig = radioConfigRepository.localConfigFlow.first()
val rawTimeout = (localConfig.power?.ls_secs ?: 0) + DEVICE_SLEEP_TIMEOUT_SECONDS
// Cap the timeout so routers or power-saving configs (ls_secs=3600) don't
// leave the UI stuck in DeviceSleep for over an hour.
val timeout = rawTimeout.coerceAtMost(MAX_SLEEP_TIMEOUT_SECONDS)
Logger.d { "Waiting for sleeping device, timeout=$timeout secs (raw=$rawTimeout)" }
delay(timeout.seconds)
Logger.w { "Device timed out, setting disconnected" }
onConnectionChanged(ConnectionState.Disconnected)
} catch (_: CancellationException) {
Logger.d { "device sleep timeout cancelled" }
}
}
serviceBroadcasts.broadcastConnection()
}
private fun handleDisconnected() {
serviceRepository.setConnectionState(ConnectionState.Disconnected)
tearDownConnection()
analytics.track(
EVENT_MESH_DISCONNECT,
DataPair(KEY_NUM_NODES, nodeManager.nodeDBbyNodeNum.size),
DataPair(KEY_NUM_ONLINE, nodeManager.nodeDBbyNodeNum.values.count { it.isOnline }),
)
analytics.track(EVENT_NUM_NODES, DataPair(KEY_NUM_NODES, nodeManager.nodeDBbyNodeNum.size))
serviceBroadcasts.broadcastConnection()
}
override fun startConfigOnly() {
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE)) }
startHandshakeStallGuard(1, HANDSHAKE_TIMEOUT_STAGE1, action)
action()
}
override fun startNodeInfoOnly() {
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE)) }
startHandshakeStallGuard(2, HANDSHAKE_TIMEOUT_STAGE2, action)
action()
}
override fun onRadioConfigLoaded() {
scope.handledLaunch {
val queuedPackets = packetRepository.getQueuedPackets()
queuedPackets.forEach { packet ->
try {
workerManager.enqueueSendMessage(packet.id)
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.e(e) { "Failed to enqueue queued packet worker" }
}
}
}
}
override fun onNodeDbReady() {
handshakeTimeout?.cancel()
handshakeTimeout = null
val myNodeNum = nodeManager.myNodeNum.value ?: 0
// NOTE: Time sync and session passkey seeding are handled by the SDK's RadioClient
// during its own handshake — no need to send set_time_only or get_owner_request here.
// Start MQTT if enabled
scope.handledLaunch {
val moduleConfig = radioConfigRepository.moduleConfigFlow.first()
mqttManager.startProxy(
moduleConfig.mqtt?.enabled == true,
moduleConfig.mqtt?.proxy_to_client_enabled == true,
)
}
reportConnection()
// Request history
scope.handledLaunch {
val moduleConfig = radioConfigRepository.moduleConfigFlow.first()
moduleConfig.store_forward?.let {
historyManager.requestHistoryReplay("onNodeDbReady", myNodeNum, it, "Unknown")
}
}
// Request immediate LocalStats and DeviceMetrics update on connection
scope.handledLaunch {
radioController.requestTelemetry(myNodeNum.hashCode(), myNodeNum, TelemetryType.LOCAL_STATS.ordinal)
radioController.requestTelemetry(myNodeNum.hashCode() + 1, myNodeNum, TelemetryType.DEVICE.ordinal)
}
}
private fun reportConnection() {
val myNode = nodeManager.getMyNodeInfo()
val radioModel = DataPair(KEY_RADIO_MODEL, myNode?.model ?: "unknown")
analytics.track(
EVENT_MESH_CONNECT,
DataPair(KEY_NUM_NODES, nodeManager.nodeDBbyNodeNum.size),
DataPair(KEY_NUM_ONLINE, nodeManager.nodeDBbyNodeNum.values.count { it.isOnline }),
radioModel,
)
// DataDog RUM custom action matching Apple's "connect" event for cross-platform analytics.
val transportType = radioInterfaceService.getDeviceAddress()?.let { DeviceType.fromAddress(it)?.name }
analytics.trackConnect(
firmwareVersion = myNode?.firmwareVersion,
transportType = transportType,
hardwareModel = myNode?.model,
nodes = nodeManager.nodeDBbyNodeNum.size,
connectionRestored = connectionRestored,
)
}
override fun updateTelemetry(t: Telemetry) {
t.local_stats?.let { nodeRepository.updateLocalStats(it) }
updateStatusNotification(t)
}
override fun updateStatusNotification(telemetry: Telemetry?) {
serviceNotifications.updateServiceStateNotification(
serviceRepository.connectionState.value,
telemetry = telemetry,
)
}
companion object {
private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30
// Maximum time (in seconds) to wait for a sleeping device before declaring it
// disconnected, regardless of the device's ls_secs configuration. Without this
// cap, routers (ls_secs=3600) leave the UI in DeviceSleep for over an hour.
private const val MAX_SLEEP_TIMEOUT_SECONDS = 300
/**
* Delay between the pre-handshake heartbeat and the want_config_id send.
*
* Ensures the heartbeat BLE write completes and the firmware's NimBLE callback context is warmed up before the
* config request arrives. 100ms is well above observed ESP32 task scheduling latency (~1050ms) while adding
* negligible connection latency.
*/
private const val PRE_HANDSHAKE_SETTLE_MS = 100L
private val HANDSHAKE_TIMEOUT_STAGE1 = 30.seconds
/**
* Stage 2 drains the full node database, which can be significantly larger than Stage 1 config on big meshes.
* 60 s matches the meshtastic-client SDK timeout and avoids premature stall-guard triggers on meshes with 50+
* nodes.
*/
private val HANDSHAKE_TIMEOUT_STAGE2 = 60.seconds
// Shorter window for the retry attempt: if the device genuinely didn't receive the
// first want_config_id the retry completes within a few seconds. Waiting another 30s
// before reconnecting just delays recovery unnecessarily.
private val HANDSHAKE_RETRY_TIMEOUT = 15.seconds
private const val EVENT_CONNECTED_SECONDS = "connected_seconds"
private const val EVENT_MESH_DISCONNECT = "mesh_disconnect"
private const val EVENT_NUM_NODES = "num_nodes"
private const val EVENT_MESH_CONNECT = "mesh_connect"
private const val KEY_NUM_NODES = "num_nodes"
private const val KEY_NUM_ONLINE = "num_online"
private const val KEY_RADIO_MODEL = "radio_model"
}
}

View File

@@ -1,531 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import okio.ByteString
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.Reaction
import org.meshtastic.core.model.util.MeshDataMapper
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.toOneLiner
import org.meshtastic.core.repository.AdminPacketHandler
import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.MessageFilter
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.StoreForwardPacketHandler
import org.meshtastic.core.repository.TelemetryPacketHandler
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.critical_alert
import org.meshtastic.core.resources.error_duty_cycle
import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.resources.waypoint_received
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Position
import org.meshtastic.proto.Routing
import org.meshtastic.proto.StatusMessage
import org.meshtastic.proto.User
import org.meshtastic.proto.Waypoint
/**
* Implementation of [MeshDataHandler] that decodes and routes incoming mesh data packets.
*
* This class handles the complexity of:
* 1. Mapping raw [MeshPacket] objects to domain-friendly [DataPacket] objects.
* 2. Routing packets to specialized handlers (e.g., Traceroute, NeighborInfo, Telemetry, Admin, SFPP).
* 3. Managing message history and persistence.
* 4. Triggering notifications for various packet types (Text, Waypoints).
*/
@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod")
@Single
class MeshDataHandlerImpl(
private val nodeManager: NodeManager,
private val packetHandler: PacketHandler,
private val serviceRepository: ServiceRepository,
private val packetRepository: Lazy<PacketRepository>,
private val serviceBroadcasts: ServiceBroadcasts,
private val notificationManager: NotificationManager,
private val serviceNotifications: MeshServiceNotifications,
private val analytics: PlatformAnalytics,
private val dataMapper: MeshDataMapper,
private val tracerouteHandler: TracerouteHandler,
private val neighborInfoHandler: NeighborInfoHandler,
private val radioConfigRepository: RadioConfigRepository,
private val messageFilter: MessageFilter,
private val storeForwardHandler: StoreForwardPacketHandler,
private val telemetryHandler: TelemetryPacketHandler,
private val adminPacketHandler: AdminPacketHandler,
@Named("ServiceScope") private val scope: CoroutineScope,
) : MeshDataHandler {
private val rememberDataType =
setOf(
PortNum.TEXT_MESSAGE_APP.value,
PortNum.ALERT_APP.value,
PortNum.WAYPOINT_APP.value,
PortNum.NODE_STATUS_APP.value,
)
override fun handleReceivedData(packet: MeshPacket, myNodeNum: Int, logUuid: String?, logInsertJob: Job?) {
val dataPacket = dataMapper.toDataPacket(packet) ?: return
val fromUs = myNodeNum == packet.from
dataPacket.status = MessageStatus.RECEIVED
val shouldBroadcast = handleDataPacket(packet, dataPacket, myNodeNum, fromUs, logUuid, logInsertJob)
if (shouldBroadcast) {
serviceBroadcasts.broadcastReceivedData(dataPacket)
}
analytics.track("num_data_receive", DataPair("num_data_receive", 1))
}
private fun handleDataPacket(
packet: MeshPacket,
dataPacket: DataPacket,
myNodeNum: Int,
fromUs: Boolean,
logUuid: String?,
logInsertJob: Job?,
): Boolean {
var shouldBroadcast = !fromUs
val decoded = packet.decoded ?: return shouldBroadcast
when (decoded.portnum) {
PortNum.TEXT_MESSAGE_APP -> handleTextMessage(packet, dataPacket, myNodeNum)
PortNum.NODE_STATUS_APP -> handleNodeStatus(packet, dataPacket, myNodeNum)
PortNum.ALERT_APP -> rememberDataPacket(dataPacket, myNodeNum)
PortNum.WAYPOINT_APP -> handleWaypoint(packet, dataPacket, myNodeNum)
PortNum.POSITION_APP -> handlePosition(packet, dataPacket, myNodeNum)
PortNum.NODEINFO_APP -> if (!fromUs) handleNodeInfo(packet)
PortNum.TELEMETRY_APP -> telemetryHandler.handleTelemetry(packet, dataPacket, myNodeNum)
else ->
shouldBroadcast =
handleSpecializedDataPacket(packet, dataPacket, myNodeNum, fromUs, logUuid, logInsertJob)
}
return shouldBroadcast
}
private fun handleSpecializedDataPacket(
packet: MeshPacket,
dataPacket: DataPacket,
myNodeNum: Int,
fromUs: Boolean,
logUuid: String?,
logInsertJob: Job?,
): Boolean {
var shouldBroadcast = !fromUs
val decoded = packet.decoded ?: return shouldBroadcast
when (decoded.portnum) {
PortNum.TRACEROUTE_APP -> {
tracerouteHandler.handleTraceroute(packet, logUuid, logInsertJob)
shouldBroadcast = false
}
PortNum.ROUTING_APP -> {
handleRouting(packet, dataPacket)
shouldBroadcast = true
}
PortNum.PAXCOUNTER_APP -> {
handlePaxCounter(packet)
}
PortNum.STORE_FORWARD_APP -> {
storeForwardHandler.handleStoreAndForward(packet, dataPacket, myNodeNum)
}
PortNum.STORE_FORWARD_PLUSPLUS_APP -> {
storeForwardHandler.handleStoreForwardPlusPlus(packet)
}
PortNum.ADMIN_APP -> {
adminPacketHandler.handleAdminMessage(packet, myNodeNum)
}
PortNum.NEIGHBORINFO_APP -> {
neighborInfoHandler.handleNeighborInfo(packet)
shouldBroadcast = true
}
PortNum.ATAK_PLUGIN,
PortNum.ATAK_FORWARDER,
PortNum.PRIVATE_APP,
-> {
shouldBroadcast = true
}
PortNum.RANGE_TEST_APP,
PortNum.DETECTION_SENSOR_APP,
-> {
handleRangeTest(dataPacket, myNodeNum)
shouldBroadcast = true
}
else -> {
// By default, if we don't know what it is, we should probably broadcast it
// so that external apps can handle it.
shouldBroadcast = true
}
}
return shouldBroadcast
}
private fun handleRangeTest(dataPacket: DataPacket, myNodeNum: Int) {
val u = dataPacket.copy(dataType = PortNum.TEXT_MESSAGE_APP.value)
rememberDataPacket(u, myNodeNum)
}
private fun handlePaxCounter(packet: MeshPacket) {
val payload = packet.decoded?.payload ?: return
val p = Paxcount.ADAPTER.decodeOrNull(payload, Logger) ?: return
nodeManager.handleReceivedPaxcounter(packet.from, p)
}
private fun handlePosition(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
val payload = packet.decoded?.payload ?: return
val p = Position.ADAPTER.decodeOrNull(payload, Logger) ?: return
Logger.d { "Position from ${packet.from}: ${Position.ADAPTER.toOneLiner(p)}" }
nodeManager.handleReceivedPosition(packet.from, myNodeNum, p, dataPacket.time)
}
private fun handleWaypoint(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
val payload = packet.decoded?.payload ?: return
val u = Waypoint.ADAPTER.decode(payload)
if (u.locked_to != 0 && u.locked_to != packet.from) return
val currentSecond = nowSeconds.toInt()
rememberDataPacket(dataPacket, myNodeNum, updateNotification = u.expire > currentSecond)
}
private fun handleTextMessage(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
val decoded = packet.decoded ?: return
if (decoded.reply_id != 0 && decoded.emoji != 0) {
rememberReaction(packet)
} else {
rememberDataPacket(dataPacket, myNodeNum)
}
}
private fun handleNodeInfo(packet: MeshPacket) {
val payload = packet.decoded?.payload ?: return
val u =
User.ADAPTER.decode(payload)
.let { if (it.is_licensed == true) it.copy(public_key = ByteString.EMPTY) else it }
.let {
if (packet.via_mqtt == true && !it.long_name.endsWith(" (MQTT)")) {
it.copy(long_name = "${it.long_name} (MQTT)")
} else {
it
}
}
nodeManager.handleReceivedUser(packet.from, u, packet.channel)
}
private fun handleNodeStatus(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
val payload = packet.decoded?.payload ?: return
val s = StatusMessage.ADAPTER.decodeOrNull(payload, Logger) ?: return
nodeManager.handleReceivedNodeStatus(packet.from, s)
rememberDataPacket(dataPacket, myNodeNum)
}
private fun handleRouting(packet: MeshPacket, dataPacket: DataPacket) {
val payload = packet.decoded?.payload ?: return
val r = Routing.ADAPTER.decodeOrNull(payload, Logger) ?: return
if (r.error_reason == Routing.Error.DUTY_CYCLE_LIMIT) {
scope.launch {
serviceRepository.setErrorMessage(getStringSuspend(Res.string.error_duty_cycle), Severity.Warn)
}
}
handleAckNak(
packet.decoded?.request_id ?: 0,
nodeManager.toNodeID(packet.from),
r.error_reason?.value ?: 0,
dataPacket.relayNode,
)
packet.decoded?.request_id?.let { packetHandler.removeResponse(it, complete = true) }
}
@Suppress("CyclomaticComplexMethod", "LongMethod")
private fun handleAckNak(requestId: Int, fromId: String, routingError: Int, relayNode: Int?) {
scope.handledLaunch {
val isAck = routingError == Routing.Error.NONE.value
val p = packetRepository.value.getPacketByPacketId(requestId)
val reaction = packetRepository.value.getReactionByPacketId(requestId)
@Suppress("MaxLineLength")
Logger.d {
val statusInfo = "status=${p?.status ?: reaction?.status}"
"[ackNak] req=$requestId routeErr=$routingError isAck=$isAck " +
"packetId=${p?.id ?: reaction?.packetId} dataId=${p?.id} $statusInfo"
}
val m =
when {
isAck && (fromId == p?.to || fromId == reaction?.to) -> MessageStatus.RECEIVED
isAck -> MessageStatus.DELIVERED
else -> MessageStatus.ERROR
}
if (p != null && p.status != MessageStatus.RECEIVED) {
val updatedPacket =
p.copy(status = m, relays = if (isAck) p.relays + 1 else p.relays, relayNode = relayNode)
packetRepository.value.update(updatedPacket, routingError = routingError)
}
reaction?.let { r ->
if (r.status != MessageStatus.RECEIVED) {
var updated = r.copy(status = m, routingError = routingError, relayNode = relayNode)
if (isAck) {
updated = updated.copy(relays = updated.relays + 1)
}
packetRepository.value.updateReaction(updated)
}
}
serviceBroadcasts.broadcastMessageStatus(requestId, m)
}
}
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 contactId = if (fromLocal || toBroadcast) dataPacket.to else dataPacket.from
// contactKey: unique contact key filter (channel)+(nodeId)
val contactKey = "${dataPacket.channel}$contactId"
scope.handledLaunch {
packetRepository.value.apply {
// Check for duplicates before inserting
val existingPackets = findPacketsWithId(dataPacket.id)
if (existingPackets.isNotEmpty()) {
Logger.d {
"Skipping duplicate packet: packetId=${dataPacket.id} from=${dataPacket.from} " +
"to=${dataPacket.to} contactKey=$contactKey" +
" (already have ${existingPackets.size} packet(s))"
}
return@handledLaunch
}
// Check if message should be filtered
val isFiltered = shouldFilterMessage(dataPacket, contactKey)
insert(
dataPacket,
myNodeNum,
contactKey,
nowMillis,
read = fromLocal || isFiltered,
filtered = isFiltered,
)
if (!isFiltered) {
handlePacketNotification(dataPacket, contactKey, updateNotification)
}
}
}
}
@Suppress("ReturnCount")
private suspend fun PacketRepository.shouldFilterMessage(dataPacket: DataPacket, contactKey: String): Boolean {
val isIgnored = nodeManager.nodeDBbyID[dataPacket.from]?.isIgnored == true
if (isIgnored) return true
if (dataPacket.dataType != PortNum.TEXT_MESSAGE_APP.value) return false
val isFilteringDisabled = getContactSettings(contactKey).filteringDisabled
return messageFilter.shouldFilter(dataPacket.text.orEmpty(), isFilteringDisabled)
}
private suspend fun handlePacketNotification(
dataPacket: DataPacket,
contactKey: String,
updateNotification: Boolean,
) {
val conversationMuted = packetRepository.value.getContactSettings(contactKey).isMuted
val nodeMuted = nodeManager.nodeDBbyID[dataPacket.from]?.isMuted == true
val isSilent = conversationMuted || nodeMuted
if (dataPacket.dataType == PortNum.ALERT_APP.value && !isSilent) {
scope.launch {
notificationManager.dispatch(
Notification(
title = getSenderName(dataPacket),
message = dataPacket.alert ?: getStringSuspend(Res.string.critical_alert),
category = Notification.Category.Alert,
contactKey = contactKey,
),
)
}
} else if (updateNotification && !isSilent) {
scope.handledLaunch { updateNotification(contactKey, dataPacket, isSilent) }
}
}
private suspend fun getSenderName(packet: DataPacket): String {
if (packet.from == DataPacket.ID_LOCAL) {
val myId = nodeManager.getMyId()
return nodeManager.nodeDBbyID[myId]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username)
}
return nodeManager.nodeDBbyID[packet.from]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username)
}
private suspend fun updateNotification(contactKey: String, dataPacket: DataPacket, isSilent: Boolean) {
when (dataPacket.dataType) {
PortNum.TEXT_MESSAGE_APP.value -> {
val message = dataPacket.text!!
val channelName =
if (dataPacket.to == DataPacket.ID_BROADCAST) {
radioConfigRepository.channelSetFlow.first().settings.getOrNull(dataPacket.channel)?.name
} else {
null
}
serviceNotifications.updateMessageNotification(
contactKey,
getSenderName(dataPacket),
message,
dataPacket.to == DataPacket.ID_BROADCAST,
channelName,
isSilent,
)
}
PortNum.WAYPOINT_APP.value -> {
val message = getStringSuspend(Res.string.waypoint_received, dataPacket.waypoint!!.name)
notificationManager.dispatch(
Notification(
title = getSenderName(dataPacket),
message = message,
category = Notification.Category.Message,
contactKey = contactKey,
isSilent = isSilent,
),
)
}
else -> return
}
}
@Suppress("LongMethod", "KotlinConstantConditions")
private fun rememberReaction(packet: MeshPacket) = scope.handledLaunch {
val decoded = packet.decoded ?: return@handledLaunch
val emoji = decoded.payload.toByteArray().decodeToString()
val fromId = nodeManager.toNodeID(packet.from)
val fromNode = nodeManager.nodeDBbyNodeNum[packet.from] ?: Node(num = packet.from)
val toNode = nodeManager.nodeDBbyNodeNum[packet.to] ?: Node(num = packet.to)
val reaction =
Reaction(
replyId = decoded.reply_id,
user = fromNode.user,
emoji = emoji,
timestamp = nowMillis,
snr = packet.rx_snr,
rssi = packet.rx_rssi,
hopsAway =
if (packet.hop_start == 0 || packet.hop_limit > packet.hop_start) {
HOPS_AWAY_UNAVAILABLE
} else {
packet.hop_start - packet.hop_limit
},
packetId = packet.id,
status = MessageStatus.RECEIVED,
to = toNode.user.id,
channel = packet.channel,
)
// Check for duplicates before inserting
val existingReactions = packetRepository.value.findReactionsWithId(packet.id)
if (existingReactions.isNotEmpty()) {
Logger.d {
"Skipping duplicate reaction: packetId=${packet.id} replyId=${decoded.reply_id} " +
"from=$fromId emoji=$emoji (already have ${existingReactions.size} reaction(s))"
}
return@handledLaunch
}
packetRepository.value.insertReaction(reaction, nodeManager.myNodeNum.value ?: 0)
// Find the original packet to get the contactKey
packetRepository.value.getPacketByPacketId(decoded.reply_id)?.let { originalPacket ->
// Skip notification if the original message was filtered
val targetId =
if (originalPacket.from == DataPacket.ID_LOCAL) originalPacket.to else originalPacket.from
val contactKey = "${originalPacket.channel}$targetId"
val conversationMuted = packetRepository.value.getContactSettings(contactKey).isMuted
val nodeMuted = nodeManager.nodeDBbyID[fromId]?.isMuted == true
val isSilent = conversationMuted || nodeMuted
if (!isSilent) {
val channelName =
if (originalPacket.to == DataPacket.ID_BROADCAST) {
radioConfigRepository.channelSetFlow
.first()
.settings
.getOrNull(originalPacket.channel)
?.name
} else {
null
}
serviceNotifications.updateReactionNotification(
contactKey,
getSenderName(dataMapper.toDataPacket(packet)!!),
emoji,
originalPacket.to == DataPacket.ID_BROADCAST,
channelName,
isSilent,
)
}
}
}
companion object {
private const val HOPS_AWAY_UNAVAILABLE = -1
}
}

View File

@@ -1,66 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.core.repository.XModemManager
/** Implementation of [MeshRouter] that orchestrates specialized mesh packet handlers. */
@Suppress("LongParameterList")
@Single
class MeshRouterImpl(
private val dataHandlerLazy: Lazy<MeshDataHandler>,
private val configHandlerLazy: Lazy<MeshConfigHandler>,
private val tracerouteHandlerLazy: Lazy<TracerouteHandler>,
private val neighborInfoHandlerLazy: Lazy<NeighborInfoHandler>,
private val configFlowManagerLazy: Lazy<MeshConfigFlowManager>,
private val mqttManagerLazy: Lazy<MqttManager>,
private val actionHandlerLazy: Lazy<MeshActionHandler>,
private val xmodemManagerLazy: Lazy<XModemManager>,
) : MeshRouter {
override val dataHandler: MeshDataHandler
get() = dataHandlerLazy.value
override val configHandler: MeshConfigHandler
get() = configHandlerLazy.value
override val tracerouteHandler: TracerouteHandler
get() = tracerouteHandlerLazy.value
override val neighborInfoHandler: NeighborInfoHandler
get() = neighborInfoHandlerLazy.value
override val configFlowManager: MeshConfigFlowManager
get() = configFlowManagerLazy.value
override val mqttManager: MqttManager
get() = mqttManagerLazy.value
override val actionHandler: MeshActionHandler
get() = actionHandlerLazy.value
override val xmodemManager: XModemManager
get() = xmodemManagerLazy.value
}

View File

@@ -0,0 +1,195 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.MessageFilter
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.critical_alert
import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.resources.waypoint_received
import org.meshtastic.proto.PortNum
/**
* SDK-era implementation of [MeshDataHandler] focused on message persistence and notifications.
*
* The full packet-routing logic (handleReceivedData) is no longer needed — the SDK's packet flow
* is consumed directly by VMs and SdkStateBridge. This class retains only [rememberDataPacket]
* which is called by [StoreForwardPacketHandlerImpl] to persist forwarded messages.
*/
@Single
class MessagePersistenceHandler(
private val nodeManager: NodeManager,
private val packetRepository: Lazy<PacketRepository>,
private val notificationManager: NotificationManager,
private val serviceNotifications: MeshServiceNotifications,
private val radioConfigRepository: RadioConfigRepository,
private val messageFilter: MessageFilter,
@Named("ServiceScope") private val scope: CoroutineScope,
) : MeshDataHandler {
private val rememberDataType =
setOf(
PortNum.TEXT_MESSAGE_APP.value,
PortNum.ALERT_APP.value,
PortNum.WAYPOINT_APP.value,
PortNum.NODE_STATUS_APP.value,
)
override fun handleReceivedData(
packet: org.meshtastic.proto.MeshPacket,
myNodeNum: Int,
logUuid: String?,
logInsertJob: kotlinx.coroutines.Job?,
) {
// No-op: Incoming packet routing is handled by SdkStateBridge / VM packet observers.
// This method exists only to satisfy the MeshDataHandler interface contract.
}
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 contactId = if (fromLocal || toBroadcast) dataPacket.to else dataPacket.from
val contactKey = "${dataPacket.channel}$contactId"
scope.handledLaunch {
packetRepository.value.apply {
val existingPackets = findPacketsWithId(dataPacket.id)
if (existingPackets.isNotEmpty()) {
Logger.d {
"Skipping duplicate packet: packetId=${dataPacket.id} from=${dataPacket.from} " +
"to=${dataPacket.to} contactKey=$contactKey" +
" (already have ${existingPackets.size} packet(s))"
}
return@handledLaunch
}
val isFiltered = shouldFilterMessage(dataPacket, contactKey)
insert(
dataPacket,
myNodeNum,
contactKey,
nowMillis,
read = fromLocal || isFiltered,
filtered = isFiltered,
)
if (!isFiltered) {
handlePacketNotification(dataPacket, contactKey, updateNotification)
}
}
}
}
@Suppress("ReturnCount")
private suspend fun PacketRepository.shouldFilterMessage(dataPacket: DataPacket, contactKey: String): Boolean {
val isIgnored = nodeManager.nodeDBbyID[dataPacket.from]?.isIgnored == true
if (isIgnored) return true
if (dataPacket.dataType != PortNum.TEXT_MESSAGE_APP.value) return false
val isFilteringDisabled = getContactSettings(contactKey).filteringDisabled
return messageFilter.shouldFilter(dataPacket.text.orEmpty(), isFilteringDisabled)
}
private suspend fun handlePacketNotification(
dataPacket: DataPacket,
contactKey: String,
updateNotification: Boolean,
) {
val conversationMuted = packetRepository.value.getContactSettings(contactKey).isMuted
val nodeMuted = nodeManager.nodeDBbyID[dataPacket.from]?.isMuted == true
val isSilent = conversationMuted || nodeMuted
if (dataPacket.dataType == PortNum.ALERT_APP.value && !isSilent) {
scope.launch {
notificationManager.dispatch(
Notification(
title = getSenderName(dataPacket),
message = dataPacket.alert ?: getStringSuspend(Res.string.critical_alert),
category = Notification.Category.Alert,
contactKey = contactKey,
),
)
}
} else if (updateNotification && !isSilent) {
scope.handledLaunch { updateNotification(contactKey, dataPacket, isSilent) }
}
}
private suspend fun getSenderName(packet: DataPacket): String {
if (packet.from == DataPacket.ID_LOCAL) {
val myId = nodeManager.getMyId()
return nodeManager.nodeDBbyID[myId]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username)
}
return nodeManager.nodeDBbyID[packet.from]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username)
}
private suspend fun updateNotification(contactKey: String, dataPacket: DataPacket, isSilent: Boolean) {
when (dataPacket.dataType) {
PortNum.TEXT_MESSAGE_APP.value -> {
val message = dataPacket.text!!
val channelName =
if (dataPacket.to == DataPacket.ID_BROADCAST) {
radioConfigRepository.channelSetFlow.first().settings.getOrNull(dataPacket.channel)?.name
} else {
null
}
serviceNotifications.updateMessageNotification(
contactKey,
getSenderName(dataPacket),
message,
dataPacket.to == DataPacket.ID_BROADCAST,
channelName,
isSilent,
)
}
PortNum.WAYPOINT_APP.value -> {
val message = getStringSuspend(Res.string.waypoint_received, dataPacket.waypoint!!.name)
notificationManager.dispatch(
Notification(
title = getSenderName(dataPacket),
message = message,
category = Notification.Category.Message,
contactKey = contactKey,
isSilent = isSilent,
),
)
}
else -> return
}
}
}

View File

@@ -26,7 +26,6 @@ import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo
@@ -35,7 +34,6 @@ import org.meshtastic.proto.NeighborInfo
class NeighborInfoHandlerImpl(
private val nodeManager: NodeManager,
private val serviceRepository: ServiceRepository,
private val serviceBroadcasts: ServiceBroadcasts,
private val nodeRepository: NodeRepository,
) : NeighborInfoHandler {
@@ -59,7 +57,7 @@ class NeighborInfoHandlerImpl(
}
// Update Node DB
nodeManager.nodeDBbyNodeNum[from]?.let { serviceBroadcasts.broadcastNodeChange(it) }
nodeManager.nodeDBbyNodeNum[from]?.let { /* SDK client.nodes is canonical source */ }
// Format for UI response
val requestId = packet.decoded?.request_id ?: 0

View File

@@ -41,7 +41,6 @@ import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.data.repository.SdkNodeRepositoryImpl
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.getStringSuspend
import org.meshtastic.core.resources.new_node_seen
@@ -60,7 +59,6 @@ import org.meshtastic.proto.Position as ProtoPosition
@Single(binds = [NodeManager::class, NodeIdLookup::class])
class NodeManagerImpl(
private val nodeRepository: NodeRepository,
private val serviceBroadcasts: ServiceBroadcasts,
private val notificationManager: NotificationManager,
@Named("ServiceScope") private val scope: CoroutineScope,
) : NodeManager {
@@ -197,9 +195,7 @@ class NodeManagerImpl(
scope.handledLaunch { nodeRepository.upsert(result) }
}
if (withBroadcast) {
serviceBroadcasts.broadcastNodeChange(result)
}
}
override fun handleReceivedUser(fromNum: Int, p: User, channel: Int, manuallyVerified: Boolean) {

View File

@@ -1,295 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.asDeferred
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.RadioNotConnectedException
import org.meshtastic.core.model.util.toOneLineString
import org.meshtastic.core.model.util.toPIIString
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.QueueStatus
import org.meshtastic.proto.ToRadio
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.uuid.Uuid
@Suppress("TooManyFunctions")
@Single
class PacketHandlerImpl(
private val packetRepository: Lazy<PacketRepository>,
private val serviceBroadcasts: ServiceBroadcasts,
private val radioInterfaceService: RadioInterfaceService,
private val meshLogRepository: Lazy<MeshLogRepository>,
private val serviceRepository: ServiceRepository,
@Named("ServiceScope") private val scope: CoroutineScope,
) : PacketHandler {
companion object {
private val TIMEOUT = 5.seconds
}
private var queueJob: Job? = null
private val queueMutex = Mutex()
private val queuedPackets = mutableListOf<MeshPacket>()
// Unbounded channel preserves FIFO ordering of fire-and-forget sendToRadio(MeshPacket)
// calls. The non-suspend entry point does trySend (always succeeds for UNLIMITED) and
// a single consumer coroutine enqueues packets under queueMutex in arrival order.
private val outboundChannel = Channel<MeshPacket>(Channel.UNLIMITED)
// Set to true by stopPacketQueue() under queueMutex. Checked by startPacketQueueLocked()
// and the queue processor's finally block to prevent restarting a stopped queue.
private var queueStopped = false
private val responseMutex = Mutex()
private val queueResponse = mutableMapOf<Int, CompletableDeferred<Boolean>>()
init {
// Single consumer serializes enqueues from the non-suspend sendToRadio(MeshPacket)
// entry point, preserving FIFO across rapid concurrent callers.
scope.launch {
outboundChannel.consumeAsFlow().collect { packet ->
queueMutex.withLock {
queueStopped = false // Allow queue to resume after a disconnect/reconnect cycle.
queuedPackets.add(packet)
startPacketQueueLocked()
}
}
}
}
override fun sendToRadio(p: ToRadio) {
Logger.d { "Sending to radio ${p.toPIIString()}" }
val b = p.encode()
radioInterfaceService.sendToRadio(b)
p.packet?.id?.let { changeStatus(it, MessageStatus.ENROUTE) }
val packet = p.packet
if (packet?.decoded != null) {
val packetToSave =
MeshLog(
uuid = Uuid.random().toString(),
message_type = "Packet",
received_date = nowMillis,
raw_message = packet.toString(),
fromNum = MeshLog.NODE_NUM_LOCAL,
portNum = packet.decoded?.portnum?.value ?: 0,
fromRadio = FromRadio(packet = packet),
)
insertMeshLog(packetToSave)
}
}
override fun sendToRadio(packet: MeshPacket) {
// Non-suspend entry point — order-preserving via unbounded channel drained by
// a single consumer coroutine. trySend on UNLIMITED never fails for capacity.
outboundChannel.trySend(packet)
}
@Suppress("TooGenericExceptionCaught", "SwallowedException")
override suspend fun sendToRadioAndAwait(packet: MeshPacket): Boolean {
// Pre-register the deferred so the queue processor and QueueStatus handler
// can find it immediately — no polling required.
val deferred = CompletableDeferred<Boolean>()
responseMutex.withLock { queueResponse[packet.id] = deferred }
queueMutex.withLock {
queueStopped = false // Allow queue to resume after a disconnect/reconnect cycle.
queuedPackets.add(packet)
startPacketQueueLocked()
}
return try {
withTimeout(TIMEOUT) { deferred.await() }
} catch (e: TimeoutCancellationException) {
Logger.d { "sendToRadioAndAwait packet id=${packet.id.toUInt()} timeout" }
false
} catch (e: CancellationException) {
throw e // Preserve structured concurrency cancellation propagation.
} catch (e: Exception) {
Logger.d { "sendToRadioAndAwait packet id=${packet.id.toUInt()} failed: ${e.message}" }
false
} finally {
responseMutex.withLock { queueResponse.remove(packet.id) }
}
}
override fun stopPacketQueue() {
// Run async so callers (non-suspend) don't block, but all mutations are
// serialized under the same mutexes used by the queue processor and senders.
scope.launch {
Logger.i { "Stopping packet queueJob" }
queueMutex.withLock {
queueStopped = true
queueJob?.cancel()
queueJob = null
queuedPackets.clear()
}
responseMutex.withLock {
queueResponse.values.forEach { if (!it.isCompleted) it.complete(false) }
queueResponse.clear()
}
}
}
override fun handleQueueStatus(queueStatus: QueueStatus) {
Logger.d { "[queueStatus] ${queueStatus.toOneLineString()}" }
val (success, isFull, requestId) = with(queueStatus) { Triple(res == 0, free == 0, mesh_packet_id) }
if (success && isFull) return
scope.launch {
responseMutex.withLock {
if (requestId != 0) {
queueResponse.remove(requestId)?.complete(success)
} else {
queueResponse.values.firstOrNull { !it.isCompleted }?.complete(success)
}
}
}
}
override fun removeResponse(dataRequestId: Int, complete: Boolean) {
scope.launch { responseMutex.withLock { queueResponse.remove(dataRequestId)?.complete(complete) } }
}
/**
* Starts the packet queue processor. Must be called while holding [queueMutex] to ensure the check-then-start is
* atomic — preventing two concurrent callers from launching duplicate processors.
*/
private fun startPacketQueueLocked() {
if (queueStopped) return
if (queueJob?.isActive == true) return
queueJob =
scope.handledLaunch {
try {
while (serviceRepository.connectionState.value == ConnectionState.Connected) {
val packet = queueMutex.withLock { queuedPackets.removeFirstOrNull() } ?: break
@Suppress("TooGenericExceptionCaught", "SwallowedException")
try {
val response = sendPacket(packet)
Logger.d { "queueJob packet id=${packet.id.toUInt()} waiting" }
val success = withTimeout(TIMEOUT) { response.await() }
Logger.d { "queueJob packet id=${packet.id.toUInt()} success $success" }
} catch (e: TimeoutCancellationException) {
Logger.d { "queueJob packet id=${packet.id.toUInt()} timeout" }
// Clean up the deferred for this packet. sendToRadioAndAwait callers
// also clean up in their own finally block (idempotent remove).
responseMutex.withLock { queueResponse.remove(packet.id) }
} catch (e: CancellationException) {
throw e // Preserve structured concurrency cancellation propagation.
} catch (e: Exception) {
Logger.d { "queueJob packet id=${packet.id.toUInt()} failed" }
responseMutex.withLock { queueResponse.remove(packet.id) }
}
// Deferred cleanup is now handled in the catch blocks above.
// handleQueueStatus (normal success) and stopPacketQueue (bulk cleanup)
// also remove entries, and these removals are idempotent.
}
} finally {
// Hold queueMutex so that clearing queueJob and the restart decision are
// atomic with respect to new senders calling startPacketQueueLocked().
queueMutex.withLock {
queueJob = null
if (!queueStopped && queuedPackets.isNotEmpty()) {
startPacketQueueLocked()
}
}
}
}
}
private fun changeStatus(packetId: Int, m: MessageStatus) = scope.handledLaunch {
if (packetId != 0) {
getDataPacketById(packetId)?.let { p ->
if (p.status == m) return@handledLaunch
packetRepository.value.updateMessageStatus(p, m)
serviceBroadcasts.broadcastMessageStatus(packetId, m)
}
}
}
private suspend fun getDataPacketById(packetId: Int): DataPacket? = withTimeoutOrNull(1.seconds) {
var dataPacket: DataPacket? = null
while (dataPacket == null) {
dataPacket = packetRepository.value.getPacketById(packetId)
if (dataPacket == null) delay(100.milliseconds)
}
dataPacket
}
@Suppress("TooGenericExceptionCaught")
private suspend fun sendPacket(packet: MeshPacket): Deferred<Boolean> {
// Reuse a deferred pre-registered by sendToRadioAndAwait, or create a new one.
val deferred = responseMutex.withLock { queueResponse.getOrPut(packet.id) { CompletableDeferred() } }
try {
if (serviceRepository.connectionState.value != ConnectionState.Connected) {
throw RadioNotConnectedException()
}
sendToRadio(ToRadio(packet = packet))
} catch (ex: RadioNotConnectedException) {
Logger.w(ex) { "sendToRadio skipped: Not connected to radio" }
deferred.complete(false)
} catch (ex: Exception) {
Logger.e(ex) { "sendToRadio error: ${ex.message}" }
deferred.complete(false)
}
// Return a read-only Deferred view (kotlinx.coroutines 1.11+) so callers can await it
// without being able to complete the underlying CompletableDeferred; cancellation is
// still exposed via Deferred/Job.
return deferred.asDeferred()
}
private fun insertMeshLog(packetToSave: MeshLog) {
scope.handledLaunch {
Logger.d {
"insert: ${packetToSave.message_type} = " +
"${packetToSave.raw_message.toOneLineString()} from=${packetToSave.fromNum}"
}
meshLogRepository.value.insert(packetToSave)
}
}
}

View File

@@ -30,7 +30,6 @@ import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.StoreForwardPacketHandler
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
@@ -43,7 +42,6 @@ import kotlin.time.Duration.Companion.milliseconds
class StoreForwardPacketHandlerImpl(
private val nodeManager: NodeManager,
private val packetRepository: Lazy<PacketRepository>,
private val serviceBroadcasts: ServiceBroadcasts,
private val historyManager: HistoryManager,
private val dataHandler: Lazy<MeshDataHandler>,
@Named("ServiceScope") private val scope: CoroutineScope,
@@ -125,7 +123,6 @@ class StoreForwardPacketHandlerImpl(
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
myNodeNum = nodeManager.myNodeNum.value ?: 0,
)
serviceBroadcasts.broadcastMessageStatus(sfpp.encapsulated_id, status)
}
}

View File

@@ -28,7 +28,6 @@ import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.toOneLiner
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationManager
@@ -48,7 +47,6 @@ import kotlin.time.Duration.Companion.milliseconds
@Single
class TelemetryPacketHandlerImpl(
private val nodeManager: NodeManager,
private val connectionManager: Lazy<MeshConnectionManager>,
private val notificationManager: NotificationManager,
@Named("ServiceScope") private val scope: CoroutineScope,
) : TelemetryPacketHandler {
@@ -66,9 +64,8 @@ class TelemetryPacketHandlerImpl(
Logger.d { "Telemetry from ${packet.from}: ${Telemetry.ADAPTER.toOneLiner(t)}" }
val fromNum = packet.from
val isRemote = (fromNum != myNodeNum)
if (!isRemote) {
connectionManager.value.updateTelemetry(t)
}
// Note: Local telemetry notification update was previously handled by
// MeshConnectionManager.updateTelemetry(), now managed via SDK flows.
nodeManager.updateNode(fromNum) { node: Node ->
val metrics = t.device_metrics

View File

@@ -0,0 +1,39 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.radio
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.sdk.RadioClient
/**
* Platform-agnostic accessor for the active [RadioClient] instance.
*
* Implemented by platform-specific providers (Android's `RadioClientProvider`, Desktop's
* `DesktopRadioClientProvider`) that handle transport creation and lifecycle. The shared
* [SdkRadioController] and [SdkStateBridge] depend on this interface rather than any
* concrete provider.
*/
interface RadioClientAccessor {
/** Active [RadioClient], or `null` when disconnected or between connections. */
val client: StateFlow<RadioClient?>
/** Tear down the existing client and rebuild + connect using the current saved address. */
fun rebuildAndConnectAsync()
/** Gracefully disconnect and release the active SDK radio client. */
fun disconnect()
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.radio
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.QueueStatus
import org.meshtastic.proto.ToRadio
/**
* SDK-backed [PacketHandler] that sends packets through the active [RadioClient].
*
* Replaces the monolithic [PacketHandlerImpl] which routed through the old
* `RadioInterfaceService.sendToRadio()` pipeline. This thin implementation only supports the
* `sendToRadio` surface needed by MQTT, XModem, and History managers.
*
* Queue management (QueueStatus, packet ordering) is handled internally by the SDK engine.
*/
@Single(binds = [PacketHandler::class])
class SdkPacketHandler(
private val accessor: RadioClientAccessor,
private val dispatchers: CoroutineDispatchers,
) : PacketHandler {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.io)
override fun sendToRadio(p: ToRadio) {
val packet = p.packet
if (packet != null) {
// Regular MeshPacket — route through the tracked send path.
sendToRadio(packet)
return
}
// Non-packet ToRadio (mqttClientProxyMessage, xmodemPacket) — send as raw frame.
val client = accessor.client.value ?: run {
Logger.w { "SdkPacketHandler: no client, dropping non-packet ToRadio" }
return
}
scope.launch {
runCatching { client.sendRaw(p) }
.onFailure { e -> Logger.w(e) { "SdkPacketHandler: sendRaw(ToRadio) failed" } }
}
}
override fun sendToRadio(packet: MeshPacket) {
val client = accessor.client.value ?: run {
Logger.w { "SdkPacketHandler: no client, dropping packet id=${packet.id}" }
return
}
client.send(packet)
}
override suspend fun sendToRadioAndAwait(packet: MeshPacket): Boolean {
val client = accessor.client.value ?: return false
return runCatching { client.send(packet) }.isSuccess
}
override fun handleQueueStatus(queueStatus: QueueStatus) {
// Queue management is internal to the SDK engine; no-op.
}
override fun removeResponse(dataRequestId: Int, complete: Boolean) {
// Response tracking is internal to the SDK engine; no-op.
}
override fun stopPacketQueue() {
// Queue management is internal to the SDK engine; no-op.
}
}

View File

@@ -14,11 +14,11 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.radio
package org.meshtastic.core.data.radio
import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.coroutines.flow.StateFlow
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Single
import org.meshtastic.core.model.ConnectionState
@@ -42,37 +42,35 @@ import org.meshtastic.sdk.AdminResult
import org.meshtastic.sdk.ChannelIndex
import org.meshtastic.sdk.NodeId
import org.meshtastic.sdk.RadioClient
import java.util.concurrent.atomic.AtomicInteger
/**
* [RadioController] implementation that delegates all operations through the meshtastic-sdk.
* Shared KMP [RadioController] implementation that delegates all operations through the meshtastic-sdk.
*
* This replaces [org.meshtastic.core.service.AndroidRadioControllerImpl] in the hard-cutover POC. Feature modules
* continue injecting [RadioController] and get SDK-backed behavior without code changes.
* Feature modules inject [RadioController] and get SDK-backed behavior without needing platform-specific code.
*
* **Command dispatch:** All admin, telemetry, and routing operations go through [RadioClient.admin],
* [RadioClient.telemetry], and [RadioClient.routing] respectively.
*
* **State distribution:** Handled separately by [SdkStateBridge], which feeds SDK flows back into
* **State distribution:** Handled by [SdkStateBridge], which feeds SDK flows into
* [ServiceRepository] and [org.meshtastic.core.repository.NodeManager].
*/
@Single(binds = [RadioController::class])
@Suppress("TooManyFunctions", "LongParameterList")
class SdkRadioControllerImpl(
private val provider: RadioClientProvider,
class SdkRadioController(
private val accessor: RadioClientAccessor,
private val serviceRepository: ServiceRepository,
private val nodeRepository: NodeRepository,
private val locationManager: MeshLocationManager,
) : RadioController {
private val packetIdCounter = AtomicInteger(1)
private val packetIdCounter = atomic(1)
private val client: RadioClient?
get() = provider.client.value
get() = accessor.client.value
private fun requireClient(): RadioClient {
return client ?: run {
Logger.w { "SdkRadioControllerImpl: no active RadioClient" }
Logger.w { "SdkRadioController: no active RadioClient" }
throw IllegalStateException("RadioClient not connected")
}
}
@@ -105,7 +103,7 @@ class SdkRadioControllerImpl(
channel = packet.channel,
decoded = Data(
portnum = PortNum.fromValue(packet.dataType) ?: PortNum.UNKNOWN_APP,
payload = packet.bytes ?: ByteString.EMPTY,
payload = packet.bytes ?: okio.ByteString.EMPTY,
want_response = false,
),
)
@@ -272,7 +270,12 @@ class SdkRadioControllerImpl(
if (isLocalNode(destNum)) {
c.admin.getCannedMessages()
} else {
sendRemoteAdmin(c, destNum, AdminMessage(get_canned_message_module_messages_request = true), wantResponse = true)
sendRemoteAdmin(
c,
destNum,
AdminMessage(get_canned_message_module_messages_request = true),
wantResponse = true,
)
}
}
@@ -281,7 +284,12 @@ class SdkRadioControllerImpl(
if (isLocalNode(destNum)) {
c.admin.getDeviceConnectionStatus()
} else {
sendRemoteAdmin(c, destNum, AdminMessage(get_device_connection_status_request = true), wantResponse = true)
sendRemoteAdmin(
c,
destNum,
AdminMessage(get_device_connection_status_request = true),
wantResponse = true,
)
}
}
@@ -366,7 +374,6 @@ class SdkRadioControllerImpl(
override suspend fun requestUserInfo(destNum: Int) {
val c = client ?: return
// Send an empty NODEINFO_APP packet with want_response to request user info
c.send(
MeshPacket(
to = destNum,
@@ -388,7 +395,6 @@ class SdkRadioControllerImpl(
override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {
val c = requireClient()
val node = NodeId(destNum)
// TelemetryType enum values: 0=DEVICE, 1=ENVIRONMENT, 2=AIR_QUALITY, 3=POWER, 4=LOCAL_STATS, 5=HEALTH
when (typeValue) {
0 -> c.telemetry.requestDevice(node)
1 -> c.telemetry.requestEnvironment(node)
@@ -411,7 +417,6 @@ class SdkRadioControllerImpl(
override suspend fun beginEditSettings(destNum: Int) {
val c = client ?: return
// Send raw begin_edit_settings admin message for compatibility with the split begin/commit pattern
val msg = AdminMessage(begin_edit_settings = true)
val target = if (isLocalNode(destNum)) NodeId(c.ownNode.value?.num ?: 0) else NodeId(destNum)
sendRemoteAdmin(c, target.raw, msg)
@@ -429,7 +434,7 @@ class SdkRadioControllerImpl(
override fun getPacketId(): Int = packetIdCounter.getAndIncrement()
override fun startProvideLocation() {
// Location provision is managed at the app level; no-op until bridge wires it
// Location provision is managed at the app level; no-op here
}
override fun stopProvideLocation() {
@@ -437,8 +442,7 @@ class SdkRadioControllerImpl(
}
override fun setDeviceAddress(address: String) {
// Changing device address requires rebuilding the SDK client connection
provider.rebuildAndConnectAsync()
accessor.rebuildAndConnectAsync()
}
// ── Private helpers ─────────────────────────────────────────────────────
@@ -449,10 +453,6 @@ class SdkRadioControllerImpl(
return destNum == ownNum
}
/**
* Sends a raw admin message to a remote node via the SDK's send path.
* Used for remote-admin operations where destNum != local node.
*/
private suspend fun sendRemoteAdmin(
c: RadioClient,
destNum: Int,

View File

@@ -14,24 +14,29 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.radio
package org.meshtastic.core.data.radio
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Single
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.service.ServiceAction
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Data
@@ -52,28 +57,23 @@ import org.meshtastic.sdk.NodeId
* and [NodeManager] so that existing feature-module UI code (which observes those repositories)
* continues to work without modification.
*
* **Node state:** The SDK's [NodeChange] flow provides fully-updated [NodeInfo] instances that
* already include position, telemetry, and user changes. No manual packet decoding is needed.
*
* **Packets:** Raw [MeshPacket]s are forwarded to [ServiceRepository.emitMeshPacket] for
* consumers that need them (RadioConfigViewModel admin responses, TAK integration).
*
* **ServiceActions:** Handled inline via SDK [AdminApi] eliminates the old
* MeshServiceOrchestrator MeshActionHandler CommandSender dispatch chain.
*
* **Lifecycle:** Created as a Koin `@Single`. Automatically subscribes to [RadioClientProvider.client]
* **Lifecycle:** Created as a Koin `@Single`. Automatically subscribes to [RadioClientAccessor.client]
* and starts/stops collection as clients come and go.
*/
@Single
@Suppress("TooManyFunctions")
class SdkStateBridge(
private val provider: RadioClientProvider,
private val accessor: RadioClientAccessor,
private val serviceRepository: ServiceRepository,
private val nodeManager: NodeManager,
private val packetRepository: Lazy<PacketRepository>,
private val locationManager: MeshLocationManager,
private val uiPrefs: UiPrefs,
private val radioController: RadioController,
private val dispatchers: CoroutineDispatchers,
) {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
private var locationJob: Job? = null
init {
startBridge()
@@ -82,13 +82,13 @@ class SdkStateBridge(
private fun startBridge() {
// ── Connection state ────────────────────────────────────────────────
provider.client
accessor.client
.flatMapLatest { client -> client?.connection ?: flowOf(SdkConnectionState.Disconnected) }
.onEach { sdkState -> serviceRepository.setConnectionState(mapConnectionState(sdkState)) }
.launchIn(scope)
// ── Node updates (position, telemetry, user all included in NodeInfo) ─
provider.client
accessor.client
.flatMapLatest { client -> client?.nodes ?: flowOf() }
.onEach { change ->
when (change) {
@@ -107,19 +107,19 @@ class SdkStateBridge(
.launchIn(scope)
// ── Own node identity ───────────────────────────────────────────────
provider.client
accessor.client
.flatMapLatest { client -> client?.ownNode ?: flowOf(null) }
.onEach { ownNode -> if (ownNode != null) nodeManager.setMyNodeNum(ownNode.num) }
.launchIn(scope)
// ── Raw packet forward (for RadioConfigViewModel + TAK) ─────────────
provider.client
accessor.client
.flatMapLatest { client -> client?.packets ?: flowOf() }
.onEach { packet -> serviceRepository.emitMeshPacket(packet) }
.launchIn(scope)
// ── Events (notifications, security, backpressure) ──────────────────
provider.client
accessor.client
.flatMapLatest { client -> client?.events ?: flowOf() }
.onEach { event ->
when (event) {
@@ -141,13 +141,43 @@ class SdkStateBridge(
.onEach { action -> handleServiceAction(action) }
.launchIn(scope)
// ── Location publishing ─────────────────────────────────────────────
accessor.client
.flatMapLatest { client -> client?.ownNode ?: flowOf(null) }
.onEach { ownNode ->
locationJob?.cancel()
locationJob = null
if (ownNode != null) {
locationJob = uiPrefs.shouldProvideNodeLocation(ownNode.num)
.onEach { shouldProvide ->
if (shouldProvide) {
locationManager.start(scope) { pos ->
scope.launch {
val packet = DataPacket(
bytes = okio.ByteString.of(
*org.meshtastic.proto.Position.ADAPTER.encode(pos),
),
dataType = PortNum.POSITION_APP.value,
)
radioController.sendMessage(packet)
}
}
} else {
locationManager.stop()
}
}
.launchIn(scope)
}
}
.launchIn(scope)
Logger.i { "SdkStateBridge started — SDK owns transport + ServiceAction dispatch" }
}
// ── ServiceAction handling ───────────────────────────────────────────────
private suspend fun handleServiceAction(action: ServiceAction) {
val client = provider.client.value
val client = accessor.client.value
if (client == null) {
Logger.w { "[SdkBridge] ServiceAction ${action::class.simpleName} dropped — no client" }
if (action is ServiceAction.SendContact) action.result.complete(false)

View File

@@ -0,0 +1,82 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.repository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import org.koin.core.annotation.Single
import org.meshtastic.core.database.DatabaseProvider
import org.meshtastic.core.database.entity.NodeMetadataEntity
import org.meshtastic.core.repository.AppMetadataRepository
import org.meshtastic.core.repository.NodeMetadata
@Single(binds = [AppMetadataRepository::class])
class AppMetadataRepositoryImpl(
private val dbManager: DatabaseProvider,
) : AppMetadataRepository {
override val metadataByNum: Flow<Map<Int, NodeMetadata>> =
dbManager.currentDb.flatMapLatest { db -> db.nodeMetadataDao().getAllFlow() }
.map { list -> list.associate { it.num to it.toModel() } }
override suspend fun setFavorite(nodeNum: Int, isFavorite: Boolean) {
ensureExists(nodeNum)
dbManager.withDb { it.nodeMetadataDao().setFavorite(nodeNum, isFavorite) }
}
override suspend fun setIgnored(nodeNum: Int, isIgnored: Boolean) {
ensureExists(nodeNum)
dbManager.withDb { it.nodeMetadataDao().setIgnored(nodeNum, isIgnored) }
}
override suspend fun setMuted(nodeNum: Int, isMuted: Boolean) {
ensureExists(nodeNum)
dbManager.withDb { it.nodeMetadataDao().setMuted(nodeNum, isMuted) }
}
override suspend fun setNotes(nodeNum: Int, notes: String) {
ensureExists(nodeNum)
dbManager.withDb { it.nodeMetadataDao().setNotes(nodeNum, notes) }
}
override suspend fun setManuallyVerified(nodeNum: Int, verified: Boolean) {
ensureExists(nodeNum)
dbManager.withDb { it.nodeMetadataDao().setManuallyVerified(nodeNum, verified) }
}
override suspend fun delete(nodeNum: Int) {
dbManager.withDb { it.nodeMetadataDao().delete(nodeNum) }
}
private suspend fun ensureExists(nodeNum: Int) {
dbManager.withDb { db ->
if (db.nodeMetadataDao().getByNum(nodeNum) == null) {
db.nodeMetadataDao().upsert(NodeMetadataEntity(num = nodeNum))
}
}
}
}
private fun NodeMetadataEntity.toModel() = NodeMetadata(
num = num,
isFavorite = isFavorite,
isIgnored = isIgnored,
isMuted = isMuted,
notes = notes,
manuallyVerified = manuallyVerified,
)

View File

@@ -1,290 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.repository
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.data.datasource.NodeInfoReadDataSource
import org.meshtastic.core.data.datasource.NodeInfoWriteDataSource
import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.datastore.LocalStatsDataSource
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.User
/** Repository for managing node-related data, including hardware info, node database, and identity. */
// @Single — Replaced by SdkNodeRepositoryImpl in SDK mode. Kept for reference/desktop fallback.
@Suppress("TooManyFunctions")
class NodeRepositoryImpl(
@Named("ProcessLifecycle") private val processLifecycle: Lifecycle,
private val nodeInfoReadDataSource: NodeInfoReadDataSource,
private val nodeInfoWriteDataSource: NodeInfoWriteDataSource,
private val dispatchers: CoroutineDispatchers,
private val localStatsDataSource: LocalStatsDataSource,
) : NodeRepository {
/** Hardware info about our local device (can be null if not connected). */
override val myNodeInfo: StateFlow<MyNodeInfo?> =
nodeInfoReadDataSource
.myNodeInfoFlow()
.map { it?.toMyNodeInfo() }
.flowOn(dispatchers.io)
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, null)
private val _ourNodeInfo = MutableStateFlow<Node?>(null)
/** Information about the locally connected node, as seen from the mesh. */
override val ourNodeInfo: StateFlow<Node?>
get() = _ourNodeInfo
private val _myId = MutableStateFlow<String?>(null)
/** The unique userId (hex string) of our local node. */
override val myId: StateFlow<String?>
get() = _myId
/** The latest local stats telemetry received from the locally connected node. */
override val localStats: StateFlow<LocalStats> =
localStatsDataSource.localStatsFlow.stateIn(
processLifecycle.coroutineScope,
SharingStarted.Eagerly,
LocalStats(),
)
/** Update the cached local stats telemetry. */
override fun updateLocalStats(stats: LocalStats) {
processLifecycle.coroutineScope.launch { localStatsDataSource.setLocalStats(stats) }
}
/** A reactive map from nodeNum to [Node] objects, representing the entire mesh. */
override val nodeDBbyNum: StateFlow<Map<Int, Node>> =
nodeInfoReadDataSource
.nodeDBbyNumFlow()
.mapLatest { map -> map.mapValues { (_, it) -> it.toModel() } }
.flowOn(dispatchers.io)
.conflate()
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap())
init {
// Backfill denormalized name columns for existing nodes on startup
processLifecycle.coroutineScope.launch {
processLifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) {
withContext(dispatchers.io) { nodeInfoWriteDataSource.backfillDenormalizedNames() }
}
}
// Keep ourNodeInfo and myId correctly updated based on current connection and node DB
combine(nodeDBbyNum, nodeInfoReadDataSource.myNodeInfoFlow()) { db, info -> info?.myNodeNum?.let { db[it] } }
.onEach { node ->
_ourNodeInfo.value = node
_myId.value = node?.user?.id
}
.launchIn(processLifecycle.coroutineScope)
}
/**
* Returns the node number used for log queries. Maps [nodeNum] to [MeshLog.NODE_NUM_LOCAL] (0) if it is the locally
* connected node.
*/
override fun effectiveLogNodeId(nodeNum: Int): Flow<Int> = nodeInfoReadDataSource
.myNodeInfoFlow()
.map { info -> if (nodeNum == info?.myNodeNum) MeshLog.NODE_NUM_LOCAL else nodeNum }
.distinctUntilChanged()
fun getNodeEntityDBbyNumFlow() =
nodeInfoReadDataSource.nodeDBbyNumFlow().map { map -> map.mapValues { (_, it) -> it.toEntity() } }
/** Returns the [Node] associated with a given [userId]. Falls back to a generic node if not found. */
override fun getNode(userId: String): Node = nodeDBbyNum.value.values.find { it.user.id == userId }
?: Node(num = DataPacket.idToDefaultNodeNum(userId) ?: 0, user = getUser(userId))
/** Returns the [User] info for a given [nodeNum]. */
override fun getUser(nodeNum: Int): User = getUser(DataPacket.nodeNumToDefaultId(nodeNum))
private val last4 = 4
/** Returns the [User] info for a given [userId]. Falls back to a generic user if not found. */
override fun getUser(userId: String): User {
val found = nodeDBbyNum.value.values.find { it.user.id == userId }?.user
if (found != null && found.long_name.isNotBlank() && found.short_name.isNotBlank()) {
return found
}
val fallbackId = userId.takeLast(last4)
val defaultLong =
if (userId == DataPacket.ID_LOCAL) {
ourNodeInfo.value?.user?.long_name?.takeIf { it.isNotBlank() } ?: "Local"
} else {
"Meshtastic $fallbackId"
}
val defaultShort =
if (userId == DataPacket.ID_LOCAL) {
ourNodeInfo.value?.user?.short_name?.takeIf { it.isNotBlank() } ?: "Local"
} else {
fallbackId
}
return found?.copy(
long_name = found.long_name.takeIf { it.isNotBlank() } ?: defaultLong,
short_name = found.short_name.takeIf { it.isNotBlank() } ?: defaultShort,
) ?: User(id = userId, long_name = defaultLong, short_name = defaultShort)
}
/** Returns a flow of nodes filtered and sorted according to the parameters. */
override fun getNodes(
sort: NodeSortOption,
filter: String,
includeUnknown: Boolean,
onlyOnline: Boolean,
onlyDirect: Boolean,
): Flow<List<Node>> = nodeInfoReadDataSource
.getNodesFlow(
sort = sort.sqlValue,
filter = filter,
includeUnknown = includeUnknown,
hopsAwayMax = if (onlyDirect) 0 else -1,
lastHeardMin = if (onlyOnline) onlineTimeThreshold() else -1,
)
.mapLatest { list -> list.map { it.toModel() } }
.flowOn(dispatchers.io)
.conflate()
/** Upserts a [Node] to the database. */
override suspend fun upsert(node: Node) =
withContext(dispatchers.io) { nodeInfoWriteDataSource.upsert(node.toEntity()) }
/** Installs initial configuration data (local info and remote nodes) into the database. */
override suspend fun installConfig(mi: MyNodeInfo, nodes: List<Node>) = withContext(dispatchers.io) {
nodeInfoWriteDataSource.installConfig(mi.toEntity(), nodes.map { it.toEntity() })
}
/** Deletes all nodes from the database, optionally preserving favorites. */
override suspend fun clearNodeDB(preserveFavorites: Boolean) =
withContext(dispatchers.io) { nodeInfoWriteDataSource.clearNodeDB(preserveFavorites) }
/** Clears the local node's connection info. */
override suspend fun clearMyNodeInfo() = withContext(dispatchers.io) { nodeInfoWriteDataSource.clearMyNodeInfo() }
/** Deletes a node and its metadata by [num]. */
override suspend fun deleteNode(num: Int) = withContext(dispatchers.io) {
nodeInfoWriteDataSource.deleteNode(num)
nodeInfoWriteDataSource.deleteMetadata(num)
}
/** Deletes multiple nodes and their metadata. */
override suspend fun deleteNodes(nodeNums: List<Int>) = withContext(dispatchers.io) {
nodeInfoWriteDataSource.deleteNodes(nodeNums)
nodeNums.forEach { nodeInfoWriteDataSource.deleteMetadata(it) }
}
override suspend fun getNodesOlderThan(lastHeard: Int): List<Node> =
withContext(dispatchers.io) { nodeInfoReadDataSource.getNodesOlderThan(lastHeard).map { it.toModel() } }
override suspend fun getUnknownNodes(): List<Node> =
withContext(dispatchers.io) { nodeInfoReadDataSource.getUnknownNodes().map { it.toModel() } }
/** Persists hardware metadata for a node. */
override suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) =
withContext(dispatchers.io) { nodeInfoWriteDataSource.upsert(MetadataEntity(nodeNum, metadata)) }
/** Flow emitting the count of nodes currently considered "online". */
override val onlineNodeCount: Flow<Int> =
nodeInfoReadDataSource
.nodeDBbyNumFlow()
.mapLatest { map -> map.values.count { it.node.lastHeard > onlineTimeThreshold() } }
.flowOn(dispatchers.io)
.conflate()
/** Flow emitting the total number of nodes in the database. */
override val totalNodeCount: Flow<Int> =
nodeInfoReadDataSource
.nodeDBbyNumFlow()
.mapLatest { map -> map.values.count() }
.flowOn(dispatchers.io)
.conflate()
override suspend fun setNodeNotes(num: Int, notes: String) =
withContext(dispatchers.io) { nodeInfoWriteDataSource.setNodeNotes(num, notes) }
private fun MyNodeInfo.toEntity() = MyNodeEntity(
myNodeNum = myNodeNum,
model = model,
firmwareVersion = firmwareVersion,
couldUpdate = couldUpdate,
shouldUpdate = shouldUpdate,
currentPacketId = currentPacketId,
messageTimeoutMsec = messageTimeoutMsec,
minAppVersion = minAppVersion,
maxChannels = maxChannels,
hasWifi = hasWifi,
deviceId = deviceId,
pioEnv = pioEnv,
)
private fun Node.toEntity() = NodeEntity(
num = num,
user = user,
position = position,
latitude = latitude,
longitude = longitude,
snr = snr,
rssi = rssi,
lastHeard = lastHeard,
deviceTelemetry = org.meshtastic.proto.Telemetry(device_metrics = deviceMetrics),
channel = channel,
viaMqtt = viaMqtt,
hopsAway = hopsAway,
isFavorite = isFavorite,
isIgnored = isIgnored,
isMuted = isMuted,
environmentTelemetry = org.meshtastic.proto.Telemetry(environment_metrics = environmentMetrics),
powerTelemetry = org.meshtastic.proto.Telemetry(power_metrics = powerMetrics),
paxcounter = paxcounter,
publicKey = publicKey,
notes = notes,
manuallyVerified = manuallyVerified,
nodeStatus = nodeStatus,
lastTransport = lastTransport,
)
}

View File

@@ -23,12 +23,15 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.database.DatabaseProvider
import org.meshtastic.core.database.entity.NodeMetadataEntity
import org.meshtastic.core.datastore.LocalStatsDataSource
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshLog
@@ -50,12 +53,13 @@ import org.meshtastic.proto.User
* SDK's NodeChange flow (bridged through SdkStateBridge).
*
* Cold start: nodes are empty until the SDK emits its snapshot from storage (<1s).
* Node notes: stored in-memory for this POC (will not survive process death).
* Node metadata (favorites, notes, ignored, muted) persists via Room's node_metadata table.
*/
@Single(binds = [NodeRepository::class])
@Suppress("TooManyFunctions")
class SdkNodeRepositoryImpl(
private val localStatsDataSource: LocalStatsDataSource,
private val dbManager: DatabaseProvider,
@Named("ServiceScope") private val scope: CoroutineScope,
) : NodeRepository {
@@ -63,8 +67,15 @@ class SdkNodeRepositoryImpl(
private val _myNodeInfo = MutableStateFlow<MyNodeInfo?>(null)
private val _myNodeNum = MutableStateFlow<Int?>(null)
// Local-only notes storage (in-memory for POC; does not survive process death)
private val nodeNotes = MutableStateFlow<Map<Int, String>>(emptyMap())
// Cached metadata from Room (loaded on init, updated on writes)
private val _metadataCache = MutableStateFlow<Map<Int, NodeMetadataEntity>>(emptyMap())
init {
scope.launch {
dbManager.currentDb.flatMapLatest { db -> db.nodeMetadataDao().getAllFlow() }
.collect { list -> _metadataCache.value = list.associateBy { it.num } }
}
}
override val nodeDBbyNum: StateFlow<Map<Int, Node>> = _nodeDBbyNum
@@ -151,11 +162,20 @@ class SdkNodeRepositoryImpl(
}
override suspend fun upsert(node: Node) {
_nodeDBbyNum.update { map -> map + (node.num to node) }
// Also keep _myNodeNum consistent
if (node.num == _myNodeNum.value) {
// ourNodeInfo will auto-update via combine
// Merge persisted metadata with incoming node data
val meta = _metadataCache.value[node.num]
val enriched = if (meta != null) {
node.copy(
isFavorite = meta.isFavorite,
isIgnored = meta.isIgnored,
isMuted = meta.isMuted,
notes = meta.notes,
manuallyVerified = meta.manuallyVerified,
)
} else {
node
}
_nodeDBbyNum.update { map -> map + (enriched.num to enriched) }
}
override suspend fun installConfig(mi: MyNodeInfo, nodes: List<Node>) {
@@ -178,12 +198,12 @@ class SdkNodeRepositoryImpl(
override suspend fun deleteNode(num: Int) {
_nodeDBbyNum.update { it - num }
nodeNotes.update { it - num }
dbManager.withDb { it.nodeMetadataDao().delete(num) }
}
override suspend fun deleteNodes(nodeNums: List<Int>) {
_nodeDBbyNum.update { map -> map - nodeNums.toSet() }
nodeNotes.update { notes -> notes - nodeNums.toSet() }
dbManager.withDb { db -> nodeNums.forEach { db.nodeMetadataDao().delete(it) } }
}
override suspend fun getNodesOlderThan(lastHeard: Int): List<Node> =
@@ -193,7 +213,8 @@ class SdkNodeRepositoryImpl(
_nodeDBbyNum.value.values.filter { it.user.hw_model == HardwareModel.UNSET }
override suspend fun setNodeNotes(num: Int, notes: String) {
nodeNotes.update { it + (num to notes) }
ensureMetadataExists(num)
dbManager.withDb { it.nodeMetadataDao().setNotes(num, notes) }
_nodeDBbyNum.update { map ->
val node = map[num] ?: return@update map
map + (num to node.copy(notes = notes))
@@ -212,6 +233,13 @@ class SdkNodeRepositoryImpl(
_myNodeNum.value = num
}
/** Ensures a metadata row exists for the given node, creating a default if needed. */
private suspend fun ensureMetadataExists(num: Int) {
if (_metadataCache.value[num] == null) {
dbManager.withDb { it.nodeMetadataDao().upsert(NodeMetadataEntity(num = num)) }
}
}
private fun sortComparator(sort: NodeSortOption): Comparator<Node> = when (sort) {
NodeSortOption.LAST_HEARD -> compareByDescending { it.lastHeard }
NodeSortOption.ALPHABETICAL -> compareBy(String.CASE_INSENSITIVE_ORDER) { it.user.long_name }

View File

@@ -1,224 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.mock
import dev.mokkery.verify
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.SessionManager
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.Data
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.PortNum
import kotlin.test.BeforeTest
import kotlin.test.Test
class AdminPacketHandlerImplTest {
private val nodeManager = mock<NodeManager>(MockMode.autofill)
private val configHandler = mock<MeshConfigHandler>(MockMode.autofill)
private val configFlowManager = mock<MeshConfigFlowManager>(MockMode.autofill)
private val sessionManager = mock<SessionManager>(MockMode.autofill)
private lateinit var handler: AdminPacketHandlerImpl
private val myNodeNum = 12345
@BeforeTest
fun setUp() {
handler =
AdminPacketHandlerImpl(
nodeManager = nodeManager,
configHandler = lazy { configHandler },
configFlowManager = lazy { configFlowManager },
sessionManager = sessionManager,
)
}
private fun makePacket(from: Int, adminMessage: AdminMessage): MeshPacket {
val payload = AdminMessage.ADAPTER.encode(adminMessage).toByteString()
return MeshPacket(from = from, decoded = Data(portnum = PortNum.ADMIN_APP, payload = payload))
}
// ---------- Session passkey ----------
@Test
fun `session passkey is updated when present`() {
val passkey = ByteString.of(1, 2, 3, 4)
val adminMsg = AdminMessage(session_passkey = passkey)
val packet = makePacket(myNodeNum, adminMsg)
handler.handleAdminMessage(packet, myNodeNum)
verify { sessionManager.recordSession(myNodeNum, passkey) }
}
@Test
fun `empty session passkey does not record refresh`() {
val adminMsg = AdminMessage(session_passkey = ByteString.EMPTY)
val packet = makePacket(myNodeNum, adminMsg)
handler.handleAdminMessage(packet, myNodeNum)
// recordSession should NOT be called for empty passkey
}
// ---------- get_config_response ----------
@Test
fun `get_config_response from own node delegates to configHandler`() {
val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT))
val adminMsg = AdminMessage(get_config_response = config)
val packet = makePacket(myNodeNum, adminMsg)
handler.handleAdminMessage(packet, myNodeNum)
verify { configHandler.handleDeviceConfig(config) }
}
@Test
fun `get_config_response from remote node is ignored`() {
val config = Config(device = Config.DeviceConfig())
val adminMsg = AdminMessage(get_config_response = config)
val packet = makePacket(99999, adminMsg)
handler.handleAdminMessage(packet, myNodeNum)
// configHandler.handleDeviceConfig should NOT be called
}
// ---------- get_module_config_response ----------
@Test
fun `get_module_config_response from own node delegates to configHandler`() {
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
val adminMsg = AdminMessage(get_module_config_response = moduleConfig)
val packet = makePacket(myNodeNum, adminMsg)
handler.handleAdminMessage(packet, myNodeNum)
verify { configHandler.handleModuleConfig(moduleConfig) }
}
@Test
fun `get_module_config_response from remote node updates node status`() {
val moduleConfig = ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Battery Low"))
val adminMsg = AdminMessage(get_module_config_response = moduleConfig)
val remoteNode = 99999
val packet = makePacket(remoteNode, adminMsg)
handler.handleAdminMessage(packet, myNodeNum)
verify { nodeManager.updateNodeStatus(remoteNode, "Battery Low") }
}
@Test
fun `get_module_config_response from remote without status message does not crash`() {
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
val adminMsg = AdminMessage(get_module_config_response = moduleConfig)
val packet = makePacket(99999, adminMsg)
handler.handleAdminMessage(packet, myNodeNum)
// No crash, no updateNodeStatus call
}
// ---------- get_channel_response ----------
@Test
fun `get_channel_response from own node delegates to configHandler`() {
val channel = Channel(index = 0)
val adminMsg = AdminMessage(get_channel_response = channel)
val packet = makePacket(myNodeNum, adminMsg)
handler.handleAdminMessage(packet, myNodeNum)
verify { configHandler.handleChannel(channel) }
}
@Test
fun `get_channel_response from remote node is ignored`() {
val channel = Channel(index = 0)
val adminMsg = AdminMessage(get_channel_response = channel)
val packet = makePacket(99999, adminMsg)
handler.handleAdminMessage(packet, myNodeNum)
// configHandler.handleChannel should NOT be called
}
// ---------- get_device_metadata_response ----------
@Test
fun `device metadata from own node delegates to configFlowManager`() {
val metadata = DeviceMetadata(firmware_version = "2.6.0", hw_model = HardwareModel.HELTEC_V3)
val adminMsg = AdminMessage(get_device_metadata_response = metadata)
val packet = makePacket(myNodeNum, adminMsg)
handler.handleAdminMessage(packet, myNodeNum)
verify { configFlowManager.handleLocalMetadata(metadata) }
}
@Test
fun `device metadata from remote node delegates to nodeManager`() {
val metadata = DeviceMetadata(firmware_version = "2.5.0", hw_model = HardwareModel.TBEAM)
val adminMsg = AdminMessage(get_device_metadata_response = metadata)
val remoteNode = 99999
val packet = makePacket(remoteNode, adminMsg)
handler.handleAdminMessage(packet, myNodeNum)
verify { nodeManager.insertMetadata(remoteNode, metadata) }
}
// ---------- Edge cases ----------
@Test
fun `packet with null decoded payload is ignored`() {
val packet = MeshPacket(from = myNodeNum, decoded = null)
handler.handleAdminMessage(packet, myNodeNum)
// No crash
}
@Test
fun `packet with empty payload bytes is ignored`() {
val packet =
MeshPacket(from = myNodeNum, decoded = Data(portnum = PortNum.ADMIN_APP, payload = ByteString.EMPTY))
handler.handleAdminMessage(packet, myNodeNum)
// No crash — decodes as default AdminMessage with no fields set
}
@Test
fun `combined admin message with passkey and config response`() {
val passkey = ByteString.of(5, 6, 7, 8)
val config = Config(lora = Config.LoRaConfig())
val adminMsg = AdminMessage(session_passkey = passkey, get_config_response = config)
val packet = makePacket(myNodeNum, adminMsg)
handler.handleAdminMessage(packet, myNodeNum)
verify { sessionManager.recordSession(myNodeNum, passkey) }
verify { configHandler.handleDeviceConfig(config) }
}
}

View File

@@ -1,583 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verify.VerifyMode.Companion.not
import dev.mokkery.verifySuspend
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class MeshActionHandlerImplTest {
private val nodeManager = mock<NodeManager>(MockMode.autofill)
private val commandSender = mock<CommandSender>(MockMode.autofill)
private val packetRepository = mock<PacketRepository>(MockMode.autofill)
private val serviceBroadcasts = mock<ServiceBroadcasts>(MockMode.autofill)
private val dataHandler = mock<MeshDataHandler>(MockMode.autofill)
private val analytics = mock<PlatformAnalytics>(MockMode.autofill)
private val meshPrefs = mock<MeshPrefs>(MockMode.autofill)
private val uiPrefs = mock<UiPrefs>(MockMode.autofill)
private val databaseManager = mock<DatabaseManager>(MockMode.autofill)
private val notificationManager = mock<NotificationManager>(MockMode.autofill)
private val radioConfigRepository = mock<RadioConfigRepository>(MockMode.autofill)
private val myNodeNumFlow = MutableStateFlow<Int?>(MY_NODE_NUM)
private lateinit var handler: MeshActionHandlerImpl
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)
companion object {
private const val MY_NODE_NUM = 12345
private const val REMOTE_NODE_NUM = 67890
}
@BeforeTest
fun setUp() {
every { nodeManager.myNodeNum } returns myNodeNumFlow
every { nodeManager.getMyId() } returns "!12345678"
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
}
private fun createHandler(scope: CoroutineScope): MeshActionHandlerImpl = MeshActionHandlerImpl(
nodeManager = nodeManager,
commandSender = commandSender,
packetRepository = lazy { packetRepository },
serviceBroadcasts = serviceBroadcasts,
dataHandler = lazy { dataHandler },
analytics = analytics,
meshPrefs = meshPrefs,
uiPrefs = uiPrefs,
databaseManager = databaseManager,
notificationManager = notificationManager,
radioConfigRepository = radioConfigRepository,
scope = scope,
)
// ---- handleUpdateLastAddress (device-switch path — P0 critical) ----
@Test
fun handleUpdateLastAddress_differentAddress_switchesDatabaseAndClearsState() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow("old_addr")
everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit
handler.handleUpdateLastAddress("new_addr")
advanceUntilIdle()
verify { meshPrefs.setDeviceAddress("new_addr") }
verify { nodeManager.clear() }
verifySuspend { databaseManager.switchActiveDatabase("new_addr") }
verify { notificationManager.cancelAll() }
verify { nodeManager.loadCachedNodeDB() }
}
@Test
fun handleUpdateLastAddress_sameAddress_noOp() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow("same_addr")
handler.handleUpdateLastAddress("same_addr")
advanceUntilIdle()
verify(not) { meshPrefs.setDeviceAddress(any()) }
verify(not) { nodeManager.clear() }
}
@Test
fun handleUpdateLastAddress_nullAddress_switchesIfDifferent() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow("old_addr")
everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit
handler.handleUpdateLastAddress(null)
advanceUntilIdle()
verify { meshPrefs.setDeviceAddress(null) }
verify { nodeManager.clear() }
verifySuspend { databaseManager.switchActiveDatabase(null) }
}
@Test
fun handleUpdateLastAddress_nullToNull_noOp() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow(null)
handler.handleUpdateLastAddress(null)
advanceUntilIdle()
verify(not) { meshPrefs.setDeviceAddress(any()) }
}
@Test
fun handleUpdateLastAddress_executesStepsInOrder() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow("old")
everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit
handler.handleUpdateLastAddress("new")
advanceUntilIdle()
// Verify critical sequence: clear -> switchDB -> cancelNotifications -> loadCachedNodeDB
verify { nodeManager.clear() }
verifySuspend { databaseManager.switchActiveDatabase("new") }
verify { notificationManager.cancelAll() }
verify { nodeManager.loadCachedNodeDB() }
}
// ---- onServiceAction: null myNodeNum early-return ----
@Test
fun onServiceAction_nullMyNodeNum_doesNothing() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
myNodeNumFlow.value = null
val node = createTestNode(REMOTE_NODE_NUM)
handler.onServiceAction(ServiceAction.Favorite(node))
advanceUntilIdle()
verify(not) { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- onServiceAction: Favorite ----
@Test
fun onServiceAction_favorite_sendsSetFavoriteWhenNotFavorite() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
val node = createTestNode(REMOTE_NODE_NUM, isFavorite = false)
handler.onServiceAction(ServiceAction.Favorite(node))
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.updateNode(any(), any(), any(), any()) }
}
@Test
fun onServiceAction_favorite_sendsRemoveFavoriteWhenAlreadyFavorite() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
val node = createTestNode(REMOTE_NODE_NUM, isFavorite = true)
handler.onServiceAction(ServiceAction.Favorite(node))
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.updateNode(any(), any(), any(), any()) }
}
// ---- onServiceAction: Ignore ----
@Test
fun onServiceAction_ignore_togglesAndUpdatesFilteredBySender() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
val node = createTestNode(REMOTE_NODE_NUM, isIgnored = false)
handler.onServiceAction(ServiceAction.Ignore(node))
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.updateNode(any(), any(), any(), any()) }
verifySuspend { packetRepository.updateFilteredBySender(any(), any()) }
}
// ---- onServiceAction: Mute ----
@Test
fun onServiceAction_mute_togglesMutedState() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
val node = createTestNode(REMOTE_NODE_NUM, isMuted = false)
handler.onServiceAction(ServiceAction.Mute(node))
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.updateNode(any(), any(), any(), any()) }
}
// ---- onServiceAction: GetDeviceMetadata ----
@Test
fun onServiceAction_getDeviceMetadata_sendsAdminRequest() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
handler.onServiceAction(ServiceAction.GetDeviceMetadata(REMOTE_NODE_NUM))
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- onServiceAction: SendContact ----
@Test
fun onServiceAction_sendContact_completesWithTrueOnSuccess() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true
val action = ServiceAction.SendContact(SharedContact())
handler.onServiceAction(action)
advanceUntilIdle()
assertTrue(action.result.isCompleted)
assertTrue(action.result.await())
}
@Test
fun onServiceAction_sendContact_completesWithFalseOnFailure() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns false
val action = ServiceAction.SendContact(SharedContact())
handler.onServiceAction(action)
advanceUntilIdle()
assertTrue(action.result.isCompleted)
assertFalse(action.result.await())
}
// ---- onServiceAction: ImportContact ----
@Test
fun onServiceAction_importContact_sendsAdminAndUpdatesNode() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
val contact =
SharedContact(node_num = REMOTE_NODE_NUM, user = User(id = "!abcdef12", long_name = "TestUser"))
handler.onServiceAction(ServiceAction.ImportContact(contact))
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.handleReceivedUser(any(), any(), any(), any()) }
}
// ---- handleSetOwner ----
@Test
fun handleSetOwner_sendsAdminAndUpdatesLocalNode() {
handler = createHandler(testScope)
val meshUser =
MeshUser(
id = "!12345678",
longName = "Test Long",
shortName = "TL",
hwModel = HardwareModel.UNSET,
isLicensed = false,
)
handler.handleSetOwner(meshUser, MY_NODE_NUM)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.handleReceivedUser(any(), any(), any(), any()) }
}
// ---- handleSend ----
@Test
fun handleSend_sendsDataAndBroadcastsStatus() {
handler = createHandler(testScope)
val packet = DataPacket(to = "!deadbeef", dataType = 1, bytes = null, channel = 0)
handler.handleSend(packet, MY_NODE_NUM)
verify { commandSender.sendData(any()) }
verify { serviceBroadcasts.broadcastMessageStatus(any(), any()) }
verify { dataHandler.rememberDataPacket(any(), any(), any()) }
}
// ---- handleRequestPosition: 3 branches ----
@Test
fun handleRequestPosition_sameNode_doesNothing() {
handler = createHandler(testScope)
handler.handleRequestPosition(MY_NODE_NUM, Position(0.0, 0.0, 0), MY_NODE_NUM)
verify(not) { commandSender.requestPosition(any(), any()) }
}
@Test
fun handleRequestPosition_provideLocation_validPosition_usesGivenPosition() {
handler = createHandler(testScope)
every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true)
val validPosition = Position(37.7749, -122.4194, 10)
handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM)
verify { commandSender.requestPosition(REMOTE_NODE_NUM, validPosition) }
}
@Test
fun handleRequestPosition_provideLocation_invalidPosition_fallsBackToNodeDB() {
handler = createHandler(testScope)
every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true)
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
val invalidPosition = Position(0.0, 0.0, 0)
handler.handleRequestPosition(REMOTE_NODE_NUM, invalidPosition, MY_NODE_NUM)
// Falls back to Position(0.0, 0.0, 0) when node has no position in DB
verify { commandSender.requestPosition(any(), any()) }
}
@Test
fun handleRequestPosition_doNotProvide_sendsZeroPosition() {
handler = createHandler(testScope)
every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(false)
val validPosition = Position(37.7749, -122.4194, 10)
handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM)
// Should send zero position regardless of valid input
verify { commandSender.requestPosition(any(), any()) }
}
// ---- handleSetConfig: optimistic persist ----
@Test
fun handleSetConfig_decodesAndSendsAdmin_thenPersistsLocally() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
everySuspend { radioConfigRepository.setLocalConfig(any()) } returns Unit
val config = Config(lora = Config.LoRaConfig(hop_limit = 5))
val payload = Config.ADAPTER.encode(config)
handler.handleSetConfig(payload, MY_NODE_NUM)
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verifySuspend { radioConfigRepository.setLocalConfig(any()) }
}
// ---- handleSetModuleConfig: conditional persist ----
@Test
fun handleSetModuleConfig_ownNode_persistsLocally() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
myNodeNumFlow.value = MY_NODE_NUM
everySuspend { radioConfigRepository.setLocalModuleConfig(any()) } returns Unit
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
val payload = ModuleConfig.ADAPTER.encode(moduleConfig)
handler.handleSetModuleConfig(0, MY_NODE_NUM, payload)
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verifySuspend { radioConfigRepository.setLocalModuleConfig(any()) }
}
@Test
fun handleSetModuleConfig_remoteNode_doesNotPersistLocally() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
myNodeNumFlow.value = MY_NODE_NUM
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
val payload = ModuleConfig.ADAPTER.encode(moduleConfig)
handler.handleSetModuleConfig(0, REMOTE_NODE_NUM, payload)
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verifySuspend(not) { radioConfigRepository.setLocalModuleConfig(any()) }
}
// ---- handleSetChannel: null payload guard ----
@Test
fun handleSetChannel_nonNullPayload_decodesAndPersists() = runTest(testDispatcher) {
handler = createHandler(backgroundScope)
everySuspend { radioConfigRepository.updateChannelSettings(any()) } returns Unit
val channel = Channel(index = 1)
val payload = Channel.ADAPTER.encode(channel)
handler.handleSetChannel(payload, MY_NODE_NUM)
advanceUntilIdle()
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verifySuspend { radioConfigRepository.updateChannelSettings(any()) }
}
@Test
fun handleSetChannel_nullPayload_doesNothing() {
handler = createHandler(testScope)
handler.handleSetChannel(null, MY_NODE_NUM)
verify(not) { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- handleRemoveByNodenum ----
@Test
fun handleRemoveByNodenum_removesAndSendsAdmin() {
handler = createHandler(testScope)
handler.handleRemoveByNodenum(REMOTE_NODE_NUM, 99, MY_NODE_NUM)
verify { nodeManager.removeByNodenum(REMOTE_NODE_NUM) }
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- handleSetRemoteOwner ----
@Test
fun handleSetRemoteOwner_decodesAndSendsAdmin() {
handler = createHandler(testScope)
val user = User(id = "!remote01", long_name = "Remote", short_name = "RM")
val payload = User.ADAPTER.encode(user)
handler.handleSetRemoteOwner(1, REMOTE_NODE_NUM, payload)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
verify { nodeManager.handleReceivedUser(any(), any(), any(), any()) }
}
// ---- handleGetRemoteConfig: sessionkey vs regular ----
@Test
fun handleGetRemoteConfig_sessionkeyConfig_sendsDeviceMetadataRequest() {
handler = createHandler(testScope)
handler.handleGetRemoteConfig(1, REMOTE_NODE_NUM, AdminMessage.ConfigType.SESSIONKEY_CONFIG.value)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
@Test
fun handleGetRemoteConfig_regularConfig_sendsConfigRequest() {
handler = createHandler(testScope)
handler.handleGetRemoteConfig(1, REMOTE_NODE_NUM, AdminMessage.ConfigType.LORA_CONFIG.value)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- handleSetRemoteChannel: null payload guard ----
@Test
fun handleSetRemoteChannel_nullPayload_doesNothing() {
handler = createHandler(testScope)
handler.handleSetRemoteChannel(1, REMOTE_NODE_NUM, null)
verify(not) { commandSender.sendAdmin(any(), any(), any(), any()) }
}
@Test
fun handleSetRemoteChannel_nonNullPayload_decodesAndSendsAdmin() {
handler = createHandler(testScope)
val channel = Channel(index = 2)
val payload = Channel.ADAPTER.encode(channel)
handler.handleSetRemoteChannel(1, REMOTE_NODE_NUM, payload)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- handleRequestRebootOta: null hash ----
@Test
fun handleRequestRebootOta_withNullHash_sendsAdmin() {
handler = createHandler(testScope)
handler.handleRequestRebootOta(1, REMOTE_NODE_NUM, 0, null)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
@Test
fun handleRequestRebootOta_withHash_sendsAdmin() {
handler = createHandler(testScope)
val hash = byteArrayOf(0x01, 0x02, 0x03)
handler.handleRequestRebootOta(1, REMOTE_NODE_NUM, 1, hash)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- handleRequestNodedbReset ----
@Test
fun handleRequestNodedbReset_sendsAdminWithPreserveFavorites() {
handler = createHandler(testScope)
handler.handleRequestNodedbReset(1, REMOTE_NODE_NUM, preserveFavorites = true)
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
}
// ---- Helper ----
private fun createTestNode(
num: Int,
isFavorite: Boolean = false,
isIgnored: Boolean = false,
isMuted: Boolean = false,
): Node = Node(
num = num,
user = User(id = "!${num.toString(16).padStart(8, '0')}", long_name = "Node $num", short_name = "N$num"),
isFavorite = isFavorite,
isIgnored = isIgnored,
isMuted = isMuted,
)
}

View File

@@ -1,471 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.answering.calls
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verify.VerifyMode
import dev.mokkery.verifySuspend
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.encodeUtf8
import org.meshtastic.core.repository.HandshakeConstants
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NotificationPrefs
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.FileInfo
import org.meshtastic.proto.FirmwareEdition
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.NodeInfo
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo
@OptIn(ExperimentalCoroutinesApi::class)
class MeshConfigFlowManagerImplTest {
private val nodeManager = mock<NodeManager>(MockMode.autofill)
private val connectionManager = mock<MeshConnectionManager>(MockMode.autofill)
private val nodeRepository = mock<NodeRepository>(MockMode.autofill)
private val radioConfigRepository = mock<RadioConfigRepository>(MockMode.autofill)
private val serviceRepository = mock<ServiceRepository>(MockMode.autofill)
private val serviceBroadcasts = mock<ServiceBroadcasts>(MockMode.autofill)
private val analytics = mock<PlatformAnalytics>(MockMode.autofill)
private val packetHandler = mock<PacketHandler>(MockMode.autofill)
private val notificationPrefs = mock<NotificationPrefs>(MockMode.autofill)
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private lateinit var manager: MeshConfigFlowManagerImpl
private val myNodeNum = 12345
private val protoMyNodeInfo =
ProtoMyNodeInfo(
my_node_num = myNodeNum,
min_app_version = 30000,
device_id = "test-device".encodeUtf8(),
pio_env = "",
)
private val metadata =
DeviceMetadata(firmware_version = "2.6.0", hw_model = HardwareModel.HELTEC_V3, hasWifi = false)
@BeforeTest
fun setUp() {
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
every { nodeManager.myNodeNum } returns MutableStateFlow(null)
every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(false)
every { notificationPrefs.nodeEventsEnabled } returns MutableStateFlow(true)
manager =
MeshConfigFlowManagerImpl(
nodeManager = nodeManager,
connectionManager = lazy { connectionManager },
nodeRepository = nodeRepository,
radioConfigRepository = radioConfigRepository,
serviceRepository = serviceRepository,
serviceBroadcasts = serviceBroadcasts,
analytics = analytics,
heartbeatSender = DataLayerHeartbeatSender(packetHandler),
notificationPrefs = notificationPrefs,
scope = testScope,
)
}
// ---------- handleMyInfo ----------
@Test
fun `handleMyInfo transitions to ReceivingConfig and sets myNodeNum`() = testScope.runTest {
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
verify { nodeManager.setMyNodeNum(myNodeNum) }
}
@Test
fun `handleMyInfo clears persisted radio config`() = testScope.runTest {
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
verifySuspend { radioConfigRepository.clearChannelSet() }
verifySuspend { radioConfigRepository.clearLocalConfig() }
verifySuspend { radioConfigRepository.clearLocalModuleConfig() }
verifySuspend { radioConfigRepository.clearDeviceUIConfig() }
verifySuspend { radioConfigRepository.clearFileManifest() }
}
// ---------- handleLocalMetadata ----------
@Test
fun `handleLocalMetadata persists metadata when in ReceivingConfig state`() = testScope.runTest {
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
manager.handleLocalMetadata(metadata)
advanceUntilIdle()
verifySuspend { nodeRepository.insertMetadata(myNodeNum, metadata) }
}
@Test
fun `handleLocalMetadata skips empty metadata`() = testScope.runTest {
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
// Default/empty DeviceMetadata should not trigger insertMetadata
manager.handleLocalMetadata(DeviceMetadata())
advanceUntilIdle()
// insertMetadata should only have been called zero times for default metadata
// (we just verify no crash occurs)
}
@Test
fun `handleLocalMetadata ignored outside ReceivingConfig state`() = testScope.runTest {
// State is Idle — handleLocalMetadata should be a no-op
manager.handleLocalMetadata(metadata)
advanceUntilIdle()
// No crash, no insertMetadata call
}
// ---------- handleConfigComplete Stage 1 ----------
@Test
fun `Stage 1 complete builds MyNodeInfo and transitions to ReceivingNodeInfo`() = testScope.runTest {
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
manager.handleLocalMetadata(metadata)
advanceUntilIdle()
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
advanceUntilIdle()
verify { connectionManager.onRadioConfigLoaded() }
verify { connectionManager.startNodeInfoOnly() }
}
@Test
fun `Stage 1 complete sends heartbeat with non-zero nonce between stages`() = testScope.runTest {
val sentPackets = mutableListOf<org.meshtastic.proto.ToRadio>()
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } calls
{ call ->
sentPackets.add(call.arg(0))
}
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
manager.handleLocalMetadata(metadata)
advanceUntilIdle()
sentPackets.clear() // Clear any packets from prior phases
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
advanceUntilIdle()
val heartbeats = sentPackets.filter { it.heartbeat != null }
assertEquals(1, heartbeats.size, "Expected exactly one inter-stage heartbeat")
assertEquals(
true,
heartbeats[0].heartbeat!!.nonce != 0,
"Inter-stage heartbeat should have a non-zero nonce",
)
}
@Test
fun `Stage 1 complete with old firmware logs warning but continues handshake`() = testScope.runTest {
val oldMetadata =
DeviceMetadata(firmware_version = "2.3.0", hw_model = HardwareModel.HELTEC_V3, hasWifi = false)
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
manager.handleLocalMetadata(oldMetadata)
advanceUntilIdle()
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
advanceUntilIdle()
// Handshake should still progress despite old firmware
verify { connectionManager.onRadioConfigLoaded() }
verify { connectionManager.startNodeInfoOnly() }
}
@Test
fun `Stage 1 complete without metadata still succeeds with null firmware version`() = testScope.runTest {
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
// No metadata provided
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
advanceUntilIdle()
verify { connectionManager.onRadioConfigLoaded() }
}
@Test
fun `Stage 1 complete id ignored when not in ReceivingConfig state`() = testScope.runTest {
// State is Idle — should be a no-op
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
advanceUntilIdle()
// No crash, no onRadioConfigLoaded
}
@Test
fun `Duplicate Stage 1 config_complete does not re-trigger`() = testScope.runTest {
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
manager.handleLocalMetadata(metadata)
advanceUntilIdle()
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
advanceUntilIdle()
// Now in ReceivingNodeInfo — a second Stage 1 complete should be ignored
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
advanceUntilIdle()
}
// ---------- handleNodeInfo ----------
@Test
fun `handleNodeInfo accumulates nodes during Stage 2`() = testScope.runTest {
// Transition to Stage 2
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
manager.handleLocalMetadata(metadata)
advanceUntilIdle()
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
advanceUntilIdle()
// Now in ReceivingNodeInfo
manager.handleNodeInfo(NodeInfo(num = 100))
manager.handleNodeInfo(NodeInfo(num = 200))
assertEquals(2, manager.newNodeCount)
}
@Test
fun `handleNodeInfo ignored outside Stage 2`() = testScope.runTest {
// State is Idle
manager.handleNodeInfo(NodeInfo(num = 999))
assertEquals(0, manager.newNodeCount)
}
// ---------- handleConfigComplete Stage 2 ----------
@Test
fun `Stage 2 complete processes nodes and sets Connected state`() = testScope.runTest {
val testNode = org.meshtastic.core.testing.TestDataFactory.createTestNode(num = 100)
every { nodeManager.nodeDBbyNodeNum } returns mapOf(100 to testNode)
// Full handshake: MyInfo -> metadata -> Stage 1 complete -> nodes -> Stage 2 complete
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
manager.handleLocalMetadata(metadata)
advanceUntilIdle()
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
advanceUntilIdle()
manager.handleNodeInfo(NodeInfo(num = 100))
manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE)
advanceUntilIdle()
verify { nodeManager.installNodeInfo(any(), withBroadcast = false) }
verify { nodeManager.setNodeDbReady(true) }
verify { nodeManager.setAllowNodeDbWrites(true) }
verify { serviceBroadcasts.broadcastConnection() }
verify { connectionManager.onNodeDbReady() }
}
@Test
fun `Stage 2 complete id ignored when not in ReceivingNodeInfo state`() = testScope.runTest {
manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE)
advanceUntilIdle()
// No crash
}
@Test
fun `Stage 2 complete with no nodes still transitions to Connected`() = testScope.runTest {
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
manager.handleLocalMetadata(metadata)
advanceUntilIdle()
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
advanceUntilIdle()
// No handleNodeInfo calls — empty node list
manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE)
advanceUntilIdle()
verify { nodeManager.setNodeDbReady(true) }
verify { connectionManager.onNodeDbReady() }
}
// ---------- Unknown config_complete_id ----------
@Test
fun `Unknown config_complete_id is ignored`() = testScope.runTest {
manager.handleConfigComplete(99999)
advanceUntilIdle()
// No crash
}
// ---------- newNodeCount ----------
@Test
fun `newNodeCount returns 0 when not in ReceivingNodeInfo state`() {
assertEquals(0, manager.newNodeCount)
}
// ---------- handleFileInfo ----------
@Test
fun `handleFileInfo delegates to radioConfigRepository`() = testScope.runTest {
val fileInfo = FileInfo(file_name = "firmware.bin", size_bytes = 1024)
manager.handleFileInfo(fileInfo)
advanceUntilIdle()
verifySuspend { radioConfigRepository.addFileInfo(fileInfo) }
}
// ---------- triggerWantConfig ----------
@Test
fun `triggerWantConfig delegates to connectionManager startConfigOnly`() {
manager.triggerWantConfig()
verify { connectionManager.startConfigOnly() }
}
// ---------- Full handshake flow ----------
@Test
fun `Full handshake from Idle to Complete`() = testScope.runTest {
val testNode = org.meshtastic.core.testing.TestDataFactory.createTestNode(num = 100)
every { nodeManager.nodeDBbyNodeNum } returns mapOf(100 to testNode)
// Stage 0: Idle -> handleMyInfo
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
verify { nodeManager.setMyNodeNum(myNodeNum) }
// Receive metadata during Stage 1
manager.handleLocalMetadata(metadata)
advanceUntilIdle()
// Stage 1 complete
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
advanceUntilIdle()
verify { connectionManager.onRadioConfigLoaded() }
// Receive NodeInfo during Stage 2
manager.handleNodeInfo(NodeInfo(num = 100))
assertEquals(1, manager.newNodeCount)
// Stage 2 complete
manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE)
advanceUntilIdle()
verify { nodeManager.setNodeDbReady(true) }
verify { connectionManager.onNodeDbReady() }
// After complete, newNodeCount should be 0 (state is Complete)
assertEquals(0, manager.newNodeCount)
}
// ---------- Interrupted handshake ----------
@Test
fun `handleMyInfo resets stale handshake state`() = testScope.runTest {
// Start first handshake
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
manager.handleLocalMetadata(metadata)
advanceUntilIdle()
// Before Stage 1 completes, a new handleMyInfo arrives (device rebooted)
val newMyInfo = protoMyNodeInfo.copy(my_node_num = 99999)
manager.handleMyInfo(newMyInfo)
advanceUntilIdle()
verify { nodeManager.setMyNodeNum(99999) }
}
// ---------- Event firmware notification defaults ----------
@Test
fun `handleMyInfo disables node notifications for event firmware`() = testScope.runTest {
every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(false)
val eventMyInfo = protoMyNodeInfo.copy(firmware_edition = FirmwareEdition.DEFCON)
manager.handleMyInfo(eventMyInfo)
advanceUntilIdle()
verify { notificationPrefs.setNodeEventsEnabled(false) }
verify { notificationPrefs.setNodeEventsAutoDisabledForEvent(true) }
}
@Test
fun `handleMyInfo does not re-disable if already auto-disabled`() = testScope.runTest {
every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(true)
val eventMyInfo = protoMyNodeInfo.copy(firmware_edition = FirmwareEdition.DEFCON)
manager.handleMyInfo(eventMyInfo)
advanceUntilIdle()
verify(mode = VerifyMode.not) { notificationPrefs.setNodeEventsEnabled(any()) }
}
@Test
fun `handleMyInfo re-enables node notifications when vanilla firmware reconnects`() = testScope.runTest {
every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(true)
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
verify { notificationPrefs.setNodeEventsEnabled(true) }
verify { notificationPrefs.setNodeEventsAutoDisabledForEvent(false) }
}
@Test
fun `handleMyInfo does not touch prefs for vanilla when not previously auto-disabled`() = testScope.runTest {
every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(false)
manager.handleMyInfo(protoMyNodeInfo)
advanceUntilIdle()
verify(mode = VerifyMode.not) { notificationPrefs.setNodeEventsEnabled(any()) }
verify(mode = VerifyMode.not) { notificationPrefs.setNodeEventsAutoDisabledForEvent(any()) }
}
}

View File

@@ -1,430 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.answering.calls
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.MeshWorkerManager
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.SessionManager
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
class MeshConnectionManagerImplTest {
private val radioInterfaceService = mock<RadioInterfaceService>(MockMode.autofill)
private val serviceRepository = mock<ServiceRepository>(MockMode.autofill)
private val serviceBroadcasts = mock<ServiceBroadcasts>(MockMode.autofill)
private val serviceNotifications = mock<MeshServiceNotifications>(MockMode.autofill)
private val uiPrefs = mock<UiPrefs>(MockMode.autofill)
private val packetHandler = mock<PacketHandler>(MockMode.autofill)
private val nodeRepository = FakeNodeRepository()
private val locationManager = mock<MeshLocationManager>(MockMode.autofill)
private val mqttManager = mock<MqttManager>(MockMode.autofill)
private val historyManager = mock<HistoryManager>(MockMode.autofill)
private val radioConfigRepository = mock<RadioConfigRepository>(MockMode.autofill)
private val radioController = mock<RadioController>(MockMode.autofill)
private val sessionManager = mock<SessionManager>(MockMode.autofill)
private val nodeManager = mock<NodeManager>(MockMode.autofill)
private val analytics = mock<PlatformAnalytics>(MockMode.autofill)
private val packetRepository = mock<PacketRepository>(MockMode.autofill)
private val workerManager = mock<MeshWorkerManager>(MockMode.autofill)
private val appWidgetUpdater = mock<AppWidgetUpdater>(MockMode.autofill)
private val dataPacket = DataPacket(id = 456, time = 0L, to = "0", from = "0", bytes = null, dataType = 0)
private val radioConnectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
private val connectionStateFlow = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
private val localConfigFlow = MutableStateFlow(LocalConfig())
private val moduleConfigFlow = MutableStateFlow(LocalModuleConfig())
private val testDispatcher = UnconfinedTestDispatcher()
private lateinit var manager: MeshConnectionManagerImpl
@BeforeTest
fun setUp() {
every { radioInterfaceService.connectionState } returns radioConnectionState
every { radioConfigRepository.localConfigFlow } returns localConfigFlow
every { radioConfigRepository.moduleConfigFlow } returns moduleConfigFlow
every { serviceRepository.connectionState } returns connectionStateFlow
every { serviceRepository.setConnectionState(any()) } calls
{ call ->
connectionStateFlow.value = call.arg<ConnectionState>(0)
}
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
every { packetHandler.stopPacketQueue() } returns Unit
every { locationManager.stop() } returns Unit
every { mqttManager.stop() } returns Unit
every { nodeManager.nodeDBbyNodeNum } returns emptyMap<Int, Node>()
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
}
private fun createManager(scope: CoroutineScope): MeshConnectionManagerImpl = MeshConnectionManagerImpl(
radioInterfaceService,
serviceRepository,
serviceBroadcasts,
serviceNotifications,
uiPrefs,
packetHandler,
nodeRepository,
locationManager,
mqttManager,
historyManager,
radioConfigRepository,
radioController,
sessionManager,
nodeManager,
analytics,
packetRepository,
workerManager,
appWidgetUpdater,
DataLayerHeartbeatSender(packetHandler),
scope,
)
@AfterTest fun tearDown() = Unit
@Test
fun `Connected state triggers broadcast and config start`() = runTest(testDispatcher) {
manager = createManager(backgroundScope)
radioConnectionState.value = ConnectionState.Connected
advanceUntilIdle()
assertEquals(
ConnectionState.Connecting,
serviceRepository.connectionState.value,
"State should be Connecting after radio Connected",
)
verify { serviceBroadcasts.broadcastConnection() }
}
@Test
fun `Connected state sends pre-handshake heartbeat before config request`() = runTest(testDispatcher) {
val sentPackets = mutableListOf<org.meshtastic.proto.ToRadio>()
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } calls
{ call ->
sentPackets.add(call.arg(0))
}
manager = createManager(backgroundScope)
radioConnectionState.value = ConnectionState.Connected
// Advance past PRE_HANDSHAKE_SETTLE_MS (100ms) but NOT the 30s stall guard timeout
advanceTimeBy(200)
// First ToRadio should be a heartbeat, second should be want_config_id
assertEquals(2, sentPackets.size, "Expected heartbeat + want_config_id, got ${sentPackets.size} packets")
val heartbeat = sentPackets[0]
val wantConfig = sentPackets[1]
assertEquals(true, heartbeat.heartbeat != null, "First packet should be a heartbeat")
assertEquals(true, heartbeat.heartbeat!!.nonce != 0, "Heartbeat should have a non-zero nonce")
assertEquals(
org.meshtastic.core.repository.HandshakeConstants.CONFIG_NONCE,
wantConfig.want_config_id,
"Second packet should be want_config_id with CONFIG_NONCE",
)
}
@Test
fun `Disconnect during pre-handshake settle cancels config start`() = runTest(testDispatcher) {
val sentPackets = mutableListOf<org.meshtastic.proto.ToRadio>()
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } calls
{ call ->
sentPackets.add(call.arg(0))
}
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
manager = createManager(backgroundScope)
radioConnectionState.value = ConnectionState.Connected
// Advance only 50ms — within the 100ms settle window
advanceTimeBy(50)
// Should have sent only the heartbeat so far, not want_config_id
assertEquals(1, sentPackets.size, "Only heartbeat should be sent before settle completes")
// Disconnect before the settle delay completes — should cancel the pending config start
radioConnectionState.value = ConnectionState.Disconnected
advanceTimeBy(200)
// The want_config_id should NOT have been sent because the job was cancelled
val configPackets = sentPackets.filter { it.want_config_id != null }
assertEquals(0, configPackets.size, "want_config_id should not be sent after disconnect")
}
@Test
fun `Disconnected state stops services`() = runTest(testDispatcher) {
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
manager = createManager(backgroundScope)
// Transition to Connected first so that Disconnected actually does something
radioConnectionState.value = ConnectionState.Connected
advanceUntilIdle()
radioConnectionState.value = ConnectionState.Disconnected
advanceUntilIdle()
assertEquals(
ConnectionState.Disconnected,
serviceRepository.connectionState.value,
"State should be Disconnected after radio Disconnected",
)
verify { packetHandler.stopPacketQueue() }
verify { locationManager.stop() }
verify { mqttManager.stop() }
}
@Test
fun `DeviceSleep behavior when power saving is off maps to Disconnected`() = runTest(testDispatcher) {
// Power saving disabled + Role CLIENT
val config =
LocalConfig(
power = Config.PowerConfig(is_power_saving = false),
device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT),
)
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
manager = createManager(backgroundScope)
advanceUntilIdle()
radioConnectionState.value = ConnectionState.DeviceSleep
advanceUntilIdle()
assertEquals(
ConnectionState.Disconnected,
serviceRepository.connectionState.value,
"State should be Disconnected when power saving is off",
)
}
@Test
fun `DeviceSleep behavior when power saving is on stays in DeviceSleep`() = runTest(testDispatcher) {
// Power saving enabled
val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true))
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
manager = createManager(backgroundScope)
advanceUntilIdle()
radioConnectionState.value = ConnectionState.DeviceSleep
advanceUntilIdle()
assertEquals(
ConnectionState.DeviceSleep,
serviceRepository.connectionState.value,
"State should stay in DeviceSleep when power saving is on",
)
}
@Test
fun `onRadioConfigLoaded enqueues queued packets and sets time`() = runTest(testDispatcher) {
manager = createManager(backgroundScope)
val packetId = 456
everySuspend { packetRepository.getQueuedPackets() } returns listOf(dataPacket)
every { workerManager.enqueueSendMessage(any()) } returns Unit
manager.onRadioConfigLoaded()
advanceUntilIdle()
verify { workerManager.enqueueSendMessage(packetId) }
}
@Test
fun `onNodeDbReady starts MQTT and requests history`() = runTest(testDispatcher) {
val moduleConfig =
LocalModuleConfig(
mqtt = ModuleConfig.MQTTConfig(enabled = true, proxy_to_client_enabled = true),
store_forward = ModuleConfig.StoreForwardConfig(enabled = true),
)
moduleConfigFlow.value = moduleConfig
every { nodeManager.myNodeNum } returns MutableStateFlow(123)
everySuspend { radioController.requestTelemetry(any(), any(), any()) } returns Unit
every { mqttManager.startProxy(any(), any()) } returns Unit
every { historyManager.requestHistoryReplay(any(), any(), any(), any()) } returns Unit
every { nodeManager.getMyNodeInfo() } returns null
manager = createManager(backgroundScope)
manager.onNodeDbReady()
advanceUntilIdle()
verify { mqttManager.startProxy(true, true) }
verify { historyManager.requestHistoryReplay(any(), any(), any(), any()) }
}
@Test
fun `DeviceSleep timeout is capped at MAX_SLEEP_TIMEOUT_SECONDS for high ls_secs`() = runTest(testDispatcher) {
// Router with ls_secs=3600 — previously this created a 3630s timeout.
// With the cap, it should be clamped to 300s.
val config =
LocalConfig(
power = Config.PowerConfig(is_power_saving = true, ls_secs = 3600),
device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER),
)
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
manager = createManager(backgroundScope)
advanceUntilIdle()
// Transition to Connected then DeviceSleep
radioConnectionState.value = ConnectionState.Connected
advanceUntilIdle()
radioConnectionState.value = ConnectionState.DeviceSleep
advanceUntilIdle()
assertEquals(
ConnectionState.DeviceSleep,
serviceRepository.connectionState.value,
"Should be in DeviceSleep initially",
)
// Advance 300 seconds (the cap) + 1 second to trigger the timeout.
advanceTimeBy(301_000L)
assertEquals(
ConnectionState.Disconnected,
serviceRepository.connectionState.value,
"Should transition to Disconnected after capped timeout (300s), not the raw 3630s",
)
}
@Test
fun `rapid state transitions are serialized by connectionMutex`() = runTest(testDispatcher) {
// Power saving enabled so DeviceSleep is preserved (not mapped to Disconnected)
val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true))
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
// Record every state transition so we can verify ordering
val observed = mutableListOf<ConnectionState>()
every { serviceRepository.setConnectionState(any()) } calls
{ call ->
val state = call.arg<ConnectionState>(0)
observed.add(state)
connectionStateFlow.value = state
}
manager = createManager(backgroundScope)
advanceUntilIdle()
// Rapid-fire: Connected -> DeviceSleep -> Disconnected without yielding between them.
// Without the Mutex, the intermediate DeviceSleep could be missed or applied out of order.
radioConnectionState.value = ConnectionState.Connected
radioConnectionState.value = ConnectionState.DeviceSleep
radioConnectionState.value = ConnectionState.Disconnected
advanceUntilIdle()
// Verify final state
assertEquals(
ConnectionState.Disconnected,
serviceRepository.connectionState.value,
"Final state should be Disconnected after rapid transitions",
)
// Verify that all intermediate states were observed in correct order.
// Connected triggers handleConnected() which sets Connecting (handshake start),
// then DeviceSleep, then Disconnected.
assertEquals(
listOf(ConnectionState.Connecting, ConnectionState.DeviceSleep, ConnectionState.Disconnected),
observed,
"State transitions should be serialized in order: Connecting -> DeviceSleep -> Disconnected",
)
}
@Test
fun `concurrent sleep-timeout and radio state change are serialized`() {
val standardDispatcher = StandardTestDispatcher()
runTest(standardDispatcher) {
// Power saving enabled with a short ls_secs so the sleep timeout fires quickly
val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true, ls_secs = 1))
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
val observed = mutableListOf<ConnectionState>()
every { serviceRepository.setConnectionState(any()) } calls
{ call ->
val state = call.arg<ConnectionState>(0)
observed.add(state)
connectionStateFlow.value = state
}
manager = createManager(backgroundScope)
advanceUntilIdle()
// Transition to Connected -> DeviceSleep to start the sleep timer
radioConnectionState.value = ConnectionState.Connected
advanceUntilIdle()
radioConnectionState.value = ConnectionState.DeviceSleep
advanceUntilIdle()
observed.clear()
// Before the sleep timeout fires, emit Connected from the radio (simulating device
// waking up). Then let the timeout fire. The mutex ensures they don't race.
radioConnectionState.value = ConnectionState.Connected
// Advance past the sleep timeout (ls_secs=1 + 30s base = 31s)
advanceTimeBy(32_000L)
advanceUntilIdle()
// The Connected transition should have cancelled the sleep timeout, so we should
// end up in Connecting (from handleConnected), NOT Disconnected (from timeout).
assertEquals(
ConnectionState.Connecting,
serviceRepository.connectionState.value,
"Connected should cancel the sleep timeout; final state should be Connecting",
)
}
}
}

View File

@@ -1,706 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verifySuspend
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.ContactSettings
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.MeshDataMapper
import org.meshtastic.core.repository.AdminPacketHandler
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.MessageFilter
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.StoreForwardPacketHandler
import org.meshtastic.core.repository.TelemetryPacketHandler
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Position
import org.meshtastic.proto.Routing
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertNotNull
@OptIn(ExperimentalCoroutinesApi::class)
class MeshDataHandlerTest {
private lateinit var handler: MeshDataHandlerImpl
private val nodeManager: NodeManager = mock(MockMode.autofill)
private val packetHandler: PacketHandler = mock(MockMode.autofill)
private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
private val packetRepository: PacketRepository = mock(MockMode.autofill)
private val serviceBroadcasts: ServiceBroadcasts = mock(MockMode.autofill)
private val notificationManager: NotificationManager = mock(MockMode.autofill)
private val serviceNotifications: MeshServiceNotifications = mock(MockMode.autofill)
private val analytics: PlatformAnalytics = mock(MockMode.autofill)
private val dataMapper: MeshDataMapper = mock(MockMode.autofill)
private val tracerouteHandler: TracerouteHandler = mock(MockMode.autofill)
private val neighborInfoHandler: NeighborInfoHandler = mock(MockMode.autofill)
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
private val messageFilter: MessageFilter = mock(MockMode.autofill)
private val storeForwardHandler: StoreForwardPacketHandler = mock(MockMode.autofill)
private val telemetryHandler: TelemetryPacketHandler = mock(MockMode.autofill)
private val adminPacketHandler: AdminPacketHandler = mock(MockMode.autofill)
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
@BeforeTest
fun setUp() {
handler =
MeshDataHandlerImpl(
nodeManager = nodeManager,
packetHandler = packetHandler,
serviceRepository = serviceRepository,
packetRepository = lazy { packetRepository },
serviceBroadcasts = serviceBroadcasts,
notificationManager = notificationManager,
serviceNotifications = serviceNotifications,
analytics = analytics,
dataMapper = dataMapper,
tracerouteHandler = tracerouteHandler,
neighborInfoHandler = neighborInfoHandler,
radioConfigRepository = radioConfigRepository,
messageFilter = messageFilter,
storeForwardHandler = storeForwardHandler,
telemetryHandler = telemetryHandler,
adminPacketHandler = adminPacketHandler,
scope = testScope,
)
// Default: mapper returns null for empty packets, which is the safe default
every { dataMapper.toDataPacket(any()) } returns null
// Stub commonly accessed properties to avoid NPE from autofill
every { nodeManager.nodeDBbyID } returns emptyMap()
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet())
}
@Test
fun testInitialization() {
assertNotNull(handler)
}
@Test
fun `handleReceivedData returns early when dataMapper returns null`() {
val packet = MeshPacket()
every { dataMapper.toDataPacket(packet) } returns null
handler.handleReceivedData(packet, 123)
// Should not broadcast if dataMapper returns null
verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) }
}
@Test
fun `handleReceivedData does not broadcast for position from local node`() {
val myNodeNum = 123
val position = Position(latitude_i = 450000000, longitude_i = 900000000)
val packet =
MeshPacket(
from = myNodeNum,
decoded = Data(portnum = PortNum.POSITION_APP, payload = position.encode().toByteString()),
)
val dataPacket =
DataPacket(
from = DataPacket.nodeNumToDefaultId(myNodeNum),
to = DataPacket.ID_BROADCAST,
bytes = position.encode().toByteString(),
dataType = PortNum.POSITION_APP.value,
time = 1000L,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, myNodeNum)
// Position from local node: shouldBroadcast stays as !fromUs = false
verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) }
}
@Test
fun `handleReceivedData broadcasts for remote packets`() {
val myNodeNum = 123
val remoteNum = 456
val packet = MeshPacket(from = remoteNum, decoded = Data(portnum = PortNum.PRIVATE_APP))
val dataPacket =
DataPacket(
from = DataPacket.nodeNumToDefaultId(remoteNum),
to = DataPacket.ID_BROADCAST,
bytes = null,
dataType = PortNum.PRIVATE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, myNodeNum)
verify { serviceBroadcasts.broadcastReceivedData(any()) }
}
@Test
fun `handleReceivedData tracks analytics`() {
val packet = MeshPacket(from = 456, decoded = Data(portnum = PortNum.PRIVATE_APP))
val dataPacket =
DataPacket(
from = "!other",
to = DataPacket.ID_BROADCAST,
bytes = null,
dataType = PortNum.PRIVATE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, 123)
verify { analytics.track("num_data_receive", any()) }
}
// --- Position handling ---
@Test
fun `position packet delegates to nodeManager`() {
val myNodeNum = 123
val remoteNum = 456
val position = Position(latitude_i = 450000000, longitude_i = 900000000)
val packet =
MeshPacket(
from = remoteNum,
decoded = Data(portnum = PortNum.POSITION_APP, payload = position.encode().toByteString()),
)
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = position.encode().toByteString(),
dataType = PortNum.POSITION_APP.value,
time = 1000L,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, myNodeNum)
verify { nodeManager.handleReceivedPosition(remoteNum, myNodeNum, any(), 1000L) }
}
// --- NodeInfo handling ---
@Test
fun `nodeinfo packet from remote delegates to handleReceivedUser`() {
val myNodeNum = 123
val remoteNum = 456
val user = User(id = "!remote", long_name = "Remote", short_name = "R")
val packet =
MeshPacket(
from = remoteNum,
decoded = Data(portnum = PortNum.NODEINFO_APP, payload = user.encode().toByteString()),
)
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = user.encode().toByteString(),
dataType = PortNum.NODEINFO_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, myNodeNum)
verify { nodeManager.handleReceivedUser(remoteNum, any(), any(), any()) }
}
@Test
fun `nodeinfo packet from local node is ignored`() {
val myNodeNum = 123
val user = User(id = "!local", long_name = "Local", short_name = "L")
val packet =
MeshPacket(
from = myNodeNum,
decoded = Data(portnum = PortNum.NODEINFO_APP, payload = user.encode().toByteString()),
)
val dataPacket =
DataPacket(
from = "!local",
to = DataPacket.ID_BROADCAST,
bytes = user.encode().toByteString(),
dataType = PortNum.NODEINFO_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, myNodeNum)
verify(mode = dev.mokkery.verify.VerifyMode.not) { nodeManager.handleReceivedUser(any(), any(), any(), any()) }
}
// --- Paxcounter handling ---
@Test
fun `paxcounter packet delegates to nodeManager`() {
val remoteNum = 456
val pax = Paxcount(wifi = 10, ble = 5, uptime = 1000)
val packet =
MeshPacket(
from = remoteNum,
decoded = Data(portnum = PortNum.PAXCOUNTER_APP, payload = pax.encode().toByteString()),
)
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = pax.encode().toByteString(),
dataType = PortNum.PAXCOUNTER_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, 123)
verify { nodeManager.handleReceivedPaxcounter(remoteNum, any()) }
}
// --- Traceroute handling ---
@Test
fun `traceroute packet delegates to tracerouteHandler and suppresses broadcast`() {
val packet =
MeshPacket(
from = 456,
decoded = Data(portnum = PortNum.TRACEROUTE_APP, payload = byteArrayOf().toByteString()),
)
val dataPacket =
DataPacket(
from = "!remote",
to = "!local",
bytes = byteArrayOf().toByteString(),
dataType = PortNum.TRACEROUTE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, 123)
verify { tracerouteHandler.handleTraceroute(packet, any(), any()) }
verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) }
}
// --- NeighborInfo handling ---
@Test
fun `neighborinfo packet delegates to neighborInfoHandler and broadcasts`() {
val ni = NeighborInfo(node_id = 456)
val packet =
MeshPacket(
from = 456,
decoded = Data(portnum = PortNum.NEIGHBORINFO_APP, payload = ni.encode().toByteString()),
)
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = ni.encode().toByteString(),
dataType = PortNum.NEIGHBORINFO_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, 123)
verify { neighborInfoHandler.handleNeighborInfo(packet) }
verify { serviceBroadcasts.broadcastReceivedData(any()) }
}
// --- Store-and-Forward handling ---
@Test
fun `store forward packet delegates to storeForwardHandler`() {
val packet =
MeshPacket(
from = 456,
decoded = Data(portnum = PortNum.STORE_FORWARD_APP, payload = byteArrayOf().toByteString()),
)
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = byteArrayOf().toByteString(),
dataType = PortNum.STORE_FORWARD_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, 123)
verify { storeForwardHandler.handleStoreAndForward(packet, any(), 123) }
}
// --- Routing/ACK-NAK handling ---
@Test
fun `routing packet with successful ack broadcasts and removes response`() {
val routing = Routing(error_reason = Routing.Error.NONE)
val packet =
MeshPacket(
from = 456,
decoded =
Data(portnum = PortNum.ROUTING_APP, payload = routing.encode().toByteString(), request_id = 99),
)
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = routing.encode().toByteString(),
dataType = PortNum.ROUTING_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
every { nodeManager.toNodeID(456) } returns "!remote"
handler.handleReceivedData(packet, 123)
verify { packetHandler.removeResponse(99, complete = true) }
}
@Test
fun `routing packet always broadcasts`() {
val routing = Routing(error_reason = Routing.Error.NONE)
val packet =
MeshPacket(
from = 456,
decoded =
Data(portnum = PortNum.ROUTING_APP, payload = routing.encode().toByteString(), request_id = 99),
)
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = routing.encode().toByteString(),
dataType = PortNum.ROUTING_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
every { nodeManager.toNodeID(456) } returns "!remote"
handler.handleReceivedData(packet, 123)
verify { serviceBroadcasts.broadcastReceivedData(any()) }
}
// --- Telemetry handling ---
@Test
fun `telemetry packet delegates to telemetryHandler`() {
val telemetry =
Telemetry(
time = 2000,
device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 80, voltage = 4.0f),
)
val packet =
MeshPacket(
from = 456,
decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = telemetry.encode().toByteString()),
)
val dataPacket =
DataPacket(
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = telemetry.encode().toByteString(),
dataType = PortNum.TELEMETRY_APP.value,
time = 2000000L,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, 123)
verify { telemetryHandler.handleTelemetry(packet, any(), 123) }
}
@Test
fun `telemetry from local node delegates to telemetryHandler`() {
val myNodeNum = 123
val telemetry =
Telemetry(
time = 2000,
device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 80, voltage = 4.0f),
)
val packet =
MeshPacket(
from = myNodeNum,
decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = telemetry.encode().toByteString()),
)
val dataPacket =
DataPacket(
from = "!local",
to = DataPacket.ID_BROADCAST,
bytes = telemetry.encode().toByteString(),
dataType = PortNum.TELEMETRY_APP.value,
time = 2000000L,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, myNodeNum)
verify { telemetryHandler.handleTelemetry(packet, any(), myNodeNum) }
}
// --- Text message handling ---
@Test
fun `text message is persisted via rememberDataPacket`() = testScope.runTest {
val packet =
MeshPacket(
id = 42,
from = 456,
decoded =
Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()),
)
val dataPacket =
DataPacket(
id = 42,
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = "hello".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
everySuspend { packetRepository.findPacketsWithId(42) } returns emptyList()
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
every { messageFilter.shouldFilter(any(), any()) } returns false
// Provide sender node so getSenderName() doesn't fall back to getString (requires Skiko)
every { nodeManager.nodeDBbyID } returns
mapOf(
"!remote" to
Node(num = 456, user = User(id = "!remote", long_name = "Remote User", short_name = "RU")),
)
handler.handleReceivedData(packet, 123)
advanceUntilIdle()
verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), any()) }
}
@Test
fun `duplicate text message is not inserted again`() = testScope.runTest {
val packet =
MeshPacket(
id = 42,
from = 456,
decoded =
Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()),
)
val dataPacket =
DataPacket(
id = 42,
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = "hello".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
// Return existing packet on duplicate check
everySuspend { packetRepository.findPacketsWithId(42) } returns listOf(dataPacket)
handler.handleReceivedData(packet, 123)
advanceUntilIdle()
verifySuspend(mode = dev.mokkery.verify.VerifyMode.not) {
packetRepository.insert(any(), any(), any(), any(), any(), any())
}
}
// --- Reaction handling ---
@Test
fun `text with reply_id and emoji is treated as reaction`() = testScope.runTest {
val emojiBytes = "👍".encodeToByteArray()
val packet =
MeshPacket(
id = 99,
from = 456,
to = 123,
decoded =
Data(
portnum = PortNum.TEXT_MESSAGE_APP,
payload = emojiBytes.toByteString(),
reply_id = 42,
emoji = 1,
),
)
val dataPacket =
DataPacket(
id = 99,
from = "!remote",
to = "!local",
bytes = emojiBytes.toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
every { nodeManager.nodeDBbyNodeNum } returns
mapOf(
456 to Node(num = 456, user = User(id = "!remote")),
123 to Node(num = 123, user = User(id = "!local")),
)
everySuspend { packetRepository.findReactionsWithId(99) } returns emptyList()
every { nodeManager.myNodeNum } returns MutableStateFlow(123)
everySuspend { packetRepository.getPacketByPacketId(42) } returns null
handler.handleReceivedData(packet, 123)
advanceUntilIdle()
verifySuspend { packetRepository.insertReaction(any(), 123) }
}
// --- Range test / detection sensor handling ---
@Test
fun `range test packet is remembered as text message type`() = testScope.runTest {
val packet =
MeshPacket(
id = 55,
from = 456,
decoded =
Data(portnum = PortNum.RANGE_TEST_APP, payload = "test".encodeToByteArray().toByteString()),
)
val dataPacket =
DataPacket(
id = 55,
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = "test".encodeToByteArray().toByteString(),
dataType = PortNum.RANGE_TEST_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
everySuspend { packetRepository.findPacketsWithId(55) } returns emptyList()
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
every { messageFilter.shouldFilter(any(), any()) } returns false
every { nodeManager.nodeDBbyID } returns
mapOf(
"!remote" to
Node(num = 456, user = User(id = "!remote", long_name = "Remote User", short_name = "RU")),
)
handler.handleReceivedData(packet, 123)
advanceUntilIdle()
// Range test should be remembered with TEXT_MESSAGE_APP dataType
verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), any()) }
}
// --- Admin message handling ---
@Test
fun `admin message delegates to adminPacketHandler`() {
val admin = org.meshtastic.proto.AdminMessage(session_passkey = okio.ByteString.of(1, 2, 3))
val packet =
MeshPacket(from = 123, decoded = Data(portnum = PortNum.ADMIN_APP, payload = admin.encode().toByteString()))
val dataPacket =
DataPacket(
from = "!local",
to = DataPacket.ID_BROADCAST,
bytes = admin.encode().toByteString(),
dataType = PortNum.ADMIN_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
handler.handleReceivedData(packet, 123)
verify { adminPacketHandler.handleAdminMessage(packet, 123) }
}
// --- Message filtering ---
@Test
fun `filtered message is inserted with filtered flag`() = testScope.runTest {
val packet =
MeshPacket(
id = 77,
from = 456,
decoded =
Data(
portnum = PortNum.TEXT_MESSAGE_APP,
payload = "spam content".encodeToByteArray().toByteString(),
),
)
val dataPacket =
DataPacket(
id = 77,
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = "spam content".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
everySuspend { packetRepository.findPacketsWithId(77) } returns emptyList()
every { nodeManager.nodeDBbyID } returns emptyMap()
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
every { messageFilter.shouldFilter("spam content", false) } returns true
handler.handleReceivedData(packet, 123)
advanceUntilIdle()
// Verify insert was called with filtered = true (6th param)
verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), filtered = true) }
}
@Test
fun `message from ignored node is filtered`() = testScope.runTest {
val packet =
MeshPacket(
id = 88,
from = 456,
decoded =
Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()),
)
val dataPacket =
DataPacket(
id = 88,
from = "!remote",
to = DataPacket.ID_BROADCAST,
bytes = "hello".encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
every { dataMapper.toDataPacket(packet) } returns dataPacket
everySuspend { packetRepository.findPacketsWithId(88) } returns emptyList()
every { nodeManager.nodeDBbyID } returns
mapOf("!remote" to Node(num = 456, user = User(id = "!remote"), isIgnored = true))
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
handler.handleReceivedData(packet, 123)
advanceUntilIdle()
verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), filtered = true) }
}
}

View File

@@ -25,7 +25,6 @@ import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.HardwareModel
@@ -43,7 +42,6 @@ import org.meshtastic.proto.Position as ProtoPosition
class NodeManagerImplTest {
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
private val serviceBroadcasts: ServiceBroadcasts = mock(MockMode.autofill)
private val notificationManager: NotificationManager = mock(MockMode.autofill)
private val testScope = TestScope()
@@ -51,7 +49,7 @@ class NodeManagerImplTest {
@BeforeTest
fun setUp() {
nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, notificationManager, testScope)
nodeManager = NodeManagerImpl(nodeRepository, notificationManager, testScope)
}
@Test

View File

@@ -1,143 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verifySuspend
import io.kotest.property.Arb
import io.kotest.property.arbitrary.int
import io.kotest.property.checkAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.QueueStatus
import org.meshtastic.proto.ToRadio
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertNotNull
class PacketHandlerImplTest {
private val packetRepository: PacketRepository = mock(MockMode.autofill)
private val serviceBroadcasts: ServiceBroadcasts = mock(MockMode.autofill)
private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill)
private val meshLogRepository: MeshLogRepository = mock(MockMode.autofill)
private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
private val connectionStateFlow = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private lateinit var handler: PacketHandlerImpl
@BeforeTest
fun setUp() {
every { serviceRepository.connectionState } returns connectionStateFlow
handler =
PacketHandlerImpl(
lazy { packetRepository },
serviceBroadcasts,
radioInterfaceService,
lazy { meshLogRepository },
serviceRepository,
testScope,
)
}
@Test
fun testInitialization() {
assertNotNull(handler)
}
@Test
fun `sendToRadio with ToRadio sends immediately`() {
val toRadio = ToRadio(packet = MeshPacket(id = 123))
handler.sendToRadio(toRadio)
verify { radioInterfaceService.sendToRadio(any()) }
}
@Test
fun `sendToRadio with MeshPacket queues and sends when connected`() = runTest(testDispatcher) {
val packet = MeshPacket(id = 456)
connectionStateFlow.value = ConnectionState.Connected
handler.sendToRadio(packet)
testScheduler.runCurrent()
verify { radioInterfaceService.sendToRadio(any()) }
}
@Test
fun `handleQueueStatus completes deferred`() = runTest(testDispatcher) {
val packet = MeshPacket(id = 789)
connectionStateFlow.value = ConnectionState.Connected
handler.sendToRadio(packet)
testScheduler.runCurrent()
val status =
QueueStatus(
mesh_packet_id = 789,
res = 0, // Success
free = 1,
)
handler.handleQueueStatus(status)
testScheduler.runCurrent()
}
@Test
fun `handleQueueStatus property test`() = runTest(testDispatcher) {
checkAll(Arb.int(0, 10), Arb.int(0, 32), Arb.int(0, 100000)) { res, free, packetId ->
val status = QueueStatus(res = res, free = free, mesh_packet_id = packetId)
// Ensure it doesn't crash on any input
handler.handleQueueStatus(status)
testScheduler.runCurrent()
}
}
@Test
fun `outgoing packets are logged with NODE_NUM_LOCAL`() = runTest(testDispatcher) {
val packet = MeshPacket(id = 123, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP))
val toRadio = ToRadio(packet = packet)
handler.sendToRadio(toRadio)
testScheduler.runCurrent()
verifySuspend { meshLogRepository.insert(any()) }
}
}

View File

@@ -36,7 +36,6 @@ import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
@@ -50,7 +49,6 @@ class StoreForwardPacketHandlerImplTest {
private val nodeManager = mock<NodeManager>(MockMode.autofill)
private val packetRepository = mock<PacketRepository>(MockMode.autofill)
private val serviceBroadcasts = mock<ServiceBroadcasts>(MockMode.autofill)
private val historyManager = mock<HistoryManager>(MockMode.autofill)
private val dataHandler = mock<MeshDataHandler>(MockMode.autofill)
@@ -69,7 +67,6 @@ class StoreForwardPacketHandlerImplTest {
StoreForwardPacketHandlerImpl(
nodeManager = nodeManager,
packetRepository = lazy { packetRepository },
serviceBroadcasts = serviceBroadcasts,
historyManager = historyManager,
dataHandler = lazy { dataHandler },
scope = testScope,
@@ -222,7 +219,6 @@ class StoreForwardPacketHandlerImplTest {
advanceUntilIdle()
verifySuspend { packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any()) }
verify { serviceBroadcasts.broadcastMessageStatus(42, any()) }
}
// ---------- SF++: CANON_ANNOUNCE ----------

View File

@@ -27,7 +27,6 @@ import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.proto.Data
@@ -44,7 +43,6 @@ import kotlin.test.Test
class TelemetryPacketHandlerImplTest {
private val nodeManager = mock<NodeManager>(MockMode.autofill)
private val connectionManager = mock<MeshConnectionManager>(MockMode.autofill)
private val notificationManager = mock<NotificationManager>(MockMode.autofill)
private val testDispatcher = StandardTestDispatcher()
@@ -60,7 +58,6 @@ class TelemetryPacketHandlerImplTest {
handler =
TelemetryPacketHandlerImpl(
nodeManager = nodeManager,
connectionManager = lazy { connectionManager },
notificationManager = notificationManager,
scope = testScope,
)
@@ -87,7 +84,7 @@ class TelemetryPacketHandlerImplTest {
// ---------- Device metrics from local node ----------
@Test
fun `local device metrics updates telemetry on connectionManager`() = testScope.runTest {
fun `local device metrics updates node`() = testScope.runTest {
val telemetry =
Telemetry(time = 1700000000, device_metrics = DeviceMetrics(battery_level = 80, voltage = 4.1f))
val packet = makeTelemetryPacket(myNodeNum, telemetry)
@@ -96,14 +93,13 @@ class TelemetryPacketHandlerImplTest {
handler.handleTelemetry(packet, dataPacket, myNodeNum)
advanceUntilIdle()
verify { connectionManager.updateTelemetry(any()) }
verify { nodeManager.updateNode(myNodeNum, any(), any(), any()) }
}
// ---------- Device metrics from remote node ----------
@Test
fun `remote device metrics updates node but not connectionManager`() = testScope.runTest {
fun `remote device metrics updates node`() = testScope.runTest {
val telemetry =
Telemetry(time = 1700000000, device_metrics = DeviceMetrics(battery_level = 90, voltage = 4.2f))
val packet = makeTelemetryPacket(remoteNodeNum, telemetry)

View File

@@ -1,123 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.repository
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.mock
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.meshtastic.core.data.datasource.NodeInfoReadDataSource
import org.meshtastic.core.data.datasource.NodeInfoWriteDataSource
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeWithRelations
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.testing.FakeLocalStatsDataSource
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
abstract class CommonNodeRepositoryTest {
protected lateinit var lifecycleOwner: LifecycleOwner
protected lateinit var readDataSource: NodeInfoReadDataSource
protected lateinit var writeDataSource: NodeInfoWriteDataSource
protected lateinit var localStatsDataSource: FakeLocalStatsDataSource
private val testDispatcher = UnconfinedTestDispatcher()
private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
private val myNodeInfoFlow = MutableStateFlow<MyNodeEntity?>(null)
protected lateinit var repository: NodeRepositoryImpl
fun setupRepo() {
Dispatchers.setMain(testDispatcher)
lifecycleOwner =
object : LifecycleOwner {
override val lifecycle = LifecycleRegistry(this)
}
(lifecycleOwner.lifecycle as LifecycleRegistry).handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
readDataSource = mock(MockMode.autofill)
writeDataSource = mock(MockMode.autofill)
localStatsDataSource = FakeLocalStatsDataSource()
every { readDataSource.myNodeInfoFlow() } returns myNodeInfoFlow
every { readDataSource.nodeDBbyNumFlow() } returns MutableStateFlow<Map<Int, NodeWithRelations>>(emptyMap())
repository =
NodeRepositoryImpl(
lifecycleOwner.lifecycle,
readDataSource,
writeDataSource,
dispatchers,
localStatsDataSource,
)
}
@AfterTest
fun tearDown() {
// Essential to stop background jobs in NodeRepositoryImpl
(lifecycleOwner.lifecycle as LifecycleRegistry).handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
Dispatchers.resetMain()
}
private fun createMyNodeEntity(nodeNum: Int) = MyNodeEntity(
myNodeNum = nodeNum,
model = "model",
firmwareVersion = "1.0",
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 0L,
messageTimeoutMsec = 0,
minAppVersion = 0,
maxChannels = 0,
hasWifi = false,
)
@Test
fun `effectiveLogNodeId maps local node number to NODE_NUM_LOCAL`() = runTest(testDispatcher) {
val myNodeNum = 12345
myNodeInfoFlow.value = createMyNodeEntity(myNodeNum)
val result = repository.effectiveLogNodeId(myNodeNum).filter { it == MeshLog.NODE_NUM_LOCAL }.first()
assertEquals(MeshLog.NODE_NUM_LOCAL, result)
}
@Test
fun `effectiveLogNodeId preserves remote node numbers`() = runTest(testDispatcher) {
val myNodeNum = 12345
val remoteNodeNum = 67890
myNodeInfoFlow.value = createMyNodeEntity(myNodeNum)
val result = repository.effectiveLogNodeId(remoteNodeNum).first()
assertEquals(remoteNodeNum, result)
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -23,11 +23,14 @@ import androidx.room3.DeleteTable
import androidx.room3.RoomDatabase
import androidx.room3.TypeConverters
import androidx.room3.migration.AutoMigrationSpec
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.execSQL
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.database.dao.DeviceHardwareDao
import org.meshtastic.core.database.dao.FirmwareReleaseDao
import org.meshtastic.core.database.dao.MeshLogDao
import org.meshtastic.core.database.dao.NodeInfoDao
import org.meshtastic.core.database.dao.NodeMetadataDao
import org.meshtastic.core.database.dao.PacketDao
import org.meshtastic.core.database.dao.QuickChatActionDao
import org.meshtastic.core.database.dao.TracerouteNodePositionDao
@@ -38,6 +41,7 @@ import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.database.entity.NodeMetadataEntity
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.entity.QuickChatAction
import org.meshtastic.core.database.entity.ReactionEntity
@@ -48,6 +52,7 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
[
MyNodeEntity::class,
NodeEntity::class,
NodeMetadataEntity::class,
Packet::class,
ContactSettings::class,
MeshLog::class,
@@ -95,8 +100,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
AutoMigration(from = 35, to = 36),
AutoMigration(from = 36, to = 37),
AutoMigration(from = 37, to = 38),
AutoMigration(from = 38, to = 39, spec = AutoMigration38to39::class),
],
version = 38,
version = 39,
exportSchema = true,
)
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)
@@ -105,6 +111,8 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
abstract class MeshtasticDatabase : RoomDatabase() {
abstract fun nodeInfoDao(): NodeInfoDao
abstract fun nodeMetadataDao(): NodeMetadataDao
abstract fun packetDao(): PacketDao
abstract fun meshLogDao(): MeshLogDao
@@ -138,3 +146,17 @@ class AutoMigration33to34 : AutoMigrationSpec
@DeleteColumn(tableName = "packet", columnName = "retry_count")
@DeleteColumn(tableName = "reactions", columnName = "retry_count")
class AutoMigration34to35 : AutoMigrationSpec
/** Copies favorites, notes, ignored, muted, and manuallyVerified from nodes → node_metadata. */
class AutoMigration38to39 : AutoMigrationSpec {
override suspend fun onPostMigrate(connection: SQLiteConnection) {
connection.execSQL(
"""
INSERT OR IGNORE INTO node_metadata (num, is_favorite, is_ignored, is_muted, notes, manually_verified)
SELECT num, is_favorite, is_ignored, is_muted, notes, manually_verified
FROM nodes
WHERE is_favorite = 1 OR is_ignored = 1 OR is_muted = 1 OR notes != '' OR manually_verified = 1
""".trimIndent()
)
}
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.dao
import androidx.room3.Dao
import androidx.room3.Query
import androidx.room3.Upsert
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.database.entity.NodeMetadataEntity
@Dao
interface NodeMetadataDao {
@Upsert
suspend fun upsert(metadata: NodeMetadataEntity)
@Query("SELECT * FROM node_metadata")
fun getAllFlow(): Flow<List<NodeMetadataEntity>>
@Query("SELECT * FROM node_metadata WHERE num = :num")
suspend fun getByNum(num: Int): NodeMetadataEntity?
@Query("UPDATE node_metadata SET is_favorite = :isFavorite WHERE num = :num")
suspend fun setFavorite(num: Int, isFavorite: Boolean)
@Query("UPDATE node_metadata SET is_ignored = :isIgnored WHERE num = :num")
suspend fun setIgnored(num: Int, isIgnored: Boolean)
@Query("UPDATE node_metadata SET is_muted = :isMuted WHERE num = :num")
suspend fun setMuted(num: Int, isMuted: Boolean)
@Query("UPDATE node_metadata SET notes = :notes WHERE num = :num")
suspend fun setNotes(num: Int, notes: String)
@Query("UPDATE node_metadata SET manually_verified = :verified WHERE num = :num")
suspend fun setManuallyVerified(num: Int, verified: Boolean)
@Query("DELETE FROM node_metadata WHERE num = :num")
suspend fun delete(num: Int)
@Query("DELETE FROM node_metadata")
suspend fun deleteAll()
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.entity
import androidx.room3.ColumnInfo
import androidx.room3.Entity
import androidx.room3.PrimaryKey
/**
* Persists app-local node metadata that survives process death.
* These fields are user preferences/annotations that the SDK does not manage.
*/
@Entity(tableName = "node_metadata")
data class NodeMetadataEntity(
@PrimaryKey val num: Int,
@ColumnInfo(name = "is_favorite", defaultValue = "0") val isFavorite: Boolean = false,
@ColumnInfo(name = "is_ignored", defaultValue = "0") val isIgnored: Boolean = false,
@ColumnInfo(name = "is_muted", defaultValue = "0") val isMuted: Boolean = false,
@ColumnInfo(name = "notes", defaultValue = "") val notes: String = "",
@ColumnInfo(name = "manually_verified", defaultValue = "0") val manuallyVerified: Boolean = false,
)

View File

@@ -1,27 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.UiPrefs
@Single
open class SetAppIntroCompletedUseCase constructor(private val uiPrefs: UiPrefs) {
operator fun invoke(value: Boolean) {
uiPrefs.setAppIntroCompleted(value)
}
}

View File

@@ -1,30 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.database.DatabaseConstants
/** Use case for setting the database cache limit. */
@Single
open class SetDatabaseCacheLimitUseCase constructor(private val databaseManager: DatabaseManager) {
operator fun invoke(limit: Int) {
val clamped = limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
databaseManager.setCacheLimit(clamped)
}
}

View File

@@ -1,27 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.UiPrefs
@Single
open class SetLocaleUseCase constructor(private val uiPrefs: UiPrefs) {
operator fun invoke(value: String) {
uiPrefs.setLocale(value)
}
}

View File

@@ -1,30 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.NotificationPrefs
/** Use case for updating application-level notification preferences. */
@Single
class SetNotificationSettingsUseCase(private val notificationPrefs: NotificationPrefs) {
fun setMessagesEnabled(enabled: Boolean) = notificationPrefs.setMessagesEnabled(enabled)
fun setNodeEventsEnabled(enabled: Boolean) = notificationPrefs.setNodeEventsEnabled(enabled)
fun setLowBatteryEnabled(enabled: Boolean) = notificationPrefs.setLowBatteryEnabled(enabled)
}

View File

@@ -1,27 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.UiPrefs
@Single
open class SetProvideLocationUseCase constructor(private val uiPrefs: UiPrefs) {
operator fun invoke(myNodeNum: Int, provideLocation: Boolean) {
uiPrefs.setShouldProvideNodeLocation(myNodeNum, provideLocation)
}
}

View File

@@ -1,27 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.UiPrefs
@Single
open class SetThemeUseCase constructor(private val uiPrefs: UiPrefs) {
operator fun invoke(value: Int) {
uiPrefs.setTheme(value)
}
}

View File

@@ -1,28 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.AnalyticsPrefs
/** Use case for toggling the analytics preference. */
@Single
open class ToggleAnalyticsUseCase constructor(private val analyticsPrefs: AnalyticsPrefs) {
open operator fun invoke() {
analyticsPrefs.setAnalyticsAllowed(!analyticsPrefs.analyticsAllowed.value)
}
}

View File

@@ -1,28 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.settings
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.HomoglyphPrefs
/** Use case for toggling the homoglyph encoding preference. */
@Single
open class ToggleHomoglyphEncodingUseCase constructor(private val homoglyphEncodingPrefs: HomoglyphPrefs) {
open operator fun invoke() {
homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(!homoglyphEncodingPrefs.homoglyphEncodingEnabled.value)
}
}

View File

@@ -1,49 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.settings
import dev.mokkery.mock
import dev.mokkery.verify
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.database.DatabaseConstants
import kotlin.test.BeforeTest
import kotlin.test.Test
class SetDatabaseCacheLimitUseCaseTest {
private lateinit var databaseManager: DatabaseManager
private lateinit var useCase: SetDatabaseCacheLimitUseCase
@BeforeTest
fun setUp() {
databaseManager = mock(dev.mokkery.MockMode.autofill)
useCase = SetDatabaseCacheLimitUseCase(databaseManager)
}
@Test
fun `invoke calls setCacheLimit with clamped value`() {
// Act & Assert
useCase(0)
verify { databaseManager.setCacheLimit(DatabaseConstants.MIN_CACHE_LIMIT) }
useCase(100)
verify { databaseManager.setCacheLimit(DatabaseConstants.MAX_CACHE_LIMIT) }
useCase(5)
verify { databaseManager.setCacheLimit(5) }
}
}

View File

@@ -1,58 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.settings
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import org.meshtastic.core.repository.NotificationPrefs
import kotlin.test.BeforeTest
import kotlin.test.Test
class SetNotificationSettingsUseCaseTest {
private val notificationPrefs: NotificationPrefs = mock()
private lateinit var useCase: SetNotificationSettingsUseCase
@BeforeTest
fun setUp() {
useCase = SetNotificationSettingsUseCase(notificationPrefs)
}
@Test
fun `setMessagesEnabled calls notificationPrefs`() {
every { notificationPrefs.setMessagesEnabled(any()) } returns Unit
useCase.setMessagesEnabled(true)
verify { notificationPrefs.setMessagesEnabled(true) }
}
@Test
fun `setNodeEventsEnabled calls notificationPrefs`() {
every { notificationPrefs.setNodeEventsEnabled(any()) } returns Unit
useCase.setNodeEventsEnabled(false)
verify { notificationPrefs.setNodeEventsEnabled(false) }
}
@Test
fun `setLowBatteryEnabled calls notificationPrefs`() {
every { notificationPrefs.setLowBatteryEnabled(any()) } returns Unit
useCase.setLowBatteryEnabled(true)
verify { notificationPrefs.setLowBatteryEnabled(true) }
}
}

View File

@@ -1,48 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.settings
import org.meshtastic.core.testing.FakeAnalyticsPrefs
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
class ToggleAnalyticsUseCaseTest {
private lateinit var analyticsPrefs: FakeAnalyticsPrefs
private lateinit var useCase: ToggleAnalyticsUseCase
@BeforeTest
fun setUp() {
analyticsPrefs = FakeAnalyticsPrefs()
useCase = ToggleAnalyticsUseCase(analyticsPrefs)
}
@Test
fun `invoke toggles from false to true`() {
analyticsPrefs.setAnalyticsAllowed(false)
useCase()
assertEquals(true, analyticsPrefs.analyticsAllowed.value)
}
@Test
fun `invoke toggles from true to false`() {
analyticsPrefs.setAnalyticsAllowed(true)
useCase()
assertEquals(false, analyticsPrefs.analyticsAllowed.value)
}
}

View File

@@ -1,48 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.settings
import org.meshtastic.core.testing.FakeHomoglyphPrefs
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
class ToggleHomoglyphEncodingUseCaseTest {
private lateinit var homoglyphPrefs: FakeHomoglyphPrefs
private lateinit var useCase: ToggleHomoglyphEncodingUseCase
@BeforeTest
fun setUp() {
homoglyphPrefs = FakeHomoglyphPrefs()
useCase = ToggleHomoglyphEncodingUseCase(homoglyphPrefs)
}
@Test
fun `invoke toggles from false to true`() {
homoglyphPrefs.setHomoglyphEncodingEnabled(false)
useCase()
assertEquals(true, homoglyphPrefs.homoglyphEncodingEnabled.value)
}
@Test
fun `invoke toggles from true to false`() {
homoglyphPrefs.setHomoglyphEncodingEnabled(true)
useCase()
assertEquals(false, homoglyphPrefs.homoglyphEncodingEnabled.value)
}
}

View File

@@ -1,30 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.repository
import org.meshtastic.proto.MeshPacket
/** Interface for handling admin messages from the mesh (config, metadata, session passkey). */
interface AdminPacketHandler {
/**
* Processes an admin message packet.
*
* @param packet The received mesh packet.
* @param myNodeNum The local node number.
*/
fun handleAdminMessage(packet: MeshPacket, myNodeNum: Int)
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.repository
import kotlinx.coroutines.flow.Flow
/**
* App-local node metadata that persists independently of the SDK's node database.
*
* This covers user annotations (favorites, notes, mute, ignore) that are NOT synced to the radio.
* VMs and feature modules inject this instead of the full [NodeRepository] when they only need
* metadata operations.
*/
interface AppMetadataRepository {
/** Flow of all node metadata, keyed by node number. */
val metadataByNum: Flow<Map<Int, NodeMetadata>>
suspend fun setFavorite(nodeNum: Int, isFavorite: Boolean)
suspend fun setIgnored(nodeNum: Int, isIgnored: Boolean)
suspend fun setMuted(nodeNum: Int, isMuted: Boolean)
suspend fun setNotes(nodeNum: Int, notes: String)
suspend fun setManuallyVerified(nodeNum: Int, verified: Boolean)
suspend fun delete(nodeNum: Int)
}
/** Lightweight metadata value object exposed to feature modules. */
data class NodeMetadata(
val num: Int,
val isFavorite: Boolean = false,
val isIgnored: Boolean = false,
val isMuted: Boolean = false,
val notes: String? = null,
val manuallyVerified: Boolean = false,
)

View File

@@ -1,86 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.repository
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Position
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.LocalConfig
/** Interface for sending commands and packets to the mesh network. */
@Suppress("TooManyFunctions")
interface CommandSender {
/** Returns the current packet ID. */
fun getCurrentPacketId(): Long
/** Returns the cached local configuration. */
fun getCachedLocalConfig(): LocalConfig
/** Returns the cached channel set. */
fun getCachedChannelSet(): ChannelSet
/** Generates a new unique packet ID. */
fun generatePacketId(): Int
/** Sends a data packet to the mesh. */
fun sendData(p: DataPacket)
/** Sends an admin message to a specific node. */
fun sendAdmin(
destNum: Int,
requestId: Int = generatePacketId(),
wantResponse: Boolean = false,
initFn: () -> AdminMessage,
)
/**
* Sends an admin message and suspends until the radio acknowledges it.
*
* This is used when the caller needs to guarantee a packet has been accepted by the radio before proceeding, such
* as sending a shared contact before the first DM to a node.
*
* @return `true` if the radio accepted the packet, `false` on timeout or failure.
*/
suspend fun sendAdminAwait(
destNum: Int,
requestId: Int = generatePacketId(),
wantResponse: Boolean = false,
initFn: () -> AdminMessage,
): Boolean
/** Sends our current position to the mesh. */
fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int? = null, wantResponse: Boolean = false)
/** Requests the position of a specific node. */
fun requestPosition(destNum: Int, currentPosition: Position)
/** Sets a fixed position for a node. */
fun setFixedPosition(destNum: Int, pos: Position)
/** Requests user info from a specific node. */
fun requestUserInfo(destNum: Int)
/** Requests a traceroute to a specific node. */
fun requestTraceroute(requestId: Int, destNum: Int)
/** Requests telemetry from a specific node. */
fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int)
/** Requests neighbor info from a specific node. */
fun requestNeighborInfo(requestId: Int, destNum: Int)
}

View File

@@ -1,119 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.repository
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.service.ServiceAction
/** Interface for handling UI-triggered actions and administrative commands for the mesh. */
@Suppress("TooManyFunctions")
interface MeshActionHandler {
/** Processes a service action from the UI. */
suspend fun onServiceAction(action: ServiceAction)
/** Sets the owner of the local node. */
fun handleSetOwner(u: MeshUser, myNodeNum: Int)
/** Sends a data packet through the mesh. */
fun handleSend(p: DataPacket, myNodeNum: Int)
/** Requests the position of a remote node. */
fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int)
/** Removes a node from the database by its node number. */
fun handleRemoveByNodenum(nodeNum: Int, requestId: Int, myNodeNum: Int)
/** Sets the owner of a remote node. */
fun handleSetRemoteOwner(id: Int, destNum: Int, payload: ByteArray)
/** Gets the owner of a remote node. */
fun handleGetRemoteOwner(id: Int, destNum: Int)
/** Sets the configuration of the local node. */
fun handleSetConfig(payload: ByteArray, myNodeNum: Int)
/** Sets the configuration of a remote node. */
fun handleSetRemoteConfig(id: Int, destNum: Int, payload: ByteArray)
/** Gets the configuration of a remote node. */
fun handleGetRemoteConfig(id: Int, destNum: Int, config: Int)
/** Sets the module configuration of a remote node. */
fun handleSetModuleConfig(id: Int, destNum: Int, payload: ByteArray)
/** Gets the module configuration of a remote node. */
fun handleGetModuleConfig(id: Int, destNum: Int, config: Int)
/** Sets the ringtone of a remote node. */
fun handleSetRingtone(destNum: Int, ringtone: String)
/** Gets the ringtone of a remote node. */
fun handleGetRingtone(id: Int, destNum: Int)
/** Sets canned messages on a remote node. */
fun handleSetCannedMessages(destNum: Int, messages: String)
/** Gets canned messages from a remote node. */
fun handleGetCannedMessages(id: Int, destNum: Int)
/** Sets a channel configuration on the local node. */
fun handleSetChannel(payload: ByteArray?, myNodeNum: Int)
/** Sets a channel configuration on a remote node. */
fun handleSetRemoteChannel(id: Int, destNum: Int, payload: ByteArray?)
/** Gets a channel configuration from a remote node. */
fun handleGetRemoteChannel(id: Int, destNum: Int, index: Int)
/** Requests neighbor information from a remote node. */
fun handleRequestNeighborInfo(requestId: Int, destNum: Int)
/** Begins editing settings on a remote node. */
fun handleBeginEditSettings(destNum: Int)
/** Commits settings edits on a remote node. */
fun handleCommitEditSettings(destNum: Int)
/** Reboots a remote node into DFU mode. */
fun handleRebootToDfu(destNum: Int)
/** Requests telemetry from a remote node. */
fun handleRequestTelemetry(requestId: Int, destNum: Int, type: Int)
/** Requests a remote node to shut down. */
fun handleRequestShutdown(requestId: Int, destNum: Int)
/** Requests a remote node to reboot. */
fun handleRequestReboot(requestId: Int, destNum: Int)
/** Requests a remote node to reboot in OTA mode. */
fun handleRequestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?)
/** Requests a factory reset on a remote node. */
fun handleRequestFactoryReset(requestId: Int, destNum: Int)
/** Requests a node database reset on a remote node. */
fun handleRequestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean)
/** Gets the connection status of a remote node. */
fun handleGetDeviceConnectionStatus(requestId: Int, destNum: Int)
/** Updates the last used device address. */
fun handleUpdateLastAddress(deviceAddr: String?)
}

View File

@@ -1,51 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.repository
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.FileInfo
import org.meshtastic.proto.MyNodeInfo
import org.meshtastic.proto.NodeInfo
/** Interface for managing the configuration flow, including local node info and metadata. */
interface MeshConfigFlowManager {
/** Handles received local node information. */
fun handleMyInfo(myInfo: MyNodeInfo)
/** Handles received local device metadata. */
fun handleLocalMetadata(metadata: DeviceMetadata)
/** Handles received node information. */
fun handleNodeInfo(info: NodeInfo)
/**
* Handles a [FileInfo] packet received during STATE_SEND_FILEMANIFEST.
*
* Each packet describes one file available on the device. Accumulated into [RadioConfigRepository.fileManifestFlow]
* and cleared at the start of each new handshake.
*/
fun handleFileInfo(info: FileInfo)
/** Returns the number of nodes received in the current stage. */
val newNodeCount: Int
/** Handles the completion of a configuration stage. */
fun handleConfigComplete(configCompleteId: Int)
/** Triggers a request for the full device configuration. */
fun triggerWantConfig()
}

View File

@@ -1,40 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.repository
import org.meshtastic.proto.Telemetry
/** Interface for managing the connection lifecycle and status with the mesh radio. */
interface MeshConnectionManager {
/** Called when the radio configuration has been fully loaded. */
fun onRadioConfigLoaded()
/** Initiates the configuration synchronization stage. */
fun startConfigOnly()
/** Initiates the node information synchronization stage. */
fun startNodeInfoOnly()
/** Called when the node database is ready and fully populated. */
fun onNodeDbReady()
/** Updates the telemetry information for the local node. */
fun updateTelemetry(t: Telemetry)
/** Updates the current status notification. */
fun updateStatusNotification(telemetry: Telemetry? = null)
}

View File

@@ -1,44 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.repository
/** Interface for the central router that orchestrates specialized mesh packet handlers. */
interface MeshRouter {
/** Access to the data handler. */
val dataHandler: MeshDataHandler
/** Access to the configuration handler. */
val configHandler: MeshConfigHandler
/** Access to the traceroute handler. */
val tracerouteHandler: TracerouteHandler
/** Access to the neighbor info handler. */
val neighborInfoHandler: NeighborInfoHandler
/** Access to the configuration flow manager. */
val configFlowManager: MeshConfigFlowManager
/** Access to the MQTT manager. */
val mqttManager: MqttManager
/** Access to the action handler. */
val actionHandler: MeshActionHandler
/** Access to the XModem file-transfer manager. */
val xmodemManager: XModemManager
}

View File

@@ -1,39 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.repository
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Node
/** Interface for broadcasting service-level events to the application. */
interface ServiceBroadcasts {
/** Subscribes a receiver to mesh broadcasts. */
fun subscribeReceiver(receiverName: String, packageName: String)
/** Broadcasts received data to the application. */
fun broadcastReceivedData(dataPacket: DataPacket)
/** Broadcasts that the radio connection state has changed. */
fun broadcastConnection()
/** Broadcasts that node information has changed. */
fun broadcastNodeChange(node: Node)
/** Broadcasts that the status of a message has changed. */
fun broadcastMessageStatus(packetId: Int, status: MessageStatus)
}

View File

@@ -1,135 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.service
import android.app.Application
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import co.touchlab.kermit.Severity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asFlow
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.model.service.TracerouteResponse
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.MeshPacket
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import kotlin.test.assertEquals
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ServiceBroadcastsTest {
private lateinit var context: Context
private val serviceRepository = FakeServiceRepository()
private lateinit var broadcasts: ServiceBroadcasts
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
broadcasts = ServiceBroadcasts(context, serviceRepository)
serviceRepository.setConnectionState(ConnectionState.Connected)
}
@Test
fun `broadcastConnection sends uppercase state string for ATAK`() {
broadcasts.broadcastConnection()
val shadowApp = shadowOf(context as Application)
val intent = shadowApp.broadcastIntents.find { it.action == ACTION_MESH_CONNECTED }
assertEquals("CONNECTED", intent?.getStringExtra(EXTRA_CONNECTED))
}
@Test
fun `broadcastConnection sends legacy connection intent`() {
broadcasts.broadcastConnection()
val shadowApp = shadowOf(context as Application)
val intent = shadowApp.broadcastIntents.find { it.action == ACTION_CONNECTION_CHANGED }
assertEquals("CONNECTED", intent?.getStringExtra(EXTRA_CONNECTED))
assertEquals(true, intent?.getBooleanExtra("connected", false))
}
private class FakeServiceRepository : ServiceRepository {
override val connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
override val clientNotification = MutableStateFlow<ClientNotification?>(null)
override val errorMessage = MutableStateFlow<String?>(null)
override val connectionProgress = MutableStateFlow<String?>(null)
private val meshPackets = MutableSharedFlow<MeshPacket>()
override val meshPacketFlow: Flow<MeshPacket> = meshPackets.asFlow()
override val tracerouteResponse = MutableStateFlow<TracerouteResponse?>(null)
override val neighborInfoResponse = MutableStateFlow<String?>(null)
private val serviceActions = MutableSharedFlow<ServiceAction>()
override val serviceAction: Flow<ServiceAction> = serviceActions
override fun setConnectionState(connectionState: ConnectionState) {
this.connectionState.value = connectionState
}
override fun setClientNotification(notification: ClientNotification?) {
clientNotification.value = notification
}
override fun clearClientNotification() {
clientNotification.value = null
}
override fun setErrorMessage(text: String, severity: Severity) {
errorMessage.value = text
}
override fun clearErrorMessage() {
errorMessage.value = null
}
override fun setConnectionProgress(text: String) {
connectionProgress.value = text
}
override suspend fun emitMeshPacket(packet: MeshPacket) {
meshPackets.emit(packet)
}
override fun setTracerouteResponse(value: TracerouteResponse?) {
tracerouteResponse.value = value
}
override fun clearTracerouteResponse() {
tracerouteResponse.value = null
}
override fun setNeighborInfoResponse(value: String?) {
neighborInfoResponse.value = value
}
override fun clearNeighborInfoResponse() {
neighborInfoResponse.value = null
}
override suspend fun onServiceAction(action: ServiceAction) {
serviceActions.emit(action)
}
}
}

View File

@@ -24,7 +24,6 @@ const val ACTION_NODE_CHANGE = MeshtasticIntent.ACTION_NODE_CHANGE
const val ACTION_MESH_CONNECTED = MeshtasticIntent.ACTION_MESH_CONNECTED
const val ACTION_MESH_DISCONNECTED = MeshtasticIntent.ACTION_MESH_DISCONNECTED
@Suppress("DEPRECATION") // Intentionally re-exported for backward-compat broadcast in ServiceBroadcasts
const val ACTION_CONNECTION_CHANGED = MeshtasticIntent.ACTION_CONNECTION_CHANGED
const val ACTION_MESSAGE_STATUS = MeshtasticIntent.ACTION_MESSAGE_STATUS

View File

@@ -30,10 +30,10 @@ import org.koin.android.ext.android.inject
import org.meshtastic.core.common.hasLocationPermission
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.SERVICE_NOTIFY_ID
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.PortNum
/**
@@ -47,10 +47,10 @@ class MeshService : Service() {
private val radioInterfaceService: RadioInterfaceService by inject()
private val connectionManager: MeshConnectionManager by inject()
private val notifications: MeshServiceNotifications by inject()
private val serviceRepository: ServiceRepository by inject()
/** Android-typed accessor for the foreground service notification. */
private val androidNotifications: MeshServiceNotificationsImpl
get() = notifications as MeshServiceNotificationsImpl
@@ -112,7 +112,7 @@ class MeshService : Service() {
val a = radioInterfaceService.getDeviceAddress()
val wantForeground = a != null && a != "n"
connectionManager.updateStatusNotification()
notifications.updateServiceStateNotification(serviceRepository.connectionState.value, null)
val notification = androidNotifications.getServiceNotification()
val foregroundServiceType =

View File

@@ -1,164 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.service
import android.content.Context
import android.content.Intent
import android.os.Parcelable
import co.touchlab.kermit.Logger
import org.koin.core.annotation.Single
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.util.toPIIString
import org.meshtastic.core.repository.ServiceRepository
import java.util.Locale
import org.meshtastic.core.repository.ServiceBroadcasts as SharedServiceBroadcasts
@Single
class ServiceBroadcasts(private val context: Context, private val serviceRepository: ServiceRepository) :
SharedServiceBroadcasts {
// A mapping of receiver class name to package name - used for explicit broadcasts.
// ConcurrentHashMap because subscribeReceiver() is called from AIDL binder threads
// while explicitBroadcast() iterates from coroutine contexts.
private val clientPackages = java.util.concurrent.ConcurrentHashMap<String, String>()
override fun subscribeReceiver(receiverName: String, packageName: String) {
clientPackages[receiverName] = packageName
}
/** Broadcast some received data Payload will be a DataPacket */
override fun broadcastReceivedData(dataPacket: DataPacket) {
val action = MeshService.actionReceived(dataPacket.dataType)
explicitBroadcast(Intent(action).putExtra(EXTRA_PAYLOAD, dataPacket))
// Also broadcast with the numeric port number for backwards compatibility with some apps
val numericAction = actionReceived(dataPacket.dataType.toString())
if (numericAction != action) {
explicitBroadcast(Intent(numericAction).putExtra(EXTRA_PAYLOAD, dataPacket))
}
}
override fun broadcastNodeChange(node: Node) {
Logger.d { "Broadcasting node change ${node.user.toPIIString()}" }
val legacy = node.toLegacy()
val intent = Intent(ACTION_NODE_CHANGE).putExtra(EXTRA_NODEINFO, legacy)
explicitBroadcast(intent)
}
private fun Node.toLegacy(): NodeInfo = NodeInfo(
num = num,
user =
org.meshtastic.core.model.MeshUser(
id = user.id,
longName = user.long_name,
shortName = user.short_name,
hwModel = user.hw_model,
role = user.role.value,
),
position =
org.meshtastic.core.model
.Position(
latitude = latitude,
longitude = longitude,
altitude = position.altitude ?: 0,
time = position.time,
satellitesInView = position.sats_in_view,
groundSpeed = position.ground_speed ?: 0,
groundTrack = position.ground_track ?: 0,
precisionBits = position.precision_bits,
)
.takeIf { latitude != 0.0 || longitude != 0.0 },
snr = snr,
rssi = rssi,
lastHeard = lastHeard,
deviceMetrics =
org.meshtastic.core.model.DeviceMetrics(
batteryLevel = deviceMetrics.battery_level ?: 0,
voltage = deviceMetrics.voltage ?: 0f,
channelUtilization = deviceMetrics.channel_utilization ?: 0f,
airUtilTx = deviceMetrics.air_util_tx ?: 0f,
uptimeSeconds = deviceMetrics.uptime_seconds ?: 0,
),
channel = channel,
environmentMetrics = org.meshtastic.core.model.EnvironmentMetrics.fromTelemetryProto(environmentMetrics, 0),
hopsAway = hopsAway,
nodeStatus = nodeStatus,
)
fun broadcastMessageStatus(p: DataPacket) = broadcastMessageStatus(p.id, p.status ?: MessageStatus.UNKNOWN)
override fun broadcastMessageStatus(packetId: Int, status: MessageStatus) {
if (packetId == 0) {
Logger.d { "Ignoring anonymous packet status" }
} else {
// Do not log, contains PII possibly
// MeshService.Logger.d { "Broadcasting message status $p" }
val intent =
Intent(ACTION_MESSAGE_STATUS).apply {
putExtra(EXTRA_PACKET_ID, packetId)
putExtra(EXTRA_STATUS, status as Parcelable)
}
explicitBroadcast(intent)
}
}
/** Broadcast our current connection status */
override fun broadcastConnection() {
val connectionState = serviceRepository.connectionState.value
// ATAK expects a String: "CONNECTED" or "DISCONNECTED"
// It uses equalsIgnoreCase, but we'll use uppercase to be specific.
val stateStr = connectionState.toString().uppercase(Locale.ROOT)
val intent = Intent(ACTION_MESH_CONNECTED).apply { putExtra(EXTRA_CONNECTED, stateStr) }
explicitBroadcast(intent)
if (connectionState == ConnectionState.Disconnected) {
explicitBroadcast(Intent(ACTION_MESH_DISCONNECTED))
}
// Restore legacy action for other consumers (e.g. ATAK plugins)
val legacyIntent =
Intent(ACTION_CONNECTION_CHANGED).apply {
putExtra(EXTRA_CONNECTED, stateStr)
// Legacy boolean extra often expected by older implementations
putExtra("connected", connectionState == ConnectionState.Connected)
}
explicitBroadcast(legacyIntent)
}
/**
* See com.geeksville.mesh broadcast intents.
*
* RECEIVED_OPAQUE for data received from other nodes
* NODE_CHANGE for new IDs appearing or disappearing
* ACTION_MESH_CONNECTED for losing/gaining connection to the packet radio
* Note: this is not the same as RadioInterfaceService.RADIO_CONNECTED_ACTION,
* because it implies we have assembled a valid node db.
*/
private fun explicitBroadcast(intent: Intent) {
context.sendBroadcast(
intent,
) // We also do a regular (not explicit broadcast) so any context-registered receivers will work
clientPackages.forEach {
intent.setClassName(it.value, it.key)
context.sendBroadcast(intent)
}
}
}

View File

@@ -1,237 +0,0 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.service
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.Channel
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Config
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
/**
* Platform-agnostic [RadioController] implementation that delegates directly to service-layer handlers.
*
* Unlike [AndroidRadioControllerImpl], which routes every call through the AIDL [IMeshService] binder, this
* implementation talks directly to [CommandSender], [MeshRouter.actionHandler], [ServiceRepository], and [NodeManager].
* This is the correct implementation for any target where the service runs in-process (Desktop, iOS, or Android in
* single-process mode).
*
* This eliminates the need for [NoopRadioController] on non-Android targets.
*/
@Suppress("TooManyFunctions", "LongParameterList")
class DirectRadioControllerImpl(
private val serviceRepository: ServiceRepository,
private val nodeRepository: NodeRepository,
private val commandSender: CommandSender,
private val router: MeshRouter,
private val nodeManager: NodeManager,
private val radioInterfaceService: RadioInterfaceService,
private val locationManager: MeshLocationManager,
) : RadioController {
private val actionHandler
get() = router.actionHandler
private val myNodeNum: Int
get() = nodeManager.myNodeNum.value ?: 0
/** Delegates to [ServiceRepository.connectionState] — the canonical app-level source of truth. */
override val connectionState: StateFlow<ConnectionState>
get() = serviceRepository.connectionState
override val clientNotification: StateFlow<ClientNotification?>
get() = serviceRepository.clientNotification
override suspend fun sendMessage(packet: DataPacket) {
actionHandler.handleSend(packet, myNodeNum)
}
override fun clearClientNotification() {
serviceRepository.clearClientNotification()
}
override suspend fun favoriteNode(nodeNum: Int) {
val nodeDef = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum))
serviceRepository.onServiceAction(ServiceAction.Favorite(nodeDef))
}
override suspend fun sendSharedContact(nodeNum: Int): Boolean {
val nodeDef = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum))
val contact =
SharedContact(node_num = nodeDef.num, user = nodeDef.user, manually_verified = nodeDef.manuallyVerified)
val action = ServiceAction.SendContact(contact)
serviceRepository.onServiceAction(action)
return action.result.await()
}
override suspend fun setLocalConfig(config: Config) {
actionHandler.handleSetConfig(config.encode(), myNodeNum)
}
override suspend fun setLocalChannel(channel: Channel) {
actionHandler.handleSetChannel(channel.encode(), myNodeNum)
}
override suspend fun setOwner(destNum: Int, user: User, packetId: Int) {
actionHandler.handleSetRemoteOwner(packetId, destNum, user.encode())
}
override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) {
actionHandler.handleSetRemoteConfig(packetId, destNum, config.encode())
}
override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) {
actionHandler.handleSetModuleConfig(packetId, destNum, config.encode())
}
override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) {
actionHandler.handleSetRemoteChannel(packetId, destNum, channel.encode())
}
override suspend fun setFixedPosition(destNum: Int, position: Position) {
commandSender.setFixedPosition(destNum, position)
}
override suspend fun setRingtone(destNum: Int, ringtone: String) {
actionHandler.handleSetRingtone(destNum, ringtone)
}
override suspend fun setCannedMessages(destNum: Int, messages: String) {
actionHandler.handleSetCannedMessages(destNum, messages)
}
override suspend fun getOwner(destNum: Int, packetId: Int) {
actionHandler.handleGetRemoteOwner(packetId, destNum)
}
override suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) {
actionHandler.handleGetRemoteConfig(packetId, destNum, configType)
}
override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) {
actionHandler.handleGetModuleConfig(packetId, destNum, moduleConfigType)
}
override suspend fun getChannel(destNum: Int, index: Int, packetId: Int) {
actionHandler.handleGetRemoteChannel(packetId, destNum, index)
}
override suspend fun getRingtone(destNum: Int, packetId: Int) {
actionHandler.handleGetRingtone(packetId, destNum)
}
override suspend fun getCannedMessages(destNum: Int, packetId: Int) {
actionHandler.handleGetCannedMessages(packetId, destNum)
}
override suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) {
actionHandler.handleGetDeviceConnectionStatus(packetId, destNum)
}
override suspend fun reboot(destNum: Int, packetId: Int) {
actionHandler.handleRequestReboot(packetId, destNum)
}
override suspend fun rebootToDfu(nodeNum: Int) {
actionHandler.handleRebootToDfu(nodeNum)
}
override suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {
actionHandler.handleRequestRebootOta(requestId, destNum, mode, hash)
}
override suspend fun shutdown(destNum: Int, packetId: Int) {
actionHandler.handleRequestShutdown(packetId, destNum)
}
override suspend fun factoryReset(destNum: Int, packetId: Int) {
actionHandler.handleRequestFactoryReset(packetId, destNum)
}
override suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) {
actionHandler.handleRequestNodedbReset(packetId, destNum, preserveFavorites)
}
override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) {
val myNode = nodeManager.myNodeNum.value
if (myNode != null) {
actionHandler.handleRemoveByNodenum(nodeNum, packetId, myNode)
} else {
nodeManager.removeByNodenum(nodeNum)
}
}
override suspend fun requestPosition(destNum: Int, currentPosition: Position) {
actionHandler.handleRequestPosition(destNum, currentPosition, myNodeNum)
}
override suspend fun requestUserInfo(destNum: Int) {
if (destNum != myNodeNum) {
commandSender.requestUserInfo(destNum)
}
}
override suspend fun requestTraceroute(requestId: Int, destNum: Int) {
commandSender.requestTraceroute(requestId, destNum)
}
override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {
actionHandler.handleRequestTelemetry(requestId, destNum, typeValue)
}
override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) {
actionHandler.handleRequestNeighborInfo(requestId, destNum)
}
override suspend fun beginEditSettings(destNum: Int) {
actionHandler.handleBeginEditSettings(destNum)
}
override suspend fun commitEditSettings(destNum: Int) {
actionHandler.handleCommitEditSettings(destNum)
}
override fun getPacketId(): Int = commandSender.generatePacketId()
override fun startProvideLocation() {
// Location provision requires a scope — typically managed by the orchestrator.
// On platforms without GPS hardware (desktop), this is a no-op via the injected locationManager.
}
override fun stopProvideLocation() {
locationManager.stop()
}
override fun setDeviceAddress(address: String) {
actionHandler.handleUpdateLastAddress(address)
radioInterfaceService.setDeviceAddress(address)
}
}

View File

@@ -27,10 +27,11 @@ import org.koin.core.annotation.Single
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.TakPrefs
import org.meshtastic.core.takserver.TAKMeshIntegration
import org.meshtastic.core.takserver.TAKServerManager
@@ -53,7 +54,8 @@ class MeshServiceOrchestrator(
private val takMeshIntegration: TAKMeshIntegration,
private val takPrefs: TakPrefs,
private val databaseManager: DatabaseManager,
private val connectionManager: MeshConnectionManager,
private val serviceRepository: ServiceRepository,
private val appWidgetUpdater: AppWidgetUpdater,
private val dispatchers: CoroutineDispatchers,
) {
// Per-start coroutine scope. A fresh scope is created on each start() and cancelled on stop(), so all collectors
@@ -86,7 +88,21 @@ class MeshServiceOrchestrator(
scope = newScope
serviceNotifications.initChannels()
connectionManager.updateStatusNotification()
serviceNotifications.updateServiceStateNotification(serviceRepository.connectionState.value, null)
// Keep notification in sync with connection state changes
serviceRepository.connectionState
.onEach { state -> serviceNotifications.updateServiceStateNotification(state, null) }
.launchIn(newScope)
// Kickstart app widget
newScope.handledLaunch {
try {
appWidgetUpdater.updateAll()
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.e(e) { "Failed to kickstart LocalStatsWidget" }
}
}
// Observe TAK server pref to start/stop
takPrefs.isTakServerEnabled
@@ -107,10 +123,6 @@ class MeshServiceOrchestrator(
Logger.i { "Per-device database initialized" }
}
// NOTE: Radio connection, packet routing, and ServiceAction dispatch are now handled
// by RadioClientProvider + SdkStateBridge. The old radioInterfaceService.connect() /
// receivedData / serviceAction subscription paths are no longer needed.
nodeManager.loadCachedNodeDB()
}

View File

@@ -30,9 +30,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
@@ -55,7 +56,7 @@ class MeshServiceOrchestratorTest {
private val takServerManager: TAKServerManager = mock(MockMode.autofill)
private val takPrefs: TakPrefs = mock(MockMode.autofill)
private val databaseManager: DatabaseManager = mock(MockMode.autofill)
private val connectionManager: MeshConnectionManager = mock(MockMode.autofill)
private val appWidgetUpdater: AppWidgetUpdater = mock(MockMode.autofill)
// TAKMeshIntegration deps (final class — constructed directly)
private val radioController: RadioController = mock(MockMode.autofill)
@@ -80,6 +81,7 @@ class MeshServiceOrchestratorTest {
every { meshConfigHandler.moduleConfig } returns MutableStateFlow(LocalModuleConfig())
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
every { serviceRepository.meshPacketFlow } returns MutableSharedFlow()
every { serviceRepository.connectionState } returns MutableStateFlow(ConnectionState.Disconnected)
val takMeshIntegration = TAKMeshIntegration(
takServerManager = takServerManager,
@@ -98,7 +100,8 @@ class MeshServiceOrchestratorTest {
takMeshIntegration = takMeshIntegration,
takPrefs = takPrefs,
databaseManager = databaseManager,
connectionManager = connectionManager,
serviceRepository = serviceRepository,
appWidgetUpdater = appWidgetUpdater,
dispatchers = dispatchers,
)
}

View File

@@ -99,6 +99,7 @@ kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
freeCompilerArgs.add("-jvm-default=no-compatibility")
freeCompilerArgs.add("-Xskip-prerelease-check")
}
}
@@ -262,6 +263,12 @@ dependencies {
implementation(projects.core.proto)
implementation(projects.core.ble)
// Meshtastic SDK (composite build — TCP, Serial transports + storage)
implementation(libs.sdk.core)
implementation(libs.sdk.transport.tcp)
implementation(libs.sdk.transport.serial)
implementation(libs.sdk.storage.sqldelight)
// Feature modules (JVM variants for real composable wiring)
implementation(projects.feature.settings)
implementation(projects.feature.node)

View File

@@ -39,7 +39,7 @@ import org.meshtastic.core.data.datasource.FirmwareReleaseJsonDataSource
import org.meshtastic.core.model.BootloaderOtaQuirk
import org.meshtastic.core.model.NetworkDeviceHardware
import org.meshtastic.core.model.NetworkFirmwareReleases
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.data.radio.RadioClientAccessor
import org.meshtastic.core.network.HttpClientDefaults
import org.meshtastic.core.network.KermitHttpLogger
import org.meshtastic.core.network.repository.MQTTRepository
@@ -54,9 +54,8 @@ import org.meshtastic.core.repository.MessageQueue
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioTransportFactory
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.service.DirectRadioControllerImpl
import org.meshtastic.core.service.SdkClientLifecycle
import org.meshtastic.core.service.ServiceRepositoryImpl
import org.meshtastic.desktop.DesktopBuildConfig
import org.meshtastic.desktop.DesktopNotificationManager
@@ -67,6 +66,7 @@ import org.meshtastic.desktop.notification.MacOSNotificationSender
import org.meshtastic.desktop.notification.NativeNotificationSender
import org.meshtastic.desktop.notification.WindowsNotificationSender
import org.meshtastic.desktop.radio.DesktopMessageQueue
import org.meshtastic.desktop.radio.DesktopRadioClientProvider
import org.meshtastic.desktop.radio.DesktopRadioTransportFactory
import org.meshtastic.desktop.stub.NoopAppWidgetUpdater
import org.meshtastic.desktop.stub.NoopCompassHeadingProvider
@@ -77,7 +77,6 @@ import org.meshtastic.desktop.stub.NoopMeshLocationManager
import org.meshtastic.desktop.stub.NoopMeshWorkerManager
import org.meshtastic.desktop.stub.NoopPhoneLocationProvider
import org.meshtastic.desktop.stub.NoopPlatformAnalytics
import org.meshtastic.desktop.stub.NoopServiceBroadcasts
import org.meshtastic.feature.node.compass.CompassHeadingProvider
import org.meshtastic.feature.node.compass.MagneticFieldProvider
import org.meshtastic.feature.node.compass.PhoneLocationProvider
@@ -159,17 +158,10 @@ private fun desktopPlatformStubsModule() = module {
connectionFactory = get(),
)
}
single<RadioController> {
DirectRadioControllerImpl(
serviceRepository = get(),
nodeRepository = get(),
commandSender = get(),
router = get(),
nodeManager = get(),
radioInterfaceService = get(),
locationManager = get(),
)
}
// SDK-backed RadioClient lifecycle — replaces DirectRadioControllerImpl
single { DesktopRadioClientProvider(radioPrefs = get()) }
single<RadioClientAccessor> { get<DesktopRadioClientProvider>() }
single<SdkClientLifecycle> { get<DesktopRadioClientProvider>() }
single<NativeNotificationSender> {
when (DesktopOS.current()) {
DesktopOS.Linux -> LinuxNotificationSender()
@@ -181,7 +173,6 @@ private fun desktopPlatformStubsModule() = module {
single<NotificationManager> { get<DesktopNotificationManager>() }
single<MeshServiceNotifications> { DesktopMeshServiceNotifications(notificationManager = get()) }
single<PlatformAnalytics> { NoopPlatformAnalytics() }
single<ServiceBroadcasts> { NoopServiceBroadcasts() }
single<AppWidgetUpdater> { NoopAppWidgetUpdater() }
single<MeshWorkerManager> { NoopMeshWorkerManager() }
single<MessageQueue> { DesktopMessageQueue(packetRepository = get(), radioController = get(), dispatchers = get()) }

View File

@@ -46,9 +46,7 @@ class DesktopMessageQueue(
// Verify we are connected before attempting to send to avoid unnecessary Exception bubbling
if (radioController.connectionState.value != ConnectionState.Connected) {
// In a real desktop environment, we might want a background loop to retry queued messages.
// For now, it will retry when connection is re-established (handled by
// MeshConnectionManager.onRadioConfigLoaded).
// Queued messages will be retried when connection is re-established.
return@launch
}

View File

@@ -0,0 +1,158 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.desktop.radio
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.meshtastic.core.data.radio.RadioClientAccessor
import org.meshtastic.core.model.InterfaceId
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.service.SdkClientLifecycle
import org.meshtastic.sdk.AutoReconnectConfig
import org.meshtastic.sdk.RadioClient
import org.meshtastic.sdk.RadioTransport
import org.meshtastic.sdk.storage.sqldelight.SqlDelightStorageProvider
import org.meshtastic.sdk.transport.serial.JvmSerialPorts
import org.meshtastic.sdk.transport.tcp.TcpTransport
/**
* Desktop (JVM) implementation of [RadioClientAccessor].
*
* Supports BLE (Kable JVM — macOS/Windows/Linux), TCP, and Serial (jSerialComm) transports.
* Storage uses file-system backed SqlDelightStorageProvider with a platform-appropriate data dir.
*
* Registered manually in [desktopPlatformStubsModule] — do NOT add @Single to avoid
* double-registration with the @ComponentScan in DesktopDiModule.
*/
class DesktopRadioClientProvider(
private val radioPrefs: RadioPrefs,
) : RadioClientAccessor, SdkClientLifecycle {
private val _client = MutableStateFlow<RadioClient?>(null)
override val client: StateFlow<RadioClient?> = _client.asStateFlow()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val mutex = Mutex()
/**
* Tear down the existing client (if any) and build + connect a new one using the current
* saved radio address from [RadioPrefs].
*
* Supports BLE (`x` prefix), TCP (`t` prefix, format `tHOST:PORT`), and Serial (`s` prefix).
*/
suspend fun rebuildAndConnect() = mutex.withLock {
val rawAddress = radioPrefs.devAddr.value
?: run {
Logger.w { "DesktopRadioClientProvider: no saved device address — skipping connect" }
return@withLock
}
val interfaceChar = rawAddress.firstOrNull() ?: run {
Logger.w { "DesktopRadioClientProvider: empty address — skipping connect" }
return@withLock
}
val addressPayload = rawAddress.substring(1)
val transport: RadioTransport = when (InterfaceId.forIdChar(interfaceChar)) {
InterfaceId.BLUETOOTH -> {
// BLE on Desktop requires a Kable Peripheral (obtained via Scanner in the connections UI).
// Direct MAC-address construction is Android-only. Desktop BLE is handled by the
// connections feature via DesktopRadioTransportFactory; skip SDK client for BLE for now.
Logger.w { "DesktopRadioClientProvider: BLE not yet supported via SDK — use connections UI" }
return@withLock
}
InterfaceId.TCP -> {
val (host, port) = parseTcpAddress(addressPayload)
Logger.i { "DesktopRadioClientProvider: building TCP transport for $host:$port" }
TcpTransport(host, port)
}
InterfaceId.SERIAL -> {
Logger.i { "DesktopRadioClientProvider: building Serial transport for $addressPayload" }
JvmSerialPorts.open(addressPayload)
}
InterfaceId.MOCK, InterfaceId.NOP, null -> {
Logger.w { "DesktopRadioClientProvider: unsupported transport '$interfaceChar' ($rawAddress)" }
return@withLock
}
}
val old = _client.value
_client.value = null
old?.let { runCatching { it.disconnect() }.onFailure { e -> Logger.w(e) { "disconnect old" } } }
val newClient = RadioClient.Builder()
.transport(transport)
.storage(SqlDelightStorageProvider(baseDir = storageDir()))
.autoReconnect(AutoReconnectConfig())
.build()
_client.value = newClient
newClient.connect()
Logger.i { "DesktopRadioClientProvider: connected via ${InterfaceId.forIdChar(interfaceChar)}" }
}
override fun rebuildAndConnectAsync() {
scope.launch {
runCatching { rebuildAndConnect() }
.onFailure { e -> Logger.e(e) { "DesktopRadioClientProvider: connect failed" } }
}
}
override fun disconnect() {
scope.launch {
mutex.withLock {
val c = _client.value ?: return@withLock
_client.value = null
runCatching { c.disconnect() }.onFailure { e -> Logger.w(e) { "disconnect" } }
}
}
}
companion object {
private const val DEFAULT_TCP_PORT = 4403
private fun parseTcpAddress(payload: String): Pair<String, Int> {
val parts = payload.split(":")
val host = parts[0]
val port = parts.getOrNull(1)?.toIntOrNull() ?: DEFAULT_TCP_PORT
return host to port
}
/** Platform-appropriate storage directory for SDK state (channels, nodeDB, etc.). */
private fun storageDir(): String {
val os = System.getProperty("os.name", "").lowercase()
val home = System.getProperty("user.home", ".")
return when {
os.contains("mac") -> "$home/Library/Application Support/Meshtastic/sdk"
os.contains("win") -> "${System.getenv("APPDATA") ?: home}/Meshtastic/sdk"
else -> "${System.getenv("XDG_DATA_HOME") ?: "$home/.local/share"}/meshtastic/sdk"
}
}
}
}

View File

@@ -28,12 +28,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.emptyFlow
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.model.InterfaceId
import org.meshtastic.core.model.MeshActivity
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Node
import org.meshtastic.core.network.repository.MQTTRepository
import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.DataPair
@@ -43,7 +40,6 @@ import org.meshtastic.core.repository.MeshLocationManager
import org.meshtastic.core.repository.MeshWorkerManager
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.proto.MqttClientProxyMessage
import org.meshtastic.mqtt.ConnectionState as MqttConnectionState
import org.meshtastic.proto.Position as ProtoPosition
@@ -122,18 +118,6 @@ class NoopPlatformAnalytics : PlatformAnalytics {
override val isPlatformServicesAvailable: Boolean = false
}
class NoopServiceBroadcasts : ServiceBroadcasts {
override fun subscribeReceiver(receiverName: String, packageName: String) {}
override fun broadcastReceivedData(dataPacket: DataPacket) {}
override fun broadcastConnection() {}
override fun broadcastNodeChange(node: Node) {}
override fun broadcastMessageStatus(packetId: Int, status: MessageStatus) {}
}
class NoopAppWidgetUpdater : AppWidgetUpdater {
override suspend fun updateAll() {}
}

View File

@@ -29,16 +29,11 @@ import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.database.DatabaseConstants
import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase
import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase
import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase
import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase
import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase
import org.meshtastic.core.domain.usecase.settings.SetLocaleUseCase
import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase
import org.meshtastic.core.domain.usecase.settings.SetNotificationSettingsUseCase
import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase
import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
@@ -64,13 +59,7 @@ class SettingsViewModel(
private val databaseManager: DatabaseManager,
private val meshLogPrefs: MeshLogPrefs,
private val notificationPrefs: NotificationPrefs,
private val setThemeUseCase: SetThemeUseCase,
private val setLocaleUseCase: SetLocaleUseCase,
private val setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase,
private val setProvideLocationUseCase: SetProvideLocationUseCase,
private val setDatabaseCacheLimitUseCase: SetDatabaseCacheLimitUseCase,
private val setMeshLogSettingsUseCase: SetMeshLogSettingsUseCase,
private val setNotificationSettingsUseCase: SetNotificationSettingsUseCase,
private val meshLocationUseCase: MeshLocationUseCase,
private val exportDataUseCase: ExportDataUseCase,
private val isOtaCapableUseCase: IsOtaCapableUseCase,
@@ -123,7 +112,7 @@ class SettingsViewModel(
val dbCacheLimit: StateFlow<Int> = databaseManager.cacheLimit
fun setDbCacheLimit(limit: Int) {
setDatabaseCacheLimitUseCase(limit)
databaseManager.setCacheLimit(limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT))
}
// Notifications
@@ -131,11 +120,11 @@ class SettingsViewModel(
val nodeEventsEnabled = notificationPrefs.nodeEventsEnabled
val lowBatteryEnabled = notificationPrefs.lowBatteryEnabled
fun setMessagesEnabled(enabled: Boolean) = setNotificationSettingsUseCase.setMessagesEnabled(enabled)
fun setMessagesEnabled(enabled: Boolean) = notificationPrefs.setMessagesEnabled(enabled)
fun setNodeEventsEnabled(enabled: Boolean) = setNotificationSettingsUseCase.setNodeEventsEnabled(enabled)
fun setNodeEventsEnabled(enabled: Boolean) = notificationPrefs.setNodeEventsEnabled(enabled)
fun setLowBatteryEnabled(enabled: Boolean) = setNotificationSettingsUseCase.setLowBatteryEnabled(enabled)
fun setLowBatteryEnabled(enabled: Boolean) = notificationPrefs.setLowBatteryEnabled(enabled)
// MeshLog retention period (bounded by MeshLogPrefsImpl constants)
private val _meshLogRetentionDays = MutableStateFlow(meshLogPrefs.retentionDays.value)
@@ -155,20 +144,20 @@ class SettingsViewModel(
}
fun setProvideLocation(value: Boolean) {
myNodeNum?.let { setProvideLocationUseCase(it, value) }
myNodeNum?.let { uiPrefs.setShouldProvideNodeLocation(it, value) }
}
fun setTheme(theme: Int) {
setThemeUseCase(theme)
uiPrefs.setTheme(theme)
}
/** Set the application locale. Empty string means system default. */
fun setLocale(languageTag: String) {
setLocaleUseCase(languageTag)
uiPrefs.setLocale(languageTag)
}
fun showAppIntro() {
setAppIntroCompletedUseCase(false)
uiPrefs.setAppIntroCompleted(false)
}
fun unlockExcludedModules() {

View File

@@ -44,8 +44,6 @@ import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase
import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase
import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase
import org.meshtastic.core.domain.usecase.settings.RadioResponseResult
import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase
import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.MqttConnectionState
import org.meshtastic.core.model.MqttProbeStatus
@@ -121,8 +119,6 @@ open class RadioConfigViewModel(
private val mapConsentPrefs: MapConsentPrefs,
private val analyticsPrefs: AnalyticsPrefs,
private val homoglyphEncodingPrefs: HomoglyphPrefs,
private val toggleAnalyticsUseCase: ToggleAnalyticsUseCase,
private val toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase,
protected val importProfileUseCase: ImportProfileUseCase,
protected val exportProfileUseCase: ExportProfileUseCase,
protected val exportSecurityConfigUseCase: ExportSecurityConfigUseCase,
@@ -137,13 +133,13 @@ open class RadioConfigViewModel(
val analyticsAllowedFlow = analyticsPrefs.analyticsAllowed
fun toggleAnalyticsAllowed() {
toggleAnalyticsUseCase()
analyticsPrefs.setAnalyticsAllowed(!analyticsPrefs.analyticsAllowed.value)
}
val homoglyphEncodingEnabledFlow = homoglyphEncodingPrefs.homoglyphEncodingEnabled
fun toggleHomoglyphCharactersEncodingEnabled() {
toggleHomoglyphEncodingUseCase()
homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(!homoglyphEncodingPrefs.homoglyphEncodingEnabled.value)
}
/** MQTT proxy connection state for the settings UI. */

View File

@@ -39,13 +39,7 @@ import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase
import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase
import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase
import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase
import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase
import org.meshtastic.core.domain.usecase.settings.SetLocaleUseCase
import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase
import org.meshtastic.core.domain.usecase.settings.SetNotificationSettingsUseCase
import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase
import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.repository.FileService
import org.meshtastic.core.repository.RadioConfigRepository
@@ -95,13 +89,7 @@ class SettingsViewModelTest {
every { isOtaCapableUseCase() } returns flowOf(true)
val uiPrefs = appPreferences.ui
val setThemeUseCase = SetThemeUseCase(uiPrefs)
val setLocaleUseCase = SetLocaleUseCase(uiPrefs)
val setAppIntroCompletedUseCase = SetAppIntroCompletedUseCase(uiPrefs)
val setProvideLocationUseCase = SetProvideLocationUseCase(uiPrefs)
val setDatabaseCacheLimitUseCase = SetDatabaseCacheLimitUseCase(databaseManager)
val setMeshLogSettingsUseCase = SetMeshLogSettingsUseCase(meshLogRepository, appPreferences.meshLog)
val setNotificationSettingsUseCase = SetNotificationSettingsUseCase(notificationPrefs)
val meshLocationUseCase = MeshLocationUseCase(radioController)
val exportDataUseCase = ExportDataUseCase(nodeRepository, meshLogRepository)
@@ -115,13 +103,7 @@ class SettingsViewModelTest {
databaseManager = databaseManager,
meshLogPrefs = appPreferences.meshLog,
notificationPrefs = notificationPrefs,
setThemeUseCase = setThemeUseCase,
setLocaleUseCase = setLocaleUseCase,
setAppIntroCompletedUseCase = setAppIntroCompletedUseCase,
setProvideLocationUseCase = setProvideLocationUseCase,
setDatabaseCacheLimitUseCase = setDatabaseCacheLimitUseCase,
setMeshLogSettingsUseCase = setMeshLogSettingsUseCase,
setNotificationSettingsUseCase = setNotificationSettingsUseCase,
meshLocationUseCase = meshLocationUseCase,
exportDataUseCase = exportDataUseCase,
isOtaCapableUseCase = isOtaCapableUseCase,

View File

@@ -44,8 +44,6 @@ import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase
import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase
import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase
import org.meshtastic.core.domain.usecase.settings.RadioResponseResult
import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase
import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.AnalyticsPrefs
import org.meshtastic.core.repository.FileService
@@ -89,8 +87,6 @@ class RadioConfigViewModelTest {
private val analyticsPrefs: AnalyticsPrefs = mock(MockMode.autofill)
private val homoglyphEncodingPrefs: HomoglyphPrefs = mock(MockMode.autofill)
private val toggleAnalyticsUseCase: ToggleAnalyticsUseCase = mock(MockMode.autofill)
private val toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase = mock(MockMode.autofill)
private val importProfileUseCase: ImportProfileUseCase = mock(MockMode.autofill)
private val exportProfileUseCase: ExportProfileUseCase = mock(MockMode.autofill)
private val exportSecurityConfigUseCase: ExportSecurityConfigUseCase = mock(MockMode.autofill)
@@ -146,8 +142,6 @@ class RadioConfigViewModelTest {
mapConsentPrefs = mapConsentPrefs,
analyticsPrefs = analyticsPrefs,
homoglyphEncodingPrefs = homoglyphEncodingPrefs,
toggleAnalyticsUseCase = toggleAnalyticsUseCase,
toggleHomoglyphEncodingUseCase = toggleHomoglyphEncodingUseCase,
importProfileUseCase = importProfileUseCase,
exportProfileUseCase = exportProfileUseCase,
exportSecurityConfigUseCase = exportSecurityConfigUseCase,
@@ -181,21 +175,21 @@ class RadioConfigViewModelTest {
}
@Test
fun `toggleAnalyticsAllowed calls useCase`() {
every { toggleAnalyticsUseCase() } returns Unit
fun `toggleAnalyticsAllowed updates prefs`() {
every { analyticsPrefs.setAnalyticsAllowed(any()) } returns Unit
viewModel.toggleAnalyticsAllowed()
verify { toggleAnalyticsUseCase() }
verify { analyticsPrefs.setAnalyticsAllowed(true) }
}
@Test
fun `toggleHomoglyphCharactersEncodingEnabled calls useCase`() {
every { toggleHomoglyphEncodingUseCase() } returns Unit
fun `toggleHomoglyphCharactersEncodingEnabled updates prefs`() {
every { homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(any()) } returns Unit
viewModel.toggleHomoglyphCharactersEncodingEnabled()
verify { toggleHomoglyphEncodingUseCase() }
verify { homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(true) }
}
@Test