feat/decoupling (#4685)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-03 07:15:28 -06:00 committed by GitHub
parent 40244f8337
commit 2c49db8041
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
254 changed files with 5132 additions and 2666 deletions

View file

@ -0,0 +1,151 @@
/*
* Copyright (c) 2025-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.di
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.data.manager.CommandSenderImpl
import org.meshtastic.core.data.manager.FromRadioPacketHandlerImpl
import org.meshtastic.core.data.manager.HistoryManagerImpl
import org.meshtastic.core.data.manager.MeshActionHandlerImpl
import org.meshtastic.core.data.manager.MeshConfigFlowManagerImpl
import org.meshtastic.core.data.manager.MeshConfigHandlerImpl
import org.meshtastic.core.data.manager.MeshConnectionManagerImpl
import org.meshtastic.core.data.manager.MeshDataHandlerImpl
import org.meshtastic.core.data.manager.MeshMessageProcessorImpl
import org.meshtastic.core.data.manager.MeshRouterImpl
import org.meshtastic.core.data.manager.MessageFilterImpl
import org.meshtastic.core.data.manager.MqttManagerImpl
import org.meshtastic.core.data.manager.NeighborInfoHandlerImpl
import org.meshtastic.core.data.manager.NodeManagerImpl
import org.meshtastic.core.data.manager.PacketHandlerImpl
import org.meshtastic.core.data.manager.TracerouteHandlerImpl
import org.meshtastic.core.data.repository.DeviceHardwareRepositoryImpl
import org.meshtastic.core.data.repository.NodeRepositoryImpl
import org.meshtastic.core.data.repository.PacketRepositoryImpl
import org.meshtastic.core.data.repository.RadioConfigRepositoryImpl
import org.meshtastic.core.model.util.MeshDataMapper
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.FromRadioPacketHandler
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshMessageProcessor
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MessageFilter
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NeighborInfoHandler
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.RadioConfigRepository
import org.meshtastic.core.repository.TracerouteHandler
import javax.inject.Singleton
@Suppress("TooManyFunctions")
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds @Singleton
abstract fun bindNodeRepository(nodeRepositoryImpl: NodeRepositoryImpl): NodeRepository
@Binds
@Singleton
abstract fun bindRadioConfigRepository(radioConfigRepositoryImpl: RadioConfigRepositoryImpl): RadioConfigRepository
@Binds
@Singleton
abstract fun bindDeviceHardwareRepository(
deviceHardwareRepositoryImpl: DeviceHardwareRepositoryImpl,
): DeviceHardwareRepository
@Binds @Singleton
abstract fun bindPacketRepository(packetRepositoryImpl: PacketRepositoryImpl): PacketRepository
@Binds @Singleton
abstract fun bindNodeManager(nodeManagerImpl: NodeManagerImpl): NodeManager
@Binds @Singleton
abstract fun bindCommandSender(commandSenderImpl: CommandSenderImpl): CommandSender
@Binds @Singleton
abstract fun bindHistoryManager(historyManagerImpl: HistoryManagerImpl): HistoryManager
@Binds
@Singleton
abstract fun bindTracerouteHandler(tracerouteHandlerImpl: TracerouteHandlerImpl): TracerouteHandler
@Binds
@Singleton
abstract fun bindNeighborInfoHandler(neighborInfoHandlerImpl: NeighborInfoHandlerImpl): NeighborInfoHandler
@Binds @Singleton
abstract fun bindMqttManager(mqttManagerImpl: MqttManagerImpl): MqttManager
@Binds @Singleton
abstract fun bindPacketHandler(packetHandlerImpl: PacketHandlerImpl): PacketHandler
@Binds
@Singleton
abstract fun bindMeshConnectionManager(meshConnectionManagerImpl: MeshConnectionManagerImpl): MeshConnectionManager
@Binds @Singleton
abstract fun bindMeshDataHandler(meshDataHandlerImpl: MeshDataHandlerImpl): MeshDataHandler
@Binds
@Singleton
abstract fun bindMeshActionHandler(meshActionHandlerImpl: MeshActionHandlerImpl): MeshActionHandler
@Binds
@Singleton
abstract fun bindMeshMessageProcessor(meshMessageProcessorImpl: MeshMessageProcessorImpl): MeshMessageProcessor
@Binds @Singleton
abstract fun bindMeshRouter(meshRouterImpl: MeshRouterImpl): MeshRouter
@Binds
@Singleton
abstract fun bindFromRadioPacketHandler(
fromRadioPacketHandlerImpl: FromRadioPacketHandlerImpl,
): FromRadioPacketHandler
@Binds
@Singleton
abstract fun bindMeshConfigHandler(meshConfigHandlerImpl: MeshConfigHandlerImpl): MeshConfigHandler
@Binds
@Singleton
abstract fun bindMeshConfigFlowManager(meshConfigFlowManagerImpl: MeshConfigFlowManagerImpl): MeshConfigFlowManager
@Binds @Singleton
abstract fun bindMessageFilter(messageFilterImpl: MessageFilterImpl): MessageFilter
companion object {
@Provides
@Singleton
fun provideMeshDataMapper(nodeManager: NodeManager): MeshDataMapper = MeshDataMapper(nodeManager)
}
}

View file

@ -0,0 +1,45 @@
/*
* Copyright (c) 2025-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.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.HomoglyphPrefs
import org.meshtastic.core.repository.MessageQueue
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.usecase.SendMessageUseCase
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object UseCaseModule {
@Provides
@Singleton
fun provideSendMessageUseCase(
nodeRepository: NodeRepository,
packetRepository: PacketRepository,
radioController: RadioController,
homoglyphEncodingPrefs: HomoglyphPrefs,
messageQueue: MessageQueue,
): SendMessageUseCase =
SendMessageUseCase(nodeRepository, packetRepository, radioController, homoglyphEncodingPrefs, messageQueue)
}

View file

@ -0,0 +1,444 @@
/*
* Copyright (c) 2025-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.Dispatchers
import kotlinx.coroutines.SupervisorJob
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.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.NodeManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Constants
import org.meshtastic.proto.Data
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Neighbor
import org.meshtastic.proto.NeighborInfo
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.absoluteValue
import kotlin.time.Duration.Companion.hours
@Suppress("TooManyFunctions", "CyclomaticComplexMethod")
@Singleton
class CommandSenderImpl
@Inject
constructor(
private val packetHandler: PacketHandler,
private val nodeManager: NodeManager,
private val radioConfigRepository: RadioConfigRepository,
) : CommandSender {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val currentPacketId = AtomicLong(java.util.Random(nowMillis).nextLong().absoluteValue)
private val sessionPasskey = AtomicReference(ByteString.EMPTY)
override val tracerouteStartTimes = ConcurrentHashMap<Int, Long>()
override val neighborInfoStartTimes = ConcurrentHashMap<Int, Long>()
private val localConfig = MutableStateFlow(LocalConfig())
private val channelSet = MutableStateFlow(ChannelSet())
override var lastNeighborInfo: NeighborInfo? = null
// We'll need a way to track connection state in shared code,
// maybe via ServiceRepository or similar.
// For now I'll assume it's injected or available.
override fun start(scope: CoroutineScope) {
this.scope = scope
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.get()
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()
}
override fun setSessionPasskey(key: ByteString) {
sessionPasskey.set(key)
}
private fun computeHopLimit(): Int = (localConfig.value.lora?.hop_limit ?: 0).takeIf { it > 0 } ?: DEFAULT_HOP_LIMIT
private fun getAdminChannelIndex(toNum: Int): Int {
val myNum = nodeManager.myNodeNum ?: return 0
val myNode = nodeManager.nodeDBbyNodeNum[myNum]
val destNode = nodeManager.nodeDBbyNodeNum[toNum]
val adminChannelIndex =
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)
}
return adminChannelIndex
}
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
// throw RemoteException("Message too long: $actualSize bytes (max ${Constants.DATA_PAYLOAD_LEN.value})")
// RemoteException is Android specific. For KMP we might want a custom exception.
error("Message too long: $actualSize bytes")
} else {
p.status = MessageStatus.QUEUED
}
// TODO: Check connection state
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 = sessionPasskey.get())
val packet =
buildAdminPacket(to = destNum, id = requestId, wantResponse = wantResponse, adminMessage = adminMsg)
packetHandler.sendToRadio(packet)
}
override fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int?, wantResponse: Boolean) {
val myNum = nodeManager.myNodeNum ?: 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 nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
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 =
org.meshtastic.proto.Position(
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 = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
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 =
org.meshtastic.proto.Position(
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 ?: 0, meshPos, nowMillis)
}
override fun requestUserInfo(destNum: Int) {
val myNum = nodeManager.myNodeNum ?: return
val myNode = nodeManager.nodeDBbyNodeNum[myNum] ?: return
packetHandler.sendToRadio(
buildMeshPacket(
to = destNum,
channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
decoded =
Data(
portnum = PortNum.NODEINFO_APP,
want_response = true,
payload = myNode.user.encode().toByteString(),
),
),
)
}
override fun requestTraceroute(requestId: Int, destNum: Int) {
tracerouteStartTimes[requestId] = nowMillis
packetHandler.sendToRadio(
buildMeshPacket(
to = destNum,
wantAck = true,
id = requestId,
channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
decoded = Data(portnum = PortNum.TRACEROUTE_APP, want_response = true),
),
)
}
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 = org.meshtastic.proto.Paxcount().encode().toByteString()
} else {
portNum = PortNum.TELEMETRY_APP
payloadBytes =
Telemetry(
device_metrics =
if (type == TelemetryType.DEVICE) org.meshtastic.proto.DeviceMetrics() else null,
environment_metrics =
if (type == TelemetryType.ENVIRONMENT) org.meshtastic.proto.EnvironmentMetrics() else null,
air_quality_metrics =
if (type == TelemetryType.AIR_QUALITY) org.meshtastic.proto.AirQualityMetrics() else null,
power_metrics = if (type == TelemetryType.POWER) org.meshtastic.proto.PowerMetrics() else null,
local_stats =
if (type == TelemetryType.LOCAL_STATS) org.meshtastic.proto.LocalStats() else null,
host_metrics = if (type == TelemetryType.HOST) org.meshtastic.proto.HostMetrics() else null,
)
.encode()
.toByteString()
}
packetHandler.sendToRadio(
buildMeshPacket(
to = destNum,
id = requestId,
channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
decoded = Data(portnum = portNum, payload = payloadBytes, want_response = true),
),
)
}
override fun requestNeighborInfo(requestId: Int, destNum: Int) {
neighborInfoStartTimes[requestId] = nowMillis
val myNum = nodeManager.myNodeNum ?: 0
if (destNum == myNum) {
val neighborInfoToSend =
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 = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
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 = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
decoded = Data(portnum = PortNum.NEIGHBORINFO_APP, want_response = true),
),
)
}
}
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
publicKey = nodeManager.nodeDBbyNodeNum[to]?.user?.public_key ?: ByteString.EMPTY
actualChannel = 0
}
return MeshPacket(
from = nodeManager.myNodeNum ?: 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

@ -0,0 +1,74 @@
/*
* Copyright (c) 2025-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 dagger.Lazy
import org.meshtastic.core.repository.FromRadioPacketHandler
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.FromRadio
import javax.inject.Inject
import javax.inject.Singleton
/** Implementation of [FromRadioPacketHandler] that dispatches [FromRadio] variants to specialized handlers. */
@Singleton
class FromRadioPacketHandlerImpl
@Inject
constructor(
private val serviceRepository: ServiceRepository,
private val router: Lazy<MeshRouter>,
private val mqttManager: MqttManager,
private val packetHandler: PacketHandler,
private val serviceNotifications: MeshServiceNotifications,
) : FromRadioPacketHandler {
@Suppress("CyclomaticComplexMethod")
override fun handleFromRadio(proto: FromRadio) {
val myInfo = proto.my_info
val metadata = proto.metadata
val nodeInfo = proto.node_info
val configCompleteId = proto.config_complete_id
val mqttProxyMessage = proto.mqttClientProxyMessage
val queueStatus = proto.queueStatus
val config = proto.config
val moduleConfig = proto.moduleConfig
val channel = proto.channel
val clientNotification = proto.clientNotification
when {
myInfo != null -> router.get().configFlowManager.handleMyInfo(myInfo)
metadata != null -> router.get().configFlowManager.handleLocalMetadata(metadata)
nodeInfo != null -> {
router.get().configFlowManager.handleNodeInfo(nodeInfo)
serviceRepository.setConnectionProgress("Nodes (${router.get().configFlowManager.newNodeCount})")
}
configCompleteId != null -> router.get().configFlowManager.handleConfigComplete(configCompleteId)
mqttProxyMessage != null -> mqttManager.handleMqttProxyMessage(mqttProxyMessage)
queueStatus != null -> packetHandler.handleQueueStatus(queueStatus)
config != null -> router.get().configHandler.handleDeviceConfig(config)
moduleConfig != null -> router.get().configHandler.handleModuleConfig(moduleConfig)
channel != null -> router.get().configHandler.handleChannel(channel)
clientNotification != null -> {
serviceRepository.setClientNotification(clientNotification)
serviceNotifications.showClientNotification(clientNotification)
packetHandler.removeResponse(0, complete = false)
}
}
}
}

View file

@ -0,0 +1,128 @@
/*
* Copyright (c) 2025-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 okio.ByteString.Companion.toByteString
import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.StoreAndForward
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class HistoryManagerImpl
@Inject
constructor(
private val meshPrefs: MeshPrefs,
private val packetHandler: PacketHandler,
) : HistoryManager {
companion object {
private const val HISTORY_TAG = "HistoryReplay"
private const val DEFAULT_HISTORY_RETURN_WINDOW_MINUTES = 60 * 24
private const val DEFAULT_HISTORY_RETURN_MAX_MESSAGES = 100
private const val NO_DEVICE_SELECTED = "No device selected"
fun buildStoreForwardHistoryRequest(
lastRequest: Int,
historyReturnWindow: Int,
historyReturnMax: Int,
): StoreAndForward {
val history =
StoreAndForward.History(
last_request = lastRequest.coerceAtLeast(0),
window = historyReturnWindow.coerceAtLeast(0),
history_messages = historyReturnMax.coerceAtLeast(0),
)
return StoreAndForward(rr = StoreAndForward.RequestResponse.CLIENT_HISTORY, history = history)
}
fun resolveHistoryRequestParameters(window: Int, max: Int): Pair<Int, Int> {
val resolvedWindow = if (window > 0) window else DEFAULT_HISTORY_RETURN_WINDOW_MINUTES
val resolvedMax = if (max > 0) max else DEFAULT_HISTORY_RETURN_MAX_MESSAGES
return resolvedWindow to resolvedMax
}
}
private val logger = Logger.withTag(HISTORY_TAG)
private fun historyLog(message: String, throwable: Throwable? = null) {
logger.i(throwable) { message }
}
private fun activeDeviceAddress(): String? =
meshPrefs.deviceAddress?.takeIf { !it.equals(NO_DEVICE_SELECTED, ignoreCase = true) && it.isNotBlank() }
override fun requestHistoryReplay(
trigger: String,
myNodeNum: Int?,
storeForwardConfig: ModuleConfig.StoreForwardConfig?,
transport: String,
) {
val address = activeDeviceAddress()
if (address == null || myNodeNum == null) {
val reason = if (address == null) "no_addr" else "no_my_node"
historyLog("requestHistory skipped trigger=$trigger reason=$reason")
return
}
val lastRequest = meshPrefs.getStoreForwardLastRequest(address)
val (window, max) =
resolveHistoryRequestParameters(
storeForwardConfig?.history_return_window ?: 0,
storeForwardConfig?.history_return_max ?: 0,
)
val request = buildStoreForwardHistoryRequest(lastRequest, window, max)
historyLog(
"requestHistory trigger=$trigger transport=$transport addr=$address " +
"lastRequest=$lastRequest window=$window max=$max",
)
runCatching {
packetHandler.sendToRadio(
MeshPacket(
from = myNodeNum,
to = myNodeNum,
decoded = Data(portnum = PortNum.STORE_FORWARD_APP, payload = request.encode().toByteString()),
priority = MeshPacket.Priority.BACKGROUND,
),
)
}
.onFailure { ex -> logger.w(ex) { "requestHistory failed" } }
}
override fun updateStoreForwardLastRequest(source: String, lastRequest: Int, transport: String) {
if (lastRequest <= 0) return
val address = activeDeviceAddress() ?: return
val current = meshPrefs.getStoreForwardLastRequest(address)
if (lastRequest != current) {
meshPrefs.setStoreForwardLastRequest(address, lastRequest)
historyLog(
"historyMarker updated source=$source transport=$transport " +
"addr=$address from=$current to=$lastRequest",
)
}
}
}

View file

@ -0,0 +1,356 @@
/*
* Copyright (c) 2025-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 dagger.Lazy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ignoreException
import org.meshtastic.core.common.util.nowMillis
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.prefs.mesh.MeshPrefs
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.DatabaseManager
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshMessageProcessor
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.ServiceBroadcasts
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
import javax.inject.Inject
import javax.inject.Singleton
@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod")
@Singleton
class MeshActionHandlerImpl
@Inject
constructor(
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 databaseManager: DatabaseManager,
private val serviceNotifications: MeshServiceNotifications,
private val messageProcessor: Lazy<MeshMessageProcessor>,
) : MeshActionHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun start(scope: CoroutineScope) {
this.scope = scope
}
companion object {
private const val DEFAULT_REBOOT_DELAY = 5
private const val EMOJI_INDICATOR = 1
}
override fun onServiceAction(action: ServiceAction) {
ignoreException {
val myNodeNum = nodeManager.myNodeNum ?: return@ignoreException
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 -> {
commandSender.sendAdmin(myNodeNum) { AdminMessage(add_contact = action.contact) }
}
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.get().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.get().insertReaction(reaction, myNodeNum)
}
}
override fun handleSetOwner(u: MeshUser, myNodeNum: Int) {
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.get().rememberDataPacket(p, myNodeNum, false)
val bytes = p.bytes ?: okio.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 = meshPrefs.shouldProvideNodeLocation(myNodeNum)
val currentPosition =
when {
provideLocation && position.isValid() -> position
else ->
nodeManager.nodeDBbyNodeNum[myNodeNum]?.position?.let { Position(it) }?.takeIf { it.isValid() }
}
currentPosition?.let { commandSender.requestPosition(destNum, it) }
}
}
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) }
}
override fun handleSetRemoteConfig(id: Int, destNum: Int, payload: ByteArray) {
val c = Config.ADAPTER.decode(payload)
commandSender.sendAdmin(destNum, id) { AdminMessage(set_config = 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) }
}
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) }
}
}
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) }
}
}
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) {
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() ?: okio.ByteString.EMPTY)
commandSender.sendAdmin(destNum, requestId) { AdminMessage(ota_request = otaEvent) }
}
override fun handleRequestFactoryReset(requestId: Int, destNum: Int) {
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
if (deviceAddr != currentAddr) {
meshPrefs.deviceAddress = deviceAddr
scope.handledLaunch {
nodeManager.clear()
messageProcessor.get().clearEarlyPackets()
databaseManager.switchActiveDatabase(deviceAddr)
serviceNotifications.clearNotifications()
nodeManager.loadCachedNodeDB()
}
}
}
}

View file

@ -0,0 +1,220 @@
/*
* Copyright (c) 2025-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 dagger.Lazy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.repository.CommandSender
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.PacketHandler
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.HardwareModel
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.NodeInfo
import org.meshtastic.proto.ToRadio
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
import org.meshtastic.core.model.MyNodeInfo as SharedMyNodeInfo
import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo
@Suppress("LongParameterList", "TooManyFunctions")
@Singleton
class MeshConfigFlowManagerImpl
@Inject
constructor(
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 commandSender: CommandSender,
private val packetHandler: PacketHandler,
) : MeshConfigFlowManager {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val configOnlyNonce = 69420
private val nodeInfoNonce = 69421
private val wantConfigDelay = 100L
override fun start(scope: CoroutineScope) {
this.scope = scope
}
private val newNodes = mutableListOf<NodeInfo>()
override val newNodeCount: Int
get() = newNodes.size
private var rawMyNodeInfo: ProtoMyNodeInfo? = null
private var lastMetadata: DeviceMetadata? = null
private var newMyNodeInfo: SharedMyNodeInfo? = null
private var myNodeInfo: SharedMyNodeInfo? = null
override fun handleConfigComplete(configCompleteId: Int) {
when (configCompleteId) {
configOnlyNonce -> handleConfigOnlyComplete()
nodeInfoNonce -> handleNodeInfoComplete()
else -> Logger.w { "Config complete id mismatch: $configCompleteId" }
}
}
private fun handleConfigOnlyComplete() {
Logger.i { "Config-only complete (Stage 1)" }
if (newMyNodeInfo == null) {
Logger.w {
"newMyNodeInfo is still null at Stage 1 complete, attempting final regen with last known metadata"
}
regenMyNodeInfo(lastMetadata)
}
val finalizedInfo = newMyNodeInfo
if (finalizedInfo == null) {
Logger.e { "Handshake stall: Did not receive a valid MyNodeInfo before Stage 1 complete" }
} else {
myNodeInfo = finalizedInfo
Logger.i { "myNodeInfo committed successfully (nodeNum=${finalizedInfo.myNodeNum})" }
connectionManager.get().onRadioConfigLoaded()
}
scope.handledLaunch {
delay(wantConfigDelay)
sendHeartbeat()
delay(wantConfigDelay)
Logger.i { "Requesting NodeInfo (Stage 2)" }
connectionManager.get().startNodeInfoOnly()
}
}
private fun sendHeartbeat() {
try {
packetHandler.sendToRadio(ToRadio(heartbeat = Heartbeat()))
Logger.d { "Heartbeat sent between nonce stages" }
} catch (ex: IOException) {
Logger.w(ex) { "Failed to send heartbeat; proceeding with node-info stage" }
}
}
private fun handleNodeInfoComplete() {
Logger.i { "NodeInfo complete (Stage 2)" }
val entities =
newNodes.map { info ->
nodeManager.installNodeInfo(info, withBroadcast = false)
nodeManager.nodeDBbyNodeNum[info.num]!!
}
newNodes.clear()
scope.handledLaunch {
myNodeInfo?.let {
nodeRepository.installConfig(it, entities)
sendAnalytics(it)
}
nodeManager.setNodeDbReady(true)
nodeManager.setAllowNodeDbWrites(true)
serviceRepository.setConnectionState(ConnectionState.Connected)
serviceBroadcasts.broadcastConnection()
connectionManager.get().onNodeDbReady()
}
}
private fun sendAnalytics(mi: SharedMyNodeInfo) {
analytics.setDeviceAttributes(mi.firmwareVersion ?: "unknown", mi.model ?: "unknown")
}
override fun handleMyInfo(myInfo: ProtoMyNodeInfo) {
Logger.i { "MyNodeInfo received: ${myInfo.my_node_num}" }
rawMyNodeInfo = myInfo
nodeManager.myNodeNum = myInfo.my_node_num
regenMyNodeInfo(lastMetadata)
scope.handledLaunch {
radioConfigRepository.clearChannelSet()
radioConfigRepository.clearLocalConfig()
radioConfigRepository.clearLocalModuleConfig()
}
}
override fun handleLocalMetadata(metadata: DeviceMetadata) {
Logger.i { "Local Metadata received: ${metadata.firmware_version}" }
lastMetadata = metadata
regenMyNodeInfo(metadata)
}
override fun handleNodeInfo(info: NodeInfo) {
newNodes.add(info)
}
override fun triggerWantConfig() {
connectionManager.get().startConfigOnly()
}
private fun regenMyNodeInfo(metadata: DeviceMetadata? = null) {
val myInfo = rawMyNodeInfo
if (myInfo != null) {
try {
val mi =
with(myInfo) {
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 = commandSender.getCurrentPacketId() and 0xffffffffL,
messageTimeoutMsec = 300000,
minAppVersion = min_app_version,
maxChannels = 8,
hasWifi = metadata?.hasWifi == true,
channelUtilization = 0f,
airUtilTx = 0f,
deviceId = device_id.utf8(),
pioEnv = myInfo.pio_env.ifEmpty { null },
)
}
if (metadata != null && metadata != DeviceMetadata()) {
scope.handledLaunch { nodeRepository.insertMetadata(mi.myNodeNum, metadata) }
}
newMyNodeInfo = mi
Logger.d { "newMyNodeInfo updated: nodeNum=${mi.myNodeNum} model=${mi.model} fw=${mi.firmwareVersion}" }
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
Logger.e(ex) { "Failed to regenMyNodeInfo" }
}
} else {
Logger.v { "regenMyNodeInfo skipped: rawMyNodeInfo is null" }
}
}
}

View file

@ -0,0 +1,88 @@
/*
* Copyright (c) 2025-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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MeshConfigHandlerImpl
@Inject
constructor(
private val radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
private val nodeManager: NodeManager,
) : MeshConfigHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val _localConfig = MutableStateFlow(LocalConfig())
override val localConfig = _localConfig.asStateFlow()
private val _moduleConfig = MutableStateFlow(LocalModuleConfig())
override val moduleConfig = _moduleConfig.asStateFlow()
override fun start(scope: CoroutineScope) {
this.scope = scope
radioConfigRepository.localConfigFlow.onEach { _localConfig.value = it }.launchIn(scope)
radioConfigRepository.moduleConfigFlow.onEach { _moduleConfig.value = it }.launchIn(scope)
}
override fun handleDeviceConfig(config: Config) {
scope.handledLaunch { radioConfigRepository.setLocalConfig(config) }
serviceRepository.setConnectionProgress("Device config received")
}
override fun handleModuleConfig(config: ModuleConfig) {
scope.handledLaunch { radioConfigRepository.setLocalModuleConfig(config) }
serviceRepository.setConnectionProgress("Module config received")
config.statusmessage?.let { sm ->
nodeManager.myNodeNum?.let { num -> nodeManager.updateNodeStatus(num, sm.node_status) }
}
}
override fun handleChannel(channel: Channel) {
// We always want to save channel settings we receive from the radio
scope.handledLaunch { radioConfigRepository.updateChannelSettings(channel) }
// Update status message if we have node info, otherwise use a generic one
val mi = nodeManager.getMyNodeInfo()
val index = channel.index
if (mi != null) {
serviceRepository.setConnectionProgress("Channels (${index + 1} / ${mi.maxChannels})")
} else {
serviceRepository.setConnectionProgress("Channels (${index + 1})")
}
}
}

View file

@ -0,0 +1,354 @@
/*
* Copyright (c) 2025-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.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.analytics.platform.PlatformAnalytics
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.TelemetryType
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.CommandSender
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.RadioConfigRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting
import org.meshtastic.core.resources.device_sleeping
import org.meshtastic.core.resources.disconnected
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.meshtastic_app_name
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Config
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.ToRadio
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.DurationUnit
@Suppress("LongParameterList", "TooManyFunctions")
@Singleton
class MeshConnectionManagerImpl
@Inject
constructor(
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 commandSender: CommandSender,
private val nodeManager: NodeManager,
private val analytics: PlatformAnalytics,
private val packetRepository: PacketRepository,
private val workerManager: MeshWorkerManager,
private val appWidgetUpdater: AppWidgetUpdater,
) : MeshConnectionManager {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var sleepTimeout: Job? = null
private var locationRequestsJob: Job? = null
private var handshakeTimeout: Job? = null
private var connectTimeMsec = 0L
@OptIn(FlowPreview::class)
override fun start(scope: CoroutineScope) {
this.scope = scope
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 -> commandSender.sendPosition(pos) }
} else {
locationManager.stop()
}
}
.launchIn(scope)
}
}
.launchIn(scope)
}
private fun onRadioConnectionState(newState: ConnectionState) {
scope.handledLaunch {
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 fun onConnectionChanged(c: ConnectionState) {
val current = serviceRepository.connectionState.value
if (current == c) return
// 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
}
Logger.i { "onConnectionChanged: $current -> $c" }
sleepTimeout?.cancel()
sleepTimeout = 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() {
// The service state remains 'Connecting' until config is fully loaded
if (serviceRepository.connectionState.value != ConnectionState.Connected) {
serviceRepository.setConnectionState(ConnectionState.Connecting)
}
serviceBroadcasts.broadcastConnection()
Logger.i { "Starting mesh handshake (Stage 1)" }
connectTimeMsec = nowMillis
startConfigOnly()
// Guard against handshake stalls
handshakeTimeout =
scope.handledLaunch {
delay(HANDSHAKE_TIMEOUT)
if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
Logger.w { "Handshake stall detected! Retrying Stage 1." }
startConfigOnly()
// Recursive timeout for one more try
delay(HANDSHAKE_TIMEOUT)
if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
Logger.e { "Handshake still stalled after retry. Resetting connection." }
onConnectionChanged(ConnectionState.Disconnected)
}
}
}
}
private fun handleDeviceSleep() {
serviceRepository.setConnectionState(ConnectionState.DeviceSleep)
packetHandler.stopPacketQueue()
locationManager.stop()
mqttManager.stop()
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 timeout = (localConfig.power?.ls_secs ?: 0) + DEVICE_SLEEP_TIMEOUT_SECONDS
Logger.d { "Waiting for sleeping device, timeout=$timeout secs" }
delay(timeout.seconds)
Logger.w { "Device timeout out, setting disconnected" }
onConnectionChanged(ConnectionState.Disconnected)
} catch (_: CancellationException) {
Logger.d { "device sleep timeout cancelled" }
}
}
serviceBroadcasts.broadcastConnection()
}
private fun handleDisconnected() {
serviceRepository.setConnectionState(ConnectionState.Disconnected)
packetHandler.stopPacketQueue()
locationManager.stop()
mqttManager.stop()
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() {
packetHandler.sendToRadio(ToRadio(want_config_id = CONFIG_ONLY_NONCE))
}
override fun startNodeInfoOnly() {
packetHandler.sendToRadio(ToRadio(want_config_id = NODE_INFO_NONCE))
}
override fun onRadioConfigLoaded() {
scope.handledLaunch {
val queuedPackets = packetRepository.getQueuedPackets() ?: emptyList()
queuedPackets.forEach { packet ->
try {
workerManager.enqueueSendMessage(packet.id)
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.e(e) { "Failed to enqueue queued packet worker" }
}
}
}
val myNodeNum = nodeManager.myNodeNum ?: 0
// Set time
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_time_only = nowSeconds.toInt()) }
}
override fun onNodeDbReady() {
handshakeTimeout?.cancel()
handshakeTimeout = null
// Start MQTT if enabled
scope.handledLaunch {
val moduleConfig = radioConfigRepository.moduleConfigFlow.first()
mqttManager.start(
scope,
moduleConfig.mqtt?.enabled == true,
moduleConfig.mqtt?.proxy_to_client_enabled == true,
)
}
reportConnection()
val myNodeNum = nodeManager.myNodeNum ?: 0
// 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 with proper request IDs
commandSender.requestTelemetry(commandSender.generatePacketId(), myNodeNum, TelemetryType.LOCAL_STATS.ordinal)
commandSender.requestTelemetry(commandSender.generatePacketId(), 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,
)
}
override fun updateTelemetry(t: Telemetry) {
t.local_stats?.let { nodeRepository.updateLocalStats(it) }
updateStatusNotification(t)
}
override fun updateStatusNotification(telemetry: Telemetry?): Any {
val summary =
when (serviceRepository.connectionState.value) {
is ConnectionState.Connected ->
getString(Res.string.meshtastic_app_name) + ": " + getString(Res.string.connected)
is ConnectionState.Disconnected -> getString(Res.string.disconnected)
is ConnectionState.DeviceSleep -> getString(Res.string.device_sleeping)
is ConnectionState.Connecting -> getString(Res.string.connecting)
}
return serviceNotifications.updateServiceStateNotification(summary, telemetry = telemetry)
}
companion object {
private const val CONFIG_ONLY_NONCE = 69420
private const val NODE_INFO_NONCE = 69421
private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30
private val HANDSHAKE_TIMEOUT = 10.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

@ -0,0 +1,778 @@
/*
* Copyright (c) 2025-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 dagger.Lazy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.analytics.platform.PlatformAnalytics
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.SfppHasher
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.toOneLiner
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshConnectionManager
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.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
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.getString
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.resources.waypoint_received
import org.meshtastic.proto.AdminMessage
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.StoreAndForward
import org.meshtastic.proto.StoreForwardPlusPlus
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import org.meshtastic.proto.Waypoint
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
/**
* 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, SFPP).
* 3. Managing message history and persistence.
* 4. Triggering notifications for various packet types (Text, Waypoints, Battery).
* 5. Tracking received telemetry for node updates.
*/
@Suppress("LongParameterList", "TooManyFunctions", "LargeClass", "CyclomaticComplexMethod")
@Singleton
class MeshDataHandlerImpl
@Inject
constructor(
private val nodeManager: NodeManager,
private val packetHandler: PacketHandler,
private val serviceRepository: ServiceRepository,
private val packetRepository: Lazy<PacketRepository>,
private val serviceBroadcasts: ServiceBroadcasts,
private val serviceNotifications: MeshServiceNotifications,
private val analytics: PlatformAnalytics,
private val dataMapper: MeshDataMapper,
private val configHandler: Lazy<MeshConfigHandler>,
private val configFlowManager: Lazy<MeshConfigFlowManager>,
private val commandSender: CommandSender,
private val historyManager: HistoryManager,
private val connectionManager: Lazy<MeshConnectionManager>,
private val tracerouteHandler: TracerouteHandler,
private val neighborInfoHandler: NeighborInfoHandler,
private val radioConfigRepository: RadioConfigRepository,
private val messageFilter: MessageFilter,
) : MeshDataHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun start(scope: CoroutineScope) {
this.scope = scope
}
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 -> 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 -> {
handleStoreAndForward(packet, dataPacket, myNodeNum)
}
PortNum.STORE_FORWARD_PLUSPLUS_APP -> {
handleStoreForwardPlusPlus(packet)
}
PortNum.ADMIN_APP -> {
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 handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
val payload = packet.decoded?.payload ?: return
val u = StoreAndForward.ADAPTER.decode(payload)
handleReceivedStoreAndForward(dataPacket, u, myNodeNum)
}
@Suppress("LongMethod", "ReturnCount")
private fun handleStoreForwardPlusPlus(packet: MeshPacket) {
val payload = packet.decoded?.payload ?: return
val sfpp =
try {
StoreForwardPlusPlus.ADAPTER.decode(payload)
} catch (e: IOException) {
Logger.e(e) { "Failed to parse StoreForwardPlusPlus packet" }
return
}
Logger.d { "Received StoreForwardPlusPlus packet: $sfpp" }
when (sfpp.sfpp_message_type) {
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE,
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF,
StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF,
-> {
val isFragment = sfpp.sfpp_message_type != StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE
// If it has a commit hash, it's already on the chain (Confirmed)
// Otherwise it's still being routed via SF++ (Routing)
val status =
if (sfpp.commit_hash.size == 0) MessageStatus.SFPP_ROUTING else MessageStatus.SFPP_CONFIRMED
// Prefer a full 16-byte hash calculated from the message bytes if available
// But only if it's NOT a fragment, otherwise the calculated hash would be wrong
val hash =
when {
sfpp.message_hash.size != 0 -> sfpp.message_hash.toByteArray()
!isFragment && sfpp.message.size != 0 -> {
SfppHasher.computeMessageHash(
encryptedPayload = sfpp.message.toByteArray(),
// Map 0 back to NODENUM_BROADCAST to match firmware hash calculation
to =
if (sfpp.encapsulated_to == 0) {
DataPacket.NODENUM_BROADCAST
} else {
sfpp.encapsulated_to
},
from = sfpp.encapsulated_from,
id = sfpp.encapsulated_id,
)
}
else -> null
} ?: return
Logger.d {
"SFPP updateStatus: packetId=${sfpp.encapsulated_id} from=${sfpp.encapsulated_from} " +
"to=${sfpp.encapsulated_to} myNodeNum=${nodeManager.myNodeNum} status=$status"
}
scope.handledLaunch {
packetRepository
.get()
.updateSFPPStatus(
packetId = sfpp.encapsulated_id,
from = sfpp.encapsulated_from,
to = sfpp.encapsulated_to,
hash = hash,
status = status,
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
myNodeNum = nodeManager.myNodeNum ?: 0,
)
serviceBroadcasts.broadcastMessageStatus(sfpp.encapsulated_id, status)
}
}
StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE -> {
scope.handledLaunch {
sfpp.message_hash.let {
packetRepository
.get()
.updateSFPPStatusByHash(
hash = it.toByteArray(),
status = MessageStatus.SFPP_CONFIRMED,
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
)
}
}
}
StoreForwardPlusPlus.SFPP_message_type.CHAIN_QUERY -> {
Logger.i { "SF++: Node ${packet.from} is querying chain status" }
}
StoreForwardPlusPlus.SFPP_message_type.LINK_REQUEST -> {
Logger.i { "SF++: Node ${packet.from} is requesting links" }
}
}
}
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 handleAdminMessage(packet: MeshPacket, myNodeNum: Int) {
val payload = packet.decoded?.payload ?: return
val u = AdminMessage.ADAPTER.decode(payload)
u.session_passkey.let { commandSender.setSessionPasskey(it) }
val fromNum = packet.from
u.get_module_config_response?.let { config ->
if (fromNum == myNodeNum) {
configHandler.get().handleModuleConfig(config)
} else {
config.statusmessage?.node_status?.let { nodeManager.updateNodeStatus(fromNum, it) }
}
}
if (fromNum == myNodeNum) {
u.get_config_response?.let { configHandler.get().handleDeviceConfig(it) }
u.get_channel_response?.let { configHandler.get().handleChannel(it) }
}
u.get_device_metadata_response?.let { metadata ->
if (fromNum == myNodeNum) {
configFlowManager.get().handleLocalMetadata(metadata)
} else {
nodeManager.insertMetadata(fromNum, metadata)
}
}
}
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 = okio.ByteString.EMPTY) else it }
.let { if (packet.via_mqtt == true) 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 handleTelemetry(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
val payload = packet.decoded?.payload ?: return
val t =
(Telemetry.ADAPTER.decodeOrNull(payload, Logger) ?: return).let {
if (it.time == 0) it.copy(time = (dataPacket.time.milliseconds.inWholeSeconds).toInt()) else it
}
Logger.d { "Telemetry from ${packet.from}: ${Telemetry.ADAPTER.toOneLiner(t)}" }
val fromNum = packet.from
val isRemote = (fromNum != myNodeNum)
if (!isRemote) {
connectionManager.get().updateTelemetry(t)
}
nodeManager.updateNode(fromNum) { node: Node ->
val metrics = t.device_metrics
val environment = t.environment_metrics
val power = t.power_metrics
var nextNode = node
when {
metrics != null -> {
nextNode = nextNode.copy(deviceMetrics = metrics)
if (fromNum == myNodeNum || (isRemote && node.isFavorite)) {
if (
(metrics.voltage ?: 0f) > BATTERY_PERCENT_UNSUPPORTED &&
(metrics.battery_level ?: 0) <= BATTERY_PERCENT_LOW_THRESHOLD
) {
if (shouldBatteryNotificationShow(fromNum, t, myNodeNum)) {
serviceNotifications.showOrUpdateLowBatteryNotification(nextNode, isRemote)
}
} else {
if (batteryPercentCooldowns.containsKey(fromNum)) {
batteryPercentCooldowns.remove(fromNum)
}
serviceNotifications.cancelLowBatteryNotification(nextNode)
}
}
}
environment != null -> nextNode = nextNode.copy(environmentMetrics = environment)
power != null -> nextNode = nextNode.copy(powerMetrics = power)
}
nextNode
}
}
@Suppress("ReturnCount")
private fun shouldBatteryNotificationShow(fromNum: Int, t: Telemetry, myNodeNum: Int): Boolean {
val isRemote = (fromNum != myNodeNum)
var shouldDisplay = false
var forceDisplay = false
val metrics = t.device_metrics ?: return false
val batteryLevel = metrics.battery_level ?: 0
when {
batteryLevel <= BATTERY_PERCENT_CRITICAL_THRESHOLD -> {
shouldDisplay = true
forceDisplay = true
}
batteryLevel == BATTERY_PERCENT_LOW_THRESHOLD -> shouldDisplay = true
batteryLevel.mod(BATTERY_PERCENT_LOW_DIVISOR) == 0 && !isRemote -> shouldDisplay = true
isRemote -> shouldDisplay = true
}
if (shouldDisplay) {
val now = nowSeconds
if (!batteryPercentCooldowns.containsKey(fromNum)) batteryPercentCooldowns[fromNum] = 0L
if ((now - batteryPercentCooldowns[fromNum]!!) >= BATTERY_PERCENT_COOLDOWN_SECONDS || forceDisplay) {
batteryPercentCooldowns[fromNum] = now
return true
}
}
return false
}
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) {
serviceRepository.setErrorMessage(getString(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.get().getPacketByPacketId(requestId)
val reaction = packetRepository.get().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.get().update(updatedPacket)
}
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.get().updateReaction(updated)
}
}
serviceBroadcasts.broadcastMessageStatus(requestId, m)
}
}
private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForward, myNodeNum: Int) {
Logger.d { "StoreAndForward: variant from ${dataPacket.from}" }
// For now, we don't have meshPrefs in commonMain, so we use a simplified transport check or abstract it.
// In the original, it was used for logging.
val h = s.history
val lastRequest = h?.last_request ?: 0
Logger.d { "rxStoreForward from=${dataPacket.from} lastRequest=$lastRequest" }
when {
s.stats != null -> {
val text = s.stats.toString()
val u =
dataPacket.copy(
bytes = text.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
rememberDataPacket(u, myNodeNum)
}
h != null -> {
val text =
"Total messages: ${h.history_messages}\n" +
"History window: ${h.window.milliseconds.inWholeMinutes} min\n" +
"Last request: ${h.last_request}"
val u =
dataPacket.copy(
bytes = text.encodeToByteArray().toByteString(),
dataType = PortNum.TEXT_MESSAGE_APP.value,
)
rememberDataPacket(u, myNodeNum)
// historyManager call remains same
historyManager.updateStoreForwardLastRequest("router_history", h.last_request, "Unknown")
}
s.heartbeat != null -> {
val hb = s.heartbeat!!
Logger.d { "rxHeartbeat from=${dataPacket.from} period=${hb.period} secondary=${hb.secondary}" }
}
s.text != null -> {
if (s.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) {
dataPacket.to = DataPacket.ID_BROADCAST
}
val u = dataPacket.copy(bytes = s.text, dataType = PortNum.TEXT_MESSAGE_APP.value)
rememberDataPacket(u, myNodeNum)
}
else -> {}
}
}
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.get().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.get().getContactSettings(contactKey).isMuted
val nodeMuted = nodeManager.nodeDBbyID[dataPacket.from]?.isMuted == true
val isSilent = conversationMuted || nodeMuted
if (dataPacket.dataType == PortNum.ALERT_APP.value && !isSilent) {
serviceNotifications.showAlertNotification(
contactKey,
getSenderName(dataPacket),
dataPacket.alert ?: getString(Res.string.critical_alert),
)
} else if (updateNotification && !isSilent) {
scope.handledLaunch { updateNotification(contactKey, dataPacket, isSilent) }
}
}
private fun getSenderName(packet: DataPacket): String {
if (packet.from == DataPacket.ID_LOCAL) {
val myId = nodeManager.getMyId()
return nodeManager.nodeDBbyID[myId]?.user?.long_name ?: getString(Res.string.unknown_username)
}
return nodeManager.nodeDBbyID[packet.from]?.user?.long_name ?: getString(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 = getString(Res.string.waypoint_received, dataPacket.waypoint!!.name)
serviceNotifications.updateWaypointNotification(
contactKey,
getSenderName(dataPacket),
message,
dataPacket.waypoint!!.id,
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.get().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.get().insertReaction(reaction, nodeManager.myNodeNum ?: 0)
// Find the original packet to get the contactKey
packetRepository.get().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.get().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
private const val BATTERY_PERCENT_UNSUPPORTED = 0.0
private const val BATTERY_PERCENT_LOW_THRESHOLD = 20
private const val BATTERY_PERCENT_LOW_DIVISOR = 5
private const val BATTERY_PERCENT_CRITICAL_THRESHOLD = 5
private const val BATTERY_PERCENT_COOLDOWN_SECONDS = 1500
private val batteryPercentCooldowns = ConcurrentHashMap<Int, Long>()
}
}

View file

@ -0,0 +1,248 @@
/*
* Copyright (c) 2025-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 dagger.Lazy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
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.data.repository.MeshLogRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.isLora
import org.meshtastic.core.repository.FromRadioPacketHandler
import org.meshtastic.core.repository.MeshMessageProcessor
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.LogRecord
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import java.util.ArrayDeque
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.uuid.Uuid
/** Implementation of [MeshMessageProcessor] that handles raw radio messages and prepares mesh packets for routing. */
@Suppress("TooManyFunctions")
@Singleton
class MeshMessageProcessorImpl
@Inject
constructor(
private val nodeManager: NodeManager,
private val serviceRepository: ServiceRepository,
private val meshLogRepository: Lazy<MeshLogRepository>,
private val router: Lazy<MeshRouter>,
private val fromRadioDispatcher: FromRadioPacketHandler,
) : MeshMessageProcessor {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val logUuidByPacketId = ConcurrentHashMap<Int, String>()
private val logInsertJobByPacketId = ConcurrentHashMap<Int, Job>()
private val earlyReceivedPackets = ArrayDeque<MeshPacket>()
private val maxEarlyPacketBuffer = 10240
override fun clearEarlyPackets() {
synchronized(earlyReceivedPackets) { earlyReceivedPackets.clear() }
}
override fun start(scope: CoroutineScope) {
this.scope = scope
nodeManager.isNodeDbReady
.onEach { ready ->
if (ready) {
flushEarlyReceivedPackets("dbReady")
}
}
.launchIn(scope)
}
override fun handleFromRadio(bytes: ByteArray, myNodeNum: Int?) {
runCatching { FromRadio.ADAPTER.decode(bytes) }
.onSuccess { proto -> processFromRadio(proto, myNodeNum) }
.onFailure { primaryException ->
runCatching {
val logRecord = LogRecord.ADAPTER.decode(bytes)
processFromRadio(FromRadio(log_record = logRecord), myNodeNum)
}
.onFailure { _ ->
Logger.e(primaryException) {
"Failed to parse radio packet (len=${bytes.size} contents=${bytes.toHexString()}). " +
"Not a valid FromRadio or LogRecord."
}
}
}
}
private fun processFromRadio(proto: FromRadio, myNodeNum: Int?) {
// Audit log every incoming variant
logVariant(proto)
val packet = proto.packet
if (packet != null) {
handleReceivedMeshPacket(packet, myNodeNum)
} else {
fromRadioDispatcher.handleFromRadio(proto)
}
}
private fun logVariant(proto: FromRadio) {
val (type, message) =
when {
proto.log_record != null -> "LogRecord" to proto.log_record.toString()
proto.rebooted != null -> "Rebooted" to proto.rebooted.toString()
proto.xmodemPacket != null -> "XmodemPacket" to proto.xmodemPacket.toString()
proto.deviceuiConfig != null -> "DeviceUIConfig" to proto.deviceuiConfig.toString()
proto.fileInfo != null -> "FileInfo" to proto.fileInfo.toString()
proto.my_info != null -> "MyInfo" to proto.my_info.toString()
proto.node_info != null -> "NodeInfo" to proto.node_info.toString()
proto.config != null -> "Config" to proto.config.toString()
proto.moduleConfig != null -> "ModuleConfig" to proto.moduleConfig.toString()
proto.channel != null -> "Channel" to proto.channel.toString()
proto.clientNotification != null -> "ClientNotification" to proto.clientNotification.toString()
else -> return
}
insertMeshLog(
MeshLog(
uuid = Uuid.random().toString(),
message_type = type,
received_date = nowMillis,
raw_message = message,
fromRadio = proto,
),
)
}
override fun handleReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) {
val rxTime =
if (packet.rx_time == 0) {
nowSeconds.toInt()
} else {
packet.rx_time
}
val preparedPacket = packet.copy(rx_time = rxTime)
if (nodeManager.isNodeDbReady.value) {
processReceivedMeshPacket(preparedPacket, myNodeNum)
} else {
synchronized(earlyReceivedPackets) {
val queueSize = earlyReceivedPackets.size
if (queueSize >= maxEarlyPacketBuffer) {
earlyReceivedPackets.removeFirst()
}
earlyReceivedPackets.addLast(preparedPacket)
}
}
}
private fun flushEarlyReceivedPackets(reason: String) {
val packets =
synchronized(earlyReceivedPackets) {
if (earlyReceivedPackets.isEmpty()) return
val list = earlyReceivedPackets.toList()
earlyReceivedPackets.clear()
list
}
Logger.d { "replayEarlyPackets reason=$reason count=${packets.size}" }
val myNodeNum = nodeManager.myNodeNum
packets.forEach { processReceivedMeshPacket(it, myNodeNum) }
}
@Suppress("LongMethod")
private fun processReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?) {
val decoded = packet.decoded ?: return
val log =
MeshLog(
uuid = Uuid.random().toString(),
message_type = "Packet",
received_date = nowMillis,
raw_message = packet.toString(),
fromNum = if (packet.from == myNodeNum) MeshLog.NODE_NUM_LOCAL else packet.from,
portNum = decoded.portnum.value,
fromRadio = FromRadio(packet = packet),
)
val logJob = insertMeshLog(log)
logInsertJobByPacketId[packet.id] = logJob
logUuidByPacketId[packet.id] = log.uuid
scope.handledLaunch { serviceRepository.emitMeshPacket(packet) }
myNodeNum?.let { myNum ->
val from = packet.from
val isOtherNode = myNum != from
nodeManager.updateNode(myNum, withBroadcast = isOtherNode) { node: Node ->
node.copy(lastHeard = nowSeconds.toInt())
}
nodeManager.updateNode(from, withBroadcast = false, channel = packet.channel) { node: Node ->
val viaMqtt = packet.via_mqtt == true
val isDirect = packet.hop_start == packet.hop_limit
var snr = node.snr
var rssi = node.rssi
if (isDirect && packet.isLora() && !viaMqtt) {
snr = packet.rx_snr
rssi = packet.rx_rssi
}
val hopsAway =
if (decoded.portnum == PortNum.RANGE_TEST_APP) {
0
} else if (viaMqtt) {
-1
} else if (packet.hop_start == 0 && (decoded.bitfield ?: 0) == 0) {
-1
} else if (packet.hop_limit > packet.hop_start) {
-1
} else {
packet.hop_start - packet.hop_limit
}
node.copy(
lastHeard = packet.rx_time,
viaMqtt = viaMqtt,
lastTransport = packet.transport_mechanism.value,
snr = snr,
rssi = rssi,
hopsAway = hopsAway,
)
}
try {
router.get().dataHandler.handleReceivedData(packet, myNum, log.uuid, logJob)
} finally {
logUuidByPacketId.remove(packet.id)
logInsertJobByPacketId.remove(packet.id)
}
}
}
private fun insertMeshLog(log: MeshLog): Job = scope.handledLaunch { meshLogRepository.get().insert(log) }
private fun ByteArray.toHexString(): String =
this.joinToString(",") { byte -> String.format(Locale.US, "0x%02x", byte) }
}

View file

@ -0,0 +1,75 @@
/*
* Copyright (c) 2025-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 dagger.Lazy
import kotlinx.coroutines.CoroutineScope
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 javax.inject.Inject
import javax.inject.Singleton
/** Implementation of [MeshRouter] that orchestrates specialized mesh packet handlers. */
@Suppress("LongParameterList")
@Singleton
class MeshRouterImpl
@Inject
constructor(
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>,
) : MeshRouter {
override val dataHandler: MeshDataHandler
get() = dataHandlerLazy.get()
override val configHandler: MeshConfigHandler
get() = configHandlerLazy.get()
override val tracerouteHandler: TracerouteHandler
get() = tracerouteHandlerLazy.get()
override val neighborInfoHandler: NeighborInfoHandler
get() = neighborInfoHandlerLazy.get()
override val configFlowManager: MeshConfigFlowManager
get() = configFlowManagerLazy.get()
override val mqttManager: MqttManager
get() = mqttManagerLazy.get()
override val actionHandler: MeshActionHandler
get() = actionHandlerLazy.get()
override fun start(scope: CoroutineScope) {
dataHandler.start(scope)
configHandler.start(scope)
tracerouteHandler.start(scope)
neighborInfoHandler.start(scope)
configFlowManager.start(scope)
actionHandler.start(scope)
}
}

View file

@ -0,0 +1,63 @@
/*
* Copyright (c) 2025-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.meshtastic.core.prefs.filter.FilterPrefs
import org.meshtastic.core.repository.MessageFilter
import java.util.regex.PatternSyntaxException
import javax.inject.Inject
import javax.inject.Singleton
/** Implementation of [MessageFilter] that uses regex and plain text matching. */
@Singleton
class MessageFilterImpl @Inject constructor(private val filterPrefs: FilterPrefs) : MessageFilter {
private var compiledPatterns: List<Regex> = emptyList()
init {
rebuildPatterns()
}
override fun shouldFilter(message: String, isFilteringDisabled: Boolean): Boolean {
if (!filterPrefs.filterEnabled || compiledPatterns.isEmpty() || isFilteringDisabled) {
return false
}
val textToCheck = message.take(MAX_CHECK_LENGTH)
return compiledPatterns.any { it.containsMatchIn(textToCheck) }
}
override fun rebuildPatterns() {
compiledPatterns =
filterPrefs.filterWords.mapNotNull { word ->
try {
if (word.startsWith(REGEX_PREFIX)) {
Regex(word.removePrefix(REGEX_PREFIX), RegexOption.IGNORE_CASE)
} else {
Regex("\\b${Regex.escape(word)}\\b", RegexOption.IGNORE_CASE)
}
} catch (e: PatternSyntaxException) {
Logger.w { "Invalid filter pattern: $word - ${e.message}" }
null
}
}
}
companion object {
private const val MAX_CHECK_LENGTH = 10_000
private const val REGEX_PREFIX = "regex:"
}
}

View file

@ -0,0 +1,87 @@
/*
* Copyright (c) 2025-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.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.network.repository.MQTTRepository
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.MqttClientProxyMessage
import org.meshtastic.proto.ToRadio
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MqttManagerImpl
@Inject
constructor(
private val mqttRepository: MQTTRepository,
private val packetHandler: PacketHandler,
private val serviceRepository: ServiceRepository,
) : MqttManager {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var mqttMessageFlow: Job? = null
override fun start(scope: CoroutineScope, enabled: Boolean, proxyToClientEnabled: Boolean) {
this.scope = scope
if (mqttMessageFlow?.isActive == true) return
if (enabled && proxyToClientEnabled) {
mqttMessageFlow =
mqttRepository.proxyMessageFlow
.onEach { message -> packetHandler.sendToRadio(ToRadio(mqttClientProxyMessage = message)) }
.catch { throwable ->
serviceRepository.setErrorMessage(
text = "MqttClientProxy failed: $throwable",
severity = Severity.Warn,
)
}
.launchIn(scope)
}
}
override fun stop() {
if (mqttMessageFlow?.isActive == true) {
Logger.i { "Stopping MqttClientProxy" }
mqttMessageFlow?.cancel()
mqttMessageFlow = null
}
}
override fun handleMqttProxyMessage(message: MqttClientProxyMessage) {
val topic = message.topic ?: ""
Logger.d { "[mqttClientProxyMessage] $topic" }
val retained = message.retained == true
when {
message.text != null -> {
mqttRepository.publish(topic, message.text!!.encodeToByteArray(), retained)
}
message.data_ != null -> {
mqttRepository.publish(topic, message.data_!!.toByteArray(), retained)
}
else -> {}
}
}
}

View file

@ -0,0 +1,93 @@
/*
* Copyright (c) 2025-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.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NeighborInfoHandlerImpl
@Inject
constructor(
private val nodeManager: NodeManager,
private val serviceRepository: ServiceRepository,
private val commandSender: CommandSender,
private val serviceBroadcasts: ServiceBroadcasts,
) : NeighborInfoHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun start(scope: CoroutineScope) {
this.scope = scope
}
override fun handleNeighborInfo(packet: MeshPacket) {
val payload = packet.decoded?.payload ?: return
val ni = NeighborInfo.ADAPTER.decode(payload)
// Store the last neighbor info from our connected radio
val from = packet.from ?: 0
if (from == nodeManager.myNodeNum) {
commandSender.lastNeighborInfo = ni
Logger.d { "Stored last neighbor info from connected radio" }
}
// Update Node DB
nodeManager.nodeDBbyNodeNum[from]?.let { serviceBroadcasts.broadcastNodeChange(it) }
// Format for UI response
val requestId = packet.decoded?.request_id ?: 0
val start = commandSender.neighborInfoStartTimes.remove(requestId)
val neighbors =
ni.neighbors.joinToString("\n") { n ->
val node = nodeManager.nodeDBbyNodeNum[n.node_id]
val name = node?.let { "${it.user.long_name} (${it.user.short_name})" } ?: "Unknown"
"$name (SNR: ${n.snr})"
}
val formatted = "Neighbors of ${nodeManager.nodeDBbyNodeNum[from]?.user?.long_name ?: "Unknown"}:\n$neighbors"
val responseText =
if (start != null) {
val elapsedMs = nowMillis - start
val seconds = elapsedMs / MILLIS_PER_SECOND
Logger.i { "Neighbor info $requestId complete in $seconds s" }
String.format(Locale.US, "%s\n\nDuration: %.1f s", formatted, seconds)
} else {
formatted
}
serviceRepository.setNeighborInfoResponse(responseText)
}
companion object {
private const val MILLIS_PER_SECOND = 1000.0
}
}

View file

@ -0,0 +1,316 @@
/*
* Copyright (c) 2025-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.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import okio.ByteString
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceMetrics
import org.meshtastic.core.model.EnvironmentMetrics
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.Position
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.StatusMessage
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import org.meshtastic.proto.NodeInfo as ProtoNodeInfo
import org.meshtastic.proto.Position as ProtoPosition
/**
* Implementation of [NodeManager] that maintains an in-memory database of the mesh.
*
* This component acts as the "brain" for node-related data during a connection session. It manages:
* 1. In-memory maps for fast node lookup by number or ID.
* 2. Synchronization of node data between the radio and the persistent database.
* 3. Processing of incoming node-related packets (User, Position, Telemetry).
* 4. Broadcasting changes to the rest of the application.
*/
@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod")
@Singleton
class NodeManagerImpl
@Inject
constructor(
private val nodeRepository: NodeRepository,
private val serviceBroadcasts: ServiceBroadcasts,
private val serviceNotifications: MeshServiceNotifications,
) : NodeManager {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override val nodeDBbyNodeNum = ConcurrentHashMap<Int, Node>()
override val nodeDBbyID = ConcurrentHashMap<String, Node>()
override val isNodeDbReady = MutableStateFlow(false)
override val allowNodeDbWrites = MutableStateFlow(false)
override fun setNodeDbReady(ready: Boolean) {
isNodeDbReady.value = ready
}
override fun setAllowNodeDbWrites(allowed: Boolean) {
allowNodeDbWrites.value = allowed
}
override var myNodeNum: Int? = null
override fun start(scope: CoroutineScope) {
this.scope = scope
}
companion object {
private const val TIME_MS_TO_S = 1000L
}
override fun loadCachedNodeDB() {
scope.handledLaunch {
val nodes = nodeRepository.nodeDBbyNum.first()
nodeDBbyNodeNum.putAll(nodes)
nodes.values.forEach { nodeDBbyID[it.user.id] = it }
myNodeNum = nodeRepository.myNodeInfo.value?.myNodeNum
}
}
override fun clear() {
nodeDBbyNodeNum.clear()
nodeDBbyID.clear()
isNodeDbReady.value = false
allowNodeDbWrites.value = false
myNodeNum = null
}
override fun getMyNodeInfo(): MyNodeInfo? {
val mi = nodeRepository.myNodeInfo.value ?: return null
val myNode = nodeDBbyNodeNum[mi.myNodeNum]
return MyNodeInfo(
myNodeNum = mi.myNodeNum,
hasGPS = (myNode?.position?.latitude_i ?: 0) != 0,
model = mi.model ?: myNode?.user?.hw_model?.name,
firmwareVersion = mi.firmwareVersion,
couldUpdate = mi.couldUpdate,
shouldUpdate = mi.shouldUpdate,
currentPacketId = mi.currentPacketId,
messageTimeoutMsec = mi.messageTimeoutMsec,
minAppVersion = mi.minAppVersion,
maxChannels = mi.maxChannels,
hasWifi = mi.hasWifi,
channelUtilization = 0f,
airUtilTx = 0f,
deviceId = mi.deviceId ?: myNode?.user?.id,
)
}
override fun getMyId(): String {
val num = myNodeNum ?: nodeRepository.myNodeInfo.value?.myNodeNum ?: return ""
return nodeDBbyNodeNum[num]?.user?.id ?: ""
}
override fun getNodes(): List<NodeInfo> = nodeDBbyNodeNum.values.map { it.toNodeInfo() }
override fun removeByNodenum(nodeNum: Int) {
nodeDBbyNodeNum.remove(nodeNum)?.let { nodeDBbyID.remove(it.user.id) }
}
fun getOrCreateNode(n: Int, channel: Int = 0): Node = nodeDBbyNodeNum.getOrPut(n) {
val userId = DataPacket.nodeNumToDefaultId(n)
val defaultUser =
User(
id = userId,
long_name = "Meshtastic ${userId.takeLast(n = 4)}",
short_name = userId.takeLast(n = 4),
hw_model = HardwareModel.UNSET,
)
Node(num = n, user = defaultUser, channel = channel)
}
override fun updateNode(nodeNum: Int, withBroadcast: Boolean, channel: Int, transform: (Node) -> Node) {
val current = nodeDBbyNodeNum[nodeNum] ?: getOrCreateNode(nodeNum, channel)
val next = transform(current)
nodeDBbyNodeNum[nodeNum] = next
if (next.user.id.isNotEmpty()) {
nodeDBbyID[next.user.id] = next
}
if (next.user.id.isNotEmpty() && isNodeDbReady.value) {
scope.handledLaunch { nodeRepository.upsert(next) }
}
if (withBroadcast) {
serviceBroadcasts.broadcastNodeChange(next)
}
}
override fun handleReceivedUser(fromNum: Int, p: User, channel: Int, manuallyVerified: Boolean) {
updateNode(fromNum) { node ->
val newNode = (node.isUnknownUser && p.hw_model != HardwareModel.UNSET)
val shouldPreserve = shouldPreserveExistingUser(node.user, p)
val next =
if (shouldPreserve) {
node.copy(channel = channel, manuallyVerified = manuallyVerified)
} else {
val keyMatch = !node.hasPKC || node.user.public_key == p.public_key
val newUser = if (keyMatch) p else p.copy(public_key = ByteString.EMPTY)
node.copy(user = newUser, channel = channel, manuallyVerified = manuallyVerified)
}
if (newNode && !shouldPreserve) {
serviceNotifications.showNewNodeSeenNotification(next)
}
next
}
}
override fun handleReceivedPosition(fromNum: Int, myNodeNum: Int, p: ProtoPosition, defaultTime: Long) {
if (myNodeNum == fromNum && (p.latitude_i ?: 0) == 0 && (p.longitude_i ?: 0) == 0) {
Logger.d { "Ignoring nop position update for the local node" }
} else {
updateNode(fromNum) { node ->
node.copy(position = p.copy(time = if (p.time != 0) p.time else (defaultTime / TIME_MS_TO_S).toInt()))
}
}
}
override fun handleReceivedTelemetry(fromNum: Int, telemetry: Telemetry) {
updateNode(fromNum) { node ->
when {
telemetry.device_metrics != null -> node.copy(deviceMetrics = telemetry.device_metrics!!)
telemetry.environment_metrics != null -> node.copy(environmentMetrics = telemetry.environment_metrics!!)
telemetry.power_metrics != null -> node.copy(powerMetrics = telemetry.power_metrics!!)
else -> node
}
}
}
override fun handleReceivedPaxcounter(fromNum: Int, p: Paxcount) {
updateNode(fromNum) { it.copy(paxcounter = p) }
}
override fun handleReceivedNodeStatus(fromNum: Int, s: StatusMessage) {
updateNodeStatus(fromNum, s.status)
}
override fun updateNodeStatus(nodeNum: Int, status: String?) {
updateNode(nodeNum) { it.copy(nodeStatus = status?.takeIf { s -> s.isNotEmpty() }) }
}
override fun installNodeInfo(info: ProtoNodeInfo, withBroadcast: Boolean) {
updateNode(info.num, withBroadcast = withBroadcast) { node ->
var next = node
val user = info.user
if (user != null) {
if (shouldPreserveExistingUser(node.user, user)) {
// keep existing names
} else {
var newUser = user.let { if (it.is_licensed) it.copy(public_key = ByteString.EMPTY) else it }
if (info.via_mqtt) {
newUser = newUser.copy(long_name = "${newUser.long_name} (MQTT)")
}
next = next.copy(user = newUser)
}
}
val position = info.position
if (position != null) {
next = next.copy(position = position)
}
next =
next.copy(
lastHeard = info.last_heard,
deviceMetrics = info.device_metrics ?: next.deviceMetrics,
channel = info.channel,
viaMqtt = info.via_mqtt,
hopsAway = info.hops_away ?: -1,
isFavorite = info.is_favorite,
isIgnored = info.is_ignored,
isMuted = info.is_muted,
)
next
}
}
override fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) {
scope.handledLaunch { nodeRepository.insertMetadata(nodeNum, metadata) }
}
private fun shouldPreserveExistingUser(existing: User, incoming: User): Boolean {
val isDefaultName = (incoming.long_name).matches(Regex("^Meshtastic [0-9a-fA-F]{4}$"))
val isDefaultHwModel = incoming.hw_model == HardwareModel.UNSET
val hasExistingUser = (existing.id).isNotEmpty() && existing.hw_model != HardwareModel.UNSET
return hasExistingUser && isDefaultName && isDefaultHwModel
}
override fun toNodeID(nodeNum: Int): String = if (nodeNum == DataPacket.NODENUM_BROADCAST) {
DataPacket.ID_BROADCAST
} else {
nodeDBbyNodeNum[nodeNum]?.user?.id ?: DataPacket.nodeNumToDefaultId(nodeNum)
}
private fun Node.toNodeInfo(): NodeInfo = NodeInfo(
num = num,
user =
MeshUser(
id = user.id,
longName = user.long_name,
shortName = user.short_name,
hwModel = user.hw_model,
role = user.role.value,
),
position =
Position(
latitude = latitude,
longitude = longitude,
altitude = position.altitude ?: 0,
time = position.time,
satellitesInView = position.sats_in_view ?: 0,
groundSpeed = position.ground_speed ?: 0,
groundTrack = position.ground_track ?: 0,
precisionBits = position.precision_bits ?: 0,
)
.takeIf { latitude != 0.0 || longitude != 0.0 },
snr = snr,
rssi = rssi,
lastHeard = lastHeard,
deviceMetrics =
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 = EnvironmentMetrics.fromTelemetryProto(environmentMetrics, 0),
hopsAway = hopsAway,
nodeStatus = nodeStatus,
)
}

View file

@ -0,0 +1,207 @@
/*
* Copyright (c) 2025-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 dagger.Lazy
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
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.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 java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.uuid.Uuid
@Suppress("TooManyFunctions")
@Singleton
class PacketHandlerImpl
@Inject
constructor(
private val packetRepository: Lazy<PacketRepository>,
private val serviceBroadcasts: ServiceBroadcasts,
private val radioInterfaceService: RadioInterfaceService,
private val meshLogRepository: Lazy<MeshLogRepository>,
private val serviceRepository: ServiceRepository,
) : PacketHandler {
companion object {
private val TIMEOUT = 5.seconds
}
private var queueJob: Job? = null
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO)
private val queuedPackets = ConcurrentLinkedQueue<MeshPacket>()
private val queueResponse = ConcurrentHashMap<Int, CompletableDeferred<Boolean>>()
override fun start(scope: CoroutineScope) {
this.scope = scope
}
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) {
queuedPackets.add(packet)
startPacketQueue()
}
override fun stopPacketQueue() {
if (queueJob?.isActive == true) {
Logger.i { "Stopping packet queueJob" }
queueJob?.cancel()
queueJob = null
queuedPackets.clear()
queueResponse.entries.lastOrNull { !it.value.isCompleted }?.value?.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
if (requestId != 0) {
queueResponse.remove(requestId)?.complete(success)
} else {
queueResponse.values.firstOrNull { !it.isCompleted }?.complete(success)
}
}
override fun removeResponse(dataRequestId: Int, complete: Boolean) {
queueResponse.remove(dataRequestId)?.complete(complete)
}
private fun startPacketQueue() {
if (queueJob?.isActive == true) return
queueJob =
scope.handledLaunch {
Logger.d { "packet queueJob started" }
while (serviceRepository.connectionState.value == ConnectionState.Connected) {
val packet = queuedPackets.poll() ?: 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" }
} catch (e: Exception) {
Logger.d { "queueJob packet id=${packet.id.toUInt()} failed" }
} finally {
queueResponse.remove(packet.id)
}
}
}
}
private fun changeStatus(packetId: Int, m: MessageStatus) = scope.handledLaunch {
if (packetId != 0) {
getDataPacketById(packetId)?.let { p ->
if (p.status == m) return@handledLaunch
packetRepository.get().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.get().getPacketById(packetId)
if (dataPacket == null) delay(100.milliseconds)
}
dataPacket
}
@Suppress("TooGenericExceptionCaught")
private fun sendPacket(packet: MeshPacket): CompletableDeferred<Boolean> {
val deferred = CompletableDeferred<Boolean>()
queueResponse[packet.id] = deferred
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 deferred
}
private fun insertMeshLog(packetToSave: MeshLog) {
scope.handledLaunch {
Logger.d {
"insert: ${packetToSave.message_type} = " +
"${packetToSave.raw_message.toOneLineString()} from=${packetToSave.fromNum}"
}
meshLogRepository.get().insert(packetToSave)
}
}
}

View file

@ -0,0 +1,113 @@
/*
* Copyright (c) 2025-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.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getFullTracerouteResponse
import org.meshtastic.core.model.service.TracerouteResponse
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.proto.MeshPacket
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TracerouteHandlerImpl
@Inject
constructor(
private val nodeManager: NodeManager,
private val serviceRepository: ServiceRepository,
private val tracerouteSnapshotRepository: TracerouteSnapshotRepository,
private val nodeRepository: NodeRepository,
private val commandSender: CommandSender,
) : TracerouteHandler {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun start(scope: CoroutineScope) {
this.scope = scope
}
override fun handleTraceroute(packet: MeshPacket, logUuid: String?, logInsertJob: kotlinx.coroutines.Job?) {
val full =
packet.getFullTracerouteResponse(
getUser = { num ->
nodeManager.nodeDBbyNodeNum[num]?.let { node: Node ->
"${node.user.long_name} (${node.user.short_name})"
} ?: "Unknown" // We don't have strings in core:data yet, but we can fix this later
},
headerTowards = "Route towards destination:",
headerBack = "Route back to us:",
) ?: return
val requestId = packet.decoded?.request_id ?: 0
if (logUuid != null) {
scope.handledLaunch {
logInsertJob?.join()
val routeDiscovery = packet.fullRouteDiscovery
val forwardRoute = routeDiscovery?.route.orEmpty()
val returnRoute = routeDiscovery?.route_back.orEmpty()
val routeNodeNums = (forwardRoute + returnRoute).distinct()
val nodeDbByNum = nodeRepository.nodeDBbyNum.value
val snapshotPositions =
routeNodeNums.mapNotNull { num -> nodeDbByNum[num]?.validPosition?.let { num to it } }.toMap()
tracerouteSnapshotRepository.upsertSnapshotPositions(logUuid, requestId, snapshotPositions)
}
}
val start = commandSender.tracerouteStartTimes.remove(requestId)
val responseText =
if (start != null) {
val elapsedMs = nowMillis - start
val seconds = elapsedMs / MILLIS_PER_SECOND
Logger.i { "Traceroute $requestId complete in $seconds s" }
val durationText = "Duration: %.1f s".format(Locale.US, seconds)
"$full\n\n$durationText"
} else {
full
}
val routeDiscovery = packet.fullRouteDiscovery
val destination = routeDiscovery?.route?.firstOrNull() ?: routeDiscovery?.route_back?.lastOrNull() ?: 0
serviceRepository.setTracerouteResponse(
TracerouteResponse(
message = responseText,
destinationNodeNum = destination,
requestId = requestId,
forwardRoute = routeDiscovery?.route.orEmpty(),
returnRoute = routeDiscovery?.route_back.orEmpty(),
logUuid = logUuid,
),
)
}
companion object {
private const val MILLIS_PER_SECOND = 1000.0
}
}

View file

@ -29,12 +29,13 @@ import org.meshtastic.core.model.BootloaderOtaQuirk
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.network.DeviceHardwareRemoteDataSource
import org.meshtastic.core.repository.DeviceHardwareRepository
import javax.inject.Inject
import javax.inject.Singleton
// Annotating with Singleton to ensure a single instance manages the cache
@Singleton
class DeviceHardwareRepository
class DeviceHardwareRepositoryImpl
@Inject
constructor(
private val remoteDataSource: DeviceHardwareRemoteDataSource,
@ -42,7 +43,7 @@ constructor(
private val jsonDataSource: DeviceHardwareJsonDataSource,
private val bootloaderOtaQuirksJsonDataSource: BootloaderOtaQuirksJsonDataSource,
private val dispatchers: CoroutineDispatchers,
) {
) : DeviceHardwareRepository {
/**
* Retrieves device hardware information by its model ID and optional target string.
@ -59,10 +60,10 @@ constructor(
* @return A [Result] containing the [DeviceHardware] on success (or null if not found), or an exception on failure.
*/
@Suppress("LongMethod", "detekt:CyclomaticComplexMethod")
suspend fun getDeviceHardwareByModel(
override suspend fun getDeviceHardwareByModel(
hwModel: Int,
target: String? = null,
forceRefresh: Boolean = false,
target: String?,
forceRefresh: Boolean,
): Result<DeviceHardware?> = withContext(dispatchers.io) {
Logger.d {
"DeviceHardwareRepository: getDeviceHardwareByModel(hwModel=$hwModel," +

View file

@ -40,13 +40,16 @@ 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.model.Node
import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.core.datastore.LocalStatsDataSource
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.di.ProcessLifecycle
import org.meshtastic.core.model.DataPacket
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.HardwareModel
import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.User
@ -56,7 +59,7 @@ import javax.inject.Singleton
/** Repository for managing node-related data, including hardware info, node database, and identity. */
@Singleton
@Suppress("TooManyFunctions")
open class NodeRepository
class NodeRepositoryImpl
@Inject
constructor(
@ProcessLifecycle private val processLifecycle: Lifecycle,
@ -64,28 +67,29 @@ constructor(
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). */
open val myNodeInfo: StateFlow<MyNodeEntity?> =
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. */
open val ourNodeInfo: StateFlow<Node?>
override val ourNodeInfo: StateFlow<Node?>
get() = _ourNodeInfo
private val _myId = MutableStateFlow<String?>(null)
/** The unique userId (hex string) of our local node. */
val myId: StateFlow<String?>
override val myId: StateFlow<String?>
get() = _myId
/** The latest local stats telemetry received from the locally connected node. */
val localStats: StateFlow<LocalStats> =
override val localStats: StateFlow<LocalStats> =
localStatsDataSource.localStatsFlow.stateIn(
processLifecycle.coroutineScope,
SharingStarted.Eagerly,
@ -93,12 +97,12 @@ constructor(
)
/** Update the cached local stats telemetry. */
fun updateLocalStats(stats: LocalStats) {
override fun updateLocalStats(stats: LocalStats) {
processLifecycle.coroutineScope.launch { localStatsDataSource.setLocalStats(stats) }
}
/** A reactive map from nodeNum to [Node] objects, representing the entire mesh. */
val nodeDBbyNum: StateFlow<Map<Int, Node>> =
override val nodeDBbyNum: StateFlow<Map<Int, Node>> =
nodeInfoReadDataSource
.nodeDBbyNumFlow()
.mapLatest { map -> map.mapValues { (_, it) -> it.toModel() } }
@ -115,7 +119,7 @@ constructor(
}
// Keep ourNodeInfo and myId correctly updated based on current connection and node DB
combine(nodeDBbyNum, myNodeInfo) { db, info -> info?.myNodeNum?.let { db[it] } }
combine(nodeDBbyNum, nodeInfoReadDataSource.myNodeInfoFlow()) { db, info -> info?.myNodeNum?.let { db[it] } }
.onEach { node ->
_ourNodeInfo.value = node
_myId.value = node?.user?.id
@ -127,7 +131,8 @@ constructor(
* Returns the node number used for log queries. Maps [nodeNum] to [MeshLog.NODE_NUM_LOCAL] (0) if it is the locally
* connected node.
*/
fun effectiveLogNodeId(nodeNum: Int): Flow<Int> = myNodeInfo
override fun effectiveLogNodeId(nodeNum: Int): Flow<Int> = nodeInfoReadDataSource
.myNodeInfoFlow()
.map { info -> if (nodeNum == info?.myNodeNum) MeshLog.NODE_NUM_LOCAL else nodeNum }
.distinctUntilChanged()
@ -135,14 +140,14 @@ constructor(
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. */
fun getNode(userId: String): Node = nodeDBbyNum.value.values.find { it.user.id == userId }
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]. */
fun getUser(nodeNum: Int): User = getUser(DataPacket.nodeNumToDefaultId(nodeNum))
override fun getUser(nodeNum: Int): User = getUser(DataPacket.nodeNumToDefaultId(nodeNum))
/** Returns the [User] info for a given [userId]. Falls back to a generic user if not found. */
fun getUser(userId: String): User = nodeDBbyNum.value.values.find { it.user.id == userId }?.user
override fun getUser(userId: String): User = nodeDBbyNum.value.values.find { it.user.id == userId }?.user
?: User(
id = userId,
long_name =
@ -161,13 +166,13 @@ constructor(
)
/** Returns a flow of nodes filtered and sorted according to the parameters. */
fun getNodes(
sort: NodeSortOption = NodeSortOption.LAST_HEARD,
filter: String = "",
includeUnknown: Boolean = true,
onlyOnline: Boolean = false,
onlyDirect: Boolean = false,
) = nodeInfoReadDataSource
override fun getNodes(
sort: NodeSortOption,
filter: String,
includeUnknown: Boolean,
onlyOnline: Boolean,
onlyDirect: Boolean,
): Flow<List<Node>> = nodeInfoReadDataSource
.getNodesFlow(
sort = sort.sqlValue,
filter = filter,
@ -179,44 +184,46 @@ constructor(
.flowOn(dispatchers.io)
.conflate()
/** Upserts a [NodeEntity] to the database. */
suspend fun upsert(node: NodeEntity) = withContext(dispatchers.io) { nodeInfoWriteDataSource.upsert(node) }
/** 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. */
suspend fun installConfig(mi: MyNodeEntity, nodes: List<NodeEntity>) =
withContext(dispatchers.io) { nodeInfoWriteDataSource.installConfig(mi, nodes) }
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. */
suspend fun clearNodeDB(preserveFavorites: Boolean = false) =
override suspend fun clearNodeDB(preserveFavorites: Boolean) =
withContext(dispatchers.io) { nodeInfoWriteDataSource.clearNodeDB(preserveFavorites) }
/** Clears the local node's connection info. */
suspend fun clearMyNodeInfo() = withContext(dispatchers.io) { nodeInfoWriteDataSource.clearMyNodeInfo() }
override suspend fun clearMyNodeInfo() = withContext(dispatchers.io) { nodeInfoWriteDataSource.clearMyNodeInfo() }
/** Deletes a node and its metadata by [num]. */
suspend fun deleteNode(num: Int) = withContext(dispatchers.io) {
override suspend fun deleteNode(num: Int) = withContext(dispatchers.io) {
nodeInfoWriteDataSource.deleteNode(num)
nodeInfoWriteDataSource.deleteMetadata(num)
}
/** Deletes multiple nodes and their metadata. */
suspend fun deleteNodes(nodeNums: List<Int>) = withContext(dispatchers.io) {
override suspend fun deleteNodes(nodeNums: List<Int>) = withContext(dispatchers.io) {
nodeInfoWriteDataSource.deleteNodes(nodeNums)
nodeNums.forEach { nodeInfoWriteDataSource.deleteMetadata(it) }
}
suspend fun getNodesOlderThan(lastHeard: Int): List<NodeEntity> =
withContext(dispatchers.io) { nodeInfoReadDataSource.getNodesOlderThan(lastHeard) }
override suspend fun getNodesOlderThan(lastHeard: Int): List<Node> =
withContext(dispatchers.io) { nodeInfoReadDataSource.getNodesOlderThan(lastHeard).map { it.toModel() } }
suspend fun getUnknownNodes(): List<NodeEntity> =
withContext(dispatchers.io) { nodeInfoReadDataSource.getUnknownNodes() }
override suspend fun getUnknownNodes(): List<Node> =
withContext(dispatchers.io) { nodeInfoReadDataSource.getUnknownNodes().map { it.toModel() } }
/** Persists hardware metadata for a node. */
suspend fun insertMetadata(metadata: MetadataEntity) =
withContext(dispatchers.io) { nodeInfoWriteDataSource.upsert(metadata) }
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". */
val onlineNodeCount: Flow<Int> =
override val onlineNodeCount: Flow<Int> =
nodeInfoReadDataSource
.nodeDBbyNumFlow()
.mapLatest { map -> map.values.count { it.node.lastHeard > onlineTimeThreshold() } }
@ -224,14 +231,52 @@ constructor(
.conflate()
/** Flow emitting the total number of nodes in the database. */
val totalNodeCount: Flow<Int> =
override val totalNodeCount: Flow<Int> =
nodeInfoReadDataSource
.nodeDBbyNumFlow()
.mapLatest { map -> map.values.count() }
.flowOn(dispatchers.io)
.conflate()
/** Updates the personal notes field for a node. */
suspend fun setNodeNotes(num: Int, notes: String) =
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,
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

@ -1,361 +0,0 @@
/*
* Copyright (c) 2025-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.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.map
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.PortNum
import javax.inject.Inject
class PacketRepository
@Inject
constructor(
private val dbManager: DatabaseManager,
private val dispatchers: CoroutineDispatchers,
) {
fun getWaypoints(): Flow<List<Packet>> =
dbManager.currentDb.flatMapLatest { db -> db.packetDao().getAllWaypointsFlow() }
fun getContacts(): Flow<Map<String, Packet>> =
dbManager.currentDb.flatMapLatest { db -> db.packetDao().getContactKeys() }
fun getContactsPaged(): Flow<PagingData<Packet>> = Pager(
config =
PagingConfig(
pageSize = CONTACTS_PAGE_SIZE,
enablePlaceholders = false,
initialLoadSize = CONTACTS_PAGE_SIZE,
),
pagingSourceFactory = { dbManager.currentDb.value.packetDao().getContactKeysPaged() },
)
.flow
suspend fun getMessageCount(contact: String): Int =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getMessageCount(contact) }
suspend fun getUnreadCount(contact: String): Int =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getUnreadCount(contact) }
fun getFirstUnreadMessageUuid(contact: String): Flow<Long?> =
dbManager.currentDb.flatMapLatest { db -> db.packetDao().getFirstUnreadMessageUuid(contact) }
fun hasUnreadMessages(contact: String): Flow<Boolean> =
dbManager.currentDb.flatMapLatest { db -> db.packetDao().hasUnreadMessages(contact) }
fun getUnreadCountTotal(): Flow<Int> =
dbManager.currentDb.flatMapLatest { db -> db.packetDao().getUnreadCountTotal() }
suspend fun clearUnreadCount(contact: String, timestamp: Long) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().clearUnreadCount(contact, timestamp) }
suspend fun updateLastReadMessage(contact: String, messageUuid: Long, lastReadTimestamp: Long) =
withContext(dispatchers.io) {
val dao = dbManager.currentDb.value.packetDao()
val current = dao.getContactSettings(contact)
val existingTimestamp = current?.lastReadMessageTimestamp ?: Long.MIN_VALUE
if (lastReadTimestamp <= existingTimestamp) {
return@withContext
}
val updated =
(current ?: ContactSettings(contact_key = contact)).copy(
lastReadMessageUuid = messageUuid,
lastReadMessageTimestamp = lastReadTimestamp,
)
dao.upsertContactSettings(listOf(updated))
}
suspend fun getQueuedPackets(): List<DataPacket>? =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getQueuedPackets() }
suspend fun insert(packet: Packet) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().insert(packet) }
suspend fun getMessagesFrom(
contact: String,
limit: Int? = null,
includeFiltered: Boolean = true,
getNode: suspend (String?) -> Node,
) = withContext(dispatchers.io) {
val dao = dbManager.currentDb.value.packetDao()
val flow =
when {
limit != null -> dao.getMessagesFrom(contact, limit)
!includeFiltered -> dao.getMessagesFrom(contact, includeFiltered = false)
else -> dao.getMessagesFrom(contact)
}
flow.mapLatest { packets ->
packets.map { packet ->
val message = packet.toMessage(getNode)
message.replyId
.takeIf { it != null && it != 0 }
?.let { getPacketByPacketId(it) }
?.toMessage(getNode)
?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
}
}
}
fun getMessagesFromPaged(contact: String, getNode: suspend (String?) -> Node): Flow<PagingData<Message>> = Pager(
config =
PagingConfig(
pageSize = MESSAGES_PAGE_SIZE,
enablePlaceholders = false,
initialLoadSize = MESSAGES_PAGE_SIZE,
),
pagingSourceFactory = { dbManager.currentDb.value.packetDao().getMessagesFromPaged(contact) },
)
.flow
.map { pagingData ->
pagingData.map { packet ->
val message = packet.toMessage(getNode)
message.replyId
.takeIf { it != null && it != 0 }
?.let { getPacketByPacketId(it) }
?.toMessage(getNode)
?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
}
}
suspend fun updateMessageStatus(d: DataPacket, m: MessageStatus) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().updateMessageStatus(d, m) }
suspend fun updateMessageId(d: DataPacket, id: Int) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().updateMessageId(d, id) }
suspend fun getPacketById(requestId: Int) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getPacketById(requestId) }
suspend fun getPacketByPacketId(packetId: Int) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getPacketByPacketId(packetId) }
suspend fun findPacketsWithId(packetId: Int) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().findPacketsWithId(packetId) }
@Suppress("CyclomaticComplexMethod")
suspend fun updateSFPPStatus(
packetId: Int,
from: Int,
to: Int,
hash: ByteArray,
status: MessageStatus = MessageStatus.SFPP_CONFIRMED,
rxTime: Long = 0,
myNodeNum: Int? = null,
) = withContext(dispatchers.io) {
val dao = dbManager.currentDb.value.packetDao()
val packets = dao.findPacketsWithId(packetId)
val reactions = dao.findReactionsWithId(packetId)
val fromId = DataPacket.nodeNumToDefaultId(from)
val isFromLocalNode = myNodeNum != null && from == myNodeNum
val toId =
if (to == 0 || to == DataPacket.NODENUM_BROADCAST) {
DataPacket.ID_BROADCAST
} else {
DataPacket.nodeNumToDefaultId(to)
}
val hashByteString = hash.toByteString()
packets.forEach { packet ->
// For sent messages, from is stored as ID_LOCAL, but SFPP packet has node number
val fromMatches =
packet.data.from == fromId || (isFromLocalNode && packet.data.from == DataPacket.ID_LOCAL)
co.touchlab.kermit.Logger.d {
"SFPP match check: packetFrom=${packet.data.from} fromId=$fromId " +
"isFromLocal=$isFromLocalNode fromMatches=$fromMatches " +
"packetTo=${packet.data.to} toId=$toId toMatches=${packet.data.to == toId}"
}
if (fromMatches && packet.data.to == toId) {
// If it's already confirmed, don't downgrade it to routing
if (packet.data.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) {
return@forEach
}
val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else packet.received_time
val updatedData = packet.data.copy(status = status, sfppHash = hashByteString, time = newTime)
dao.update(packet.copy(data = updatedData, sfpp_hash = hashByteString, received_time = newTime))
}
}
reactions.forEach { reaction ->
val reactionFrom = reaction.userId
// For sent reactions, from is stored as ID_LOCAL, but SFPP packet has node number
val fromMatches = reactionFrom == fromId || (isFromLocalNode && reactionFrom == DataPacket.ID_LOCAL)
val toMatches = reaction.to == toId
co.touchlab.kermit.Logger.d {
"SFPP reaction match check: reactionFrom=$reactionFrom fromId=$fromId " +
"isFromLocal=$isFromLocalNode fromMatches=$fromMatches " +
"reactionTo=${reaction.to} toId=$toId toMatches=$toMatches"
}
if (fromMatches && (reaction.to == null || toMatches)) {
if (reaction.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) {
return@forEach
}
val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else reaction.timestamp
val updatedReaction =
reaction.copy(status = status, sfpp_hash = hashByteString, timestamp = newTime)
dao.update(updatedReaction)
}
}
}
suspend fun updateSFPPStatusByHash(
hash: ByteArray,
status: MessageStatus = MessageStatus.SFPP_CONFIRMED,
rxTime: Long = 0,
) = withContext(dispatchers.io) {
val dao = dbManager.currentDb.value.packetDao()
val hashByteString = hash.toByteString()
dao.findPacketBySfppHash(hashByteString)?.let { packet ->
// If it's already confirmed, don't downgrade it
if (packet.data.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) {
return@let
}
val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else packet.received_time
val updatedData = packet.data.copy(status = status, sfppHash = hashByteString, time = newTime)
dao.update(packet.copy(data = updatedData, sfpp_hash = hashByteString, received_time = newTime))
}
dao.findReactionBySfppHash(hashByteString)?.let { reaction ->
if (reaction.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) {
return@let
}
val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else reaction.timestamp
val updatedReaction = reaction.copy(status = status, sfpp_hash = hashByteString, timestamp = newTime)
dao.update(updatedReaction)
}
}
suspend fun deleteMessages(uuidList: List<Long>) = withContext(dispatchers.io) {
for (chunk in uuidList.chunked(DELETE_CHUNK_SIZE)) {
// Fetch DAO per chunk to avoid holding a stale reference if the active DB switches
dbManager.currentDb.value.packetDao().deleteMessages(chunk)
}
}
suspend fun deleteContacts(contactList: List<String>) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().deleteContacts(contactList) }
suspend fun deleteWaypoint(id: Int) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().deleteWaypoint(id) }
suspend fun delete(packet: Packet) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().delete(packet) }
suspend fun update(packet: Packet) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().update(packet) }
fun getContactSettings(): Flow<Map<String, ContactSettings>> =
dbManager.currentDb.flatMapLatest { db -> db.packetDao().getContactSettings() }
suspend fun getContactSettings(contact: String) = withContext(dispatchers.io) {
dbManager.currentDb.value.packetDao().getContactSettings(contact) ?: ContactSettings(contact)
}
suspend fun setMuteUntil(contacts: List<String>, until: Long) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().setMuteUntil(contacts, until) }
suspend fun insertReaction(reaction: ReactionEntity) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().insert(reaction) }
suspend fun updateReaction(reaction: ReactionEntity) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().update(reaction) }
suspend fun getReactionByPacketId(packetId: Int) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getReactionByPacketId(packetId) }
suspend fun findReactionsWithId(packetId: Int) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().findReactionsWithId(packetId) }
fun getFilteredCountFlow(contactKey: String): Flow<Int> =
dbManager.currentDb.flatMapLatest { db -> db.packetDao().getFilteredCountFlow(contactKey) }
suspend fun getFilteredCount(contactKey: String): Int =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getFilteredCount(contactKey) }
fun getMessagesFromPaged(
contactKey: String,
includeFiltered: Boolean,
getNode: suspend (String?) -> Node,
): Flow<PagingData<Message>> = Pager(
config =
PagingConfig(
pageSize = MESSAGES_PAGE_SIZE,
enablePlaceholders = false,
initialLoadSize = MESSAGES_PAGE_SIZE,
),
pagingSourceFactory = {
dbManager.currentDb.value.packetDao().getMessagesFromPaged(contactKey, includeFiltered)
},
)
.flow
.map { pagingData ->
pagingData.map { packet ->
val message = packet.toMessage(getNode)
message.replyId
.takeIf { it != null && it != 0 }
?.let { getPacketByPacketId(it) }
?.toMessage(getNode)
?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
}
}
suspend fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) = withContext(dispatchers.io) {
dbManager.currentDb.value.packetDao().setContactFilteringDisabled(contactKey, disabled)
}
suspend fun clearPacketDB() = withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().deleteAll() }
suspend fun migrateChannelsByPSK(oldSettings: List<ChannelSettings>, newSettings: List<ChannelSettings>) =
withContext(dispatchers.io) {
dbManager.currentDb.value.packetDao().migrateChannelsByPSK(oldSettings, newSettings)
}
suspend fun updateFilteredBySender(senderId: String, filtered: Boolean) {
val pattern = "%\"from\":\"${senderId}\"%"
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().updateFilteredBySender(pattern, filtered) }
}
private fun org.meshtastic.core.database.dao.PacketDao.getAllWaypointsFlow(): Flow<List<Packet>> =
getAllPackets(PortNum.WAYPOINT_APP.value)
companion object {
private const val CONTACTS_PAGE_SIZE = 30
private const val MESSAGES_PAGE_SIZE = 50
private const val DELETE_CHUNK_SIZE = 500
private const val MILLISECONDS_IN_SECOND = 1000L
}
}

View file

@ -0,0 +1,482 @@
/*
* Copyright (c) 2025-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.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.map
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.toReaction
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.ContactSettings
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Message
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.Reaction
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.PortNum
import javax.inject.Inject
import org.meshtastic.core.database.entity.ContactSettings as ContactSettingsEntity
import org.meshtastic.core.database.entity.Packet as RoomPacket
import org.meshtastic.core.database.entity.ReactionEntity as RoomReaction
import org.meshtastic.core.repository.PacketRepository as SharedPacketRepository
@Suppress("TooManyFunctions", "LongParameterList")
class PacketRepositoryImpl
@Inject
constructor(
private val dbManager: DatabaseManager,
private val dispatchers: CoroutineDispatchers,
) : SharedPacketRepository {
override fun getWaypoints(): Flow<List<DataPacket>> = dbManager.currentDb
.flatMapLatest { db -> db.packetDao().getAllWaypointsFlow() }
.map { list -> list.map { it.data } }
override fun getContacts(): Flow<Map<String, DataPacket>> = dbManager.currentDb
.flatMapLatest { db -> db.packetDao().getContactKeys() }
.map { map -> map.mapValues { it.value.data } }
override fun getContactsPaged(): Flow<PagingData<DataPacket>> = Pager(
config =
PagingConfig(
pageSize = CONTACTS_PAGE_SIZE,
enablePlaceholders = false,
initialLoadSize = CONTACTS_PAGE_SIZE,
),
pagingSourceFactory = { dbManager.currentDb.value.packetDao().getContactKeysPaged() },
)
.flow
.map { pagingData -> pagingData.map { it.data } }
override suspend fun getMessageCount(contact: String): Int =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getMessageCount(contact) }
override suspend fun getUnreadCount(contact: String): Int =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getUnreadCount(contact) }
override fun getFirstUnreadMessageUuid(contact: String): Flow<Long?> =
dbManager.currentDb.flatMapLatest { db -> db.packetDao().getFirstUnreadMessageUuid(contact) }
override fun hasUnreadMessages(contact: String): Flow<Boolean> =
dbManager.currentDb.flatMapLatest { db -> db.packetDao().hasUnreadMessages(contact) }
override fun getUnreadCountTotal(): Flow<Int> =
dbManager.currentDb.flatMapLatest { db -> db.packetDao().getUnreadCountTotal() }
override suspend fun clearUnreadCount(contact: String, timestamp: Long) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().clearUnreadCount(contact, timestamp) }
override suspend fun updateLastReadMessage(contact: String, messageUuid: Long, lastReadTimestamp: Long) =
withContext(dispatchers.io) {
val dao = dbManager.currentDb.value.packetDao()
val current = dao.getContactSettings(contact)
val existingTimestamp = current?.lastReadMessageTimestamp ?: Long.MIN_VALUE
if (lastReadTimestamp <= existingTimestamp) {
return@withContext
}
val updated =
(current ?: ContactSettingsEntity(contact_key = contact)).copy(
lastReadMessageUuid = messageUuid,
lastReadMessageTimestamp = lastReadTimestamp,
)
dao.upsertContactSettings(listOf(updated))
}
override suspend fun getQueuedPackets(): List<DataPacket>? =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getQueuedPackets() }
suspend fun insertRoomPacket(packet: RoomPacket) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().insert(packet) }
override suspend fun savePacket(
myNodeNum: Int,
contactKey: String,
packet: DataPacket,
receivedTime: Long,
read: Boolean,
filtered: Boolean,
) {
val packetToSave =
RoomPacket(
uuid = 0L,
myNodeNum = myNodeNum,
packetId = packet.id,
port_num = packet.dataType,
contact_key = contactKey,
received_time = receivedTime,
read = read,
data = packet,
snr = packet.snr,
rssi = packet.rssi,
hopsAway = packet.hopsAway,
filtered = filtered,
)
insertRoomPacket(packetToSave)
}
override suspend fun getMessagesFrom(
contact: String,
limit: Int?,
includeFiltered: Boolean,
getNode: suspend (String?) -> Node,
): Flow<List<Message>> = withContext(dispatchers.io) {
val dao = dbManager.currentDb.value.packetDao()
val flow =
when {
limit != null -> dao.getMessagesFrom(contact, limit)
!includeFiltered -> dao.getMessagesFrom(contact, includeFiltered = false)
else -> dao.getMessagesFrom(contact)
}
flow.mapLatest { packets ->
packets.map { packet ->
val message = packet.toMessage(getNode)
message.replyId
.takeIf { it != null && it != 0 }
?.let { getPacketByPacketIdInternal(it) }
?.let { originalPacket -> originalPacket.toMessage(getNode) }
?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
}
}
}
override fun getMessagesFromPaged(contact: String, getNode: suspend (String?) -> Node): Flow<PagingData<Message>> =
Pager(
config =
PagingConfig(
pageSize = MESSAGES_PAGE_SIZE,
enablePlaceholders = false,
initialLoadSize = MESSAGES_PAGE_SIZE,
),
pagingSourceFactory = { dbManager.currentDb.value.packetDao().getMessagesFromPaged(contact) },
)
.flow
.map { pagingData ->
pagingData.map { packet ->
val message = packet.toMessage(getNode)
message.replyId
.takeIf { it != null && it != 0 }
?.let { getPacketByPacketIdInternal(it) }
?.let { originalPacket -> originalPacket.toMessage(getNode) }
?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
}
}
override fun getMessagesFromPaged(
contactKey: String,
includeFiltered: Boolean,
getNode: suspend (String?) -> Node,
): Flow<PagingData<Message>> = Pager(
config =
PagingConfig(
pageSize = MESSAGES_PAGE_SIZE,
enablePlaceholders = false,
initialLoadSize = MESSAGES_PAGE_SIZE,
),
pagingSourceFactory = {
dbManager.currentDb.value.packetDao().getMessagesFromPaged(contactKey, includeFiltered)
},
)
.flow
.map { pagingData ->
pagingData.map { packet ->
val message = packet.toMessage(getNode)
message.replyId
.takeIf { it != null && it != 0 }
?.let { getPacketByPacketIdInternal(it) }
?.let { originalPacket -> originalPacket.toMessage(getNode) }
?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
}
}
override suspend fun updateMessageStatus(d: DataPacket, m: MessageStatus) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().updateMessageStatus(d, m) }
override suspend fun updateMessageId(d: DataPacket, id: Int) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().updateMessageId(d, id) }
override suspend fun getPacketById(id: Int): DataPacket? =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getPacketById(id)?.data }
override suspend fun getPacketByPacketId(packetId: Int): DataPacket? = withContext(dispatchers.io) {
dbManager.currentDb.value.packetDao().getPacketByPacketId(packetId)?.packet?.data
}
private suspend fun getPacketByPacketIdInternal(packetId: Int) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getPacketByPacketId(packetId) }
override suspend fun insert(
packet: DataPacket,
myNodeNum: Int,
contactKey: String,
receivedTime: Long,
read: Boolean,
filtered: Boolean,
) {
val packetToSave =
RoomPacket(
uuid = 0L,
myNodeNum = myNodeNum,
packetId = packet.id,
port_num = packet.dataType,
contact_key = contactKey,
received_time = receivedTime,
read = read,
data = packet,
snr = packet.snr,
rssi = packet.rssi,
hopsAway = packet.hopsAway,
filtered = filtered,
)
insertRoomPacket(packetToSave)
}
override suspend fun update(packet: DataPacket): Unit = withContext(dispatchers.io) {
val dao = dbManager.currentDb.value.packetDao()
// Match on key fields that identify the packet, rather than the entire data object
dao.findPacketsWithId(packet.id)
.find { it.data.id == packet.id && it.data.from == packet.from && it.data.to == packet.to }
?.let { dao.update(it.copy(data = packet)) }
}
override suspend fun insertReaction(reaction: Reaction, myNodeNum: Int) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().insert(reaction.toEntity(myNodeNum)) }
override suspend fun updateReaction(reaction: Reaction) = withContext(dispatchers.io) {
val dao = dbManager.currentDb.value.packetDao()
dao.findReactionsWithId(reaction.packetId)
.find { it.userId == reaction.user.id && it.emoji == reaction.emoji }
?.let { dao.update(reaction.toEntity(it.myNodeNum)) } ?: Unit
}
override suspend fun getReactionByPacketId(packetId: Int): Reaction? = withContext(dispatchers.io) {
dbManager.currentDb.value.packetDao().getReactionByPacketId(packetId)?.toReaction { null }
}
override suspend fun findPacketsWithId(packetId: Int): List<DataPacket> = withContext(dispatchers.io) {
dbManager.currentDb.value.packetDao().findPacketsWithId(packetId).map { it.data }
}
private suspend fun findPacketsWithIdInternal(packetId: Int) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().findPacketsWithId(packetId) }
override suspend fun findReactionsWithId(packetId: Int): List<Reaction> = withContext(dispatchers.io) {
dbManager.currentDb.value.packetDao().findReactionsWithId(packetId).toReaction { null }
}
private suspend fun findReactionsWithIdInternal(packetId: Int) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().findReactionsWithId(packetId) }
@Suppress("CyclomaticComplexMethod")
override suspend fun updateSFPPStatus(
packetId: Int,
from: Int,
to: Int,
hash: ByteArray,
status: MessageStatus,
rxTime: Long,
myNodeNum: Int?,
) = withContext(dispatchers.io) {
val dao = dbManager.currentDb.value.packetDao()
val packets = findPacketsWithIdInternal(packetId)
val reactions = findReactionsWithIdInternal(packetId)
val fromId = DataPacket.nodeNumToDefaultId(from)
val isFromLocalNode = myNodeNum != null && from == myNodeNum
val toId =
if (to == 0 || to == DataPacket.NODENUM_BROADCAST) {
DataPacket.ID_BROADCAST
} else {
DataPacket.nodeNumToDefaultId(to)
}
val hashByteString = hash.toByteString()
packets.forEach { packet ->
// For sent messages, from is stored as ID_LOCAL, but SFPP packet has node number
val fromMatches =
packet.data.from == fromId || (isFromLocalNode && packet.data.from == DataPacket.ID_LOCAL)
co.touchlab.kermit.Logger.d {
"SFPP match check: packetFrom=${packet.data.from} fromId=$fromId " +
"isFromLocal=$isFromLocalNode fromMatches=$fromMatches " +
"packetTo=${packet.data.to} toId=$toId toMatches=${packet.data.to == toId}"
}
if (fromMatches && packet.data.to == toId) {
// If it's already confirmed, don't downgrade it to routing
if (packet.data.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) {
return@forEach
}
val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else packet.received_time
val updatedData = packet.data.copy(status = status, sfppHash = hashByteString, time = newTime)
dao.update(packet.copy(data = updatedData, sfpp_hash = hashByteString, received_time = newTime))
}
}
reactions.forEach { reaction ->
val reactionFrom = reaction.userId
// For sent reactions, from is stored as ID_LOCAL, but SFPP packet has node number
val fromMatches = reactionFrom == fromId || (isFromLocalNode && reactionFrom == DataPacket.ID_LOCAL)
val toMatches = reaction.to == toId
co.touchlab.kermit.Logger.d {
"SFPP reaction match check: reactionFrom=$reactionFrom fromId=$fromId " +
"isFromLocal=$isFromLocalNode fromMatches=$fromMatches " +
"reactionTo=${reaction.to} toId=$toId toMatches=$toMatches"
}
if (fromMatches && (reaction.to == null || toMatches)) {
if (reaction.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) {
return@forEach
}
val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else reaction.timestamp
val updatedReaction =
reaction.copy(status = status, sfpp_hash = hashByteString, timestamp = newTime)
dao.update(updatedReaction)
}
}
}
override suspend fun updateSFPPStatusByHash(hash: ByteArray, status: MessageStatus, rxTime: Long): Unit =
withContext(dispatchers.io) {
val dao = dbManager.currentDb.value.packetDao()
val hashByteString = hash.toByteString()
dao.findPacketBySfppHash(hashByteString)?.let { packet ->
// If it's already confirmed, don't downgrade it
if (packet.data.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) {
return@let
}
val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else packet.received_time
val updatedData = packet.data.copy(status = status, sfppHash = hashByteString, time = newTime)
dao.update(packet.copy(data = updatedData, sfpp_hash = hashByteString, received_time = newTime))
}
dao.findReactionBySfppHash(hashByteString)?.let { reaction ->
if (reaction.status == MessageStatus.SFPP_CONFIRMED && status == MessageStatus.SFPP_ROUTING) {
return@let
}
val newTime = if (rxTime > 0) rxTime * MILLISECONDS_IN_SECOND else reaction.timestamp
val updatedReaction = reaction.copy(status = status, sfpp_hash = hashByteString, timestamp = newTime)
dao.update(updatedReaction)
}
}
override suspend fun deleteMessages(uuidList: List<Long>) = withContext(dispatchers.io) {
for (chunk in uuidList.chunked(DELETE_CHUNK_SIZE)) {
// Fetch DAO per chunk to avoid holding a stale reference if the active DB switches
dbManager.currentDb.value.packetDao().deleteMessages(chunk)
}
}
override suspend fun deleteContacts(contactList: List<String>) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().deleteContacts(contactList) }
override suspend fun deleteWaypoint(id: Int) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().deleteWaypoint(id) }
suspend fun delete(packet: RoomPacket) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().delete(packet) }
suspend fun update(packet: RoomPacket) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().update(packet) }
override fun getContactSettings(): Flow<Map<String, ContactSettings>> = dbManager.currentDb
.flatMapLatest { db -> db.packetDao().getContactSettings() }
.map { map -> map.mapValues { it.value.toShared() } }
override suspend fun getContactSettings(contact: String): ContactSettings = withContext(dispatchers.io) {
dbManager.currentDb.value.packetDao().getContactSettings(contact)?.toShared() ?: ContactSettings(contact)
}
override suspend fun setMuteUntil(contacts: List<String>, until: Long) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().setMuteUntil(contacts, until) }
suspend fun insertReaction(reaction: RoomReaction) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().insert(reaction) }
suspend fun updateReaction(reaction: RoomReaction) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().update(reaction) }
override fun getFilteredCountFlow(contactKey: String): Flow<Int> =
dbManager.currentDb.flatMapLatest { db -> db.packetDao().getFilteredCountFlow(contactKey) }
override suspend fun getFilteredCount(contactKey: String): Int =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getFilteredCount(contactKey) }
override suspend fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) =
withContext(dispatchers.io) {
dbManager.currentDb.value.packetDao().setContactFilteringDisabled(contactKey, disabled)
}
override suspend fun clearPacketDB() =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().deleteAll() }
override suspend fun migrateChannelsByPSK(oldSettings: List<ChannelSettings>, newSettings: List<ChannelSettings>) =
withContext(dispatchers.io) {
dbManager.currentDb.value.packetDao().migrateChannelsByPSK(oldSettings, newSettings)
}
override suspend fun updateFilteredBySender(senderId: String, filtered: Boolean) {
val pattern = "%\"from\":\"${senderId}\"%"
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().updateFilteredBySender(pattern, filtered) }
}
private fun org.meshtastic.core.database.dao.PacketDao.getAllWaypointsFlow(): Flow<List<RoomPacket>> =
getAllPackets(PortNum.WAYPOINT_APP.value)
private fun ContactSettingsEntity.toShared() = ContactSettings(
contactKey = contact_key,
muteUntil = muteUntil,
lastReadMessageUuid = lastReadMessageUuid,
lastReadMessageTimestamp = lastReadMessageTimestamp,
filteringDisabled = filteringDisabled,
isMuted = isMuted,
)
private fun Reaction.toEntity(myNodeNum: Int) = RoomReaction(
myNodeNum = myNodeNum,
replyId = replyId,
userId = user.id,
emoji = emoji,
timestamp = timestamp,
snr = snr,
rssi = rssi,
hopsAway = hopsAway,
packetId = packetId,
status = status,
routingError = routingError,
relays = relays,
relayNode = relayNode,
to = to,
channel = channel,
sfpp_hash = sfppHash,
)
companion object {
private const val CONTACTS_PAGE_SIZE = 30
private const val MESSAGES_PAGE_SIZE = 50
private const val DELETE_CHUNK_SIZE = 500
private const val MILLISECONDS_IN_SECOND = 1000L
}
}

View file

@ -22,6 +22,8 @@ import org.meshtastic.core.datastore.ChannelSetDataSource
import org.meshtastic.core.datastore.LocalConfigDataSource
import org.meshtastic.core.datastore.ModuleConfigDataSource
import org.meshtastic.core.model.util.getChannelUrl
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.proto.Channel
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ChannelSettings
@ -36,25 +38,25 @@ import javax.inject.Inject
* Class responsible for radio configuration data. Combines access to [nodeDB], [ChannelSet], [LocalConfig] &
* [LocalModuleConfig].
*/
open class RadioConfigRepository
open class RadioConfigRepositoryImpl
@Inject
constructor(
private val nodeDB: NodeRepository,
private val channelSetDataSource: ChannelSetDataSource,
private val localConfigDataSource: LocalConfigDataSource,
private val moduleConfigDataSource: ModuleConfigDataSource,
) {
) : RadioConfigRepository {
/** Flow representing the [ChannelSet] data store. */
val channelSetFlow: Flow<ChannelSet> = channelSetDataSource.channelSetFlow
override val channelSetFlow: Flow<ChannelSet> = channelSetDataSource.channelSetFlow
/** Clears the [ChannelSet] data in the data store. */
suspend fun clearChannelSet() {
override suspend fun clearChannelSet() {
channelSetDataSource.clearChannelSet()
}
/** Replaces the [ChannelSettings] list with a new [settingsList]. */
suspend fun replaceAllSettings(settingsList: List<ChannelSettings>) {
override suspend fun replaceAllSettings(settingsList: List<ChannelSettings>) {
channelSetDataSource.replaceAllSettings(settingsList)
}
@ -65,13 +67,13 @@ constructor(
* @param channel The [Channel] provided.
* @return the index of the admin channel after the update (if not found, returns 0).
*/
suspend fun updateChannelSettings(channel: Channel) = channelSetDataSource.updateChannelSettings(channel)
override suspend fun updateChannelSettings(channel: Channel) = channelSetDataSource.updateChannelSettings(channel)
/** Flow representing the [LocalConfig] data store. */
open val localConfigFlow: Flow<LocalConfig> = localConfigDataSource.localConfigFlow
override val localConfigFlow: Flow<LocalConfig> = localConfigDataSource.localConfigFlow
/** Clears the [LocalConfig] data in the data store. */
suspend fun clearLocalConfig() {
override suspend fun clearLocalConfig() {
localConfigDataSource.clearLocalConfig()
}
@ -80,16 +82,16 @@ constructor(
*
* @param config The [Config] to be set.
*/
suspend fun setLocalConfig(config: Config) {
override suspend fun setLocalConfig(config: Config) {
localConfigDataSource.setLocalConfig(config)
config.lora?.let { channelSetDataSource.setLoraConfig(it) }
}
/** Flow representing the [LocalModuleConfig] data store. */
val moduleConfigFlow: Flow<LocalModuleConfig> = moduleConfigDataSource.moduleConfigFlow
override val moduleConfigFlow: Flow<LocalModuleConfig> = moduleConfigDataSource.moduleConfigFlow
/** Clears the [LocalModuleConfig] data in the data store. */
suspend fun clearLocalModuleConfig() {
override suspend fun clearLocalModuleConfig() {
moduleConfigDataSource.clearLocalModuleConfig()
}
@ -98,12 +100,12 @@ constructor(
*
* @param config The [ModuleConfig] to be set.
*/
suspend fun setLocalModuleConfig(config: ModuleConfig) {
override suspend fun setLocalModuleConfig(config: ModuleConfig) {
moduleConfigDataSource.setLocalModuleConfig(config)
}
/** Flow representing the combined [DeviceProfile] protobuf. */
val deviceProfileFlow: Flow<DeviceProfile> =
override val deviceProfileFlow: Flow<DeviceProfile> =
combine(nodeDB.ourNodeInfo, channelSetFlow, localConfigFlow, moduleConfigFlow) {
node,
channels,

View file

@ -0,0 +1,129 @@
/*
* 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 io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.User
class CommandSenderHopLimitTest {
private val packetHandler: PacketHandler = mockk(relaxed = true)
private val nodeManager: NodeManager = mockk(relaxed = true)
private val radioConfigRepository: RadioConfigRepository = mockk(relaxed = true)
private val localConfigFlow = MutableStateFlow(LocalConfig())
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = CoroutineScope(testDispatcher)
private lateinit var commandSender: CommandSender
@Before
fun setUp() {
val myNum = 123
val myNode = Node(num = myNum, user = User(id = "!id", long_name = "long", short_name = "shrt"))
every { radioConfigRepository.localConfigFlow } returns localConfigFlow
every { nodeManager.myNodeNum } returns myNum
every { nodeManager.nodeDBbyNodeNum } returns mapOf(myNum to myNode)
commandSender = CommandSenderImpl(packetHandler, nodeManager, radioConfigRepository)
commandSender.start(testScope)
}
@Test
fun `sendData uses default hop limit when config hop limit is zero`() = runTest(testDispatcher) {
val packet =
DataPacket(
to = DataPacket.ID_BROADCAST,
bytes = byteArrayOf(1, 2, 3).toByteString(),
dataType = 1, // PortNum.TEXT_MESSAGE_APP
)
val meshPacketSlot = slot<MeshPacket>()
every { packetHandler.sendToRadio(capture(meshPacketSlot)) } returns Unit
// Ensure localConfig has lora.hop_limit = 0
localConfigFlow.value = LocalConfig(lora = Config.LoRaConfig(hop_limit = 0))
commandSender.sendData(packet)
verify(exactly = 1) { packetHandler.sendToRadio(any<MeshPacket>()) }
val capturedHopLimit = meshPacketSlot.captured.hop_limit ?: 0
assertTrue("Hop limit should be greater than 0, but was $capturedHopLimit", capturedHopLimit > 0)
assertEquals(3, capturedHopLimit)
assertEquals(3, meshPacketSlot.captured.hop_start)
}
@Test
fun `sendData respects non-zero hop limit from config`() = runTest(testDispatcher) {
val packet =
DataPacket(to = DataPacket.ID_BROADCAST, bytes = byteArrayOf(1, 2, 3).toByteString(), dataType = 1)
val meshPacketSlot = slot<MeshPacket>()
every { packetHandler.sendToRadio(capture(meshPacketSlot)) } returns Unit
localConfigFlow.value = LocalConfig(lora = Config.LoRaConfig(hop_limit = 7))
commandSender.sendData(packet)
verify { packetHandler.sendToRadio(any<MeshPacket>()) }
assertEquals(7, meshPacketSlot.captured.hop_limit)
assertEquals(7, meshPacketSlot.captured.hop_start)
}
@Test
fun `requestUserInfo sets hopStart equal to hopLimit`() = runTest(testDispatcher) {
val destNum = 12345
val meshPacketSlot = slot<MeshPacket>()
every { packetHandler.sendToRadio(capture(meshPacketSlot)) } returns Unit
localConfigFlow.value = LocalConfig(lora = Config.LoRaConfig(hop_limit = 6))
// Mock node manager interactions
// Note: we need to keep myNode in the map for requestUserInfo to not return early
val myNum = 123
val myNode = Node(num = myNum, user = User(id = "!id", long_name = "long", short_name = "shrt"))
every { nodeManager.nodeDBbyNodeNum } returns mapOf(myNum to myNode)
commandSender.requestUserInfo(destNum)
verify { packetHandler.sendToRadio(any<MeshPacket>()) }
assertEquals("Hop Limit should be 6", 6, meshPacketSlot.captured.hop_limit)
assertEquals("Hop Start should be 6", 6, meshPacketSlot.captured.hop_start)
}
}

View file

@ -0,0 +1,76 @@
/*
* Copyright (c) 2025-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 io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.proto.User
class CommandSenderImplTest {
private lateinit var commandSender: CommandSenderImpl
private lateinit var nodeManager: NodeManager
@Before
fun setUp() {
nodeManager = mockk(relaxed = true)
commandSender = CommandSenderImpl(mockk(relaxed = true), nodeManager, mockk(relaxed = true))
}
@Test
fun `generatePacketId produces unique non-zero IDs`() {
val ids = mutableSetOf<Int>()
repeat(1000) {
val id = commandSender.generatePacketId()
assertNotEquals(0, id)
ids.add(id)
}
assertEquals(1000, ids.size)
}
@Test
fun `resolveNodeNum handles broadcast ID`() {
assertEquals(DataPacket.NODENUM_BROADCAST, commandSender.resolveNodeNum(DataPacket.ID_BROADCAST))
}
@Test
fun `resolveNodeNum handles hex ID with exclamation mark`() {
assertEquals(123, commandSender.resolveNodeNum("!0000007b"))
}
@Test
fun `resolveNodeNum handles custom node ID from database`() {
val nodeNum = 456
val userId = "custom_id"
val node = Node(num = nodeNum, user = User(id = userId))
every { nodeManager.nodeDBbyID } returns mapOf(userId to node)
assertEquals(nodeNum, commandSender.resolveNodeNum(userId))
}
@Test(expected = IllegalArgumentException::class)
fun `resolveNodeNum throws for unknown ID`() {
commandSender.resolveNodeNum("unknown")
}
}

View file

@ -0,0 +1,126 @@
/*
* Copyright (c) 2025-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 io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MyNodeInfo
import org.meshtastic.proto.NodeInfo
import org.meshtastic.proto.QueueStatus
class FromRadioPacketHandlerImplTest {
private val serviceRepository: ServiceRepository = mockk(relaxed = true)
private val router: MeshRouter = mockk(relaxed = true)
private val mqttManager: MqttManager = mockk(relaxed = true)
private val packetHandler: PacketHandler = mockk(relaxed = true)
private val serviceNotifications: MeshServiceNotifications = mockk(relaxed = true)
private lateinit var handler: FromRadioPacketHandlerImpl
@Before
fun setup() {
handler =
FromRadioPacketHandlerImpl(serviceRepository, { router }, mqttManager, packetHandler, serviceNotifications)
}
@Test
fun `handleFromRadio routes MY_INFO to configFlowManager`() {
val myInfo = MyNodeInfo(my_node_num = 1234)
val proto = FromRadio(my_info = myInfo)
handler.handleFromRadio(proto)
verify { router.configFlowManager.handleMyInfo(myInfo) }
}
@Test
fun `handleFromRadio routes METADATA to configFlowManager`() {
val metadata = DeviceMetadata(firmware_version = "v1.0")
val proto = FromRadio(metadata = metadata)
handler.handleFromRadio(proto)
verify { router.configFlowManager.handleLocalMetadata(metadata) }
}
@Test
fun `handleFromRadio routes NODE_INFO to configFlowManager and updates status`() {
val nodeInfo = NodeInfo(num = 1234)
val proto = FromRadio(node_info = nodeInfo)
every { router.configFlowManager.newNodeCount } returns 1
handler.handleFromRadio(proto)
verify { router.configFlowManager.handleNodeInfo(nodeInfo) }
verify { serviceRepository.setConnectionProgress("Nodes (1)") }
}
@Test
fun `handleFromRadio routes CONFIG_COMPLETE_ID to configFlowManager`() {
val nonce = 69420
val proto = FromRadio(config_complete_id = nonce)
handler.handleFromRadio(proto)
verify { router.configFlowManager.handleConfigComplete(nonce) }
}
@Test
fun `handleFromRadio routes QUEUESTATUS to packetHandler`() {
val queueStatus = QueueStatus(free = 10)
val proto = FromRadio(queueStatus = queueStatus)
handler.handleFromRadio(proto)
verify { packetHandler.handleQueueStatus(queueStatus) }
}
@Test
fun `handleFromRadio routes CONFIG to configHandler`() {
val config = Config(lora = Config.LoRaConfig(use_preset = true))
val proto = FromRadio(config = config)
handler.handleFromRadio(proto)
verify { router.configHandler.handleDeviceConfig(config) }
}
@Test
fun `handleFromRadio routes CLIENTNOTIFICATION to serviceRepository and notifications`() {
val notification = ClientNotification(message = "test")
val proto = FromRadio(clientNotification = notification)
handler.handleFromRadio(proto)
verify { serviceRepository.setClientNotification(notification) }
verify { serviceNotifications.showClientNotification(notification) }
verify { packetHandler.removeResponse(0, complete = false) }
}
}

View file

@ -0,0 +1,70 @@
/*
* Copyright (c) 2025-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.junit.Assert.assertEquals
import org.junit.Test
import org.meshtastic.proto.StoreAndForward
class HistoryManagerImplTest {
@Test
fun `buildStoreForwardHistoryRequest copies positive parameters`() {
val request =
HistoryManagerImpl.buildStoreForwardHistoryRequest(
lastRequest = 42,
historyReturnWindow = 15,
historyReturnMax = 25,
)
assertEquals(StoreAndForward.RequestResponse.CLIENT_HISTORY, request.rr)
assertEquals(42, request.history?.last_request)
assertEquals(15, request.history?.window)
assertEquals(25, request.history?.history_messages)
}
@Test
fun `buildStoreForwardHistoryRequest clamps non-positive parameters`() {
val request =
HistoryManagerImpl.buildStoreForwardHistoryRequest(
lastRequest = 0,
historyReturnWindow = -1,
historyReturnMax = 0,
)
assertEquals(StoreAndForward.RequestResponse.CLIENT_HISTORY, request.rr)
assertEquals(0, request.history?.last_request)
assertEquals(0, request.history?.window)
assertEquals(0, request.history?.history_messages)
}
@Test
fun `resolveHistoryRequestParameters uses config values when positive`() {
val (window, max) = HistoryManagerImpl.resolveHistoryRequestParameters(window = 30, max = 10)
assertEquals(30, window)
assertEquals(10, max)
}
@Test
fun `resolveHistoryRequestParameters falls back to defaults when non-positive`() {
val (window, max) = HistoryManagerImpl.resolveHistoryRequestParameters(window = 0, max = -5)
assertEquals(1440, window)
assertEquals(100, max)
}
}

View file

@ -0,0 +1,240 @@
/*
* 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 io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.CommandSender
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.NodeRepository
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
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.resources.getString
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.ToRadio
class MeshConnectionManagerImplTest {
private val radioInterfaceService: RadioInterfaceService = mockk(relaxed = true)
private val serviceRepository: ServiceRepository = mockk(relaxed = true)
private val serviceBroadcasts: ServiceBroadcasts = mockk(relaxed = true)
private val serviceNotifications: MeshServiceNotifications = mockk(relaxed = true)
private val uiPrefs: UiPrefs = mockk(relaxed = true)
private val packetHandler: PacketHandler = mockk(relaxed = true)
private val nodeRepository: NodeRepository = mockk(relaxed = true)
private val locationManager: MeshLocationManager = mockk(relaxed = true)
private val mqttManager: MqttManager = mockk(relaxed = true)
private val historyManager: HistoryManager = mockk(relaxed = true)
private val radioConfigRepository: RadioConfigRepository = mockk(relaxed = true)
private val commandSender: CommandSender = mockk(relaxed = true)
private val nodeManager: NodeManager = mockk(relaxed = true)
private val analytics: PlatformAnalytics = mockk(relaxed = true)
private val packetRepository: PacketRepository = mockk(relaxed = true)
private val workerManager: MeshWorkerManager = mockk(relaxed = true)
private val appWidgetUpdater: AppWidgetUpdater = mockk(relaxed = true)
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
@Before
fun setUp() {
mockkStatic("org.meshtastic.core.resources.ContextExtKt")
every { getString(any()) } returns "Mocked String"
every { getString(any(), *anyVararg()) } returns "Mocked String"
every { radioInterfaceService.connectionState } returns radioConnectionState
every { radioConfigRepository.localConfigFlow } returns localConfigFlow
every { radioConfigRepository.moduleConfigFlow } returns moduleConfigFlow
every { nodeRepository.myNodeInfo } returns MutableStateFlow<MyNodeInfo?>(null)
every { nodeRepository.ourNodeInfo } returns MutableStateFlow<Node?>(null)
every { nodeRepository.localStats } returns MutableStateFlow(LocalStats())
every { serviceRepository.connectionState } returns connectionStateFlow
every { serviceRepository.setConnectionState(any()) } answers { connectionStateFlow.value = firstArg() }
manager =
MeshConnectionManagerImpl(
radioInterfaceService,
serviceRepository,
serviceBroadcasts,
serviceNotifications,
uiPrefs,
packetHandler,
nodeRepository,
locationManager,
mqttManager,
historyManager,
radioConfigRepository,
commandSender,
nodeManager,
analytics,
packetRepository,
workerManager,
appWidgetUpdater,
)
}
@After
fun tearDown() {
unmockkStatic("org.meshtastic.core.resources.ContextExtKt")
}
@Test
fun `Connected state triggers broadcast and config start`() = runTest(testDispatcher) {
manager.start(backgroundScope)
radioConnectionState.value = ConnectionState.Connected
advanceUntilIdle()
assertEquals(
"State should be Connecting after radio Connected",
ConnectionState.Connecting,
serviceRepository.connectionState.value,
)
verify { serviceBroadcasts.broadcastConnection() }
verify { packetHandler.sendToRadio(any<ToRadio>()) }
}
@Test
fun `Disconnected state stops services`() = runTest(testDispatcher) {
manager.start(backgroundScope)
// Transition to Connected first so that Disconnected actually does something
radioConnectionState.value = ConnectionState.Connected
advanceUntilIdle()
radioConnectionState.value = ConnectionState.Disconnected
advanceUntilIdle()
assertEquals(
"State should be Disconnected after radio Disconnected",
ConnectionState.Disconnected,
serviceRepository.connectionState.value,
)
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)
manager.start(backgroundScope)
advanceUntilIdle()
radioConnectionState.value = ConnectionState.DeviceSleep
advanceUntilIdle()
assertEquals(
"State should be Disconnected when power saving is off",
ConnectionState.Disconnected,
serviceRepository.connectionState.value,
)
}
@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.start(backgroundScope)
advanceUntilIdle()
radioConnectionState.value = ConnectionState.DeviceSleep
advanceUntilIdle()
assertEquals(
"State should stay in DeviceSleep when power saving is on",
ConnectionState.DeviceSleep,
serviceRepository.connectionState.value,
)
}
@Test
fun `onRadioConfigLoaded enqueues queued packets and sets time`() = runTest(testDispatcher) {
manager.start(backgroundScope)
val packetId = 456
val dataPacket = mockk<DataPacket>(relaxed = true)
every { dataPacket.id } returns packetId
coEvery { packetRepository.getQueuedPackets() } returns listOf(dataPacket)
manager.onRadioConfigLoaded()
advanceUntilIdle()
verify { workerManager.enqueueSendMessage(packetId) }
verify { commandSender.sendAdmin(any(), initFn = any()) }
}
@Test
fun `onNodeDbReady starts MQTT and requests history`() = runTest(testDispatcher) {
val moduleConfig = mockk<LocalModuleConfig>(relaxed = true)
every { moduleConfig.mqtt } returns ModuleConfig.MQTTConfig(enabled = true)
every { moduleConfig.store_forward } returns ModuleConfig.StoreForwardConfig(enabled = true)
moduleConfigFlow.value = moduleConfig
manager.start(backgroundScope)
manager.onNodeDbReady()
advanceUntilIdle()
verify { mqttManager.start(any(), true, any()) }
verify { historyManager.requestHistoryReplay("onNodeDbReady", any(), any(), "Unknown") }
}
}

View file

@ -0,0 +1,167 @@
/*
* Copyright (c) 2025-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 dagger.Lazy
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.MeshDataMapper
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshConnectionManager
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.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.TracerouteHandler
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.StoreForwardPlusPlus
class MeshDataHandlerTest {
private val nodeManager: NodeManager = mockk(relaxed = true)
private val packetHandler: PacketHandler = mockk(relaxed = true)
private val serviceRepository: ServiceRepository = mockk(relaxed = true)
private val packetRepository: PacketRepository = mockk(relaxed = true)
private val packetRepositoryLazy: Lazy<PacketRepository> = mockk { every { get() } returns packetRepository }
private val serviceBroadcasts: ServiceBroadcasts = mockk(relaxed = true)
private val serviceNotifications: MeshServiceNotifications = mockk(relaxed = true)
private val analytics: PlatformAnalytics = mockk(relaxed = true)
private val dataMapper: MeshDataMapper = mockk(relaxed = true)
private val configHandler: MeshConfigHandler = mockk(relaxed = true)
private val configHandlerLazy: Lazy<MeshConfigHandler> = mockk { every { get() } returns configHandler }
private val configFlowManager: MeshConfigFlowManager = mockk(relaxed = true)
private val configFlowManagerLazy: Lazy<MeshConfigFlowManager> = mockk { every { get() } returns configFlowManager }
private val commandSender: CommandSender = mockk(relaxed = true)
private val historyManager: HistoryManager = mockk(relaxed = true)
private val connectionManager: MeshConnectionManager = mockk(relaxed = true)
private val connectionManagerLazy: Lazy<MeshConnectionManager> = mockk { every { get() } returns connectionManager }
private val tracerouteHandler: TracerouteHandler = mockk(relaxed = true)
private val neighborInfoHandler: NeighborInfoHandler = mockk(relaxed = true)
private val radioConfigRepository: RadioConfigRepository = mockk(relaxed = true)
private val messageFilter: MessageFilter = mockk(relaxed = true)
private lateinit var meshDataHandler: MeshDataHandlerImpl
@OptIn(ExperimentalCoroutinesApi::class)
@Before
fun setUp() {
mockkStatic(android.util.Log::class)
every { android.util.Log.d(any(), any()) } returns 0
every { android.util.Log.i(any(), any()) } returns 0
every { android.util.Log.w(any(), any<String>()) } returns 0
every { android.util.Log.e(any(), any()) } returns 0
meshDataHandler =
MeshDataHandlerImpl(
nodeManager,
packetHandler,
serviceRepository,
packetRepositoryLazy,
serviceBroadcasts,
serviceNotifications,
analytics,
dataMapper,
configHandlerLazy,
configFlowManagerLazy,
commandSender,
historyManager,
connectionManagerLazy,
tracerouteHandler,
neighborInfoHandler,
radioConfigRepository,
messageFilter,
)
// Use UnconfinedTestDispatcher for running coroutines synchronously in tests
meshDataHandler.start(CoroutineScope(UnconfinedTestDispatcher()))
every { nodeManager.myNodeNum } returns 123
every { nodeManager.getMyId() } returns "!0000007b"
// Default behavior for dataMapper to return a valid DataPacket when requested
every { dataMapper.toDataPacket(any()) } answers
{
val packet = firstArg<MeshPacket>()
DataPacket(
to = "to",
channel = 0,
bytes = packet.decoded?.payload,
dataType = packet.decoded?.portnum?.value ?: 0,
id = packet.id,
)
}
}
@Test
fun `handleReceivedData with SFPP LINK_PROVIDE updates SFPP status`() = runTest {
val sfppMessage =
StoreForwardPlusPlus(
sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE,
encapsulated_id = 999,
encapsulated_from = 456,
encapsulated_to = 789,
encapsulated_rxtime = 1000,
message = "EncryptedPayload".toByteArray().toByteString(),
message_hash = "Hash".toByteArray().toByteString(),
)
val payload = StoreForwardPlusPlus.ADAPTER.encode(sfppMessage).toByteString()
val meshPacket =
MeshPacket(
from = 456,
to = 123,
decoded = Data(portnum = PortNum.STORE_FORWARD_PLUSPLUS_APP, payload = payload),
id = 1001,
)
meshDataHandler.handleReceivedData(meshPacket, 123)
// SFPP_ROUTING because commit_hash is empty
coVerify {
packetRepository.updateSFPPStatus(
packetId = 999,
from = 456,
to = 789,
hash = any(),
status = MessageStatus.SFPP_ROUTING,
rxTime = 1000L,
myNodeNum = 123,
)
}
}
}

View file

@ -0,0 +1,97 @@
/*
* Copyright (c) 2025-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 io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.prefs.filter.FilterPrefs
class MessageFilterImplTest {
private lateinit var filterPrefs: FilterPrefs
private lateinit var filterService: MessageFilterImpl
@Before
fun setup() {
filterPrefs = mockk {
every { filterEnabled } returns true
every { filterWords } returns setOf("spam", "bad")
}
filterService = MessageFilterImpl(filterPrefs)
}
@Test
fun `shouldFilter returns false when filter is disabled`() {
every { filterPrefs.filterEnabled } returns false
assertFalse(filterService.shouldFilter("spam message"))
}
@Test
fun `shouldFilter returns false when filter words is empty`() {
every { filterPrefs.filterWords } returns emptySet()
filterService.rebuildPatterns()
assertFalse(filterService.shouldFilter("any message"))
}
@Test
fun `shouldFilter returns true for exact word match`() {
filterService.rebuildPatterns()
assertTrue(filterService.shouldFilter("this is spam"))
}
@Test
fun `shouldFilter is case insensitive`() {
filterService.rebuildPatterns()
assertTrue(filterService.shouldFilter("This is SPAM"))
}
@Test
fun `shouldFilter matches whole words only`() {
filterService.rebuildPatterns()
assertFalse(filterService.shouldFilter("antispam software"))
}
@Test
fun `shouldFilter supports regex patterns`() {
every { filterPrefs.filterWords } returns setOf("regex:test\\d+")
filterService.rebuildPatterns()
assertTrue(filterService.shouldFilter("this is test123"))
assertFalse(filterService.shouldFilter("this is test"))
}
@Test
fun `shouldFilter handles invalid regex gracefully`() {
every { filterPrefs.filterWords } returns setOf("regex:[invalid")
filterService.rebuildPatterns()
assertFalse(filterService.shouldFilter("any message"))
}
@Test
fun `shouldFilter returns false when contact has filtering disabled`() {
filterService.rebuildPatterns()
assertFalse(filterService.shouldFilter("spam message", isFilteringDisabled = true))
}
@Test
fun `shouldFilter filters when contact has filtering enabled`() {
filterService.rebuildPatterns()
assertTrue(filterService.shouldFilter("spam message", isFilteringDisabled = false))
}
}

View file

@ -0,0 +1,117 @@
/*
* 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 io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Position
import org.meshtastic.proto.User
class NodeManagerImplTest {
private val nodeRepository: NodeRepository = mockk(relaxed = true)
private val serviceBroadcasts: ServiceBroadcasts = mockk(relaxed = true)
private val serviceNotifications: MeshServiceNotifications = mockk(relaxed = true)
private lateinit var nodeManager: NodeManagerImpl
@Before
fun setUp() {
nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, serviceNotifications)
}
@Test
fun `getOrCreateNode creates default user for unknown node`() {
val nodeNum = 1234
val result = nodeManager.getOrCreateNode(nodeNum)
assertNotNull(result)
assertEquals(nodeNum, result.num)
assertTrue(result.user.long_name.startsWith("Meshtastic"))
assertEquals(DataPacket.nodeNumToDefaultId(nodeNum), result.user.id)
}
@Test
fun `handleReceivedUser preserves existing user if incoming is default`() {
val nodeNum = 1234
val existingUser =
User(id = "!12345678", long_name = "My Custom Name", short_name = "MCN", hw_model = HardwareModel.TLORA_V2)
// Setup existing node
nodeManager.updateNode(nodeNum) { it.copy(user = existingUser) }
val incomingDefaultUser =
User(id = "!12345678", long_name = "Meshtastic 5678", short_name = "5678", hw_model = HardwareModel.UNSET)
nodeManager.handleReceivedUser(nodeNum, incomingDefaultUser)
val result = nodeManager.nodeDBbyNodeNum[nodeNum]
assertEquals("My Custom Name", result!!.user.long_name)
assertEquals(HardwareModel.TLORA_V2, result.user.hw_model)
}
@Test
fun `handleReceivedUser updates user if incoming is higher detail`() {
val nodeNum = 1234
val existingUser =
User(id = "!12345678", long_name = "Meshtastic 5678", short_name = "5678", hw_model = HardwareModel.UNSET)
nodeManager.updateNode(nodeNum) { it.copy(user = existingUser) }
val incomingDetailedUser =
User(id = "!12345678", long_name = "Real User", short_name = "RU", hw_model = HardwareModel.TLORA_V1)
nodeManager.handleReceivedUser(nodeNum, incomingDetailedUser)
val result = nodeManager.nodeDBbyNodeNum[nodeNum]
assertEquals("Real User", result!!.user.long_name)
assertEquals(HardwareModel.TLORA_V1, result.user.hw_model)
}
@Test
fun `handleReceivedPosition updates node position`() {
val nodeNum = 1234
val position = Position(latitude_i = 450000000, longitude_i = 900000000)
nodeManager.handleReceivedPosition(nodeNum, 9999, position, 0)
val result = nodeManager.nodeDBbyNodeNum[nodeNum]
assertNotNull(result!!.position)
assertEquals(45.0, result.latitude, 0.0001)
assertEquals(90.0, result.longitude, 0.0001)
}
@Test
fun `clear resets internal state`() {
nodeManager.updateNode(1234) { it.copy(user = it.user.copy(long_name = "Test")) }
nodeManager.clear()
assertTrue(nodeManager.nodeDBbyNodeNum.isEmpty())
assertTrue(nodeManager.nodeDBbyID.isEmpty())
assertNull(nodeManager.myNodeNum)
}
}

View file

@ -0,0 +1,121 @@
/*
* 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 io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.ConnectionState
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
class PacketHandlerImplTest {
private val packetRepository: PacketRepository = mockk(relaxed = true)
private val serviceBroadcasts: ServiceBroadcasts = mockk(relaxed = true)
private val radioInterfaceService: RadioInterfaceService = mockk(relaxed = true)
private val meshLogRepository: MeshLogRepository = mockk(relaxed = true)
private val serviceRepository: ServiceRepository = mockk(relaxed = true)
private val connectionStateFlow = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private lateinit var handler: PacketHandlerImpl
@Before
fun setUp() {
every { serviceRepository.connectionState } returns connectionStateFlow
every { serviceRepository.setConnectionState(any()) } answers { connectionStateFlow.value = firstArg() }
handler =
PacketHandlerImpl(
{ packetRepository },
serviceBroadcasts,
radioInterfaceService,
{ meshLogRepository },
serviceRepository,
)
handler.start(testScope)
}
@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 `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()
coVerify { meshLogRepository.insert(match { log -> log.fromNum == MeshLog.NODE_NUM_LOCAL }) }
}
}

View file

@ -41,7 +41,7 @@ class DeviceHardwareRepositoryTest {
private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
private val repository =
DeviceHardwareRepository(
DeviceHardwareRepositoryImpl(
remoteDataSource,
localDataSource,
jsonDataSource,

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2026 Meshtastic LLC
* Copyright (c) 2025-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

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2026 Meshtastic LLC
* Copyright (c) 2025-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
@ -91,7 +91,7 @@ class NodeRepositoryTest {
myNodeInfoFlow.value = createMyNodeEntity(myNodeNum)
val repository =
NodeRepository(lifecycle, readDataSource, writeDataSource, dispatchers, localStatsDataSource)
NodeRepositoryImpl(lifecycle, readDataSource, writeDataSource, dispatchers, localStatsDataSource)
testScheduler.runCurrent()
val result = repository.effectiveLogNodeId(myNodeNum).filter { it == MeshLog.NODE_NUM_LOCAL }.first()
@ -106,7 +106,7 @@ class NodeRepositoryTest {
myNodeInfoFlow.value = createMyNodeEntity(myNodeNum)
val repository =
NodeRepository(lifecycle, readDataSource, writeDataSource, dispatchers, localStatsDataSource)
NodeRepositoryImpl(lifecycle, readDataSource, writeDataSource, dispatchers, localStatsDataSource)
testScheduler.runCurrent()
val result = repository.effectiveLogNodeId(remoteNodeNum).first()
@ -122,7 +122,7 @@ class NodeRepositoryTest {
myNodeInfoFlow.value = createMyNodeEntity(firstNodeNum)
val repository =
NodeRepository(lifecycle, readDataSource, writeDataSource, dispatchers, localStatsDataSource)
NodeRepositoryImpl(lifecycle, readDataSource, writeDataSource, dispatchers, localStatsDataSource)
testScheduler.runCurrent()
// Initially should be mapped to LOCAL because it matches