mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat/decoupling (#4685)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
40244f8337
commit
2c49db8041
254 changed files with 5132 additions and 2666 deletions
|
|
@ -81,7 +81,7 @@ constructor(
|
|||
@SuppressLint("MissingPermission")
|
||||
suspend fun bond(peripheral: Peripheral) {
|
||||
peripheral.createBond()
|
||||
refreshState()
|
||||
updateBluetoothState()
|
||||
}
|
||||
|
||||
internal suspend fun updateBluetoothState() {
|
||||
|
|
@ -112,6 +112,24 @@ constructor(
|
|||
emptyList()
|
||||
}
|
||||
|
||||
/** @return true if the given address is currently bonded to the system. */
|
||||
@SuppressLint("MissingPermission")
|
||||
fun isBonded(address: String): Boolean {
|
||||
val enabled = androidEnvironment.isBluetoothEnabled
|
||||
val hasPerms =
|
||||
if (androidEnvironment.requiresBluetoothRuntimePermissions) {
|
||||
androidEnvironment.isBluetoothScanPermissionGranted &&
|
||||
androidEnvironment.isBluetoothConnectPermissionGranted
|
||||
} else {
|
||||
androidEnvironment.isLocationPermissionGranted
|
||||
}
|
||||
return if (enabled && hasPerms) {
|
||||
centralManager.getBondedPeripherals().any { it.address == address }
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks if a peripheral is one of ours, either by its advertised name or by the services it provides. */
|
||||
private fun isMatchingPeripheral(peripheral: Peripheral): Boolean {
|
||||
val nameMatches = peripheral.name?.matches(Regex(BLE_NAME_PATTERN)) ?: false
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ plugins {
|
|||
configure<LibraryExtension> { namespace = "org.meshtastic.core.data" }
|
||||
|
||||
dependencies {
|
||||
api(projects.core.repository)
|
||||
implementation(projects.core.analytics)
|
||||
implementation(projects.core.common)
|
||||
implementation(projects.core.database)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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})")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>()
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -14,34 +14,25 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.service.filter
|
||||
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
|
||||
|
||||
/**
|
||||
* Service for filtering messages based on user-configured filter words. Supports both plain text word matching and
|
||||
* regex patterns.
|
||||
*/
|
||||
/** Implementation of [MessageFilter] that uses regex and plain text matching. */
|
||||
@Singleton
|
||||
class MessageFilterService @Inject constructor(private val filterPrefs: FilterPrefs) {
|
||||
class MessageFilterImpl @Inject constructor(private val filterPrefs: FilterPrefs) : MessageFilter {
|
||||
private var compiledPatterns: List<Regex> = emptyList()
|
||||
|
||||
init {
|
||||
rebuildPatterns()
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a message should be filtered based on the configured filter words.
|
||||
*
|
||||
* @param message The message text to check.
|
||||
* @param isFilteringDisabled Whether filtering is disabled for this contact.
|
||||
* @return true if the message should be filtered, false otherwise.
|
||||
*/
|
||||
fun shouldFilter(message: String, isFilteringDisabled: Boolean = false): Boolean {
|
||||
override fun shouldFilter(message: String, isFilteringDisabled: Boolean): Boolean {
|
||||
if (!filterPrefs.filterEnabled || compiledPatterns.isEmpty() || isFilteringDisabled) {
|
||||
return false
|
||||
}
|
||||
|
|
@ -49,11 +40,7 @@ class MessageFilterService @Inject constructor(private val filterPrefs: FilterPr
|
|||
return compiledPatterns.any { it.containsMatchIn(textToCheck) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the compiled regex patterns from the current filter words. Should be called whenever the filter words
|
||||
* are updated.
|
||||
*/
|
||||
fun rebuildPatterns() {
|
||||
override fun rebuildPatterns() {
|
||||
compiledPatterns =
|
||||
filterPrefs.filterWords.mapNotNull { word ->
|
||||
try {
|
||||
|
|
@ -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 -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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," +
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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") }
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.service.filter
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
|
|
@ -24,9 +24,9 @@ import org.junit.Before
|
|||
import org.junit.Test
|
||||
import org.meshtastic.core.prefs.filter.FilterPrefs
|
||||
|
||||
class MessageFilterServiceTest {
|
||||
class MessageFilterImplTest {
|
||||
private lateinit var filterPrefs: FilterPrefs
|
||||
private lateinit var filterService: MessageFilterService
|
||||
private lateinit var filterService: MessageFilterImpl
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
|
|
@ -34,7 +34,7 @@ class MessageFilterServiceTest {
|
|||
every { filterEnabled } returns true
|
||||
every { filterWords } returns setOf("spam", "bad")
|
||||
}
|
||||
filterService = MessageFilterService(filterPrefs)
|
||||
filterService = MessageFilterImpl(filterPrefs)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }) }
|
||||
}
|
||||
}
|
||||
|
|
@ -41,7 +41,7 @@ class DeviceHardwareRepositoryTest {
|
|||
private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
|
||||
|
||||
private val repository =
|
||||
DeviceHardwareRepository(
|
||||
DeviceHardwareRepositoryImpl(
|
||||
remoteDataSource,
|
||||
localDataSource,
|
||||
jsonDataSource,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ configure<LibraryExtension> {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.repository)
|
||||
implementation(projects.core.common)
|
||||
implementation(projects.core.di)
|
||||
implementation(projects.core.model)
|
||||
|
|
|
|||
|
|
@ -34,8 +34,8 @@ import org.junit.runner.RunWith
|
|||
import org.meshtastic.core.database.MeshtasticDatabase
|
||||
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.model.Node
|
||||
import org.meshtastic.core.model.NodeSortOption
|
||||
import org.meshtastic.core.model.util.onlineTimeThreshold
|
||||
import org.meshtastic.proto.HardwareModel
|
||||
import org.meshtastic.proto.User
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import java.io.File
|
|||
import java.security.MessageDigest
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import org.meshtastic.core.repository.DatabaseManager as SharedDatabaseManager
|
||||
|
||||
/** Manages per-device Room database instances for node data, with LRU eviction. */
|
||||
@Singleton
|
||||
|
|
@ -51,21 +52,21 @@ open class DatabaseManager
|
|||
constructor(
|
||||
private val app: Application,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) {
|
||||
) : SharedDatabaseManager {
|
||||
val prefs: SharedPreferences = app.getSharedPreferences("db-manager-prefs", Context.MODE_PRIVATE)
|
||||
private val managerScope = CoroutineScope(SupervisorJob() + dispatchers.default)
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
// Expose the DB cache limit as a reactive stream so UI can observe changes.
|
||||
private val _cacheLimit = MutableStateFlow(getCacheLimit())
|
||||
open val cacheLimit: StateFlow<Int> = _cacheLimit
|
||||
private val _cacheLimit = MutableStateFlow(getCurrentCacheLimit())
|
||||
override val cacheLimit: StateFlow<Int> = _cacheLimit
|
||||
|
||||
// Keep cache-limit StateFlow in sync if some other component updates SharedPreferences.
|
||||
private val prefsListener =
|
||||
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||
if (key == DatabaseConstants.CACHE_LIMIT_KEY) {
|
||||
_cacheLimit.value = getCacheLimit()
|
||||
_cacheLimit.value = getCurrentCacheLimit()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -88,7 +89,7 @@ constructor(
|
|||
}
|
||||
|
||||
/** Switch active database to the one associated with [address]. Serialized via mutex. */
|
||||
suspend fun switchActiveDatabase(address: String?) = mutex.withLock {
|
||||
override suspend fun switchActiveDatabase(address: String?) = mutex.withLock {
|
||||
val dbName = buildDbName(address)
|
||||
|
||||
// Remember the previously active DB name (any) so we can record its last-used time as well.
|
||||
|
|
@ -159,7 +160,7 @@ constructor(
|
|||
}
|
||||
|
||||
private suspend fun enforceCacheLimit(activeDbName: String) = mutex.withLock {
|
||||
val limit = getCacheLimit()
|
||||
val limit = getCurrentCacheLimit()
|
||||
val all = listExistingDbNames()
|
||||
// Only enforce the limit over device-specific DBs; exclude legacy and default DBs
|
||||
val deviceDbs =
|
||||
|
|
@ -189,13 +190,13 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun getCacheLimit(): Int = prefs
|
||||
override fun getCurrentCacheLimit(): Int = prefs
|
||||
.getInt(DatabaseConstants.CACHE_LIMIT_KEY, DatabaseConstants.DEFAULT_CACHE_LIMIT)
|
||||
.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
|
||||
|
||||
fun setCacheLimit(limit: Int) {
|
||||
override fun setCacheLimit(limit: Int) {
|
||||
val clamped = limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
|
||||
if (clamped == getCacheLimit()) return
|
||||
if (clamped == getCurrentCacheLimit()) return
|
||||
prefs.edit().putInt(DatabaseConstants.CACHE_LIMIT_KEY, clamped).apply()
|
||||
_cacheLimit.value = clamped
|
||||
// Enforce asynchronously with current active DB protected
|
||||
|
|
|
|||
|
|
@ -241,17 +241,19 @@ interface PacketDao {
|
|||
@Transaction
|
||||
suspend fun updateMessageStatus(data: DataPacket, m: MessageStatus) {
|
||||
val new = data.copy(status = m)
|
||||
// Find by packet ID first for better performance and reliability
|
||||
findPacketsWithId(data.id).find { it.data == data }?.let { update(it.copy(data = new)) }
|
||||
?: findDataPacket(data)?.let { update(it.copy(data = new)) }
|
||||
// Match on key fields that identify the packet, rather than the entire data object
|
||||
findPacketsWithId(data.id)
|
||||
.find { it.data.id == data.id && it.data.from == data.from && it.data.to == data.to }
|
||||
?.let { update(it.copy(data = new)) }
|
||||
}
|
||||
|
||||
@Transaction
|
||||
suspend fun updateMessageId(data: DataPacket, id: Int) {
|
||||
val new = data.copy(id = id)
|
||||
// Find by packet ID first for better performance and reliability
|
||||
findPacketsWithId(data.id).find { it.data == data }?.let { update(it.copy(data = new, packetId = id)) }
|
||||
?: findDataPacket(data)?.let { update(it.copy(data = new, packetId = id)) }
|
||||
// Match on key fields that identify the packet
|
||||
findPacketsWithId(data.id)
|
||||
.find { it.data.id == data.id && it.data.from == data.from && it.data.to == data.to }
|
||||
?.let { update(it.copy(data = new, packetId = id)) }
|
||||
}
|
||||
|
||||
@Query(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 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
|
||||
|
|
@ -14,14 +14,15 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.meshtastic.core.database.di
|
||||
|
||||
import android.app.Application
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import org.meshtastic.core.database.DatabaseManager
|
||||
import org.meshtastic.core.database.MeshtasticDatabase
|
||||
import org.meshtastic.core.database.dao.DeviceHardwareDao
|
||||
import org.meshtastic.core.database.dao.FirmwareReleaseDao
|
||||
|
|
@ -34,26 +35,34 @@ import javax.inject.Singleton
|
|||
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@Module
|
||||
class DatabaseModule {
|
||||
@Provides @Singleton
|
||||
fun provideDatabase(app: Application): MeshtasticDatabase = MeshtasticDatabase.getDatabase(app)
|
||||
abstract class DatabaseModule {
|
||||
|
||||
@Provides fun provideNodeInfoDao(database: MeshtasticDatabase): NodeInfoDao = database.nodeInfoDao()
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindDatabaseManager(impl: DatabaseManager): org.meshtastic.core.repository.DatabaseManager
|
||||
|
||||
@Provides fun providePacketDao(database: MeshtasticDatabase): PacketDao = database.packetDao()
|
||||
companion object {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDatabase(app: Application): MeshtasticDatabase = MeshtasticDatabase.getDatabase(app)
|
||||
|
||||
@Provides fun provideMeshLogDao(database: MeshtasticDatabase): MeshLogDao = database.meshLogDao()
|
||||
@Provides fun provideNodeInfoDao(database: MeshtasticDatabase): NodeInfoDao = database.nodeInfoDao()
|
||||
|
||||
@Provides
|
||||
fun provideQuickChatActionDao(database: MeshtasticDatabase): QuickChatActionDao = database.quickChatActionDao()
|
||||
@Provides fun providePacketDao(database: MeshtasticDatabase): PacketDao = database.packetDao()
|
||||
|
||||
@Provides
|
||||
fun provideDeviceHardwareDao(database: MeshtasticDatabase): DeviceHardwareDao = database.deviceHardwareDao()
|
||||
@Provides fun provideMeshLogDao(database: MeshtasticDatabase): MeshLogDao = database.meshLogDao()
|
||||
|
||||
@Provides
|
||||
fun provideFirmwareReleaseDao(database: MeshtasticDatabase): FirmwareReleaseDao = database.firmwareReleaseDao()
|
||||
@Provides
|
||||
fun provideQuickChatActionDao(database: MeshtasticDatabase): QuickChatActionDao = database.quickChatActionDao()
|
||||
|
||||
@Provides
|
||||
fun provideTracerouteNodePositionDao(database: MeshtasticDatabase): TracerouteNodePositionDao =
|
||||
database.tracerouteNodePositionDao()
|
||||
@Provides
|
||||
fun provideDeviceHardwareDao(database: MeshtasticDatabase): DeviceHardwareDao = database.deviceHardwareDao()
|
||||
|
||||
@Provides
|
||||
fun provideFirmwareReleaseDao(database: MeshtasticDatabase): FirmwareReleaseDao = database.firmwareReleaseDao()
|
||||
|
||||
@Provides
|
||||
fun provideTracerouteNodePositionDao(database: MeshtasticDatabase): TracerouteNodePositionDao =
|
||||
database.tracerouteNodePositionDao()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,10 +26,10 @@ import okio.ByteString
|
|||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.DeviceMetrics
|
||||
import org.meshtastic.core.model.EnvironmentMetrics
|
||||
import org.meshtastic.core.model.MeshUser
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.NodeInfo
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.util.onlineTimeThreshold
|
||||
|
|
@ -65,6 +65,7 @@ data class NodeWithRelations(
|
|||
environmentMetrics = environmentMetrics ?: org.meshtastic.proto.EnvironmentMetrics(),
|
||||
powerMetrics = powerMetrics ?: org.meshtastic.proto.PowerMetrics(),
|
||||
paxcounter = paxcounter,
|
||||
publicKey = publicKey ?: user.public_key,
|
||||
notes = notes,
|
||||
manuallyVerified = manuallyVerified,
|
||||
nodeStatus = nodeStatus,
|
||||
|
|
@ -90,6 +91,7 @@ data class NodeWithRelations(
|
|||
environmentTelemetry = environmentTelemetry,
|
||||
powerTelemetry = powerTelemetry,
|
||||
paxcounter = paxcounter,
|
||||
publicKey = publicKey ?: user.public_key,
|
||||
notes = notes,
|
||||
manuallyVerified = manuallyVerified,
|
||||
nodeStatus = nodeStatus,
|
||||
|
|
|
|||
|
|
@ -24,12 +24,12 @@ import androidx.room.PrimaryKey
|
|||
import androidx.room.Relation
|
||||
import okio.ByteString
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.database.model.Message
|
||||
import org.meshtastic.core.database.model.Node
|
||||
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.core.model.util.getShortDateTime
|
||||
import org.meshtastic.proto.User
|
||||
|
||||
data class PacketEntity(
|
||||
@Embedded val packet: Packet,
|
||||
|
|
@ -130,24 +130,6 @@ data class ContactSettings(
|
|||
get() = nowMillis <= muteUntil
|
||||
}
|
||||
|
||||
data class Reaction(
|
||||
val replyId: Int,
|
||||
val user: User,
|
||||
val emoji: String,
|
||||
val timestamp: Long,
|
||||
val snr: Float,
|
||||
val rssi: Int,
|
||||
val hopsAway: Int,
|
||||
val packetId: Int = 0,
|
||||
val status: MessageStatus = MessageStatus.UNKNOWN,
|
||||
val routingError: Int = 0,
|
||||
val relays: Int = 0,
|
||||
val relayNode: Int? = null,
|
||||
val to: String? = null,
|
||||
val channel: Int = 0,
|
||||
val sfppHash: ByteString? = null,
|
||||
)
|
||||
|
||||
@Suppress("ConstructorParameterNaming")
|
||||
@Entity(
|
||||
tableName = "reactions",
|
||||
|
|
@ -173,11 +155,11 @@ data class ReactionEntity(
|
|||
@ColumnInfo(name = "sfpp_hash") val sfpp_hash: ByteString? = null,
|
||||
)
|
||||
|
||||
private suspend fun ReactionEntity.toReaction(getNode: suspend (userId: String?) -> Node): Reaction {
|
||||
val node = getNode(userId)
|
||||
suspend fun ReactionEntity.toReaction(getNode: suspend (userId: String?) -> Node?): Reaction {
|
||||
val user = getNode(userId)?.user ?: org.meshtastic.proto.User(id = userId)
|
||||
return Reaction(
|
||||
replyId = replyId,
|
||||
user = node.user,
|
||||
user = user,
|
||||
emoji = emoji,
|
||||
timestamp = timestamp,
|
||||
snr = snr,
|
||||
|
|
@ -194,5 +176,5 @@ private suspend fun ReactionEntity.toReaction(getNode: suspend (userId: String?)
|
|||
)
|
||||
}
|
||||
|
||||
private suspend fun List<ReactionEntity>.toReaction(getNode: suspend (userId: String?) -> Node) =
|
||||
suspend fun List<ReactionEntity>.toReaction(getNode: suspend (userId: String?) -> Node?) =
|
||||
this.map { it.toReaction(getNode) }
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.database.model
|
||||
package org.meshtastic.core.model
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ plugins {
|
|||
android { namespace = "org.meshtastic.core.domain" }
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.repository)
|
||||
implementation(projects.core.model)
|
||||
implementation(projects.core.proto)
|
||||
implementation(projects.core.common)
|
||||
|
|
|
|||
|
|
@ -16,11 +16,16 @@
|
|||
*/
|
||||
package org.meshtastic.core.domain.usecase.settings
|
||||
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Use case for performing administrative actions on the radio. */
|
||||
/**
|
||||
* Use case for performing administrative and destructive actions on mesh nodes.
|
||||
*
|
||||
* This component provides methods for rebooting, shutting down, or resetting nodes within the mesh. It also handles
|
||||
* local database synchronization when these actions are performed on the locally connected device.
|
||||
*/
|
||||
open class AdminActionsUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
|
|
|
|||
|
|
@ -16,14 +16,14 @@
|
|||
*/
|
||||
package org.meshtastic.core.domain.usecase.settings
|
||||
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
/** Use case for cleaning up nodes from the database. */
|
||||
class CleanNodeDatabaseUseCase
|
||||
open class CleanNodeDatabaseUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val nodeRepository: NodeRepository,
|
||||
|
|
@ -43,11 +43,9 @@ constructor(
|
|||
nodeRepository.getNodesOlderThan(olderThanTimestamp.toInt())
|
||||
}
|
||||
|
||||
return nodesToConsider
|
||||
.filterNot { node ->
|
||||
(node.hasPKC && node.lastHeard >= sevenDaysAgoSeconds) || node.isIgnored || node.isFavorite
|
||||
}
|
||||
.map { it.toModel() }
|
||||
return nodesToConsider.filterNot { node ->
|
||||
(node.hasPKC && node.lastHeard >= sevenDaysAgoSeconds) || node.isIgnored || node.isFavorite
|
||||
}
|
||||
}
|
||||
|
||||
/** Performs the cleanup of specified nodes. */
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@ package org.meshtastic.core.domain.usecase.settings
|
|||
import android.icu.text.SimpleDateFormat
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.meshtastic.core.data.repository.MeshLogRepository
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.util.positionToMeter
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.proto.PortNum
|
||||
import java.io.BufferedWriter
|
||||
import java.util.Locale
|
||||
|
|
@ -30,7 +30,7 @@ import kotlin.math.roundToInt
|
|||
import org.meshtastic.proto.Position as ProtoPosition
|
||||
|
||||
/** Use case for exporting persisted packet data to a CSV format. */
|
||||
class ExportDataUseCase
|
||||
open class ExportDataUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val nodeRepository: NodeRepository,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import java.io.OutputStream
|
|||
import javax.inject.Inject
|
||||
|
||||
/** Use case for exporting a device profile to an output stream. */
|
||||
class ExportProfileUseCase @Inject constructor() {
|
||||
open class ExportProfileUseCase @Inject constructor() {
|
||||
/**
|
||||
* Exports the provided [DeviceProfile] to the given [OutputStream].
|
||||
*
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import java.io.OutputStream
|
|||
import javax.inject.Inject
|
||||
|
||||
/** Use case for exporting security configuration to a JSON format. */
|
||||
class ExportSecurityConfigUseCase @Inject constructor() {
|
||||
open class ExportSecurityConfigUseCase @Inject constructor() {
|
||||
/**
|
||||
* Exports the provided [Config.SecurityConfig] as a JSON string to the given [OutputStream].
|
||||
*
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import java.io.InputStream
|
|||
import javax.inject.Inject
|
||||
|
||||
/** Use case for importing a device profile from an input stream. */
|
||||
class ImportProfileUseCase @Inject constructor() {
|
||||
open class ImportProfileUseCase @Inject constructor() {
|
||||
/**
|
||||
* Imports a [DeviceProfile] from the provided [InputStream].
|
||||
*
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import org.meshtastic.proto.User
|
|||
import javax.inject.Inject
|
||||
|
||||
/** Use case for installing a device profile onto a radio. */
|
||||
class InstallProfileUseCase @Inject constructor(private val radioController: RadioController) {
|
||||
open class InstallProfileUseCase @Inject constructor(private val radioController: RadioController) {
|
||||
/**
|
||||
* Installs the provided [DeviceProfile] onto the radio at [destNum].
|
||||
*
|
||||
|
|
|
|||
|
|
@ -20,19 +20,19 @@ import kotlinx.coroutines.flow.Flow
|
|||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.meshtastic.core.data.repository.DeviceHardwareRepository
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.prefs.radio.RadioPrefs
|
||||
import org.meshtastic.core.prefs.radio.isBle
|
||||
import org.meshtastic.core.prefs.radio.isSerial
|
||||
import org.meshtastic.core.prefs.radio.isTcp
|
||||
import org.meshtastic.core.repository.DeviceHardwareRepository
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Use case to determine if the currently connected device is capable of over-the-air (OTA) updates. */
|
||||
class IsOtaCapableUseCase
|
||||
open class IsOtaCapableUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val nodeRepository: NodeRepository,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import org.meshtastic.core.model.RadioController
|
|||
import javax.inject.Inject
|
||||
|
||||
/** Use case for controlling location sharing with the mesh. */
|
||||
class MeshLocationUseCase @Inject constructor(private val radioController: RadioController) {
|
||||
open class MeshLocationUseCase @Inject constructor(private val radioController: RadioController) {
|
||||
/** Starts providing the phone's location to the mesh. */
|
||||
fun startProvidingLocation() {
|
||||
radioController.startProvideLocation()
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
package org.meshtastic.core.domain.usecase.settings
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.meshtastic.core.database.model.getStringResFrom
|
||||
import org.meshtastic.core.model.getStringResFrom
|
||||
import org.meshtastic.core.resources.UiText
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
import org.meshtastic.proto.Channel
|
||||
|
|
@ -54,7 +54,7 @@ sealed class RadioResponseResult {
|
|||
}
|
||||
|
||||
/** Use case for processing incoming [MeshPacket]s that are responses to admin requests. */
|
||||
class ProcessRadioResponseUseCase @Inject constructor() {
|
||||
open class ProcessRadioResponseUseCase @Inject constructor() {
|
||||
/**
|
||||
* Decodes and processes the provided [packet].
|
||||
*
|
||||
|
|
|
|||
|
|
@ -20,7 +20,11 @@ import org.meshtastic.core.datastore.UiPreferencesDataSource
|
|||
import javax.inject.Inject
|
||||
|
||||
/** Use case for setting whether the application intro has been completed. */
|
||||
class SetAppIntroCompletedUseCase @Inject constructor(private val uiPreferencesDataSource: UiPreferencesDataSource) {
|
||||
open class SetAppIntroCompletedUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val uiPreferencesDataSource: UiPreferencesDataSource,
|
||||
) {
|
||||
operator fun invoke(completed: Boolean) {
|
||||
uiPreferencesDataSource.setAppIntroCompleted(completed)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,11 +17,11 @@
|
|||
package org.meshtastic.core.domain.usecase.settings
|
||||
|
||||
import org.meshtastic.core.database.DatabaseConstants
|
||||
import org.meshtastic.core.database.DatabaseManager
|
||||
import org.meshtastic.core.repository.DatabaseManager
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Use case for setting the database cache limit. */
|
||||
class SetDatabaseCacheLimitUseCase @Inject constructor(private val databaseManager: DatabaseManager) {
|
||||
open class SetDatabaseCacheLimitUseCase @Inject constructor(private val databaseManager: DatabaseManager) {
|
||||
operator fun invoke(limit: Int) {
|
||||
val clamped = limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
|
||||
databaseManager.setCacheLimit(clamped)
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
|
|||
import javax.inject.Inject
|
||||
|
||||
/** Use case for managing mesh log settings. */
|
||||
class SetMeshLogSettingsUseCase
|
||||
open class SetMeshLogSettingsUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val meshLogRepository: MeshLogRepository,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import org.meshtastic.core.prefs.ui.UiPrefs
|
|||
import javax.inject.Inject
|
||||
|
||||
/** Use case for setting whether to provide the node location to the mesh. */
|
||||
class SetProvideLocationUseCase @Inject constructor(private val uiPrefs: UiPrefs) {
|
||||
open class SetProvideLocationUseCase @Inject constructor(private val uiPrefs: UiPrefs) {
|
||||
operator fun invoke(myNodeNum: Int, provideLocation: Boolean) {
|
||||
uiPrefs.setShouldProvideNodeLocation(myNodeNum, provideLocation)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import org.meshtastic.core.datastore.UiPreferencesDataSource
|
|||
import javax.inject.Inject
|
||||
|
||||
/** Use case for setting the application theme. */
|
||||
class SetThemeUseCase @Inject constructor(private val uiPreferencesDataSource: UiPreferencesDataSource) {
|
||||
open class SetThemeUseCase @Inject constructor(private val uiPreferencesDataSource: UiPreferencesDataSource) {
|
||||
operator fun invoke(themeMode: Int) {
|
||||
uiPreferencesDataSource.setTheme(themeMode)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
|
|||
import javax.inject.Inject
|
||||
|
||||
/** Use case for toggling the analytics preference. */
|
||||
class ToggleAnalyticsUseCase @Inject constructor(private val analyticsPrefs: AnalyticsPrefs) {
|
||||
open class ToggleAnalyticsUseCase @Inject constructor(private val analyticsPrefs: AnalyticsPrefs) {
|
||||
operator fun invoke() {
|
||||
analyticsPrefs.analyticsAllowed = !analyticsPrefs.analyticsAllowed
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
|
|||
import javax.inject.Inject
|
||||
|
||||
/** Use case for toggling the homoglyph encoding preference. */
|
||||
class ToggleHomoglyphEncodingUseCase @Inject constructor(private val homoglyphEncodingPrefs: HomoglyphPrefs) {
|
||||
open class ToggleHomoglyphEncodingUseCase @Inject constructor(private val homoglyphEncodingPrefs: HomoglyphPrefs) {
|
||||
operator fun invoke() {
|
||||
homoglyphEncodingPrefs.homoglyphEncodingEnabled = !homoglyphEncodingPrefs.homoglyphEncodingEnabled
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ class FakeRadioController : RadioController {
|
|||
sentSharedContacts.add(nodeNum)
|
||||
}
|
||||
|
||||
override suspend fun setLocalConfig(config: org.meshtastic.proto.Config) {}
|
||||
|
||||
override suspend fun setLocalChannel(channel: org.meshtastic.proto.Channel) {}
|
||||
|
||||
override suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int) {}
|
||||
|
||||
override suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int) {}
|
||||
|
|
@ -83,6 +87,10 @@ class FakeRadioController : RadioController {
|
|||
|
||||
override suspend fun reboot(destNum: Int, packetId: Int) {}
|
||||
|
||||
override suspend fun rebootToDfu(nodeNum: Int) {}
|
||||
|
||||
override suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {}
|
||||
|
||||
override suspend fun shutdown(destNum: Int, packetId: Int) {}
|
||||
|
||||
override suspend fun factoryReset(destNum: Int, packetId: Int) {}
|
||||
|
|
@ -91,6 +99,16 @@ class FakeRadioController : RadioController {
|
|||
|
||||
override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) {}
|
||||
|
||||
override suspend fun requestPosition(destNum: Int, currentPosition: org.meshtastic.core.model.Position) {}
|
||||
|
||||
override suspend fun requestUserInfo(destNum: Int) {}
|
||||
|
||||
override suspend fun requestTraceroute(requestId: Int, destNum: Int) {}
|
||||
|
||||
override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {}
|
||||
|
||||
override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) {}
|
||||
|
||||
override suspend fun beginEditSettings(destNum: Int) {}
|
||||
|
||||
override suspend fun commitEditSettings(destNum: Int) {}
|
||||
|
|
@ -101,6 +119,8 @@ class FakeRadioController : RadioController {
|
|||
|
||||
override fun stopProvideLocation() {}
|
||||
|
||||
override fun setDeviceAddress(address: String) {}
|
||||
|
||||
// --- Helper methods for testing ---
|
||||
|
||||
fun setConnectionState(state: ConnectionState) {
|
||||
|
|
|
|||
|
|
@ -29,15 +29,15 @@ import org.junit.Assert.assertEquals
|
|||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.data.repository.PacketRepository
|
||||
import org.meshtastic.core.database.entity.Packet
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.domain.FakeRadioController
|
||||
import org.meshtastic.core.domain.MessageQueue
|
||||
import org.meshtastic.core.model.Capabilities
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
|
||||
import org.meshtastic.core.model.Node
|
||||
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 org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.DeviceMetadata
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ class SendMessageUseCaseTest {
|
|||
assertEquals(0, radioController.favoritedNodes.size)
|
||||
assertEquals(0, radioController.sentSharedContacts.size)
|
||||
|
||||
coVerify { packetRepository.insert(any<Packet>()) }
|
||||
coVerify { packetRepository.savePacket(any(), any(), any(), any()) }
|
||||
coVerify { messageQueue.enqueue(any()) }
|
||||
}
|
||||
|
||||
|
|
@ -120,7 +120,7 @@ class SendMessageUseCaseTest {
|
|||
assertEquals(1, radioController.favoritedNodes.size)
|
||||
assertEquals(12345, radioController.favoritedNodes[0])
|
||||
|
||||
coVerify { packetRepository.insert(any<Packet>()) }
|
||||
coVerify { packetRepository.savePacket(any(), any(), any(), any()) }
|
||||
coVerify { messageQueue.enqueue(any()) }
|
||||
}
|
||||
|
||||
|
|
@ -149,7 +149,7 @@ class SendMessageUseCaseTest {
|
|||
assertEquals(1, radioController.sentSharedContacts.size)
|
||||
assertEquals(67890, radioController.sentSharedContacts[0])
|
||||
|
||||
coVerify { packetRepository.insert(any<Packet>()) }
|
||||
coVerify { packetRepository.savePacket(any(), any(), any(), any()) }
|
||||
coVerify { messageQueue.enqueue(any()) }
|
||||
}
|
||||
|
||||
|
|
@ -166,9 +166,9 @@ class SendMessageUseCaseTest {
|
|||
useCase(originalText, "0${DataPacket.ID_BROADCAST}", null)
|
||||
|
||||
// Assert
|
||||
val packetSlot = slot<Packet>()
|
||||
coVerify { packetRepository.insert(capture(packetSlot)) }
|
||||
assertTrue(packetSlot.captured.data?.text?.contains("Apple") == true)
|
||||
val packetSlot = slot<DataPacket>()
|
||||
coVerify { packetRepository.savePacket(any(), any(), capture(packetSlot), any()) }
|
||||
assertTrue(packetSlot.captured.text?.contains("Apple") == true)
|
||||
coVerify { messageQueue.enqueue(any()) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ import kotlinx.coroutines.test.runTest
|
|||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
|
||||
class AdminActionsUseCaseTest {
|
||||
|
||||
|
|
|
|||
|
|
@ -23,9 +23,9 @@ import kotlinx.coroutines.test.runTest
|
|||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.database.entity.NodeEntity
|
||||
import org.meshtastic.core.domain.FakeRadioController
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
class CleanNodeDatabaseUseCaseTest {
|
||||
|
|
@ -47,9 +47,9 @@ class CleanNodeDatabaseUseCaseTest {
|
|||
val currentTime = 1000000L
|
||||
val olderThanTimestamp = currentTime - 30.days.inWholeSeconds
|
||||
|
||||
val oldNode = NodeEntity(num = 1, lastHeard = (olderThanTimestamp - 1).toInt())
|
||||
val newNode = NodeEntity(num = 2, lastHeard = (currentTime - 1).toInt())
|
||||
val ignoredNode = NodeEntity(num = 3, lastHeard = (olderThanTimestamp - 1).toInt(), isIgnored = true)
|
||||
val oldNode = Node(num = 1, lastHeard = (olderThanTimestamp - 1).toInt())
|
||||
val newNode = Node(num = 2, lastHeard = (currentTime - 1).toInt())
|
||||
val ignoredNode = Node(num = 3, lastHeard = (olderThanTimestamp - 1).toInt(), isIgnored = true)
|
||||
|
||||
coEvery { nodeRepository.getNodesOlderThan(any()) } returns listOf(oldNode, ignoredNode)
|
||||
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@ import org.junit.Before
|
|||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.meshtastic.core.data.repository.MeshLogRepository
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.database.entity.MeshLog
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.proto.Data
|
||||
import org.meshtastic.proto.FromRadio
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
|
|
@ -63,7 +63,6 @@ class ExportDataUseCaseTest {
|
|||
val nodes = mapOf(senderNodeNum to senderNode)
|
||||
val stateFlow = MutableStateFlow(nodes)
|
||||
every { nodeRepository.nodeDBbyNum } returns stateFlow
|
||||
every { nodeRepository.getNodeEntityDBbyNumFlow() } returns flowOf(emptyMap())
|
||||
|
||||
val meshPacket =
|
||||
MeshPacket(
|
||||
|
|
|
|||
|
|
@ -26,12 +26,12 @@ import org.junit.Assert.assertFalse
|
|||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.data.repository.DeviceHardwareRepository
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.prefs.radio.RadioPrefs
|
||||
import org.meshtastic.core.repository.DeviceHardwareRepository
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
|
||||
class IsOtaCapableUseCaseTest {
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import io.mockk.verify
|
|||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.database.DatabaseConstants
|
||||
import org.meshtastic.core.database.DatabaseManager
|
||||
import org.meshtastic.core.repository.DatabaseManager
|
||||
|
||||
class SetDatabaseCacheLimitUseCaseTest {
|
||||
|
||||
|
|
|
|||
|
|
@ -36,11 +36,13 @@ kotlin {
|
|||
commonMain.dependencies {
|
||||
api(projects.core.proto)
|
||||
api(projects.core.common)
|
||||
api(projects.core.resources)
|
||||
|
||||
api(libs.kotlinx.serialization.json)
|
||||
api(libs.kotlinx.datetime)
|
||||
implementation(libs.kermit)
|
||||
api(libs.okio)
|
||||
api(libs.compose.multiplatform.resources)
|
||||
}
|
||||
androidMain.dependencies {
|
||||
api(libs.androidx.annotation)
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ class ChannelSetTest {
|
|||
val url = Uri.parse("https://meshtastic.org/e/#CgMSAQESBggBQANIAQ")
|
||||
val cs = url.toChannelSet()
|
||||
Assert.assertEquals("LongFast", cs.primaryChannel!!.name)
|
||||
Assert.assertEquals(url, cs.getChannelUrl(false))
|
||||
Assert.assertEquals(url.toString(), cs.getChannelUrl(false).toString())
|
||||
}
|
||||
|
||||
/** validate against the host or path in a case-insensitive way */
|
||||
|
|
|
|||
|
|
@ -56,11 +56,43 @@ class SharedContactTest {
|
|||
assertEquals("Suzume", contact.user?.long_name)
|
||||
}
|
||||
|
||||
@Test(expected = java.net.MalformedURLException::class)
|
||||
@Test(expected = MalformedMeshtasticUrlException::class)
|
||||
fun testInvalidHostThrows() {
|
||||
val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
|
||||
val urlStr = original.getSharedContactUrl().toString().replace("meshtastic.org", "example.com")
|
||||
val url = Uri.parse(urlStr)
|
||||
url.toSharedContact()
|
||||
}
|
||||
|
||||
@Test(expected = MalformedMeshtasticUrlException::class)
|
||||
fun testInvalidPathThrows() {
|
||||
val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
|
||||
val urlStr = original.getSharedContactUrl().toString().replace("/v/", "/wrong/")
|
||||
val url = Uri.parse(urlStr)
|
||||
url.toSharedContact()
|
||||
}
|
||||
|
||||
@Test(expected = MalformedMeshtasticUrlException::class)
|
||||
fun testMissingFragmentThrows() {
|
||||
val urlStr = "https://meshtastic.org/v/"
|
||||
val url = Uri.parse(urlStr)
|
||||
url.toSharedContact()
|
||||
}
|
||||
|
||||
@Test(expected = MalformedMeshtasticUrlException::class)
|
||||
fun testInvalidBase64Throws() {
|
||||
val urlStr = "https://meshtastic.org/v/#InvalidBase64!!!!"
|
||||
val url = Uri.parse(urlStr)
|
||||
url.toSharedContact()
|
||||
}
|
||||
|
||||
@Test(expected = MalformedMeshtasticUrlException::class)
|
||||
fun testInvalidProtoThrows() {
|
||||
// Tag 0 is invalid in Protobuf
|
||||
// 0x00 -> Tag 0, Type 0.
|
||||
// Base64 for 0x00 is "AA=="
|
||||
val urlStr = "https://meshtastic.org/v/#AA=="
|
||||
val url = Uri.parse(urlStr)
|
||||
url.toSharedContact()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
/*
|
||||
* 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.model.util
|
||||
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.proto.Data
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.PortNum
|
||||
|
||||
class MeshDataMapperTest {
|
||||
|
||||
private val nodeIdLookup: NodeIdLookup = mockk()
|
||||
private lateinit var mapper: MeshDataMapper
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mapper = MeshDataMapper(nodeIdLookup)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toDataPacket returns null when no decoded data`() {
|
||||
val packet = MeshPacket()
|
||||
assertNull(mapper.toDataPacket(packet))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toDataPacket maps basic fields correctly`() {
|
||||
val nodeNum = 1234
|
||||
val nodeId = "!1234abcd"
|
||||
every { nodeIdLookup.toNodeID(nodeNum) } returns nodeId
|
||||
every { nodeIdLookup.toNodeID(DataPacket.NODENUM_BROADCAST) } returns DataPacket.ID_BROADCAST
|
||||
|
||||
val proto =
|
||||
MeshPacket(
|
||||
id = 42,
|
||||
from = nodeNum,
|
||||
to = DataPacket.NODENUM_BROADCAST,
|
||||
rx_time = 1600000000,
|
||||
rx_snr = 5.5f,
|
||||
rx_rssi = -100,
|
||||
hop_limit = 3,
|
||||
hop_start = 3,
|
||||
decoded =
|
||||
Data(
|
||||
portnum = PortNum.TEXT_MESSAGE_APP,
|
||||
payload = "hello".encodeToByteArray().toByteString(),
|
||||
reply_id = 123,
|
||||
),
|
||||
)
|
||||
|
||||
val result = mapper.toDataPacket(proto)
|
||||
assertNotNull(result)
|
||||
assertEquals(42, result!!.id)
|
||||
assertEquals(nodeId, result.from)
|
||||
assertEquals(DataPacket.ID_BROADCAST, result.to)
|
||||
assertEquals(1600000000000L, result.time)
|
||||
assertEquals(5.5f, result.snr)
|
||||
assertEquals(-100, result.rssi)
|
||||
assertEquals(PortNum.TEXT_MESSAGE_APP.value, result.dataType)
|
||||
assertEquals("hello", result.bytes?.utf8())
|
||||
assertEquals(123, result.replyId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toDataPacket maps PKC channel correctly for encrypted packets`() {
|
||||
val proto = MeshPacket(pki_encrypted = true, channel = 1, decoded = Data())
|
||||
|
||||
every { nodeIdLookup.toNodeID(any()) } returns "any"
|
||||
|
||||
val result = mapper.toDataPacket(proto)
|
||||
assertEquals(DataPacket.PKC_CHANNEL_INDEX, result!!.channel)
|
||||
}
|
||||
}
|
||||
|
|
@ -103,9 +103,6 @@ enum class RegionInfo(
|
|||
val freqEnd: Float,
|
||||
val wideLora: Boolean = false,
|
||||
) {
|
||||
/** This needs to be last. Same as US. */
|
||||
UNSET(RegionCode.UNSET, "Please set a region", 902.0f, 928.0f),
|
||||
|
||||
/**
|
||||
* United States
|
||||
*
|
||||
|
|
@ -288,6 +285,9 @@ enum class RegionInfo(
|
|||
* @see [Firmware Issue #7399](https://github.com/meshtastic/firmware/pull/7399)
|
||||
*/
|
||||
BR_902(RegionCode.BR_902, "Brazil 902MHz", 902.0f, 907.5f, wideLora = false),
|
||||
|
||||
/** This needs to be last. Same as US. */
|
||||
UNSET(RegionCode.UNSET, "Please set a region", 902.0f, 928.0f),
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
|
|
|||
|
|
@ -32,3 +32,12 @@ data class Contact(
|
|||
val isUnmessageable: Boolean,
|
||||
val nodeColors: Pair<Int, Int>? = null,
|
||||
) : CommonParcelable
|
||||
|
||||
data class ContactSettings(
|
||||
val contactKey: String,
|
||||
val muteUntil: Long = 0L,
|
||||
val lastReadMessageUuid: Long? = null,
|
||||
val lastReadMessageTimestamp: Long? = null,
|
||||
val filteringDisabled: Boolean = false,
|
||||
val isMuted: Boolean = false,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* 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.model
|
||||
|
||||
/** Address identifiers for all supported radio backend implementations. */
|
||||
enum class InterfaceId(val id: Char) {
|
||||
BLUETOOTH('x'),
|
||||
MOCK('m'),
|
||||
NOP('n'),
|
||||
SERIAL('s'),
|
||||
TCP('t'),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun forIdChar(id: Char): InterfaceId? = entries.firstOrNull { it.id == id }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* 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.model
|
||||
|
||||
/** Represents activity on the mesh network. */
|
||||
sealed class MeshActivity {
|
||||
/** Data is being sent to the radio. */
|
||||
data object Send : MeshActivity()
|
||||
|
||||
/** Data is being received from the radio. */
|
||||
data object Receive : MeshActivity()
|
||||
}
|
||||
|
|
@ -14,11 +14,9 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.database.model
|
||||
package org.meshtastic.core.model
|
||||
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.meshtastic.core.database.entity.Reaction
|
||||
import org.meshtastic.core.model.MessageStatus
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.delivery_confirmed
|
||||
import org.meshtastic.core.resources.error
|
||||
|
|
@ -14,16 +14,15 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.database.model
|
||||
package org.meshtastic.core.model
|
||||
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.common.util.GPSFormat
|
||||
import org.meshtastic.core.common.util.bearing
|
||||
import org.meshtastic.core.common.util.latLongToMeter
|
||||
import org.meshtastic.core.database.entity.NodeEntity
|
||||
import org.meshtastic.core.model.Capabilities
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
|
||||
import org.meshtastic.core.model.util.onlineTimeThreshold
|
||||
import org.meshtastic.core.model.util.toDistanceString
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.DeviceMetadata
|
||||
|
|
@ -34,7 +33,6 @@ import org.meshtastic.proto.MeshPacket
|
|||
import org.meshtastic.proto.Paxcount
|
||||
import org.meshtastic.proto.Position
|
||||
import org.meshtastic.proto.PowerMetrics
|
||||
import org.meshtastic.proto.Telemetry
|
||||
import org.meshtastic.proto.User
|
||||
|
||||
/**
|
||||
|
|
@ -70,6 +68,9 @@ data class Node(
|
|||
) {
|
||||
val capabilities: Capabilities by lazy { Capabilities(metadata?.firmware_version) }
|
||||
|
||||
val isOnline: Boolean
|
||||
get() = lastHeard > onlineTimeThreshold()
|
||||
|
||||
val colors: Pair<Int, Int>
|
||||
get() { // returns foreground and background @ColorInt for each 'num'
|
||||
val r = (num and 0xFF0000) shr 16
|
||||
|
|
@ -88,7 +89,7 @@ data class Node(
|
|||
get() = (publicKey ?: user.public_key)?.size?.let { it > 0 } == true
|
||||
|
||||
val mismatchKey
|
||||
get() = (publicKey ?: user.public_key) == NodeEntity.ERROR_BYTE_STRING
|
||||
get() = (publicKey ?: user.public_key) == ERROR_BYTE_STRING
|
||||
|
||||
val hasEnvironmentMetrics: Boolean
|
||||
get() = environmentMetrics != EnvironmentMetrics()
|
||||
|
|
@ -137,6 +138,7 @@ data class Node(
|
|||
|
||||
fun gpsString(): String = GPSFormat.toDec(latitude, longitude)
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
private fun EnvironmentMetrics.getDisplayStrings(isFahrenheit: Boolean): List<String> {
|
||||
val temp =
|
||||
if ((temperature ?: 0f) != 0f) {
|
||||
|
|
@ -188,34 +190,31 @@ data class Node(
|
|||
fun getTelemetryStrings(isFahrenheit: Boolean = false): List<String> =
|
||||
environmentMetrics.getDisplayStrings(isFahrenheit)
|
||||
|
||||
fun toEntity() = NodeEntity(
|
||||
num = num,
|
||||
user = user,
|
||||
position = position,
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
snr = snr,
|
||||
rssi = rssi,
|
||||
lastHeard = lastHeard,
|
||||
deviceTelemetry = Telemetry(device_metrics = deviceMetrics),
|
||||
channel = channel,
|
||||
viaMqtt = viaMqtt,
|
||||
hopsAway = hopsAway,
|
||||
isFavorite = isFavorite,
|
||||
isIgnored = isIgnored,
|
||||
isMuted = isMuted,
|
||||
environmentTelemetry = Telemetry(environment_metrics = environmentMetrics),
|
||||
powerTelemetry = Telemetry(power_metrics = powerMetrics),
|
||||
paxcounter = paxcounter,
|
||||
publicKey = publicKey ?: user.public_key,
|
||||
notes = notes,
|
||||
manuallyVerified = manuallyVerified,
|
||||
nodeStatus = nodeStatus,
|
||||
lastTransport = lastTransport,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_ID_SUFFIX_LENGTH = 4
|
||||
private const val RELAY_NODE_SUFFIX_MASK = 0xFF
|
||||
|
||||
val ERROR_BYTE_STRING: ByteString = ByteArray(32) { 0 }.toByteString()
|
||||
|
||||
fun getRelayNode(relayNodeId: Int, nodes: List<Node>, ourNodeNum: Int?): Node? {
|
||||
val relayNodeIdSuffix = relayNodeId and RELAY_NODE_SUFFIX_MASK
|
||||
|
||||
val candidateRelayNodes =
|
||||
nodes.filter {
|
||||
it.num != ourNodeNum &&
|
||||
it.lastHeard != 0 &&
|
||||
(it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix
|
||||
}
|
||||
|
||||
val closestRelayNode =
|
||||
if (candidateRelayNodes.size == 1) {
|
||||
candidateRelayNodes.first()
|
||||
} else {
|
||||
candidateRelayNodes.minByOrNull { it.hopsAway }
|
||||
}
|
||||
|
||||
return closestRelayNode
|
||||
}
|
||||
|
||||
/** Creates a fallback [Node] when the node is not found in the database. */
|
||||
fun createFallback(nodeNum: Int, fallbackNamePrefix: String): Node {
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.database.model
|
||||
package org.meshtastic.core.model
|
||||
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.meshtastic.core.resources.Res
|
||||
|
|
@ -19,67 +19,299 @@ package org.meshtastic.core.model
|
|||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
|
||||
/**
|
||||
* Central interface for controlling the radio and mesh network.
|
||||
*
|
||||
* This component provides an abstraction over the underlying communication transport (e.g., BLE, Serial, TCP) and the
|
||||
* low-level mesh protocols. It allows feature modules to interact with the mesh without needing to know about
|
||||
* platform-specific service details or AIDL interfaces.
|
||||
*/
|
||||
@Suppress("TooManyFunctions")
|
||||
interface RadioController {
|
||||
/** Reactive connection state of the radio. */
|
||||
val connectionState: StateFlow<ConnectionState>
|
||||
|
||||
/**
|
||||
* Flow of notifications from the radio client.
|
||||
*
|
||||
* These represent high-level events like "Handshake completed" or "Channel configuration updated."
|
||||
*/
|
||||
val clientNotification: StateFlow<ClientNotification?>
|
||||
|
||||
/**
|
||||
* Sends a data packet to the mesh.
|
||||
*
|
||||
* @param packet The [DataPacket] containing the payload and routing information.
|
||||
*/
|
||||
suspend fun sendMessage(packet: DataPacket)
|
||||
|
||||
/** Clears the current [clientNotification]. */
|
||||
fun clearClientNotification()
|
||||
|
||||
// Abstracted ServiceActions
|
||||
/**
|
||||
* Toggles the favorite status of a node on the radio.
|
||||
*
|
||||
* @param nodeNum The node number to favorite/unfavorite.
|
||||
*/
|
||||
suspend fun favoriteNode(nodeNum: Int)
|
||||
|
||||
/**
|
||||
* Sends our shared contact information (identity and public key) to a remote node.
|
||||
*
|
||||
* @param nodeNum The destination node number.
|
||||
*/
|
||||
suspend fun sendSharedContact(nodeNum: Int)
|
||||
|
||||
// Radio configuration
|
||||
/**
|
||||
* Updates the local radio configuration.
|
||||
*
|
||||
* @param config The new configuration [org.meshtastic.proto.Config].
|
||||
*/
|
||||
suspend fun setLocalConfig(config: org.meshtastic.proto.Config)
|
||||
|
||||
/**
|
||||
* Updates a local radio channel.
|
||||
*
|
||||
* @param channel The channel configuration [org.meshtastic.proto.Channel].
|
||||
*/
|
||||
suspend fun setLocalChannel(channel: org.meshtastic.proto.Channel)
|
||||
|
||||
/**
|
||||
* Updates the owner (user info) on a remote node.
|
||||
*
|
||||
* @param destNum The destination node number.
|
||||
* @param user The new user info [org.meshtastic.proto.User].
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int)
|
||||
|
||||
/**
|
||||
* Updates the general configuration on a remote node.
|
||||
*
|
||||
* @param destNum The destination node number.
|
||||
* @param config The new configuration [org.meshtastic.proto.Config].
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int)
|
||||
|
||||
/**
|
||||
* Updates a module configuration on a remote node.
|
||||
*
|
||||
* @param destNum The destination node number.
|
||||
* @param config The new module configuration [org.meshtastic.proto.ModuleConfig].
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int)
|
||||
|
||||
/**
|
||||
* Updates a channel configuration on a remote node.
|
||||
*
|
||||
* @param destNum The destination node number.
|
||||
* @param channel The new channel configuration [org.meshtastic.proto.Channel].
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int)
|
||||
|
||||
/**
|
||||
* Sets a fixed position on a remote node.
|
||||
*
|
||||
* @param destNum The destination node number.
|
||||
* @param position The position to set.
|
||||
*/
|
||||
suspend fun setFixedPosition(destNum: Int, position: Position)
|
||||
|
||||
/**
|
||||
* Updates the notification ringtone on a remote node.
|
||||
*
|
||||
* @param destNum The destination node number.
|
||||
* @param ringtone The name/ID of the ringtone.
|
||||
*/
|
||||
suspend fun setRingtone(destNum: Int, ringtone: String)
|
||||
|
||||
/**
|
||||
* Updates the canned messages configuration on a remote node.
|
||||
*
|
||||
* @param destNum The destination node number.
|
||||
* @param messages The canned messages string.
|
||||
*/
|
||||
suspend fun setCannedMessages(destNum: Int, messages: String)
|
||||
|
||||
// Admin get operations
|
||||
/**
|
||||
* Requests the current owner (user info) from a remote node.
|
||||
*
|
||||
* @param destNum The remote node number.
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun getOwner(destNum: Int, packetId: Int)
|
||||
|
||||
/**
|
||||
* Requests a specific configuration section from a remote node.
|
||||
*
|
||||
* @param destNum The remote node number.
|
||||
* @param configType The numeric type of the configuration section.
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun getConfig(destNum: Int, configType: Int, packetId: Int)
|
||||
|
||||
/**
|
||||
* Requests a module configuration section from a remote node.
|
||||
*
|
||||
* @param destNum The remote node number.
|
||||
* @param moduleConfigType The numeric type of the module configuration section.
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int)
|
||||
|
||||
/**
|
||||
* Requests a specific channel configuration from a remote node.
|
||||
*
|
||||
* @param destNum The remote node number.
|
||||
* @param index The channel index.
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun getChannel(destNum: Int, index: Int, packetId: Int)
|
||||
|
||||
/**
|
||||
* Requests the current ringtone from a remote node.
|
||||
*
|
||||
* @param destNum The remote node number.
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun getRingtone(destNum: Int, packetId: Int)
|
||||
|
||||
/**
|
||||
* Requests the current canned messages from a remote node.
|
||||
*
|
||||
* @param destNum The remote node number.
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun getCannedMessages(destNum: Int, packetId: Int)
|
||||
|
||||
/**
|
||||
* Requests the hardware connection status from a remote node.
|
||||
*
|
||||
* @param destNum The remote node number.
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int)
|
||||
|
||||
// Admin operations
|
||||
/**
|
||||
* Commands a node to reboot.
|
||||
*
|
||||
* @param destNum The target node number.
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun reboot(destNum: Int, packetId: Int)
|
||||
|
||||
/**
|
||||
* Commands a node to reboot into DFU (Device Firmware Update) mode.
|
||||
*
|
||||
* @param nodeNum The target node number.
|
||||
*/
|
||||
suspend fun rebootToDfu(nodeNum: Int)
|
||||
|
||||
/**
|
||||
* Initiates an Over-The-Air (OTA) reboot request.
|
||||
*
|
||||
* @param requestId The request ID.
|
||||
* @param destNum The target node number.
|
||||
* @param mode The OTA mode.
|
||||
* @param hash Optional hash for verification.
|
||||
*/
|
||||
suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?)
|
||||
|
||||
/**
|
||||
* Commands a node to shut down.
|
||||
*
|
||||
* @param destNum The target node number.
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun shutdown(destNum: Int, packetId: Int)
|
||||
|
||||
/**
|
||||
* Performs a factory reset on a node.
|
||||
*
|
||||
* @param destNum The target node number.
|
||||
* @param packetId The request packet ID.
|
||||
*/
|
||||
suspend fun factoryReset(destNum: Int, packetId: Int)
|
||||
|
||||
/**
|
||||
* Resets the NodeDB on a node.
|
||||
*
|
||||
* @param destNum The target node number.
|
||||
* @param packetId The request packet ID.
|
||||
* @param preserveFavorites Whether to keep favorite nodes in the database.
|
||||
*/
|
||||
suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean)
|
||||
|
||||
/**
|
||||
* Removes a node from the mesh by its node number.
|
||||
*
|
||||
* @param packetId The request packet ID.
|
||||
* @param nodeNum The node number to remove.
|
||||
*/
|
||||
suspend fun removeByNodenum(packetId: Int, nodeNum: Int)
|
||||
|
||||
// Batch editing
|
||||
/**
|
||||
* Requests the current GPS position from a remote node.
|
||||
*
|
||||
* @param destNum The target node number.
|
||||
* @param currentPosition Our current position to provide in the request.
|
||||
*/
|
||||
suspend fun requestPosition(destNum: Int, currentPosition: Position)
|
||||
|
||||
/**
|
||||
* Requests detailed user info from a remote node.
|
||||
*
|
||||
* @param destNum The target node number.
|
||||
*/
|
||||
suspend fun requestUserInfo(destNum: Int)
|
||||
|
||||
/**
|
||||
* Initiates a traceroute request to a remote node.
|
||||
*
|
||||
* @param requestId The request ID.
|
||||
* @param destNum The destination node number.
|
||||
*/
|
||||
suspend fun requestTraceroute(requestId: Int, destNum: Int)
|
||||
|
||||
/**
|
||||
* Requests telemetry data from a remote node.
|
||||
*
|
||||
* @param requestId The request ID.
|
||||
* @param destNum The destination node number.
|
||||
* @param typeValue The numeric type of telemetry requested.
|
||||
*/
|
||||
suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int)
|
||||
|
||||
/**
|
||||
* Requests neighbor information (detected nodes) from a remote node.
|
||||
*
|
||||
* @param requestId The request ID.
|
||||
* @param destNum The destination node number.
|
||||
*/
|
||||
suspend fun requestNeighborInfo(requestId: Int, destNum: Int)
|
||||
|
||||
/**
|
||||
* Signals the start of a batch configuration session.
|
||||
*
|
||||
* @param destNum The target node number.
|
||||
*/
|
||||
suspend fun beginEditSettings(destNum: Int)
|
||||
|
||||
/**
|
||||
* Commits all pending configuration changes in a batch session.
|
||||
*
|
||||
* @param destNum The target node number.
|
||||
*/
|
||||
suspend fun commitEditSettings(destNum: Int)
|
||||
|
||||
// Helpers
|
||||
/**
|
||||
* Generates a unique packet ID for a new request.
|
||||
*
|
||||
* @return A unique 32-bit integer.
|
||||
*/
|
||||
fun getPacketId(): Int
|
||||
|
||||
/** Starts providing the phone's location to the mesh. */
|
||||
|
|
@ -87,4 +319,11 @@ interface RadioController {
|
|||
|
||||
/** Stops providing the phone's location to the mesh. */
|
||||
fun stopProvideLocation()
|
||||
|
||||
/**
|
||||
* Changes the device address (e.g., BLE MAC, IP address) we are communicating with.
|
||||
*
|
||||
* @param address The new device identifier.
|
||||
*/
|
||||
fun setDeviceAddress(address: String)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* 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.model
|
||||
|
||||
/** Exception thrown when an operation is attempted while not connected to a mesh radio. */
|
||||
open class RadioNotConnectedException(message: String = "Not connected to radio") : Exception(message)
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* 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.model
|
||||
|
||||
import okio.ByteString
|
||||
import org.meshtastic.proto.User
|
||||
|
||||
data class Reaction(
|
||||
val replyId: Int,
|
||||
val user: User,
|
||||
val emoji: String,
|
||||
val timestamp: Long,
|
||||
val snr: Float,
|
||||
val rssi: Int,
|
||||
val hopsAway: Int,
|
||||
val packetId: Int = 0,
|
||||
val status: MessageStatus = MessageStatus.UNKNOWN,
|
||||
val routingError: Int = 0,
|
||||
val relays: Int = 0,
|
||||
val relayNode: Int? = null,
|
||||
val to: String? = null,
|
||||
val channel: Int = 0,
|
||||
val sfppHash: ByteString? = null,
|
||||
)
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.database.model
|
||||
package org.meshtastic.core.model
|
||||
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.meshtastic.core.resources.Res
|
||||
|
|
@ -14,9 +14,9 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.service
|
||||
package org.meshtastic.core.model.service
|
||||
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.proto.SharedContact
|
||||
|
||||
sealed class ServiceAction {
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* 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.model.service
|
||||
|
||||
data class TracerouteResponse(
|
||||
val message: String,
|
||||
val destinationNodeNum: Int,
|
||||
val requestId: Int,
|
||||
val forwardRoute: List<Int> = emptyList(),
|
||||
val returnRoute: List<Int> = emptyList(),
|
||||
val logUuid: String? = null,
|
||||
) {
|
||||
val hasOverlay: Boolean
|
||||
get() = forwardRoute.isNotEmpty() || returnRoute.isNotEmpty()
|
||||
}
|
||||
|
|
@ -83,7 +83,7 @@ fun ChannelSet.hasLoraConfig(): Boolean = lora_config != null
|
|||
*/
|
||||
fun ChannelSet.getChannelUrl(upperCasePrefix: Boolean = false, shouldAdd: Boolean = false): CommonUri {
|
||||
val channelBytes = ChannelSet.ADAPTER.encode(this)
|
||||
val enc = channelBytes.toByteString().base64Url()
|
||||
val enc = channelBytes.toByteString().base64Url().replace("=", "")
|
||||
val p = if (upperCasePrefix) CHANNEL_URL_PREFIX.uppercase() else CHANNEL_URL_PREFIX
|
||||
val query = if (shouldAdd) "?add=true" else ""
|
||||
return CommonUri.parse("$p$query#$enc")
|
||||
|
|
|
|||
|
|
@ -29,10 +29,13 @@ configure<LibraryExtension> {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.core.repository)
|
||||
implementation(projects.core.di)
|
||||
implementation(projects.core.model)
|
||||
implementation(projects.core.proto)
|
||||
|
||||
implementation(libs.coil.network.core)
|
||||
implementation(libs.org.eclipse.paho.client.mqttv3)
|
||||
implementation(libs.okio)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
implementation(libs.coil.svg)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
|
@ -40,6 +43,7 @@ dependencies {
|
|||
implementation(libs.ktor.client.okhttp)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
implementation(libs.okhttp3.logging.interceptor)
|
||||
implementation(libs.kermit)
|
||||
|
||||
googleImplementation(libs.dd.sdk.android.okhttp)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,180 @@
|
|||
/*
|
||||
* 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.network.repository
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.eclipse.paho.client.mqttv3.DisconnectedBufferOptions
|
||||
import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken
|
||||
import org.eclipse.paho.client.mqttv3.MqttAsyncClient
|
||||
import org.eclipse.paho.client.mqttv3.MqttAsyncClient.generateClientId
|
||||
import org.eclipse.paho.client.mqttv3.MqttCallbackExtended
|
||||
import org.eclipse.paho.client.mqttv3.MqttConnectOptions
|
||||
import org.eclipse.paho.client.mqttv3.MqttMessage
|
||||
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence
|
||||
import org.meshtastic.core.common.util.ignoreException
|
||||
import org.meshtastic.core.model.util.subscribeList
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.proto.MqttClientProxyMessage
|
||||
import java.net.URI
|
||||
import java.security.SecureRandom
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManager
|
||||
|
||||
@Singleton
|
||||
class MQTTRepository
|
||||
@Inject
|
||||
constructor(
|
||||
private val radioConfigRepository: RadioConfigRepository,
|
||||
private val nodeRepository: NodeRepository,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Quality of Service (QoS) levels in MQTT:
|
||||
* - QoS 0: "at most once". Packets are sent once without validation if it has been received.
|
||||
* - QoS 1: "at least once". Packets are sent and stored until the client receives confirmation from the server.
|
||||
* MQTT ensures delivery, but duplicates may occur.
|
||||
* - QoS 2: "exactly once". Similar to QoS 1, but with no duplicates.
|
||||
*/
|
||||
private const val DEFAULT_QOS = 1
|
||||
private const val DEFAULT_TOPIC_ROOT = "msh"
|
||||
private const val DEFAULT_TOPIC_LEVEL = "/2/e/"
|
||||
private const val JSON_TOPIC_LEVEL = "/2/json/"
|
||||
private const val DEFAULT_SERVER_ADDRESS = "mqtt.meshtastic.org"
|
||||
}
|
||||
|
||||
private var mqttClient: MqttAsyncClient? = null
|
||||
|
||||
fun disconnect() {
|
||||
Logger.i { "MQTT Disconnected" }
|
||||
mqttClient?.apply {
|
||||
if (isConnected) {
|
||||
ignoreException { disconnect() }
|
||||
}
|
||||
ignoreException { close(true) }
|
||||
}
|
||||
mqttClient = null
|
||||
}
|
||||
|
||||
val proxyMessageFlow: Flow<MqttClientProxyMessage> = callbackFlow {
|
||||
val ownerId = "MeshtasticAndroidMqttProxy-${nodeRepository.myId.value ?: generateClientId()}"
|
||||
val channelSet = radioConfigRepository.channelSetFlow.first()
|
||||
val mqttConfig = radioConfigRepository.moduleConfigFlow.first().mqtt
|
||||
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
// Create a custom SSLContext that trusts all certificates
|
||||
sslContext.init(null, arrayOf<TrustManager>(TrustAllX509TrustManager()), SecureRandom())
|
||||
|
||||
val rootTopic = mqttConfig?.root?.ifEmpty { DEFAULT_TOPIC_ROOT }
|
||||
|
||||
val connectOptions =
|
||||
MqttConnectOptions().apply {
|
||||
userName = mqttConfig?.username
|
||||
password = mqttConfig?.password?.toCharArray()
|
||||
isAutomaticReconnect = true
|
||||
if (mqttConfig?.tls_enabled == true) {
|
||||
socketFactory = sslContext.socketFactory
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
val bufferOptions =
|
||||
DisconnectedBufferOptions().apply {
|
||||
isBufferEnabled = true
|
||||
bufferSize = 512
|
||||
isPersistBuffer = false
|
||||
isDeleteOldestMessages = true
|
||||
}
|
||||
|
||||
val callback =
|
||||
object : MqttCallbackExtended {
|
||||
override fun connectComplete(reconnect: Boolean, serverURI: String) {
|
||||
Logger.i { "MQTT connectComplete: $serverURI reconnect: $reconnect" }
|
||||
channelSet.subscribeList
|
||||
.ifEmpty {
|
||||
return
|
||||
}
|
||||
.forEach { globalId ->
|
||||
subscribe("$rootTopic$DEFAULT_TOPIC_LEVEL$globalId/+")
|
||||
if (mqttConfig?.json_enabled == true) subscribe("$rootTopic$JSON_TOPIC_LEVEL$globalId/+")
|
||||
}
|
||||
subscribe("$rootTopic${DEFAULT_TOPIC_LEVEL}PKI/+")
|
||||
}
|
||||
|
||||
override fun connectionLost(cause: Throwable) {
|
||||
Logger.i { "MQTT connectionLost cause: $cause" }
|
||||
if (cause is IllegalArgumentException) close(cause)
|
||||
}
|
||||
|
||||
override fun messageArrived(topic: String, message: MqttMessage) {
|
||||
trySend(
|
||||
MqttClientProxyMessage(
|
||||
topic = topic,
|
||||
data_ = message.payload.toByteString(),
|
||||
retained = message.isRetained,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun deliveryComplete(token: IMqttDeliveryToken?) {
|
||||
Logger.i { "MQTT deliveryComplete messageId: ${token?.messageId}" }
|
||||
}
|
||||
}
|
||||
|
||||
val scheme = if (mqttConfig?.tls_enabled == true) "ssl" else "tcp"
|
||||
val (host, port) =
|
||||
(mqttConfig?.address ?: DEFAULT_SERVER_ADDRESS).split(":", limit = 2).let {
|
||||
it[0] to (it.getOrNull(1)?.toIntOrNull() ?: -1)
|
||||
}
|
||||
|
||||
mqttClient =
|
||||
MqttAsyncClient(URI(scheme, null, host, port, "", "", "").toString(), ownerId, MemoryPersistence()).apply {
|
||||
setCallback(callback)
|
||||
setBufferOpts(bufferOptions)
|
||||
connect(connectOptions)
|
||||
}
|
||||
|
||||
awaitClose { disconnect() }
|
||||
}
|
||||
|
||||
private fun subscribe(topic: String) {
|
||||
mqttClient?.subscribe(topic, DEFAULT_QOS)
|
||||
Logger.i { "MQTT Subscribed to topic: $topic" }
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
fun publish(topic: String, data: ByteArray, retained: Boolean) {
|
||||
try {
|
||||
val token = mqttClient?.publish(topic, data, DEFAULT_QOS, retained)
|
||||
Logger.i { "MQTT Publish messageId: ${token?.messageId}" }
|
||||
} catch (ex: Exception) {
|
||||
if (ex.message?.contains("Client is disconnected") == true) {
|
||||
Logger.w { "MQTT Publish skipped: Client is disconnected" }
|
||||
} else {
|
||||
Logger.e(ex) { "MQTT Publish error: ${ex.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* 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.network.repository
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
@SuppressLint("CustomX509TrustManager", "TrustAllX509TrustManager")
|
||||
@Suppress("EmptyFunctionBlock")
|
||||
class TrustAllX509TrustManager : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate>?, authType: String?) {}
|
||||
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate>?, authType: String?) {}
|
||||
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ plugins {
|
|||
configure<LibraryExtension> { namespace = "org.meshtastic.core.prefs" }
|
||||
|
||||
dependencies {
|
||||
implementation(projects.core.repository)
|
||||
googleImplementation(libs.maps.compose)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
|
|
|
|||
|
|
@ -109,6 +109,11 @@ interface PrefsModule {
|
|||
|
||||
@Binds fun bindHomoglyphEncodingPrefs(homoglyphEncodingPrefsImpl: HomoglyphPrefsImpl): HomoglyphPrefs
|
||||
|
||||
@Binds
|
||||
fun bindSharedHomoglyphPrefs(
|
||||
homoglyphEncodingPrefsImpl: HomoglyphPrefsImpl,
|
||||
): org.meshtastic.core.repository.HomoglyphPrefs
|
||||
|
||||
@Binds fun bindCustomEmojiPrefs(customEmojiPrefsImpl: CustomEmojiPrefsImpl): CustomEmojiPrefs
|
||||
|
||||
@Binds fun bindMapConsentPrefs(mapConsentPrefsImpl: MapConsentPrefsImpl): MapConsentPrefs
|
||||
|
|
|
|||
|
|
@ -24,11 +24,12 @@ import org.meshtastic.core.prefs.PrefDelegate
|
|||
import org.meshtastic.core.prefs.di.HomoglyphEncodingSharedPreferences
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import org.meshtastic.core.repository.HomoglyphPrefs as SharedHomoglyphPrefs
|
||||
|
||||
interface HomoglyphPrefs {
|
||||
interface HomoglyphPrefs : SharedHomoglyphPrefs {
|
||||
|
||||
/** Preference for whether homoglyph encoding is enabled by the user. */
|
||||
var homoglyphEncodingEnabled: Boolean
|
||||
override var homoglyphEncodingEnabled: Boolean
|
||||
|
||||
/**
|
||||
* Provides a [Flow] that emits the current state of [homoglyphEncodingEnabled] and subsequent changes.
|
||||
|
|
|
|||
35
core/repository/build.gradle.kts
Normal file
35
core/repository/build.gradle.kts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
plugins { alias(libs.plugins.meshtastic.kmp.library) }
|
||||
|
||||
kotlin {
|
||||
@Suppress("UnstableApiUsage")
|
||||
android { androidResources.enable = false }
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(projects.core.model)
|
||||
api(projects.core.proto)
|
||||
implementation(projects.core.common)
|
||||
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.kermit)
|
||||
implementation(libs.androidx.paging.common)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* 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.repository
|
||||
|
||||
/** Interface for triggering updates to application widgets. */
|
||||
interface AppWidgetUpdater {
|
||||
/** Triggers an update for all app widgets. */
|
||||
suspend fun updateAll()
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
/*
|
||||
* 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.repository
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import okio.ByteString
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.LocalConfig
|
||||
import org.meshtastic.proto.NeighborInfo
|
||||
|
||||
/** Interface for sending commands and packets to the mesh network. */
|
||||
@Suppress("TooManyFunctions")
|
||||
interface CommandSender {
|
||||
/** Starts the command sender with the given coroutine scope. */
|
||||
fun start(scope: CoroutineScope)
|
||||
|
||||
/** Returns the current packet ID. */
|
||||
fun getCurrentPacketId(): Long
|
||||
|
||||
/** Returns the cached local configuration. */
|
||||
fun getCachedLocalConfig(): LocalConfig
|
||||
|
||||
/** Returns the cached channel set. */
|
||||
fun getCachedChannelSet(): ChannelSet
|
||||
|
||||
/** Generates a new unique packet ID. */
|
||||
fun generatePacketId(): Int
|
||||
|
||||
/** The latest neighbor info received from the connected radio. */
|
||||
var lastNeighborInfo: NeighborInfo?
|
||||
|
||||
/** Start times of traceroute requests for duration calculation. */
|
||||
val tracerouteStartTimes: MutableMap<Int, Long>
|
||||
|
||||
/** Start times of neighbor info requests for duration calculation. */
|
||||
val neighborInfoStartTimes: MutableMap<Int, Long>
|
||||
|
||||
/** Sets the session passkey for admin messages. */
|
||||
fun setSessionPasskey(key: ByteString)
|
||||
|
||||
/** Sends a data packet to the mesh. */
|
||||
fun sendData(p: DataPacket)
|
||||
|
||||
/** Sends an admin message to a specific node. */
|
||||
fun sendAdmin(
|
||||
destNum: Int,
|
||||
requestId: Int = generatePacketId(),
|
||||
wantResponse: Boolean = false,
|
||||
initFn: () -> AdminMessage,
|
||||
)
|
||||
|
||||
/** Sends our current position to the mesh. */
|
||||
fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int? = null, wantResponse: Boolean = false)
|
||||
|
||||
/** Requests the position of a specific node. */
|
||||
fun requestPosition(destNum: Int, currentPosition: Position)
|
||||
|
||||
/** Sets a fixed position for a node. */
|
||||
fun setFixedPosition(destNum: Int, pos: Position)
|
||||
|
||||
/** Requests user info from a specific node. */
|
||||
fun requestUserInfo(destNum: Int)
|
||||
|
||||
/** Requests a traceroute to a specific node. */
|
||||
fun requestTraceroute(requestId: Int, destNum: Int)
|
||||
|
||||
/** Requests telemetry from a specific node. */
|
||||
fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int)
|
||||
|
||||
/** Requests neighbor info from a specific node. */
|
||||
fun requestNeighborInfo(requestId: Int, destNum: Int)
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* 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.repository
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/** Interface for managing database instances and cache limits. */
|
||||
interface DatabaseManager {
|
||||
/** Reactive stream of the current database cache limit. */
|
||||
val cacheLimit: StateFlow<Int>
|
||||
|
||||
/** Returns the current database cache limit from storage. */
|
||||
fun getCurrentCacheLimit(): Int
|
||||
|
||||
/** Sets the database cache limit. */
|
||||
fun setCacheLimit(limit: Int)
|
||||
|
||||
/** Switches the active database to the one associated with the given [address]. */
|
||||
suspend fun switchActiveDatabase(address: String?)
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* 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.repository
|
||||
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
|
||||
interface DeviceHardwareRepository {
|
||||
/**
|
||||
* Retrieves device hardware information by its model ID and optional target string.
|
||||
*
|
||||
* @param hwModel The hardware model identifier.
|
||||
* @param target Optional PlatformIO target environment name to disambiguate multiple variants.
|
||||
* @param forceRefresh If true, the local cache will be invalidated and data will be fetched remotely.
|
||||
* @return A [Result] containing the [DeviceHardware] on success (or null if not found), or an exception on failure.
|
||||
*/
|
||||
suspend fun getDeviceHardwareByModel(
|
||||
hwModel: Int,
|
||||
target: String? = null,
|
||||
forceRefresh: Boolean = false,
|
||||
): Result<DeviceHardware?>
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* 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.repository
|
||||
|
||||
import org.meshtastic.proto.FromRadio
|
||||
|
||||
/** Interface for dispatching non-packet [FromRadio] variants to their respective handlers. */
|
||||
interface FromRadioPacketHandler {
|
||||
/** Processes a [FromRadio] message. */
|
||||
fun handleFromRadio(proto: FromRadio)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue