mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-19 04:09:37 -04:00
feat: DRY SDK integration — shared bridge, Desktop cutover, dead infra deletion
Major architectural changes:
1. SHARED KMP BRIDGE (core/data/radio/):
- RadioClientAccessor: Platform-agnostic interface for SDK RadioClient
- SdkRadioController: Shared RadioController impl (replaces per-platform copies)
- SdkStateBridge: Shared SDK→repository bridge (event dispatch, node sync)
- SdkPacketHandler: Thin SDK-backed PacketHandler for MQTT/XModem/History
2. DESKTOP SDK CUTOVER:
- DesktopRadioClientProvider: TCP + Serial transport support
- Removed DirectRadioControllerImpl (old desktop radio path)
- Desktop now shares the same SDK bridge code as Android
3. DEAD INFRASTRUCTURE DELETION (~5,100 LOC removed):
- PacketHandlerImpl, MeshDataHandlerImpl, MeshRouterImpl
- CommandSenderImpl, MeshActionHandlerImpl, MeshConfigFlowManagerImpl
- MeshConnectionManagerImpl, AdminPacketHandlerImpl
- ServiceBroadcasts (Android intent-based pub/sub)
- NodeRepositoryImpl (Room-backed, replaced by SdkNodeRepositoryImpl)
- 8 trivial UseCases (SetLocale, SetTheme, ToggleAnalytics, etc.)
- All associated test files for deleted impls
- Deleted interfaces: AdminPacketHandler, CommandSender, MeshActionHandler,
MeshConfigFlowManager, MeshConnectionManager, MeshRouter, ServiceBroadcasts
4. NEW FEATURES:
- NodeMetadataEntity + Room migration 38→39 (persistent favorites/notes)
- AppMetadataRepository (clean access to node metadata)
- MessagePersistenceHandler (focused rememberDataPacket for StoreForward)
All three targets compile clean: :app:compileGoogleDebugKotlin,
:desktop:compileKotlin, :core:data:jvmTest passes.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -28,6 +28,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.data.radio.RadioClientAccessor
|
||||
import org.meshtastic.core.model.InterfaceId
|
||||
import org.meshtastic.core.repository.RadioPrefs
|
||||
import org.meshtastic.core.service.SdkClientLifecycle
|
||||
@@ -45,15 +46,15 @@ import org.meshtastic.sdk.transport.tcp.TcpTransport
|
||||
* This is the SDK integration point for the POC. The [RadioClient] is exposed as a [StateFlow] so ViewModels and the
|
||||
* service can react to connection changes with `flatMapLatest`.
|
||||
*/
|
||||
@Single(binds = [SdkClientLifecycle::class])
|
||||
@Single(binds = [SdkClientLifecycle::class, RadioClientAccessor::class])
|
||||
class RadioClientProvider(
|
||||
private val context: Context,
|
||||
private val radioPrefs: RadioPrefs,
|
||||
) : SdkClientLifecycle {
|
||||
) : SdkClientLifecycle, RadioClientAccessor {
|
||||
private val _client = MutableStateFlow<RadioClient?>(null)
|
||||
|
||||
/** Active [RadioClient], or `null` when disconnected or between connections. */
|
||||
val client: StateFlow<RadioClient?> = _client.asStateFlow()
|
||||
override val client: StateFlow<RadioClient?> = _client.asStateFlow()
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val mutex = Mutex()
|
||||
@@ -121,7 +122,7 @@ class RadioClientProvider(
|
||||
}
|
||||
|
||||
/** Fire-and-forget version of [rebuildAndConnect] for non-suspending call sites. */
|
||||
fun rebuildAndConnectAsync() {
|
||||
override fun rebuildAndConnectAsync() {
|
||||
scope.launch {
|
||||
runCatching { rebuildAndConnect() }.onFailure { e -> Logger.e(e) { "RadioClientProvider: connect failed" } }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.radio
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import org.meshtastic.sdk.RadioClient
|
||||
import org.meshtastic.sdk.TransportIdentity
|
||||
import org.meshtastic.sdk.testing.FakeRadioTransport
|
||||
import org.meshtastic.sdk.testing.InMemoryStorageProvider
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
/**
|
||||
* Test-only RadioClient setup using FakeRadioTransport.
|
||||
* Provides deterministic handshake and packet injection for integration tests.
|
||||
*/
|
||||
class TestRadioClientProvider(
|
||||
val nodeNum: Int = 1,
|
||||
coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Default,
|
||||
) {
|
||||
val transport = FakeRadioTransport(
|
||||
identity = TransportIdentity("fake:test-radio-provider"),
|
||||
autoHandshake = true,
|
||||
nodeNum = nodeNum,
|
||||
)
|
||||
|
||||
val client: RadioClient = RadioClient.Builder()
|
||||
.transport(transport)
|
||||
.storage(InMemoryStorageProvider())
|
||||
.autoSyncTimeOnConnect(false)
|
||||
.coroutineContext(coroutineContext)
|
||||
.build()
|
||||
|
||||
suspend fun connect() {
|
||||
client.connect()
|
||||
}
|
||||
|
||||
suspend fun disconnect() {
|
||||
client.disconnect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.radio
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.io.bytestring.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.proto.Data
|
||||
import org.meshtastic.proto.FromRadio
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.NodeInfo
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.User
|
||||
import org.meshtastic.sdk.ConnectionState
|
||||
import org.meshtastic.sdk.Frame
|
||||
import org.meshtastic.sdk.NodeChange
|
||||
import org.meshtastic.sdk.NodeId
|
||||
import org.meshtastic.sdk.TransportState
|
||||
import org.meshtastic.sdk.decodeAsNodeInfo
|
||||
import org.meshtastic.sdk.testing.FakeRadioTransport
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class TestRadioClientProviderTest {
|
||||
|
||||
@Test
|
||||
fun connectInjectNodeAndDisconnect() = runTest {
|
||||
val provider = TestRadioClientProvider(coroutineContext = backgroundScope.coroutineContext)
|
||||
|
||||
provider.connect()
|
||||
assertEquals(ConnectionState.Connected, provider.client.connection.value)
|
||||
|
||||
val nodeInfo = NodeInfo(
|
||||
num = 0x1234,
|
||||
user = User(
|
||||
id = "!00001234",
|
||||
long_name = "Test Node",
|
||||
short_name = "TN",
|
||||
),
|
||||
)
|
||||
|
||||
val packetAwaiter = backgroundScope.async {
|
||||
provider.client.packets.first { packet ->
|
||||
packet.from == nodeInfo.num && packet.decodeAsNodeInfo()?.num == nodeInfo.num
|
||||
}
|
||||
}
|
||||
runCurrent()
|
||||
|
||||
provider.transport.injectPacket(
|
||||
MeshPacket(
|
||||
from = nodeInfo.num,
|
||||
to = provider.nodeNum,
|
||||
decoded = Data(
|
||||
portnum = PortNum.NODEINFO_APP,
|
||||
payload = NodeInfo.ADAPTER.encode(nodeInfo).toByteString(),
|
||||
),
|
||||
),
|
||||
)
|
||||
runCurrent()
|
||||
runCurrent()
|
||||
|
||||
assertEquals(nodeInfo.num, packetAwaiter.await().decodeAsNodeInfo()?.num)
|
||||
|
||||
val nodeAwaiter = backgroundScope.async {
|
||||
provider.client.nodes.first { change ->
|
||||
change is NodeChange.Added && change.node.num == nodeInfo.num
|
||||
}
|
||||
}
|
||||
runCurrent()
|
||||
|
||||
provider.transport.injectNodeInfo(nodeInfo)
|
||||
runCurrent()
|
||||
runCurrent()
|
||||
|
||||
val added = assertIs<NodeChange.Added>(nodeAwaiter.await())
|
||||
assertEquals(nodeInfo.num, added.node.num)
|
||||
assertEquals(nodeInfo.user?.long_name, provider.client.nodeSnapshot()[NodeId(nodeInfo.num)]?.user?.long_name)
|
||||
|
||||
provider.disconnect()
|
||||
assertEquals(ConnectionState.Disconnected, provider.client.connection.value)
|
||||
assertEquals(TransportState.Disconnected, provider.transport.state.value)
|
||||
}
|
||||
|
||||
private fun FakeRadioTransport.injectNodeInfo(nodeInfo: NodeInfo) {
|
||||
val proto = FromRadio.ADAPTER.encode(FromRadio(node_info = nodeInfo))
|
||||
val frame = ByteArray(4 + proto.size).apply {
|
||||
this[0] = 0x94.toByte()
|
||||
this[1] = 0xC3.toByte()
|
||||
this[2] = (proto.size shr 8).toByte()
|
||||
this[3] = (proto.size and 0xFF).toByte()
|
||||
proto.copyInto(this, destinationOffset = 4)
|
||||
}
|
||||
injectFrame(Frame(ByteString(frame)))
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,9 @@ kotlin {
|
||||
implementation(projects.core.proto)
|
||||
implementation(projects.core.takserver)
|
||||
|
||||
// Meshtastic SDK — shared RadioController and StateBridge implementations
|
||||
api(libs.sdk.core)
|
||||
|
||||
implementation(libs.jetbrains.lifecycle.runtime)
|
||||
implementation(libs.androidx.paging.common)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
@@ -69,6 +72,7 @@ kotlin {
|
||||
commonTest.dependencies {
|
||||
implementation(projects.core.testing)
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
implementation(libs.sdk.testing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.datasource
|
||||
|
||||
import org.meshtastic.core.database.entity.MetadataEntity
|
||||
import org.meshtastic.core.database.entity.MyNodeEntity
|
||||
import org.meshtastic.core.database.entity.NodeEntity
|
||||
|
||||
interface NodeInfoWriteDataSource {
|
||||
suspend fun upsert(node: NodeEntity)
|
||||
|
||||
suspend fun installConfig(mi: MyNodeEntity, nodes: List<NodeEntity>)
|
||||
|
||||
suspend fun clearNodeDB(preserveFavorites: Boolean)
|
||||
|
||||
suspend fun clearMyNodeInfo()
|
||||
|
||||
suspend fun deleteNode(num: Int)
|
||||
|
||||
suspend fun deleteNodes(nodeNums: List<Int>)
|
||||
|
||||
suspend fun deleteMetadata(num: Int)
|
||||
|
||||
suspend fun upsert(metadata: MetadataEntity)
|
||||
|
||||
suspend fun setNodeNotes(num: Int, notes: String)
|
||||
|
||||
suspend fun backfillDenormalizedNames()
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.datasource
|
||||
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.database.DatabaseProvider
|
||||
import org.meshtastic.core.database.entity.MetadataEntity
|
||||
import org.meshtastic.core.database.entity.MyNodeEntity
|
||||
import org.meshtastic.core.database.entity.NodeEntity
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
|
||||
@Single
|
||||
class SwitchingNodeInfoWriteDataSource(
|
||||
private val dbManager: DatabaseProvider,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : NodeInfoWriteDataSource {
|
||||
|
||||
override suspend fun upsert(node: NodeEntity) {
|
||||
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().upsert(node) } }
|
||||
}
|
||||
|
||||
override suspend fun installConfig(mi: MyNodeEntity, nodes: List<NodeEntity>) {
|
||||
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().installConfig(mi, nodes) } }
|
||||
}
|
||||
|
||||
override suspend fun clearNodeDB(preserveFavorites: Boolean) {
|
||||
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().clearNodeInfo(preserveFavorites) } }
|
||||
}
|
||||
|
||||
override suspend fun clearMyNodeInfo() {
|
||||
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().clearMyNodeInfo() } }
|
||||
}
|
||||
|
||||
override suspend fun deleteNode(num: Int) {
|
||||
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().deleteNode(num) } }
|
||||
}
|
||||
|
||||
override suspend fun deleteNodes(nodeNums: List<Int>) {
|
||||
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().deleteNodes(nodeNums) } }
|
||||
}
|
||||
|
||||
override suspend fun deleteMetadata(num: Int) {
|
||||
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().deleteMetadata(num) } }
|
||||
}
|
||||
|
||||
override suspend fun upsert(metadata: MetadataEntity) {
|
||||
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().upsert(metadata) } }
|
||||
}
|
||||
|
||||
override suspend fun setNodeNotes(num: Int, notes: String) {
|
||||
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().setNodeNotes(num, notes) } }
|
||||
}
|
||||
|
||||
override suspend fun backfillDenormalizedNames() {
|
||||
withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().backfillDenormalizedNames() } }
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.repository.AdminPacketHandler
|
||||
import org.meshtastic.core.repository.MeshConfigFlowManager
|
||||
import org.meshtastic.core.repository.MeshConfigHandler
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.SessionManager
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
|
||||
/**
|
||||
* Implementation of [AdminPacketHandler] that processes admin messages, including session passkeys, device/module
|
||||
* configuration, and metadata.
|
||||
*/
|
||||
@Single
|
||||
class AdminPacketHandlerImpl(
|
||||
private val nodeManager: NodeManager,
|
||||
private val configHandler: Lazy<MeshConfigHandler>,
|
||||
private val configFlowManager: Lazy<MeshConfigFlowManager>,
|
||||
private val sessionManager: SessionManager,
|
||||
) : AdminPacketHandler {
|
||||
|
||||
override fun handleAdminMessage(packet: MeshPacket, myNodeNum: Int) {
|
||||
val payload = packet.decoded?.payload ?: return
|
||||
val u = AdminMessage.ADAPTER.decode(payload)
|
||||
Logger.d { "Admin message from=${packet.from} fields=${u.summarize()}" }
|
||||
// Firmware embeds the session_passkey in every admin response. A missing (default-empty)
|
||||
// field must not reset stored state, so only record refreshes when bytes arrived.
|
||||
val incomingPasskey = u.session_passkey
|
||||
if (incomingPasskey.size > 0) {
|
||||
sessionManager.recordSession(packet.from, incomingPasskey)
|
||||
}
|
||||
|
||||
val fromNum = packet.from
|
||||
u.get_module_config_response?.let {
|
||||
if (fromNum == myNodeNum) {
|
||||
configHandler.value.handleModuleConfig(it)
|
||||
} else {
|
||||
it.statusmessage?.node_status?.let { nodeManager.updateNodeStatus(fromNum, it) }
|
||||
}
|
||||
}
|
||||
|
||||
if (fromNum == myNodeNum) {
|
||||
u.get_config_response?.let { configHandler.value.handleDeviceConfig(it) }
|
||||
u.get_channel_response?.let { configHandler.value.handleChannel(it) }
|
||||
}
|
||||
|
||||
u.get_device_metadata_response?.let {
|
||||
if (fromNum == myNodeNum) {
|
||||
configFlowManager.value.handleLocalMetadata(it)
|
||||
} else {
|
||||
nodeManager.insertMetadata(fromNum, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a short summary of the non-null admin message fields for logging. */
|
||||
private fun AdminMessage.summarize(): String = buildList {
|
||||
get_config_response?.let { add("get_config_response") }
|
||||
get_module_config_response?.let { add("get_module_config_response") }
|
||||
get_channel_response?.let { add("get_channel_response") }
|
||||
get_device_metadata_response?.let { add("get_device_metadata_response") }
|
||||
if (session_passkey.size > 0) add("session_passkey")
|
||||
}
|
||||
.joinToString()
|
||||
.ifEmpty { "empty" }
|
||||
@@ -1,466 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.atomicfu.atomic
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.MessageStatus
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.TelemetryType
|
||||
import org.meshtastic.core.model.util.isWithinSizeLimit
|
||||
import org.meshtastic.core.repository.CommandSender
|
||||
import org.meshtastic.core.repository.NeighborInfoHandler
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.PacketHandler
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.SessionManager
|
||||
import org.meshtastic.core.repository.TracerouteHandler
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
import org.meshtastic.proto.AirQualityMetrics
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.Constants
|
||||
import org.meshtastic.proto.Data
|
||||
import org.meshtastic.proto.DeviceMetrics
|
||||
import org.meshtastic.proto.EnvironmentMetrics
|
||||
import org.meshtastic.proto.HostMetrics
|
||||
import org.meshtastic.proto.LocalConfig
|
||||
import org.meshtastic.proto.LocalStats
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.Neighbor
|
||||
import org.meshtastic.proto.NeighborInfo
|
||||
import org.meshtastic.proto.Paxcount
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.PowerMetrics
|
||||
import org.meshtastic.proto.Telemetry
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import org.meshtastic.proto.Position as ProtoPosition
|
||||
|
||||
@Suppress("TooManyFunctions", "CyclomaticComplexMethod", "LongParameterList")
|
||||
@Single
|
||||
class CommandSenderImpl(
|
||||
private val packetHandler: PacketHandler,
|
||||
private val nodeManager: NodeManager,
|
||||
private val radioConfigRepository: RadioConfigRepository,
|
||||
private val tracerouteHandler: TracerouteHandler,
|
||||
private val neighborInfoHandler: NeighborInfoHandler,
|
||||
private val sessionManager: SessionManager,
|
||||
@Named("ServiceScope") private val scope: CoroutineScope,
|
||||
) : CommandSender {
|
||||
private val currentPacketId = atomic(Random(nowMillis).nextLong().absoluteValue)
|
||||
|
||||
private val localConfig = MutableStateFlow(LocalConfig())
|
||||
private val channelSet = MutableStateFlow(ChannelSet())
|
||||
|
||||
init {
|
||||
radioConfigRepository.localConfigFlow.onEach { localConfig.value = it }.launchIn(scope)
|
||||
radioConfigRepository.channelSetFlow.onEach { channelSet.value = it }.launchIn(scope)
|
||||
}
|
||||
|
||||
override fun getCachedLocalConfig(): LocalConfig = localConfig.value
|
||||
|
||||
override fun getCachedChannelSet(): ChannelSet = channelSet.value
|
||||
|
||||
override fun getCurrentPacketId(): Long = currentPacketId.value
|
||||
|
||||
override fun generatePacketId(): Int {
|
||||
val numPacketIds = ((1L shl PACKET_ID_SHIFT_BITS) - 1)
|
||||
val next = currentPacketId.incrementAndGet() and PACKET_ID_MASK
|
||||
return ((next % numPacketIds) + 1L).toInt()
|
||||
}
|
||||
|
||||
private fun computeHopLimit(): Int = (localConfig.value.lora?.hop_limit ?: 0).takeIf { it > 0 } ?: DEFAULT_HOP_LIMIT
|
||||
|
||||
/**
|
||||
* Resolves the correct channel index for sending a packet to [toNum].
|
||||
*
|
||||
* PKI encryption ([DataPacket.PKC_CHANNEL_INDEX]) is only used for **admin** packets, where end-to-end encryption
|
||||
* is appropriate. Protocol-level requests (traceroute, telemetry, position, nodeinfo, neighborinfo) must NOT use
|
||||
* PKI because relay nodes need to read and/or modify the inner payload (e.g. traceroute appends each hop's node
|
||||
* number). These requests fall back to the node's heard-on channel.
|
||||
*/
|
||||
private fun getAdminChannelIndex(toNum: Int): Int {
|
||||
val myNum = nodeManager.myNodeNum.value ?: return 0
|
||||
val myNode = nodeManager.nodeDBbyNodeNum[myNum]
|
||||
val destNode = nodeManager.nodeDBbyNodeNum[toNum]
|
||||
|
||||
return when {
|
||||
myNum == toNum -> 0
|
||||
|
||||
myNode?.hasPKC == true && destNode?.hasPKC == true -> DataPacket.PKC_CHANNEL_INDEX
|
||||
|
||||
else ->
|
||||
channelSet.value.settings
|
||||
.indexOfFirst { it.name.equals(ADMIN_CHANNEL_NAME, ignoreCase = true) }
|
||||
.coerceAtLeast(0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the heard-on channel for a non-admin request to [toNum]. Does NOT use PKI — protocol-level requests need
|
||||
* clear inner payloads.
|
||||
*/
|
||||
private fun getChannelIndex(toNum: Int): Int = nodeManager.nodeDBbyNodeNum[toNum]?.channel ?: 0
|
||||
|
||||
override fun sendData(p: DataPacket) {
|
||||
if (p.id == 0) p.id = generatePacketId()
|
||||
val bytes = p.bytes ?: ByteString.EMPTY
|
||||
require(p.dataType != 0) { "Port numbers must be non-zero!" }
|
||||
|
||||
// Use Wire extension for accurate size validation
|
||||
val data =
|
||||
Data(
|
||||
portnum = PortNum.fromValue(p.dataType) ?: PortNum.UNKNOWN_APP,
|
||||
payload = bytes,
|
||||
reply_id = p.replyId ?: 0,
|
||||
emoji = p.emoji,
|
||||
)
|
||||
|
||||
if (!Data.ADAPTER.isWithinSizeLimit(data, Constants.DATA_PAYLOAD_LEN.value)) {
|
||||
val actualSize = Data.ADAPTER.encodedSize(data)
|
||||
p.status = MessageStatus.ERROR
|
||||
error("Message too long: $actualSize bytes")
|
||||
} else {
|
||||
p.status = MessageStatus.QUEUED
|
||||
}
|
||||
|
||||
sendNow(p)
|
||||
}
|
||||
|
||||
private fun sendNow(p: DataPacket) {
|
||||
val meshPacket =
|
||||
buildMeshPacket(
|
||||
to = resolveNodeNum(p.to ?: DataPacket.ID_BROADCAST),
|
||||
id = p.id,
|
||||
wantAck = p.wantAck,
|
||||
hopLimit = if (p.hopLimit > 0) p.hopLimit else computeHopLimit(),
|
||||
channel = p.channel,
|
||||
decoded =
|
||||
Data(
|
||||
portnum = PortNum.fromValue(p.dataType) ?: PortNum.UNKNOWN_APP,
|
||||
payload = p.bytes ?: ByteString.EMPTY,
|
||||
reply_id = p.replyId ?: 0,
|
||||
emoji = p.emoji,
|
||||
),
|
||||
)
|
||||
p.time = nowMillis
|
||||
packetHandler.sendToRadio(meshPacket)
|
||||
}
|
||||
|
||||
override fun sendAdmin(destNum: Int, requestId: Int, wantResponse: Boolean, initFn: () -> AdminMessage) {
|
||||
val adminMsg = initFn().copy(session_passkey = sessionManager.getPasskey(destNum))
|
||||
val packet =
|
||||
buildAdminPacket(to = destNum, id = requestId, wantResponse = wantResponse, adminMessage = adminMsg)
|
||||
packetHandler.sendToRadio(packet)
|
||||
}
|
||||
|
||||
override suspend fun sendAdminAwait(
|
||||
destNum: Int,
|
||||
requestId: Int,
|
||||
wantResponse: Boolean,
|
||||
initFn: () -> AdminMessage,
|
||||
): Boolean {
|
||||
val adminMsg = initFn().copy(session_passkey = sessionManager.getPasskey(destNum))
|
||||
val packet =
|
||||
buildAdminPacket(to = destNum, id = requestId, wantResponse = wantResponse, adminMessage = adminMsg)
|
||||
return packetHandler.sendToRadioAndAwait(packet)
|
||||
}
|
||||
|
||||
override fun sendPosition(pos: ProtoPosition, destNum: Int?, wantResponse: Boolean) {
|
||||
val myNum = nodeManager.myNodeNum.value ?: return
|
||||
val idNum = destNum ?: myNum
|
||||
Logger.d { "Sending our position/time to=$idNum $pos" }
|
||||
|
||||
if (localConfig.value.position?.fixed_position != true) {
|
||||
nodeManager.handleReceivedPosition(myNum, myNum, pos, nowMillis)
|
||||
}
|
||||
|
||||
packetHandler.sendToRadio(
|
||||
buildMeshPacket(
|
||||
to = idNum,
|
||||
channel = if (destNum == null) 0 else getChannelIndex(destNum),
|
||||
priority = MeshPacket.Priority.BACKGROUND,
|
||||
decoded =
|
||||
Data(
|
||||
portnum = PortNum.POSITION_APP,
|
||||
payload = pos.encode().toByteString(),
|
||||
want_response = wantResponse,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun requestPosition(destNum: Int, currentPosition: Position) {
|
||||
val meshPosition =
|
||||
ProtoPosition(
|
||||
latitude_i = Position.degI(currentPosition.latitude),
|
||||
longitude_i = Position.degI(currentPosition.longitude),
|
||||
altitude = currentPosition.altitude,
|
||||
time = (nowMillis / 1000L).toInt(),
|
||||
)
|
||||
packetHandler.sendToRadio(
|
||||
buildMeshPacket(
|
||||
to = destNum,
|
||||
channel = getChannelIndex(destNum),
|
||||
priority = MeshPacket.Priority.BACKGROUND,
|
||||
decoded =
|
||||
Data(
|
||||
portnum = PortNum.POSITION_APP,
|
||||
payload = meshPosition.encode().toByteString(),
|
||||
want_response = true,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun setFixedPosition(destNum: Int, pos: Position) {
|
||||
val meshPos =
|
||||
ProtoPosition(
|
||||
latitude_i = Position.degI(pos.latitude),
|
||||
longitude_i = Position.degI(pos.longitude),
|
||||
altitude = pos.altitude,
|
||||
)
|
||||
sendAdmin(destNum) {
|
||||
if (pos != Position(0.0, 0.0, 0)) {
|
||||
AdminMessage(set_fixed_position = meshPos)
|
||||
} else {
|
||||
AdminMessage(remove_fixed_position = true)
|
||||
}
|
||||
}
|
||||
nodeManager.handleReceivedPosition(destNum, nodeManager.myNodeNum.value ?: 0, meshPos, nowMillis)
|
||||
}
|
||||
|
||||
override fun requestUserInfo(destNum: Int) {
|
||||
val myNum = nodeManager.myNodeNum.value ?: return
|
||||
val myNode = nodeManager.nodeDBbyNodeNum[myNum] ?: return
|
||||
packetHandler.sendToRadio(
|
||||
buildMeshPacket(
|
||||
to = destNum,
|
||||
channel = getChannelIndex(destNum),
|
||||
decoded =
|
||||
Data(
|
||||
portnum = PortNum.NODEINFO_APP,
|
||||
want_response = true,
|
||||
payload = myNode.user.encode().toByteString(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun requestTraceroute(requestId: Int, destNum: Int) {
|
||||
tracerouteHandler.recordStartTime(requestId)
|
||||
packetHandler.sendToRadio(
|
||||
buildMeshPacket(
|
||||
to = destNum,
|
||||
wantAck = true,
|
||||
id = requestId,
|
||||
channel = getChannelIndex(destNum),
|
||||
decoded = Data(portnum = PortNum.TRACEROUTE_APP, want_response = true, dest = destNum),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {
|
||||
val type = TelemetryType.entries.getOrNull(typeValue) ?: TelemetryType.DEVICE
|
||||
|
||||
val portNum: PortNum
|
||||
val payloadBytes: ByteString
|
||||
|
||||
if (type == TelemetryType.PAX) {
|
||||
portNum = PortNum.PAXCOUNTER_APP
|
||||
payloadBytes = Paxcount().encode().toByteString()
|
||||
} else {
|
||||
portNum = PortNum.TELEMETRY_APP
|
||||
payloadBytes =
|
||||
Telemetry(
|
||||
device_metrics = if (type == TelemetryType.DEVICE) DeviceMetrics() else null,
|
||||
environment_metrics = if (type == TelemetryType.ENVIRONMENT) EnvironmentMetrics() else null,
|
||||
air_quality_metrics = if (type == TelemetryType.AIR_QUALITY) AirQualityMetrics() else null,
|
||||
power_metrics = if (type == TelemetryType.POWER) PowerMetrics() else null,
|
||||
local_stats = if (type == TelemetryType.LOCAL_STATS) LocalStats() else null,
|
||||
host_metrics = if (type == TelemetryType.HOST) HostMetrics() else null,
|
||||
)
|
||||
.encode()
|
||||
.toByteString()
|
||||
}
|
||||
|
||||
packetHandler.sendToRadio(
|
||||
buildMeshPacket(
|
||||
to = destNum,
|
||||
id = requestId,
|
||||
channel = getChannelIndex(destNum),
|
||||
decoded = Data(portnum = portNum, payload = payloadBytes, want_response = true, dest = destNum),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun requestNeighborInfo(requestId: Int, destNum: Int) {
|
||||
neighborInfoHandler.recordStartTime(requestId)
|
||||
val myNum = nodeManager.myNodeNum.value ?: 0
|
||||
if (destNum == myNum) {
|
||||
val neighborInfoToSend =
|
||||
neighborInfoHandler.lastNeighborInfo
|
||||
?: run {
|
||||
val oneHour = 1.hours.inWholeMinutes.toInt()
|
||||
Logger.d { "No stored neighbor info from connected radio, sending dummy data" }
|
||||
NeighborInfo(
|
||||
node_id = myNum,
|
||||
last_sent_by_id = myNum,
|
||||
node_broadcast_interval_secs = oneHour,
|
||||
neighbors =
|
||||
listOf(
|
||||
Neighbor(
|
||||
node_id = 0, // Dummy node ID that can be intercepted
|
||||
snr = 0f,
|
||||
last_rx_time = (nowMillis / 1000L).toInt(),
|
||||
node_broadcast_interval_secs = oneHour,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Send the neighbor info from our connected radio to ourselves (simulated)
|
||||
packetHandler.sendToRadio(
|
||||
buildMeshPacket(
|
||||
to = destNum,
|
||||
wantAck = true,
|
||||
id = requestId,
|
||||
channel = getChannelIndex(destNum),
|
||||
decoded =
|
||||
Data(
|
||||
portnum = PortNum.NEIGHBORINFO_APP,
|
||||
payload = neighborInfoToSend.encode().toByteString(),
|
||||
want_response = true,
|
||||
),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
// Send request to remote
|
||||
packetHandler.sendToRadio(
|
||||
buildMeshPacket(
|
||||
to = destNum,
|
||||
wantAck = true,
|
||||
id = requestId,
|
||||
channel = getChannelIndex(destNum),
|
||||
decoded = Data(portnum = PortNum.NEIGHBORINFO_APP, want_response = true, dest = destNum),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun resolveNodeNum(toId: String): Int = when (toId) {
|
||||
DataPacket.ID_BROADCAST -> DataPacket.NODENUM_BROADCAST
|
||||
|
||||
else -> {
|
||||
val numericNum =
|
||||
if (toId.startsWith(NODE_ID_PREFIX)) {
|
||||
toId.substring(NODE_ID_START_INDEX).toLongOrNull(HEX_RADIX)?.toInt()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
numericNum
|
||||
?: nodeManager.nodeDBbyID[toId]?.num
|
||||
?: throw IllegalArgumentException("Unknown node ID $toId")
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildMeshPacket(
|
||||
to: Int,
|
||||
wantAck: Boolean = false,
|
||||
id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one
|
||||
hopLimit: Int = 0,
|
||||
channel: Int = 0,
|
||||
priority: MeshPacket.Priority = MeshPacket.Priority.UNSET,
|
||||
decoded: Data,
|
||||
): MeshPacket {
|
||||
val actualHopLimit = if (hopLimit > 0) hopLimit else computeHopLimit()
|
||||
|
||||
var pkiEncrypted = false
|
||||
var publicKey: ByteString = ByteString.EMPTY
|
||||
var actualChannel = channel
|
||||
|
||||
if (channel == DataPacket.PKC_CHANNEL_INDEX) {
|
||||
pkiEncrypted = true
|
||||
val destNode = nodeManager.nodeDBbyNodeNum[to]
|
||||
// Resolve the public key using the same fallback as Node.hasPKC:
|
||||
// standalone publicKey (populated after Room round-trip) first, then
|
||||
// the embedded user.public_key (always available in-memory).
|
||||
publicKey = destNode?.let { it.publicKey ?: it.user.public_key } ?: ByteString.EMPTY
|
||||
if (publicKey.size == 0) {
|
||||
Logger.w { "buildMeshPacket: no public key for node ${to.toUInt()}, PKI encryption will fail" }
|
||||
}
|
||||
actualChannel = 0
|
||||
}
|
||||
|
||||
return MeshPacket(
|
||||
from = nodeManager.myNodeNum.value ?: 0,
|
||||
to = to,
|
||||
id = id,
|
||||
want_ack = wantAck,
|
||||
hop_limit = actualHopLimit,
|
||||
hop_start = actualHopLimit,
|
||||
priority = priority,
|
||||
pki_encrypted = pkiEncrypted,
|
||||
public_key = publicKey,
|
||||
channel = actualChannel,
|
||||
decoded = decoded,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildAdminPacket(
|
||||
to: Int,
|
||||
id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one
|
||||
wantResponse: Boolean = false,
|
||||
adminMessage: AdminMessage,
|
||||
): MeshPacket =
|
||||
buildMeshPacket(
|
||||
to = to,
|
||||
id = id,
|
||||
wantAck = true,
|
||||
channel = getAdminChannelIndex(to),
|
||||
priority = MeshPacket.Priority.RELIABLE,
|
||||
decoded =
|
||||
Data(
|
||||
want_response = wantResponse,
|
||||
portnum = PortNum.ADMIN_APP,
|
||||
payload = adminMessage.encode().toByteString(),
|
||||
),
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val PACKET_ID_MASK = 0xffffffffL
|
||||
private const val PACKET_ID_SHIFT_BITS = 32
|
||||
|
||||
private const val ADMIN_CHANNEL_NAME = "admin"
|
||||
private const val NODE_ID_PREFIX = "!"
|
||||
private const val NODE_ID_START_INDEX = 1
|
||||
private const val HEX_RADIX = 16
|
||||
|
||||
private const val DEFAULT_HOP_LIMIT = 3
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.atomicfu.atomic
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.repository.PacketHandler
|
||||
import org.meshtastic.proto.Heartbeat
|
||||
import org.meshtastic.proto.ToRadio
|
||||
|
||||
/**
|
||||
* Centralized heartbeat sender for the data layer.
|
||||
*
|
||||
* Consolidates heartbeat nonce management into a single monotonically increasing counter, preventing the firmware's
|
||||
* per-connection duplicate-write filter (byte-level memcmp) from silently dropping consecutive heartbeats.
|
||||
*
|
||||
* This is distinct from [org.meshtastic.core.network.transport.HeartbeatSender], which operates at the transport layer
|
||||
* with raw byte encoding. This class works at the protobuf/data layer through [PacketHandler].
|
||||
*/
|
||||
@Single
|
||||
class DataLayerHeartbeatSender(private val packetHandler: PacketHandler) {
|
||||
private val nonce = atomic(0)
|
||||
|
||||
/**
|
||||
* Enqueues a heartbeat with a unique nonce.
|
||||
*
|
||||
* @param tag descriptive label for log messages (e.g. "pre-handshake", "inter-stage")
|
||||
*/
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
fun sendHeartbeat(tag: String = "handshake") {
|
||||
try {
|
||||
val n = nonce.incrementAndGet()
|
||||
packetHandler.sendToRadio(ToRadio(heartbeat = Heartbeat(nonce = n)))
|
||||
Logger.d { "[$tag] Heartbeat enqueued (nonce=$n)" }
|
||||
} catch (e: Exception) {
|
||||
Logger.w(e) { "[$tag] Failed to enqueue heartbeat; proceeding" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,401 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.database.DatabaseManager
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.ignoreExceptionSuspend
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.common.util.safeCatching
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.MeshUser
|
||||
import org.meshtastic.core.model.MessageStatus
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.Reaction
|
||||
import org.meshtastic.core.model.service.ServiceAction
|
||||
import org.meshtastic.core.repository.CommandSender
|
||||
import org.meshtastic.core.repository.DataPair
|
||||
import org.meshtastic.core.repository.MeshActionHandler
|
||||
import org.meshtastic.core.repository.MeshDataHandler
|
||||
import org.meshtastic.core.repository.MeshPrefs
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
import org.meshtastic.core.repository.UiPrefs
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
import org.meshtastic.proto.Channel
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
import org.meshtastic.proto.OTAMode
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.User
|
||||
|
||||
@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod")
|
||||
@Single
|
||||
class MeshActionHandlerImpl(
|
||||
private val nodeManager: NodeManager,
|
||||
private val commandSender: CommandSender,
|
||||
private val packetRepository: Lazy<PacketRepository>,
|
||||
private val serviceBroadcasts: ServiceBroadcasts,
|
||||
private val dataHandler: Lazy<MeshDataHandler>,
|
||||
private val analytics: PlatformAnalytics,
|
||||
private val meshPrefs: MeshPrefs,
|
||||
private val uiPrefs: UiPrefs,
|
||||
private val databaseManager: DatabaseManager,
|
||||
private val notificationManager: NotificationManager,
|
||||
private val radioConfigRepository: RadioConfigRepository,
|
||||
@Named("ServiceScope") private val scope: CoroutineScope,
|
||||
) : MeshActionHandler {
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_REBOOT_DELAY = 5
|
||||
private const val EMOJI_INDICATOR = 1
|
||||
}
|
||||
|
||||
override suspend fun onServiceAction(action: ServiceAction) {
|
||||
Logger.d { "ServiceAction dispatched: ${action::class.simpleName}" }
|
||||
ignoreExceptionSuspend {
|
||||
val myNodeNum = nodeManager.myNodeNum.value
|
||||
if (myNodeNum == null) {
|
||||
Logger.w { "MeshActionHandlerImpl: myNodeNum is null, skipping ServiceAction!" }
|
||||
if (action is ServiceAction.SendContact) {
|
||||
action.result.complete(false)
|
||||
}
|
||||
return@ignoreExceptionSuspend
|
||||
}
|
||||
when (action) {
|
||||
is ServiceAction.Favorite -> handleFavorite(action, myNodeNum)
|
||||
|
||||
is ServiceAction.Ignore -> handleIgnore(action, myNodeNum)
|
||||
|
||||
is ServiceAction.Mute -> handleMute(action, myNodeNum)
|
||||
|
||||
is ServiceAction.Reaction -> handleReaction(action, myNodeNum)
|
||||
|
||||
is ServiceAction.ImportContact -> handleImportContact(action, myNodeNum)
|
||||
|
||||
is ServiceAction.SendContact -> {
|
||||
val accepted =
|
||||
safeCatching {
|
||||
commandSender.sendAdminAwait(myNodeNum) { AdminMessage(add_contact = action.contact) }
|
||||
}
|
||||
.getOrDefault(false)
|
||||
action.result.complete(accepted)
|
||||
}
|
||||
|
||||
is ServiceAction.GetDeviceMetadata -> {
|
||||
commandSender.sendAdmin(action.destNum, wantResponse = true) {
|
||||
AdminMessage(get_device_metadata_request = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleFavorite(action: ServiceAction.Favorite, myNodeNum: Int) {
|
||||
val node = action.node
|
||||
commandSender.sendAdmin(myNodeNum) {
|
||||
if (node.isFavorite) {
|
||||
AdminMessage(remove_favorite_node = node.num)
|
||||
} else {
|
||||
AdminMessage(set_favorite_node = node.num)
|
||||
}
|
||||
}
|
||||
nodeManager.updateNode(node.num) { it.copy(isFavorite = !node.isFavorite) }
|
||||
}
|
||||
|
||||
private fun handleIgnore(action: ServiceAction.Ignore, myNodeNum: Int) {
|
||||
val node = action.node
|
||||
val newIgnoredStatus = !node.isIgnored
|
||||
commandSender.sendAdmin(myNodeNum) {
|
||||
if (newIgnoredStatus) {
|
||||
AdminMessage(set_ignored_node = node.num)
|
||||
} else {
|
||||
AdminMessage(remove_ignored_node = node.num)
|
||||
}
|
||||
}
|
||||
nodeManager.updateNode(node.num) { it.copy(isIgnored = newIgnoredStatus) }
|
||||
scope.handledLaunch { packetRepository.value.updateFilteredBySender(node.user.id, newIgnoredStatus) }
|
||||
}
|
||||
|
||||
private fun handleMute(action: ServiceAction.Mute, myNodeNum: Int) {
|
||||
val node = action.node
|
||||
commandSender.sendAdmin(myNodeNum) { AdminMessage(toggle_muted_node = node.num) }
|
||||
nodeManager.updateNode(node.num) { it.copy(isMuted = !node.isMuted) }
|
||||
}
|
||||
|
||||
private fun handleReaction(action: ServiceAction.Reaction, myNodeNum: Int) {
|
||||
val channel = action.contactKey[0].digitToInt()
|
||||
val destId = action.contactKey.substring(1)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
to = destId,
|
||||
dataType = PortNum.TEXT_MESSAGE_APP.value,
|
||||
bytes = action.emoji.encodeToByteArray().toByteString(),
|
||||
channel = channel,
|
||||
replyId = action.replyId,
|
||||
wantAck = true,
|
||||
emoji = EMOJI_INDICATOR,
|
||||
)
|
||||
.apply { from = nodeManager.getMyId().takeIf { it.isNotEmpty() } ?: DataPacket.ID_LOCAL }
|
||||
commandSender.sendData(dataPacket)
|
||||
rememberReaction(action, dataPacket.id, myNodeNum)
|
||||
}
|
||||
|
||||
private fun handleImportContact(action: ServiceAction.ImportContact, myNodeNum: Int) {
|
||||
val verifiedContact = action.contact.copy(manually_verified = true)
|
||||
commandSender.sendAdmin(myNodeNum) { AdminMessage(add_contact = verifiedContact) }
|
||||
nodeManager.handleReceivedUser(
|
||||
verifiedContact.node_num,
|
||||
verifiedContact.user ?: User(),
|
||||
manuallyVerified = true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun rememberReaction(action: ServiceAction.Reaction, packetId: Int, myNodeNum: Int) {
|
||||
scope.handledLaunch {
|
||||
val user = nodeManager.nodeDBbyNodeNum[myNodeNum]?.user ?: User(id = nodeManager.getMyId())
|
||||
val reaction =
|
||||
Reaction(
|
||||
replyId = action.replyId,
|
||||
user = user,
|
||||
emoji = action.emoji,
|
||||
timestamp = nowMillis,
|
||||
snr = 0f,
|
||||
rssi = 0,
|
||||
hopsAway = 0,
|
||||
packetId = packetId,
|
||||
status = MessageStatus.QUEUED,
|
||||
to = action.contactKey.substring(1),
|
||||
channel = action.contactKey[0].digitToInt(),
|
||||
)
|
||||
packetRepository.value.insertReaction(reaction, myNodeNum)
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleSetOwner(u: MeshUser, myNodeNum: Int) {
|
||||
Logger.d { "Setting owner: longName=${u.longName}, shortName=${u.shortName}" }
|
||||
val newUser = User(id = u.id, long_name = u.longName, short_name = u.shortName, is_licensed = u.isLicensed)
|
||||
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_owner = newUser) }
|
||||
nodeManager.handleReceivedUser(myNodeNum, newUser)
|
||||
}
|
||||
|
||||
override fun handleSend(p: DataPacket, myNodeNum: Int) {
|
||||
commandSender.sendData(p)
|
||||
serviceBroadcasts.broadcastMessageStatus(p.id, p.status ?: MessageStatus.UNKNOWN)
|
||||
dataHandler.value.rememberDataPacket(p, myNodeNum, false)
|
||||
val bytes = p.bytes ?: ByteString.EMPTY
|
||||
analytics.track("data_send", DataPair("num_bytes", bytes.size), DataPair("type", p.dataType))
|
||||
}
|
||||
|
||||
override fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int) {
|
||||
if (destNum != myNodeNum) {
|
||||
val provideLocation = uiPrefs.shouldProvideNodeLocation(myNodeNum).value
|
||||
val currentPosition =
|
||||
when {
|
||||
provideLocation && position.isValid() -> position
|
||||
|
||||
provideLocation ->
|
||||
nodeManager.nodeDBbyNodeNum[myNodeNum]?.position?.let { Position(it) }?.takeIf { it.isValid() }
|
||||
?: Position(0.0, 0.0, 0)
|
||||
|
||||
else -> Position(0.0, 0.0, 0)
|
||||
}
|
||||
commandSender.requestPosition(destNum, currentPosition)
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleRemoveByNodenum(nodeNum: Int, requestId: Int, myNodeNum: Int) {
|
||||
nodeManager.removeByNodenum(nodeNum)
|
||||
commandSender.sendAdmin(myNodeNum, requestId) { AdminMessage(remove_by_nodenum = nodeNum) }
|
||||
}
|
||||
|
||||
override fun handleSetRemoteOwner(id: Int, destNum: Int, payload: ByteArray) {
|
||||
val u = User.ADAPTER.decode(payload)
|
||||
commandSender.sendAdmin(destNum, id) { AdminMessage(set_owner = u) }
|
||||
nodeManager.handleReceivedUser(destNum, u)
|
||||
}
|
||||
|
||||
override fun handleGetRemoteOwner(id: Int, destNum: Int) {
|
||||
commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_owner_request = true) }
|
||||
}
|
||||
|
||||
override fun handleSetConfig(payload: ByteArray, myNodeNum: Int) {
|
||||
val c = Config.ADAPTER.decode(payload)
|
||||
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_config = c) }
|
||||
// Optimistically persist the config locally so CommandSender picks up
|
||||
// the new values (e.g. hop_limit) immediately instead of waiting for
|
||||
// the next want_config handshake.
|
||||
scope.handledLaunch { radioConfigRepository.setLocalConfig(c) }
|
||||
}
|
||||
|
||||
override fun handleSetRemoteConfig(id: Int, destNum: Int, payload: ByteArray) {
|
||||
val c = Config.ADAPTER.decode(payload)
|
||||
commandSender.sendAdmin(destNum, id) { AdminMessage(set_config = c) }
|
||||
// When targeting the local node, optimistically persist the config so the
|
||||
// UI reflects changes immediately (matching handleSetConfig behaviour).
|
||||
if (destNum == nodeManager.myNodeNum.value) {
|
||||
scope.handledLaunch { radioConfigRepository.setLocalConfig(c) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleGetRemoteConfig(id: Int, destNum: Int, config: Int) {
|
||||
commandSender.sendAdmin(destNum, id, wantResponse = true) {
|
||||
if (config == AdminMessage.ConfigType.SESSIONKEY_CONFIG.value) {
|
||||
AdminMessage(get_device_metadata_request = true)
|
||||
} else {
|
||||
AdminMessage(get_config_request = AdminMessage.ConfigType.fromValue(config))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleSetModuleConfig(id: Int, destNum: Int, payload: ByteArray) {
|
||||
val c = ModuleConfig.ADAPTER.decode(payload)
|
||||
commandSender.sendAdmin(destNum, id) { AdminMessage(set_module_config = c) }
|
||||
c.statusmessage?.let { sm -> nodeManager.updateNodeStatus(destNum, sm.node_status) }
|
||||
// Optimistically persist module config locally so the UI reflects the
|
||||
// new values immediately instead of waiting for the next want_config handshake.
|
||||
if (destNum == nodeManager.myNodeNum.value) {
|
||||
scope.handledLaunch { radioConfigRepository.setLocalModuleConfig(c) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleGetModuleConfig(id: Int, destNum: Int, config: Int) {
|
||||
commandSender.sendAdmin(destNum, id, wantResponse = true) {
|
||||
AdminMessage(get_module_config_request = AdminMessage.ModuleConfigType.fromValue(config))
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleSetRingtone(destNum: Int, ringtone: String) {
|
||||
commandSender.sendAdmin(destNum) { AdminMessage(set_ringtone_message = ringtone) }
|
||||
}
|
||||
|
||||
override fun handleGetRingtone(id: Int, destNum: Int) {
|
||||
commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_ringtone_request = true) }
|
||||
}
|
||||
|
||||
override fun handleSetCannedMessages(destNum: Int, messages: String) {
|
||||
commandSender.sendAdmin(destNum) { AdminMessage(set_canned_message_module_messages = messages) }
|
||||
}
|
||||
|
||||
override fun handleGetCannedMessages(id: Int, destNum: Int) {
|
||||
commandSender.sendAdmin(destNum, id, wantResponse = true) {
|
||||
AdminMessage(get_canned_message_module_messages_request = true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleSetChannel(payload: ByteArray?, myNodeNum: Int) {
|
||||
if (payload != null) {
|
||||
val c = Channel.ADAPTER.decode(payload)
|
||||
commandSender.sendAdmin(myNodeNum) { AdminMessage(set_channel = c) }
|
||||
// Optimistically persist the channel settings locally so the UI
|
||||
// reflects changes immediately instead of waiting for the next
|
||||
// want_config handshake.
|
||||
scope.handledLaunch { radioConfigRepository.updateChannelSettings(c) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleSetRemoteChannel(id: Int, destNum: Int, payload: ByteArray?) {
|
||||
if (payload != null) {
|
||||
val c = Channel.ADAPTER.decode(payload)
|
||||
commandSender.sendAdmin(destNum, id) { AdminMessage(set_channel = c) }
|
||||
// When targeting the local node, optimistically persist the channel so
|
||||
// the UI reflects changes immediately (matching handleSetChannel behaviour).
|
||||
if (destNum == nodeManager.myNodeNum.value) {
|
||||
scope.handledLaunch { radioConfigRepository.updateChannelSettings(c) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleGetRemoteChannel(id: Int, destNum: Int, index: Int) {
|
||||
commandSender.sendAdmin(destNum, id, wantResponse = true) { AdminMessage(get_channel_request = index + 1) }
|
||||
}
|
||||
|
||||
override fun handleRequestNeighborInfo(requestId: Int, destNum: Int) {
|
||||
commandSender.requestNeighborInfo(requestId, destNum)
|
||||
}
|
||||
|
||||
override fun handleBeginEditSettings(destNum: Int) {
|
||||
commandSender.sendAdmin(destNum) { AdminMessage(begin_edit_settings = true) }
|
||||
}
|
||||
|
||||
override fun handleCommitEditSettings(destNum: Int) {
|
||||
commandSender.sendAdmin(destNum) { AdminMessage(commit_edit_settings = true) }
|
||||
}
|
||||
|
||||
override fun handleRebootToDfu(destNum: Int) {
|
||||
commandSender.sendAdmin(destNum) { AdminMessage(enter_dfu_mode_request = true) }
|
||||
}
|
||||
|
||||
override fun handleRequestTelemetry(requestId: Int, destNum: Int, type: Int) {
|
||||
commandSender.requestTelemetry(requestId, destNum, type)
|
||||
}
|
||||
|
||||
override fun handleRequestShutdown(requestId: Int, destNum: Int) {
|
||||
commandSender.sendAdmin(destNum, requestId) { AdminMessage(shutdown_seconds = DEFAULT_REBOOT_DELAY) }
|
||||
}
|
||||
|
||||
override fun handleRequestReboot(requestId: Int, destNum: Int) {
|
||||
Logger.i { "Reboot requested for node $destNum" }
|
||||
commandSender.sendAdmin(destNum, requestId) { AdminMessage(reboot_seconds = DEFAULT_REBOOT_DELAY) }
|
||||
}
|
||||
|
||||
override fun handleRequestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {
|
||||
val otaMode = OTAMode.fromValue(mode) ?: OTAMode.NO_REBOOT_OTA
|
||||
val otaEvent =
|
||||
AdminMessage.OTAEvent(reboot_ota_mode = otaMode, ota_hash = hash?.toByteString() ?: ByteString.EMPTY)
|
||||
commandSender.sendAdmin(destNum, requestId) { AdminMessage(ota_request = otaEvent) }
|
||||
}
|
||||
|
||||
override fun handleRequestFactoryReset(requestId: Int, destNum: Int) {
|
||||
Logger.i { "Factory reset requested for node $destNum" }
|
||||
commandSender.sendAdmin(destNum, requestId) { AdminMessage(factory_reset_device = 1) }
|
||||
}
|
||||
|
||||
override fun handleRequestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean) {
|
||||
commandSender.sendAdmin(destNum, requestId) { AdminMessage(nodedb_reset = preserveFavorites) }
|
||||
}
|
||||
|
||||
override fun handleGetDeviceConnectionStatus(requestId: Int, destNum: Int) {
|
||||
commandSender.sendAdmin(destNum, requestId, wantResponse = true) {
|
||||
AdminMessage(get_device_connection_status_request = true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleUpdateLastAddress(deviceAddr: String?) {
|
||||
val currentAddr = meshPrefs.deviceAddress.value
|
||||
if (deviceAddr != currentAddr) {
|
||||
Logger.i { "Device address changed, switching database and clearing node DB" }
|
||||
meshPrefs.setDeviceAddress(deviceAddr)
|
||||
scope.handledLaunch {
|
||||
nodeManager.clear()
|
||||
databaseManager.switchActiveDatabase(deviceAddr)
|
||||
notificationManager.cancelAll()
|
||||
nodeManager.loadCachedNodeDB()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.atomicfu.atomic
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DeviceVersion
|
||||
import org.meshtastic.core.repository.HandshakeConstants
|
||||
import org.meshtastic.core.repository.MeshConfigFlowManager
|
||||
import org.meshtastic.core.repository.MeshConnectionManager
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.NotificationPrefs
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.proto.DeviceMetadata
|
||||
import org.meshtastic.proto.FileInfo
|
||||
import org.meshtastic.proto.FirmwareEdition
|
||||
import org.meshtastic.proto.HardwareModel
|
||||
import org.meshtastic.proto.NodeInfo
|
||||
import org.meshtastic.core.model.MyNodeInfo as SharedMyNodeInfo
|
||||
import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo
|
||||
|
||||
@Suppress("LongParameterList", "TooManyFunctions")
|
||||
@Single
|
||||
class MeshConfigFlowManagerImpl(
|
||||
private val nodeManager: NodeManager,
|
||||
private val connectionManager: Lazy<MeshConnectionManager>,
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val radioConfigRepository: RadioConfigRepository,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val serviceBroadcasts: ServiceBroadcasts,
|
||||
private val analytics: PlatformAnalytics,
|
||||
private val heartbeatSender: DataLayerHeartbeatSender,
|
||||
private val notificationPrefs: NotificationPrefs,
|
||||
@Named("ServiceScope") private val scope: CoroutineScope,
|
||||
) : MeshConfigFlowManager {
|
||||
private val wantConfigDelay = 100L
|
||||
|
||||
/** Monotonically increasing generation so async clears from a stale handshake are discarded. */
|
||||
private val handshakeGeneration = atomic(0L)
|
||||
|
||||
/**
|
||||
* Type-safe handshake state machine. Each state carries exactly the data that is valid during that phase,
|
||||
* eliminating the possibility of accessing stale or uninitialized fields.
|
||||
*
|
||||
* Guards [handleConfigComplete] so that duplicate or out-of-order `config_complete_id` signals from the firmware
|
||||
* cannot trigger the wrong stage handler or drive the state machine backward.
|
||||
*/
|
||||
private sealed class HandshakeState {
|
||||
/** No handshake in progress. */
|
||||
data object Idle : HandshakeState()
|
||||
|
||||
/**
|
||||
* Stage 1: receiving device config, module config, channels, and metadata.
|
||||
*
|
||||
* [rawMyNodeInfo] arrives first (my_info packet); [metadata] may arrive shortly after. Both are consumed
|
||||
* together by [buildMyNodeInfo] at Stage 1 completion.
|
||||
*/
|
||||
data class ReceivingConfig(val rawMyNodeInfo: ProtoMyNodeInfo, val metadata: DeviceMetadata? = null) :
|
||||
HandshakeState()
|
||||
|
||||
/**
|
||||
* Stage 2: receiving node-info packets from the firmware.
|
||||
*
|
||||
* [myNodeInfo] was committed at the Stage 1→2 transition. [nodes] accumulates [NodeInfo] packets until
|
||||
* `config_complete_id` arrives.
|
||||
*/
|
||||
data class ReceivingNodeInfo(val myNodeInfo: SharedMyNodeInfo, val nodes: List<NodeInfo> = emptyList()) :
|
||||
HandshakeState()
|
||||
|
||||
/** Both stages finished. The app is fully connected. */
|
||||
data class Complete(val myNodeInfo: SharedMyNodeInfo) : HandshakeState()
|
||||
}
|
||||
|
||||
private var handshakeState: HandshakeState = HandshakeState.Idle
|
||||
|
||||
override val newNodeCount: Int
|
||||
get() = (handshakeState as? HandshakeState.ReceivingNodeInfo)?.nodes?.size ?: 0
|
||||
|
||||
override fun handleConfigComplete(configCompleteId: Int) {
|
||||
val state = handshakeState
|
||||
when (configCompleteId) {
|
||||
HandshakeConstants.CONFIG_NONCE -> {
|
||||
if (state !is HandshakeState.ReceivingConfig) {
|
||||
Logger.w { "Ignoring Stage 1 config_complete in state=$state" }
|
||||
return
|
||||
}
|
||||
handleConfigOnlyComplete(state)
|
||||
}
|
||||
|
||||
HandshakeConstants.NODE_INFO_NONCE -> {
|
||||
if (state !is HandshakeState.ReceivingNodeInfo) {
|
||||
Logger.w { "Ignoring Stage 2 config_complete in state=$state" }
|
||||
return
|
||||
}
|
||||
handleNodeInfoComplete(state)
|
||||
}
|
||||
|
||||
else -> Logger.w { "Config complete id mismatch: $configCompleteId" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleConfigOnlyComplete(state: HandshakeState.ReceivingConfig) {
|
||||
Logger.i { "Config-only complete (Stage 1)" }
|
||||
|
||||
val finalizedInfo = buildMyNodeInfo(state.rawMyNodeInfo, state.metadata)
|
||||
if (finalizedInfo == null) {
|
||||
Logger.w { "Stage 1 failed: could not build MyNodeInfo, retrying Stage 1" }
|
||||
handshakeState = HandshakeState.Idle
|
||||
scope.handledLaunch {
|
||||
delay(wantConfigDelay)
|
||||
connectionManager.value.startConfigOnly()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Warn if firmware is below the absolute minimum supported version.
|
||||
// The UI layer already enforces this via FirmwareVersionCheck, so we just log here
|
||||
// for diagnostics rather than hard-disconnecting.
|
||||
finalizedInfo.firmwareVersion?.let { fwVersion ->
|
||||
if (DeviceVersion(fwVersion) < DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION)) {
|
||||
Logger.w {
|
||||
"Firmware $fwVersion is below minimum ${DeviceVersion.ABS_MIN_FW_VERSION} — " +
|
||||
"protocol incompatibilities may occur"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handshakeState = HandshakeState.ReceivingNodeInfo(myNodeInfo = finalizedInfo)
|
||||
Logger.i { "myNodeInfo committed (nodeNum=${finalizedInfo.myNodeNum})" }
|
||||
connectionManager.value.onRadioConfigLoaded()
|
||||
|
||||
scope.handledLaunch {
|
||||
delay(wantConfigDelay)
|
||||
heartbeatSender.sendHeartbeat("inter-stage")
|
||||
delay(wantConfigDelay)
|
||||
Logger.i { "Requesting NodeInfo (Stage 2)" }
|
||||
connectionManager.value.startNodeInfoOnly()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNodeInfoComplete(state: HandshakeState.ReceivingNodeInfo) {
|
||||
Logger.i { "NodeInfo complete (Stage 2)" }
|
||||
|
||||
val info = state.myNodeInfo
|
||||
|
||||
// Transition state immediately (synchronously) to prevent duplicate handling.
|
||||
// The async work below (DB writes, broadcasts) proceeds without the guard.
|
||||
// Because nodes is now immutable, no snapshot is needed — state.nodes IS the snapshot.
|
||||
// Any stall-guard retry that re-enters handleNodeInfo will see Complete state and be ignored.
|
||||
handshakeState = HandshakeState.Complete(myNodeInfo = info)
|
||||
|
||||
val entities =
|
||||
state.nodes.mapNotNull { nodeInfo ->
|
||||
nodeManager.installNodeInfo(nodeInfo, withBroadcast = false)
|
||||
nodeManager.nodeDBbyNodeNum[nodeInfo.num]
|
||||
?: run {
|
||||
Logger.w { "Node ${nodeInfo.num} missing from DB after installNodeInfo; skipping" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
scope.handledLaunch {
|
||||
nodeRepository.installConfig(info, entities)
|
||||
analytics.setDeviceAttributes(info.firmwareVersion ?: "unknown", info.model ?: "unknown")
|
||||
nodeManager.setNodeDbReady(true)
|
||||
nodeManager.setAllowNodeDbWrites(true)
|
||||
serviceRepository.setConnectionState(ConnectionState.Connected)
|
||||
serviceBroadcasts.broadcastConnection()
|
||||
connectionManager.value.onNodeDbReady()
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleMyInfo(myInfo: ProtoMyNodeInfo) {
|
||||
Logger.i { "MyNodeInfo received: ${myInfo.my_node_num}" }
|
||||
|
||||
// Transition to Stage 1, discarding any stale data from a prior interrupted handshake.
|
||||
handshakeState = HandshakeState.ReceivingConfig(rawMyNodeInfo = myInfo)
|
||||
nodeManager.setMyNodeNum(myInfo.my_node_num)
|
||||
nodeManager.setFirmwareEdition(myInfo.firmware_edition)
|
||||
applyEventFirmwareNotificationDefaults(myInfo.firmware_edition)
|
||||
|
||||
// Bump the generation so that a pending clear from a prior (interrupted) handshake
|
||||
// will see a stale snapshot and skip its writes, preventing it from wiping config
|
||||
// that was saved by this (newer) handshake's incoming packets.
|
||||
val gen = handshakeGeneration.incrementAndGet()
|
||||
|
||||
// Clear persisted radio config so the new handshake starts from a clean slate.
|
||||
// DataStore serializes its own writes, so the clear will precede subsequent
|
||||
// setLocalConfig / updateChannelSettings calls dispatched by later packets in this
|
||||
// session (handleFromRadio processes packets sequentially, so later dispatches always
|
||||
// occur after this one returns).
|
||||
scope.handledLaunch {
|
||||
if (handshakeGeneration.value != gen) return@handledLaunch // Stale handshake; skip.
|
||||
radioConfigRepository.clearChannelSet()
|
||||
radioConfigRepository.clearLocalConfig()
|
||||
radioConfigRepository.clearLocalModuleConfig()
|
||||
radioConfigRepository.clearDeviceUIConfig()
|
||||
radioConfigRepository.clearFileManifest()
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleLocalMetadata(metadata: DeviceMetadata) {
|
||||
Logger.i { "Local Metadata received: ${metadata.firmware_version}" }
|
||||
val state = handshakeState
|
||||
if (state is HandshakeState.ReceivingConfig) {
|
||||
handshakeState = state.copy(metadata = metadata)
|
||||
// Persist the metadata immediately — buildMyNodeInfo() reads it at Stage 1 complete,
|
||||
// but the DB write does not need to wait until then.
|
||||
if (metadata != DeviceMetadata()) {
|
||||
scope.handledLaunch { nodeRepository.insertMetadata(state.rawMyNodeInfo.my_node_num, metadata) }
|
||||
}
|
||||
} else {
|
||||
Logger.w { "Ignoring metadata outside Stage 1 (state=$state)" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleNodeInfo(info: NodeInfo) {
|
||||
val state = handshakeState
|
||||
if (state is HandshakeState.ReceivingNodeInfo) {
|
||||
handshakeState = state.copy(nodes = state.nodes + info)
|
||||
} else {
|
||||
Logger.w { "Ignoring NodeInfo outside Stage 2 (state=$state)" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleFileInfo(info: FileInfo) {
|
||||
Logger.d { "FileInfo received: ${info.file_name} (${info.size_bytes} bytes)" }
|
||||
scope.handledLaunch { radioConfigRepository.addFileInfo(info) }
|
||||
}
|
||||
|
||||
override fun triggerWantConfig() {
|
||||
connectionManager.value.startConfigOnly()
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a [SharedMyNodeInfo] from the raw proto and optional firmware metadata. Pure function — no side effects.
|
||||
* Returns null only if construction throws.
|
||||
*/
|
||||
private fun buildMyNodeInfo(raw: ProtoMyNodeInfo, metadata: DeviceMetadata?): SharedMyNodeInfo? = try {
|
||||
with(raw) {
|
||||
SharedMyNodeInfo(
|
||||
myNodeNum = my_node_num,
|
||||
hasGPS = false,
|
||||
model =
|
||||
when (val hwModel = metadata?.hw_model) {
|
||||
null,
|
||||
HardwareModel.UNSET,
|
||||
-> null
|
||||
|
||||
else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase()
|
||||
},
|
||||
firmwareVersion = metadata?.firmware_version?.takeIf { it.isNotBlank() },
|
||||
couldUpdate = false,
|
||||
shouldUpdate = false,
|
||||
currentPacketId = 0L,
|
||||
messageTimeoutMsec = 300000,
|
||||
minAppVersion = min_app_version,
|
||||
maxChannels = 8,
|
||||
hasWifi = metadata?.hasWifi == true,
|
||||
channelUtilization = 0f,
|
||||
airUtilTx = 0f,
|
||||
deviceId = device_id.utf8(),
|
||||
pioEnv = pio_env.ifEmpty { null },
|
||||
)
|
||||
}
|
||||
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
|
||||
Logger.e(ex) { "Failed to build MyNodeInfo" }
|
||||
null
|
||||
}
|
||||
|
||||
private fun applyEventFirmwareNotificationDefaults(edition: FirmwareEdition) {
|
||||
if (edition != FirmwareEdition.VANILLA) {
|
||||
if (!notificationPrefs.nodeEventsAutoDisabledForEvent.value) {
|
||||
notificationPrefs.setNodeEventsEnabled(false)
|
||||
notificationPrefs.setNodeEventsAutoDisabledForEvent(true)
|
||||
}
|
||||
} else {
|
||||
if (notificationPrefs.nodeEventsAutoDisabledForEvent.value) {
|
||||
notificationPrefs.setNodeEventsEnabled(true)
|
||||
notificationPrefs.setNodeEventsAutoDisabledForEvent(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,438 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.DeviceType
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.model.TelemetryType
|
||||
import org.meshtastic.core.repository.AppWidgetUpdater
|
||||
import org.meshtastic.core.repository.DataPair
|
||||
import org.meshtastic.core.repository.HandshakeConstants
|
||||
import org.meshtastic.core.repository.HistoryManager
|
||||
import org.meshtastic.core.repository.MeshConnectionManager
|
||||
import org.meshtastic.core.repository.MeshLocationManager
|
||||
import org.meshtastic.core.repository.MeshServiceNotifications
|
||||
import org.meshtastic.core.repository.MeshWorkerManager
|
||||
import org.meshtastic.core.repository.MqttManager
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.PacketHandler
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.repository.SessionManager
|
||||
import org.meshtastic.core.repository.UiPrefs
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.Telemetry
|
||||
import org.meshtastic.proto.ToRadio
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.DurationUnit
|
||||
|
||||
@Suppress("LongParameterList", "TooManyFunctions")
|
||||
@Single
|
||||
class MeshConnectionManagerImpl(
|
||||
private val radioInterfaceService: RadioInterfaceService,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val serviceBroadcasts: ServiceBroadcasts,
|
||||
private val serviceNotifications: MeshServiceNotifications,
|
||||
private val uiPrefs: UiPrefs,
|
||||
private val packetHandler: PacketHandler,
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val locationManager: MeshLocationManager,
|
||||
private val mqttManager: MqttManager,
|
||||
private val historyManager: HistoryManager,
|
||||
private val radioConfigRepository: RadioConfigRepository,
|
||||
private val radioController: RadioController,
|
||||
private val sessionManager: SessionManager,
|
||||
private val nodeManager: NodeManager,
|
||||
private val analytics: PlatformAnalytics,
|
||||
private val packetRepository: PacketRepository,
|
||||
private val workerManager: MeshWorkerManager,
|
||||
private val appWidgetUpdater: AppWidgetUpdater,
|
||||
private val heartbeatSender: DataLayerHeartbeatSender,
|
||||
@Named("ServiceScope") private val scope: CoroutineScope,
|
||||
) : MeshConnectionManager {
|
||||
/**
|
||||
* Serializes [onConnectionChanged] to prevent TOCTOU races when multiple coroutines emit state transitions
|
||||
* concurrently (e.g. flow collector vs. sleep-timeout coroutine).
|
||||
*/
|
||||
private val connectionMutex = Mutex()
|
||||
|
||||
private var preHandshakeJob: Job? = null
|
||||
private var sleepTimeout: Job? = null
|
||||
private var locationRequestsJob: Job? = null
|
||||
private var handshakeTimeout: Job? = null
|
||||
private var connectTimeMsec = 0L
|
||||
private var connectionRestored = false
|
||||
|
||||
init {
|
||||
// Bridge transport-level state into the canonical app-level state.
|
||||
// This is the ONLY consumer of RadioInterfaceService.connectionState — it applies
|
||||
// light-sleep policy and handshake awareness before writing to ServiceRepository.
|
||||
radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(scope)
|
||||
|
||||
// Ensure notification title and content stay in sync with state changes
|
||||
serviceRepository.connectionState.onEach { updateStatusNotification() }.launchIn(scope)
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
appWidgetUpdater.updateAll()
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
Logger.e(e) { "Failed to kickstart LocalStatsWidget" }
|
||||
}
|
||||
}
|
||||
|
||||
nodeRepository.myNodeInfo
|
||||
.onEach { myNodeEntity ->
|
||||
locationRequestsJob?.cancel()
|
||||
if (myNodeEntity != null) {
|
||||
locationRequestsJob =
|
||||
uiPrefs
|
||||
.shouldProvideNodeLocation(myNodeEntity.myNodeNum)
|
||||
.onEach { shouldProvide ->
|
||||
if (shouldProvide) {
|
||||
locationManager.start(scope) { pos ->
|
||||
scope.handledLaunch {
|
||||
val packet = DataPacket(
|
||||
bytes = okio.ByteString.of(*org.meshtastic.proto.Position.ADAPTER.encode(pos)),
|
||||
dataType = org.meshtastic.proto.PortNum.POSITION_APP.value,
|
||||
)
|
||||
radioController.sendMessage(packet)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
locationManager.stop()
|
||||
}
|
||||
}
|
||||
.launchIn(scope)
|
||||
}
|
||||
}
|
||||
.launchIn(scope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges a transport-level [ConnectionState] into the canonical app-level state.
|
||||
*
|
||||
* Applies light-sleep policy (power-saving / router role) to decide whether a [ConnectionState.DeviceSleep] event
|
||||
* should be surfaced as sleep or as a full disconnect, then delegates to [onConnectionChanged] for the actual state
|
||||
* transition.
|
||||
*/
|
||||
private suspend fun onRadioConnectionState(newState: ConnectionState) {
|
||||
val localConfig = radioConfigRepository.localConfigFlow.first()
|
||||
val isRouter = localConfig.device?.role == Config.DeviceConfig.Role.ROUTER
|
||||
val lsEnabled = localConfig.power?.is_power_saving == true || isRouter
|
||||
|
||||
val effectiveState =
|
||||
when (newState) {
|
||||
is ConnectionState.Connected -> ConnectionState.Connected
|
||||
|
||||
is ConnectionState.DeviceSleep ->
|
||||
if (lsEnabled) ConnectionState.DeviceSleep else ConnectionState.Disconnected
|
||||
|
||||
is ConnectionState.Connecting -> ConnectionState.Connecting
|
||||
|
||||
is ConnectionState.Disconnected -> ConnectionState.Disconnected
|
||||
}
|
||||
onConnectionChanged(effectiveState)
|
||||
}
|
||||
|
||||
private suspend fun onConnectionChanged(c: ConnectionState) = connectionMutex.withLock {
|
||||
val current = serviceRepository.connectionState.value
|
||||
if (current == c) return@withLock
|
||||
|
||||
// If the transport reports 'Connected', but we are already in the middle of a handshake (Connecting)
|
||||
if (c is ConnectionState.Connected && current is ConnectionState.Connecting) {
|
||||
Logger.d { "Ignoring redundant transport connection signal while handshake is in progress" }
|
||||
return@withLock
|
||||
}
|
||||
|
||||
Logger.i { "onConnectionChanged: $current -> $c" }
|
||||
|
||||
sleepTimeout?.cancel()
|
||||
sleepTimeout = null
|
||||
preHandshakeJob?.cancel()
|
||||
preHandshakeJob = null
|
||||
handshakeTimeout?.cancel()
|
||||
handshakeTimeout = null
|
||||
|
||||
when (c) {
|
||||
is ConnectionState.Connecting -> serviceRepository.setConnectionState(ConnectionState.Connecting)
|
||||
is ConnectionState.Connected -> handleConnected()
|
||||
is ConnectionState.DeviceSleep -> handleDeviceSleep()
|
||||
is ConnectionState.Disconnected -> handleDisconnected()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleConnected() {
|
||||
// Track whether this connection was restored from device sleep (vs. a fresh connect),
|
||||
// matching Apple's "connectionRestored" attribute for cross-platform DataDog parity.
|
||||
connectionRestored = serviceRepository.connectionState.value is ConnectionState.DeviceSleep
|
||||
// The service state remains 'Connecting' until config is fully loaded
|
||||
if (serviceRepository.connectionState.value != ConnectionState.Connected) {
|
||||
serviceRepository.setConnectionState(ConnectionState.Connecting)
|
||||
}
|
||||
serviceBroadcasts.broadcastConnection()
|
||||
connectTimeMsec = nowMillis
|
||||
|
||||
// Send a wake-up heartbeat before the config request. The firmware may be in a
|
||||
// power-saving state where the NimBLE callback context needs warming up. The 100ms
|
||||
// delay ensures the heartbeat BLE write is enqueued before the want_config_id
|
||||
// (sendToRadio is fire-and-forget through async coroutine launches).
|
||||
preHandshakeJob =
|
||||
scope.handledLaunch {
|
||||
heartbeatSender.sendHeartbeat("pre-handshake")
|
||||
delay(PRE_HANDSHAKE_SETTLE_MS)
|
||||
Logger.i { "Starting mesh handshake (Stage 1)" }
|
||||
startConfigOnly()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startHandshakeStallGuard(stage: Int, timeout: Duration, action: () -> Unit) {
|
||||
handshakeTimeout?.cancel()
|
||||
handshakeTimeout =
|
||||
scope.handledLaunch {
|
||||
delay(timeout)
|
||||
if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
|
||||
// Attempt one retry. Note: the firmware silently drops identical consecutive
|
||||
// writes (per-connection dedup). If the first want_config_id was received and
|
||||
// the stall is on our side, the retry will be dropped and the reconnect below
|
||||
// will trigger instead — which is the right recovery in that case.
|
||||
Logger.w {
|
||||
"Handshake stall detected at Stage $stage — retrying, then reconnecting if still stalled"
|
||||
}
|
||||
action()
|
||||
delay(HANDSHAKE_RETRY_TIMEOUT)
|
||||
if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
|
||||
Logger.e { "Handshake still stalled after retry, forcing reconnect" }
|
||||
onConnectionChanged(ConnectionState.Disconnected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun tearDownConnection() {
|
||||
packetHandler.stopPacketQueue()
|
||||
sessionManager.clearAll() // Prevent stale per-node passkeys on reconnect.
|
||||
locationManager.stop()
|
||||
mqttManager.stop()
|
||||
}
|
||||
|
||||
private fun handleDeviceSleep() {
|
||||
serviceRepository.setConnectionState(ConnectionState.DeviceSleep)
|
||||
tearDownConnection()
|
||||
|
||||
if (connectTimeMsec != 0L) {
|
||||
val now = nowMillis
|
||||
val duration = now - connectTimeMsec
|
||||
connectTimeMsec = 0L
|
||||
analytics.track(
|
||||
EVENT_CONNECTED_SECONDS,
|
||||
DataPair(EVENT_CONNECTED_SECONDS, duration.milliseconds.toDouble(DurationUnit.SECONDS)),
|
||||
)
|
||||
}
|
||||
|
||||
sleepTimeout =
|
||||
scope.handledLaunch {
|
||||
try {
|
||||
val localConfig = radioConfigRepository.localConfigFlow.first()
|
||||
val rawTimeout = (localConfig.power?.ls_secs ?: 0) + DEVICE_SLEEP_TIMEOUT_SECONDS
|
||||
// Cap the timeout so routers or power-saving configs (ls_secs=3600) don't
|
||||
// leave the UI stuck in DeviceSleep for over an hour.
|
||||
val timeout = rawTimeout.coerceAtMost(MAX_SLEEP_TIMEOUT_SECONDS)
|
||||
Logger.d { "Waiting for sleeping device, timeout=$timeout secs (raw=$rawTimeout)" }
|
||||
delay(timeout.seconds)
|
||||
Logger.w { "Device timed out, setting disconnected" }
|
||||
onConnectionChanged(ConnectionState.Disconnected)
|
||||
} catch (_: CancellationException) {
|
||||
Logger.d { "device sleep timeout cancelled" }
|
||||
}
|
||||
}
|
||||
|
||||
serviceBroadcasts.broadcastConnection()
|
||||
}
|
||||
|
||||
private fun handleDisconnected() {
|
||||
serviceRepository.setConnectionState(ConnectionState.Disconnected)
|
||||
tearDownConnection()
|
||||
|
||||
analytics.track(
|
||||
EVENT_MESH_DISCONNECT,
|
||||
DataPair(KEY_NUM_NODES, nodeManager.nodeDBbyNodeNum.size),
|
||||
DataPair(KEY_NUM_ONLINE, nodeManager.nodeDBbyNodeNum.values.count { it.isOnline }),
|
||||
)
|
||||
analytics.track(EVENT_NUM_NODES, DataPair(KEY_NUM_NODES, nodeManager.nodeDBbyNodeNum.size))
|
||||
|
||||
serviceBroadcasts.broadcastConnection()
|
||||
}
|
||||
|
||||
override fun startConfigOnly() {
|
||||
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE)) }
|
||||
startHandshakeStallGuard(1, HANDSHAKE_TIMEOUT_STAGE1, action)
|
||||
action()
|
||||
}
|
||||
|
||||
override fun startNodeInfoOnly() {
|
||||
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE)) }
|
||||
startHandshakeStallGuard(2, HANDSHAKE_TIMEOUT_STAGE2, action)
|
||||
action()
|
||||
}
|
||||
|
||||
override fun onRadioConfigLoaded() {
|
||||
scope.handledLaunch {
|
||||
val queuedPackets = packetRepository.getQueuedPackets()
|
||||
queuedPackets.forEach { packet ->
|
||||
try {
|
||||
workerManager.enqueueSendMessage(packet.id)
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
Logger.e(e) { "Failed to enqueue queued packet worker" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNodeDbReady() {
|
||||
handshakeTimeout?.cancel()
|
||||
handshakeTimeout = null
|
||||
|
||||
val myNodeNum = nodeManager.myNodeNum.value ?: 0
|
||||
|
||||
// NOTE: Time sync and session passkey seeding are handled by the SDK's RadioClient
|
||||
// during its own handshake — no need to send set_time_only or get_owner_request here.
|
||||
|
||||
// Start MQTT if enabled
|
||||
scope.handledLaunch {
|
||||
val moduleConfig = radioConfigRepository.moduleConfigFlow.first()
|
||||
mqttManager.startProxy(
|
||||
moduleConfig.mqtt?.enabled == true,
|
||||
moduleConfig.mqtt?.proxy_to_client_enabled == true,
|
||||
)
|
||||
}
|
||||
|
||||
reportConnection()
|
||||
|
||||
// Request history
|
||||
scope.handledLaunch {
|
||||
val moduleConfig = radioConfigRepository.moduleConfigFlow.first()
|
||||
moduleConfig.store_forward?.let {
|
||||
historyManager.requestHistoryReplay("onNodeDbReady", myNodeNum, it, "Unknown")
|
||||
}
|
||||
}
|
||||
|
||||
// Request immediate LocalStats and DeviceMetrics update on connection
|
||||
scope.handledLaunch {
|
||||
radioController.requestTelemetry(myNodeNum.hashCode(), myNodeNum, TelemetryType.LOCAL_STATS.ordinal)
|
||||
radioController.requestTelemetry(myNodeNum.hashCode() + 1, myNodeNum, TelemetryType.DEVICE.ordinal)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reportConnection() {
|
||||
val myNode = nodeManager.getMyNodeInfo()
|
||||
val radioModel = DataPair(KEY_RADIO_MODEL, myNode?.model ?: "unknown")
|
||||
analytics.track(
|
||||
EVENT_MESH_CONNECT,
|
||||
DataPair(KEY_NUM_NODES, nodeManager.nodeDBbyNodeNum.size),
|
||||
DataPair(KEY_NUM_ONLINE, nodeManager.nodeDBbyNodeNum.values.count { it.isOnline }),
|
||||
radioModel,
|
||||
)
|
||||
|
||||
// DataDog RUM custom action matching Apple's "connect" event for cross-platform analytics.
|
||||
val transportType = radioInterfaceService.getDeviceAddress()?.let { DeviceType.fromAddress(it)?.name }
|
||||
analytics.trackConnect(
|
||||
firmwareVersion = myNode?.firmwareVersion,
|
||||
transportType = transportType,
|
||||
hardwareModel = myNode?.model,
|
||||
nodes = nodeManager.nodeDBbyNodeNum.size,
|
||||
connectionRestored = connectionRestored,
|
||||
)
|
||||
}
|
||||
|
||||
override fun updateTelemetry(t: Telemetry) {
|
||||
t.local_stats?.let { nodeRepository.updateLocalStats(it) }
|
||||
updateStatusNotification(t)
|
||||
}
|
||||
|
||||
override fun updateStatusNotification(telemetry: Telemetry?) {
|
||||
serviceNotifications.updateServiceStateNotification(
|
||||
serviceRepository.connectionState.value,
|
||||
telemetry = telemetry,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30
|
||||
|
||||
// Maximum time (in seconds) to wait for a sleeping device before declaring it
|
||||
// disconnected, regardless of the device's ls_secs configuration. Without this
|
||||
// cap, routers (ls_secs=3600) leave the UI in DeviceSleep for over an hour.
|
||||
private const val MAX_SLEEP_TIMEOUT_SECONDS = 300
|
||||
|
||||
/**
|
||||
* Delay between the pre-handshake heartbeat and the want_config_id send.
|
||||
*
|
||||
* Ensures the heartbeat BLE write completes and the firmware's NimBLE callback context is warmed up before the
|
||||
* config request arrives. 100ms is well above observed ESP32 task scheduling latency (~10–50ms) while adding
|
||||
* negligible connection latency.
|
||||
*/
|
||||
private const val PRE_HANDSHAKE_SETTLE_MS = 100L
|
||||
|
||||
private val HANDSHAKE_TIMEOUT_STAGE1 = 30.seconds
|
||||
|
||||
/**
|
||||
* Stage 2 drains the full node database, which can be significantly larger than Stage 1 config on big meshes.
|
||||
* 60 s matches the meshtastic-client SDK timeout and avoids premature stall-guard triggers on meshes with 50+
|
||||
* nodes.
|
||||
*/
|
||||
private val HANDSHAKE_TIMEOUT_STAGE2 = 60.seconds
|
||||
|
||||
// Shorter window for the retry attempt: if the device genuinely didn't receive the
|
||||
// first want_config_id the retry completes within a few seconds. Waiting another 30s
|
||||
// before reconnecting just delays recovery unnecessarily.
|
||||
private val HANDSHAKE_RETRY_TIMEOUT = 15.seconds
|
||||
|
||||
private const val EVENT_CONNECTED_SECONDS = "connected_seconds"
|
||||
private const val EVENT_MESH_DISCONNECT = "mesh_disconnect"
|
||||
private const val EVENT_NUM_NODES = "num_nodes"
|
||||
private const val EVENT_MESH_CONNECT = "mesh_connect"
|
||||
|
||||
private const val KEY_NUM_NODES = "num_nodes"
|
||||
private const val KEY_NUM_ONLINE = "num_online"
|
||||
private const val KEY_RADIO_MODEL = "radio_model"
|
||||
}
|
||||
}
|
||||
@@ -1,531 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import co.touchlab.kermit.Severity
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import okio.ByteString
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.MessageStatus
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.Reaction
|
||||
import org.meshtastic.core.model.util.MeshDataMapper
|
||||
import org.meshtastic.core.model.util.decodeOrNull
|
||||
import org.meshtastic.core.model.util.toOneLiner
|
||||
import org.meshtastic.core.repository.AdminPacketHandler
|
||||
import org.meshtastic.core.repository.DataPair
|
||||
import org.meshtastic.core.repository.MeshDataHandler
|
||||
import org.meshtastic.core.repository.MeshServiceNotifications
|
||||
import org.meshtastic.core.repository.MessageFilter
|
||||
import org.meshtastic.core.repository.NeighborInfoHandler
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.Notification
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.PacketHandler
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.repository.StoreForwardPacketHandler
|
||||
import org.meshtastic.core.repository.TelemetryPacketHandler
|
||||
import org.meshtastic.core.repository.TracerouteHandler
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.critical_alert
|
||||
import org.meshtastic.core.resources.error_duty_cycle
|
||||
import org.meshtastic.core.resources.getStringSuspend
|
||||
import org.meshtastic.core.resources.unknown_username
|
||||
import org.meshtastic.core.resources.waypoint_received
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.Paxcount
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.Position
|
||||
import org.meshtastic.proto.Routing
|
||||
import org.meshtastic.proto.StatusMessage
|
||||
import org.meshtastic.proto.User
|
||||
import org.meshtastic.proto.Waypoint
|
||||
|
||||
/**
|
||||
* Implementation of [MeshDataHandler] that decodes and routes incoming mesh data packets.
|
||||
*
|
||||
* This class handles the complexity of:
|
||||
* 1. Mapping raw [MeshPacket] objects to domain-friendly [DataPacket] objects.
|
||||
* 2. Routing packets to specialized handlers (e.g., Traceroute, NeighborInfo, Telemetry, Admin, SFPP).
|
||||
* 3. Managing message history and persistence.
|
||||
* 4. Triggering notifications for various packet types (Text, Waypoints).
|
||||
*/
|
||||
@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod")
|
||||
@Single
|
||||
class MeshDataHandlerImpl(
|
||||
private val nodeManager: NodeManager,
|
||||
private val packetHandler: PacketHandler,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val packetRepository: Lazy<PacketRepository>,
|
||||
private val serviceBroadcasts: ServiceBroadcasts,
|
||||
private val notificationManager: NotificationManager,
|
||||
private val serviceNotifications: MeshServiceNotifications,
|
||||
private val analytics: PlatformAnalytics,
|
||||
private val dataMapper: MeshDataMapper,
|
||||
private val tracerouteHandler: TracerouteHandler,
|
||||
private val neighborInfoHandler: NeighborInfoHandler,
|
||||
private val radioConfigRepository: RadioConfigRepository,
|
||||
private val messageFilter: MessageFilter,
|
||||
private val storeForwardHandler: StoreForwardPacketHandler,
|
||||
private val telemetryHandler: TelemetryPacketHandler,
|
||||
private val adminPacketHandler: AdminPacketHandler,
|
||||
@Named("ServiceScope") private val scope: CoroutineScope,
|
||||
) : MeshDataHandler {
|
||||
|
||||
private val rememberDataType =
|
||||
setOf(
|
||||
PortNum.TEXT_MESSAGE_APP.value,
|
||||
PortNum.ALERT_APP.value,
|
||||
PortNum.WAYPOINT_APP.value,
|
||||
PortNum.NODE_STATUS_APP.value,
|
||||
)
|
||||
|
||||
override fun handleReceivedData(packet: MeshPacket, myNodeNum: Int, logUuid: String?, logInsertJob: Job?) {
|
||||
val dataPacket = dataMapper.toDataPacket(packet) ?: return
|
||||
val fromUs = myNodeNum == packet.from
|
||||
dataPacket.status = MessageStatus.RECEIVED
|
||||
|
||||
val shouldBroadcast = handleDataPacket(packet, dataPacket, myNodeNum, fromUs, logUuid, logInsertJob)
|
||||
|
||||
if (shouldBroadcast) {
|
||||
serviceBroadcasts.broadcastReceivedData(dataPacket)
|
||||
}
|
||||
analytics.track("num_data_receive", DataPair("num_data_receive", 1))
|
||||
}
|
||||
|
||||
private fun handleDataPacket(
|
||||
packet: MeshPacket,
|
||||
dataPacket: DataPacket,
|
||||
myNodeNum: Int,
|
||||
fromUs: Boolean,
|
||||
logUuid: String?,
|
||||
logInsertJob: Job?,
|
||||
): Boolean {
|
||||
var shouldBroadcast = !fromUs
|
||||
val decoded = packet.decoded ?: return shouldBroadcast
|
||||
when (decoded.portnum) {
|
||||
PortNum.TEXT_MESSAGE_APP -> handleTextMessage(packet, dataPacket, myNodeNum)
|
||||
|
||||
PortNum.NODE_STATUS_APP -> handleNodeStatus(packet, dataPacket, myNodeNum)
|
||||
|
||||
PortNum.ALERT_APP -> rememberDataPacket(dataPacket, myNodeNum)
|
||||
|
||||
PortNum.WAYPOINT_APP -> handleWaypoint(packet, dataPacket, myNodeNum)
|
||||
|
||||
PortNum.POSITION_APP -> handlePosition(packet, dataPacket, myNodeNum)
|
||||
|
||||
PortNum.NODEINFO_APP -> if (!fromUs) handleNodeInfo(packet)
|
||||
|
||||
PortNum.TELEMETRY_APP -> telemetryHandler.handleTelemetry(packet, dataPacket, myNodeNum)
|
||||
|
||||
else ->
|
||||
shouldBroadcast =
|
||||
handleSpecializedDataPacket(packet, dataPacket, myNodeNum, fromUs, logUuid, logInsertJob)
|
||||
}
|
||||
return shouldBroadcast
|
||||
}
|
||||
|
||||
private fun handleSpecializedDataPacket(
|
||||
packet: MeshPacket,
|
||||
dataPacket: DataPacket,
|
||||
myNodeNum: Int,
|
||||
fromUs: Boolean,
|
||||
logUuid: String?,
|
||||
logInsertJob: Job?,
|
||||
): Boolean {
|
||||
var shouldBroadcast = !fromUs
|
||||
val decoded = packet.decoded ?: return shouldBroadcast
|
||||
when (decoded.portnum) {
|
||||
PortNum.TRACEROUTE_APP -> {
|
||||
tracerouteHandler.handleTraceroute(packet, logUuid, logInsertJob)
|
||||
shouldBroadcast = false
|
||||
}
|
||||
|
||||
PortNum.ROUTING_APP -> {
|
||||
handleRouting(packet, dataPacket)
|
||||
shouldBroadcast = true
|
||||
}
|
||||
|
||||
PortNum.PAXCOUNTER_APP -> {
|
||||
handlePaxCounter(packet)
|
||||
}
|
||||
|
||||
PortNum.STORE_FORWARD_APP -> {
|
||||
storeForwardHandler.handleStoreAndForward(packet, dataPacket, myNodeNum)
|
||||
}
|
||||
|
||||
PortNum.STORE_FORWARD_PLUSPLUS_APP -> {
|
||||
storeForwardHandler.handleStoreForwardPlusPlus(packet)
|
||||
}
|
||||
|
||||
PortNum.ADMIN_APP -> {
|
||||
adminPacketHandler.handleAdminMessage(packet, myNodeNum)
|
||||
}
|
||||
|
||||
PortNum.NEIGHBORINFO_APP -> {
|
||||
neighborInfoHandler.handleNeighborInfo(packet)
|
||||
shouldBroadcast = true
|
||||
}
|
||||
|
||||
PortNum.ATAK_PLUGIN,
|
||||
PortNum.ATAK_FORWARDER,
|
||||
PortNum.PRIVATE_APP,
|
||||
-> {
|
||||
shouldBroadcast = true
|
||||
}
|
||||
|
||||
PortNum.RANGE_TEST_APP,
|
||||
PortNum.DETECTION_SENSOR_APP,
|
||||
-> {
|
||||
handleRangeTest(dataPacket, myNodeNum)
|
||||
shouldBroadcast = true
|
||||
}
|
||||
|
||||
else -> {
|
||||
// By default, if we don't know what it is, we should probably broadcast it
|
||||
// so that external apps can handle it.
|
||||
shouldBroadcast = true
|
||||
}
|
||||
}
|
||||
return shouldBroadcast
|
||||
}
|
||||
|
||||
private fun handleRangeTest(dataPacket: DataPacket, myNodeNum: Int) {
|
||||
val u = dataPacket.copy(dataType = PortNum.TEXT_MESSAGE_APP.value)
|
||||
rememberDataPacket(u, myNodeNum)
|
||||
}
|
||||
|
||||
private fun handlePaxCounter(packet: MeshPacket) {
|
||||
val payload = packet.decoded?.payload ?: return
|
||||
val p = Paxcount.ADAPTER.decodeOrNull(payload, Logger) ?: return
|
||||
nodeManager.handleReceivedPaxcounter(packet.from, p)
|
||||
}
|
||||
|
||||
private fun handlePosition(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
|
||||
val payload = packet.decoded?.payload ?: return
|
||||
val p = Position.ADAPTER.decodeOrNull(payload, Logger) ?: return
|
||||
Logger.d { "Position from ${packet.from}: ${Position.ADAPTER.toOneLiner(p)}" }
|
||||
nodeManager.handleReceivedPosition(packet.from, myNodeNum, p, dataPacket.time)
|
||||
}
|
||||
|
||||
private fun handleWaypoint(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
|
||||
val payload = packet.decoded?.payload ?: return
|
||||
val u = Waypoint.ADAPTER.decode(payload)
|
||||
if (u.locked_to != 0 && u.locked_to != packet.from) return
|
||||
val currentSecond = nowSeconds.toInt()
|
||||
rememberDataPacket(dataPacket, myNodeNum, updateNotification = u.expire > currentSecond)
|
||||
}
|
||||
|
||||
private fun handleTextMessage(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
|
||||
val decoded = packet.decoded ?: return
|
||||
if (decoded.reply_id != 0 && decoded.emoji != 0) {
|
||||
rememberReaction(packet)
|
||||
} else {
|
||||
rememberDataPacket(dataPacket, myNodeNum)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNodeInfo(packet: MeshPacket) {
|
||||
val payload = packet.decoded?.payload ?: return
|
||||
val u =
|
||||
User.ADAPTER.decode(payload)
|
||||
.let { if (it.is_licensed == true) it.copy(public_key = ByteString.EMPTY) else it }
|
||||
.let {
|
||||
if (packet.via_mqtt == true && !it.long_name.endsWith(" (MQTT)")) {
|
||||
it.copy(long_name = "${it.long_name} (MQTT)")
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
nodeManager.handleReceivedUser(packet.from, u, packet.channel)
|
||||
}
|
||||
|
||||
private fun handleNodeStatus(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
|
||||
val payload = packet.decoded?.payload ?: return
|
||||
val s = StatusMessage.ADAPTER.decodeOrNull(payload, Logger) ?: return
|
||||
nodeManager.handleReceivedNodeStatus(packet.from, s)
|
||||
rememberDataPacket(dataPacket, myNodeNum)
|
||||
}
|
||||
|
||||
private fun handleRouting(packet: MeshPacket, dataPacket: DataPacket) {
|
||||
val payload = packet.decoded?.payload ?: return
|
||||
val r = Routing.ADAPTER.decodeOrNull(payload, Logger) ?: return
|
||||
if (r.error_reason == Routing.Error.DUTY_CYCLE_LIMIT) {
|
||||
scope.launch {
|
||||
serviceRepository.setErrorMessage(getStringSuspend(Res.string.error_duty_cycle), Severity.Warn)
|
||||
}
|
||||
}
|
||||
handleAckNak(
|
||||
packet.decoded?.request_id ?: 0,
|
||||
nodeManager.toNodeID(packet.from),
|
||||
r.error_reason?.value ?: 0,
|
||||
dataPacket.relayNode,
|
||||
)
|
||||
packet.decoded?.request_id?.let { packetHandler.removeResponse(it, complete = true) }
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod", "LongMethod")
|
||||
private fun handleAckNak(requestId: Int, fromId: String, routingError: Int, relayNode: Int?) {
|
||||
scope.handledLaunch {
|
||||
val isAck = routingError == Routing.Error.NONE.value
|
||||
val p = packetRepository.value.getPacketByPacketId(requestId)
|
||||
val reaction = packetRepository.value.getReactionByPacketId(requestId)
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
Logger.d {
|
||||
val statusInfo = "status=${p?.status ?: reaction?.status}"
|
||||
"[ackNak] req=$requestId routeErr=$routingError isAck=$isAck " +
|
||||
"packetId=${p?.id ?: reaction?.packetId} dataId=${p?.id} $statusInfo"
|
||||
}
|
||||
|
||||
val m =
|
||||
when {
|
||||
isAck && (fromId == p?.to || fromId == reaction?.to) -> MessageStatus.RECEIVED
|
||||
isAck -> MessageStatus.DELIVERED
|
||||
else -> MessageStatus.ERROR
|
||||
}
|
||||
if (p != null && p.status != MessageStatus.RECEIVED) {
|
||||
val updatedPacket =
|
||||
p.copy(status = m, relays = if (isAck) p.relays + 1 else p.relays, relayNode = relayNode)
|
||||
packetRepository.value.update(updatedPacket, routingError = routingError)
|
||||
}
|
||||
|
||||
reaction?.let { r ->
|
||||
if (r.status != MessageStatus.RECEIVED) {
|
||||
var updated = r.copy(status = m, routingError = routingError, relayNode = relayNode)
|
||||
if (isAck) {
|
||||
updated = updated.copy(relays = updated.relays + 1)
|
||||
}
|
||||
packetRepository.value.updateReaction(updated)
|
||||
}
|
||||
}
|
||||
|
||||
serviceBroadcasts.broadcastMessageStatus(requestId, m)
|
||||
}
|
||||
}
|
||||
|
||||
override fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean) {
|
||||
if (dataPacket.dataType !in rememberDataType) return
|
||||
val fromLocal =
|
||||
dataPacket.from == DataPacket.ID_LOCAL || dataPacket.from == DataPacket.nodeNumToDefaultId(myNodeNum)
|
||||
val toBroadcast = dataPacket.to == DataPacket.ID_BROADCAST
|
||||
val contactId = if (fromLocal || toBroadcast) dataPacket.to else dataPacket.from
|
||||
|
||||
// contactKey: unique contact key filter (channel)+(nodeId)
|
||||
val contactKey = "${dataPacket.channel}$contactId"
|
||||
|
||||
scope.handledLaunch {
|
||||
packetRepository.value.apply {
|
||||
// Check for duplicates before inserting
|
||||
val existingPackets = findPacketsWithId(dataPacket.id)
|
||||
if (existingPackets.isNotEmpty()) {
|
||||
Logger.d {
|
||||
"Skipping duplicate packet: packetId=${dataPacket.id} from=${dataPacket.from} " +
|
||||
"to=${dataPacket.to} contactKey=$contactKey" +
|
||||
" (already have ${existingPackets.size} packet(s))"
|
||||
}
|
||||
return@handledLaunch
|
||||
}
|
||||
|
||||
// Check if message should be filtered
|
||||
val isFiltered = shouldFilterMessage(dataPacket, contactKey)
|
||||
|
||||
insert(
|
||||
dataPacket,
|
||||
myNodeNum,
|
||||
contactKey,
|
||||
nowMillis,
|
||||
read = fromLocal || isFiltered,
|
||||
filtered = isFiltered,
|
||||
)
|
||||
if (!isFiltered) {
|
||||
handlePacketNotification(dataPacket, contactKey, updateNotification)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
private suspend fun PacketRepository.shouldFilterMessage(dataPacket: DataPacket, contactKey: String): Boolean {
|
||||
val isIgnored = nodeManager.nodeDBbyID[dataPacket.from]?.isIgnored == true
|
||||
if (isIgnored) return true
|
||||
|
||||
if (dataPacket.dataType != PortNum.TEXT_MESSAGE_APP.value) return false
|
||||
val isFilteringDisabled = getContactSettings(contactKey).filteringDisabled
|
||||
return messageFilter.shouldFilter(dataPacket.text.orEmpty(), isFilteringDisabled)
|
||||
}
|
||||
|
||||
private suspend fun handlePacketNotification(
|
||||
dataPacket: DataPacket,
|
||||
contactKey: String,
|
||||
updateNotification: Boolean,
|
||||
) {
|
||||
val conversationMuted = packetRepository.value.getContactSettings(contactKey).isMuted
|
||||
val nodeMuted = nodeManager.nodeDBbyID[dataPacket.from]?.isMuted == true
|
||||
val isSilent = conversationMuted || nodeMuted
|
||||
if (dataPacket.dataType == PortNum.ALERT_APP.value && !isSilent) {
|
||||
scope.launch {
|
||||
notificationManager.dispatch(
|
||||
Notification(
|
||||
title = getSenderName(dataPacket),
|
||||
message = dataPacket.alert ?: getStringSuspend(Res.string.critical_alert),
|
||||
category = Notification.Category.Alert,
|
||||
contactKey = contactKey,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else if (updateNotification && !isSilent) {
|
||||
scope.handledLaunch { updateNotification(contactKey, dataPacket, isSilent) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getSenderName(packet: DataPacket): String {
|
||||
if (packet.from == DataPacket.ID_LOCAL) {
|
||||
val myId = nodeManager.getMyId()
|
||||
return nodeManager.nodeDBbyID[myId]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username)
|
||||
}
|
||||
return nodeManager.nodeDBbyID[packet.from]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username)
|
||||
}
|
||||
|
||||
private suspend fun updateNotification(contactKey: String, dataPacket: DataPacket, isSilent: Boolean) {
|
||||
when (dataPacket.dataType) {
|
||||
PortNum.TEXT_MESSAGE_APP.value -> {
|
||||
val message = dataPacket.text!!
|
||||
val channelName =
|
||||
if (dataPacket.to == DataPacket.ID_BROADCAST) {
|
||||
radioConfigRepository.channelSetFlow.first().settings.getOrNull(dataPacket.channel)?.name
|
||||
} else {
|
||||
null
|
||||
}
|
||||
serviceNotifications.updateMessageNotification(
|
||||
contactKey,
|
||||
getSenderName(dataPacket),
|
||||
message,
|
||||
dataPacket.to == DataPacket.ID_BROADCAST,
|
||||
channelName,
|
||||
isSilent,
|
||||
)
|
||||
}
|
||||
|
||||
PortNum.WAYPOINT_APP.value -> {
|
||||
val message = getStringSuspend(Res.string.waypoint_received, dataPacket.waypoint!!.name)
|
||||
notificationManager.dispatch(
|
||||
Notification(
|
||||
title = getSenderName(dataPacket),
|
||||
message = message,
|
||||
category = Notification.Category.Message,
|
||||
contactKey = contactKey,
|
||||
isSilent = isSilent,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
else -> return
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod", "KotlinConstantConditions")
|
||||
private fun rememberReaction(packet: MeshPacket) = scope.handledLaunch {
|
||||
val decoded = packet.decoded ?: return@handledLaunch
|
||||
val emoji = decoded.payload.toByteArray().decodeToString()
|
||||
val fromId = nodeManager.toNodeID(packet.from)
|
||||
|
||||
val fromNode = nodeManager.nodeDBbyNodeNum[packet.from] ?: Node(num = packet.from)
|
||||
val toNode = nodeManager.nodeDBbyNodeNum[packet.to] ?: Node(num = packet.to)
|
||||
|
||||
val reaction =
|
||||
Reaction(
|
||||
replyId = decoded.reply_id,
|
||||
user = fromNode.user,
|
||||
emoji = emoji,
|
||||
timestamp = nowMillis,
|
||||
snr = packet.rx_snr,
|
||||
rssi = packet.rx_rssi,
|
||||
hopsAway =
|
||||
if (packet.hop_start == 0 || packet.hop_limit > packet.hop_start) {
|
||||
HOPS_AWAY_UNAVAILABLE
|
||||
} else {
|
||||
packet.hop_start - packet.hop_limit
|
||||
},
|
||||
packetId = packet.id,
|
||||
status = MessageStatus.RECEIVED,
|
||||
to = toNode.user.id,
|
||||
channel = packet.channel,
|
||||
)
|
||||
|
||||
// Check for duplicates before inserting
|
||||
val existingReactions = packetRepository.value.findReactionsWithId(packet.id)
|
||||
if (existingReactions.isNotEmpty()) {
|
||||
Logger.d {
|
||||
"Skipping duplicate reaction: packetId=${packet.id} replyId=${decoded.reply_id} " +
|
||||
"from=$fromId emoji=$emoji (already have ${existingReactions.size} reaction(s))"
|
||||
}
|
||||
return@handledLaunch
|
||||
}
|
||||
|
||||
packetRepository.value.insertReaction(reaction, nodeManager.myNodeNum.value ?: 0)
|
||||
|
||||
// Find the original packet to get the contactKey
|
||||
packetRepository.value.getPacketByPacketId(decoded.reply_id)?.let { originalPacket ->
|
||||
// Skip notification if the original message was filtered
|
||||
val targetId =
|
||||
if (originalPacket.from == DataPacket.ID_LOCAL) originalPacket.to else originalPacket.from
|
||||
val contactKey = "${originalPacket.channel}$targetId"
|
||||
val conversationMuted = packetRepository.value.getContactSettings(contactKey).isMuted
|
||||
val nodeMuted = nodeManager.nodeDBbyID[fromId]?.isMuted == true
|
||||
val isSilent = conversationMuted || nodeMuted
|
||||
|
||||
if (!isSilent) {
|
||||
val channelName =
|
||||
if (originalPacket.to == DataPacket.ID_BROADCAST) {
|
||||
radioConfigRepository.channelSetFlow
|
||||
.first()
|
||||
.settings
|
||||
.getOrNull(originalPacket.channel)
|
||||
?.name
|
||||
} else {
|
||||
null
|
||||
}
|
||||
serviceNotifications.updateReactionNotification(
|
||||
contactKey,
|
||||
getSenderName(dataMapper.toDataPacket(packet)!!),
|
||||
emoji,
|
||||
originalPacket.to == DataPacket.ID_BROADCAST,
|
||||
channelName,
|
||||
isSilent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val HOPS_AWAY_UNAVAILABLE = -1
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.repository.MeshActionHandler
|
||||
import org.meshtastic.core.repository.MeshConfigFlowManager
|
||||
import org.meshtastic.core.repository.MeshConfigHandler
|
||||
import org.meshtastic.core.repository.MeshDataHandler
|
||||
import org.meshtastic.core.repository.MeshRouter
|
||||
import org.meshtastic.core.repository.MqttManager
|
||||
import org.meshtastic.core.repository.NeighborInfoHandler
|
||||
import org.meshtastic.core.repository.TracerouteHandler
|
||||
import org.meshtastic.core.repository.XModemManager
|
||||
|
||||
/** Implementation of [MeshRouter] that orchestrates specialized mesh packet handlers. */
|
||||
@Suppress("LongParameterList")
|
||||
@Single
|
||||
class MeshRouterImpl(
|
||||
private val dataHandlerLazy: Lazy<MeshDataHandler>,
|
||||
private val configHandlerLazy: Lazy<MeshConfigHandler>,
|
||||
private val tracerouteHandlerLazy: Lazy<TracerouteHandler>,
|
||||
private val neighborInfoHandlerLazy: Lazy<NeighborInfoHandler>,
|
||||
private val configFlowManagerLazy: Lazy<MeshConfigFlowManager>,
|
||||
private val mqttManagerLazy: Lazy<MqttManager>,
|
||||
private val actionHandlerLazy: Lazy<MeshActionHandler>,
|
||||
private val xmodemManagerLazy: Lazy<XModemManager>,
|
||||
) : MeshRouter {
|
||||
override val dataHandler: MeshDataHandler
|
||||
get() = dataHandlerLazy.value
|
||||
|
||||
override val configHandler: MeshConfigHandler
|
||||
get() = configHandlerLazy.value
|
||||
|
||||
override val tracerouteHandler: TracerouteHandler
|
||||
get() = tracerouteHandlerLazy.value
|
||||
|
||||
override val neighborInfoHandler: NeighborInfoHandler
|
||||
get() = neighborInfoHandlerLazy.value
|
||||
|
||||
override val configFlowManager: MeshConfigFlowManager
|
||||
get() = configFlowManagerLazy.value
|
||||
|
||||
override val mqttManager: MqttManager
|
||||
get() = mqttManagerLazy.value
|
||||
|
||||
override val actionHandler: MeshActionHandler
|
||||
get() = actionHandlerLazy.value
|
||||
|
||||
override val xmodemManager: XModemManager
|
||||
get() = xmodemManagerLazy.value
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.repository.MeshDataHandler
|
||||
import org.meshtastic.core.repository.MeshServiceNotifications
|
||||
import org.meshtastic.core.repository.MessageFilter
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.Notification
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.critical_alert
|
||||
import org.meshtastic.core.resources.getStringSuspend
|
||||
import org.meshtastic.core.resources.unknown_username
|
||||
import org.meshtastic.core.resources.waypoint_received
|
||||
import org.meshtastic.proto.PortNum
|
||||
|
||||
/**
|
||||
* SDK-era implementation of [MeshDataHandler] focused on message persistence and notifications.
|
||||
*
|
||||
* The full packet-routing logic (handleReceivedData) is no longer needed — the SDK's packet flow
|
||||
* is consumed directly by VMs and SdkStateBridge. This class retains only [rememberDataPacket]
|
||||
* which is called by [StoreForwardPacketHandlerImpl] to persist forwarded messages.
|
||||
*/
|
||||
@Single
|
||||
class MessagePersistenceHandler(
|
||||
private val nodeManager: NodeManager,
|
||||
private val packetRepository: Lazy<PacketRepository>,
|
||||
private val notificationManager: NotificationManager,
|
||||
private val serviceNotifications: MeshServiceNotifications,
|
||||
private val radioConfigRepository: RadioConfigRepository,
|
||||
private val messageFilter: MessageFilter,
|
||||
@Named("ServiceScope") private val scope: CoroutineScope,
|
||||
) : MeshDataHandler {
|
||||
|
||||
private val rememberDataType =
|
||||
setOf(
|
||||
PortNum.TEXT_MESSAGE_APP.value,
|
||||
PortNum.ALERT_APP.value,
|
||||
PortNum.WAYPOINT_APP.value,
|
||||
PortNum.NODE_STATUS_APP.value,
|
||||
)
|
||||
|
||||
override fun handleReceivedData(
|
||||
packet: org.meshtastic.proto.MeshPacket,
|
||||
myNodeNum: Int,
|
||||
logUuid: String?,
|
||||
logInsertJob: kotlinx.coroutines.Job?,
|
||||
) {
|
||||
// No-op: Incoming packet routing is handled by SdkStateBridge / VM packet observers.
|
||||
// This method exists only to satisfy the MeshDataHandler interface contract.
|
||||
}
|
||||
|
||||
override fun rememberDataPacket(dataPacket: DataPacket, myNodeNum: Int, updateNotification: Boolean) {
|
||||
if (dataPacket.dataType !in rememberDataType) return
|
||||
val fromLocal =
|
||||
dataPacket.from == DataPacket.ID_LOCAL || dataPacket.from == DataPacket.nodeNumToDefaultId(myNodeNum)
|
||||
val toBroadcast = dataPacket.to == DataPacket.ID_BROADCAST
|
||||
val contactId = if (fromLocal || toBroadcast) dataPacket.to else dataPacket.from
|
||||
|
||||
val contactKey = "${dataPacket.channel}$contactId"
|
||||
|
||||
scope.handledLaunch {
|
||||
packetRepository.value.apply {
|
||||
val existingPackets = findPacketsWithId(dataPacket.id)
|
||||
if (existingPackets.isNotEmpty()) {
|
||||
Logger.d {
|
||||
"Skipping duplicate packet: packetId=${dataPacket.id} from=${dataPacket.from} " +
|
||||
"to=${dataPacket.to} contactKey=$contactKey" +
|
||||
" (already have ${existingPackets.size} packet(s))"
|
||||
}
|
||||
return@handledLaunch
|
||||
}
|
||||
|
||||
val isFiltered = shouldFilterMessage(dataPacket, contactKey)
|
||||
|
||||
insert(
|
||||
dataPacket,
|
||||
myNodeNum,
|
||||
contactKey,
|
||||
nowMillis,
|
||||
read = fromLocal || isFiltered,
|
||||
filtered = isFiltered,
|
||||
)
|
||||
if (!isFiltered) {
|
||||
handlePacketNotification(dataPacket, contactKey, updateNotification)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
private suspend fun PacketRepository.shouldFilterMessage(dataPacket: DataPacket, contactKey: String): Boolean {
|
||||
val isIgnored = nodeManager.nodeDBbyID[dataPacket.from]?.isIgnored == true
|
||||
if (isIgnored) return true
|
||||
|
||||
if (dataPacket.dataType != PortNum.TEXT_MESSAGE_APP.value) return false
|
||||
val isFilteringDisabled = getContactSettings(contactKey).filteringDisabled
|
||||
return messageFilter.shouldFilter(dataPacket.text.orEmpty(), isFilteringDisabled)
|
||||
}
|
||||
|
||||
private suspend fun handlePacketNotification(
|
||||
dataPacket: DataPacket,
|
||||
contactKey: String,
|
||||
updateNotification: Boolean,
|
||||
) {
|
||||
val conversationMuted = packetRepository.value.getContactSettings(contactKey).isMuted
|
||||
val nodeMuted = nodeManager.nodeDBbyID[dataPacket.from]?.isMuted == true
|
||||
val isSilent = conversationMuted || nodeMuted
|
||||
if (dataPacket.dataType == PortNum.ALERT_APP.value && !isSilent) {
|
||||
scope.launch {
|
||||
notificationManager.dispatch(
|
||||
Notification(
|
||||
title = getSenderName(dataPacket),
|
||||
message = dataPacket.alert ?: getStringSuspend(Res.string.critical_alert),
|
||||
category = Notification.Category.Alert,
|
||||
contactKey = contactKey,
|
||||
),
|
||||
)
|
||||
}
|
||||
} else if (updateNotification && !isSilent) {
|
||||
scope.handledLaunch { updateNotification(contactKey, dataPacket, isSilent) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getSenderName(packet: DataPacket): String {
|
||||
if (packet.from == DataPacket.ID_LOCAL) {
|
||||
val myId = nodeManager.getMyId()
|
||||
return nodeManager.nodeDBbyID[myId]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username)
|
||||
}
|
||||
return nodeManager.nodeDBbyID[packet.from]?.user?.long_name ?: getStringSuspend(Res.string.unknown_username)
|
||||
}
|
||||
|
||||
private suspend fun updateNotification(contactKey: String, dataPacket: DataPacket, isSilent: Boolean) {
|
||||
when (dataPacket.dataType) {
|
||||
PortNum.TEXT_MESSAGE_APP.value -> {
|
||||
val message = dataPacket.text!!
|
||||
val channelName =
|
||||
if (dataPacket.to == DataPacket.ID_BROADCAST) {
|
||||
radioConfigRepository.channelSetFlow.first().settings.getOrNull(dataPacket.channel)?.name
|
||||
} else {
|
||||
null
|
||||
}
|
||||
serviceNotifications.updateMessageNotification(
|
||||
contactKey,
|
||||
getSenderName(dataPacket),
|
||||
message,
|
||||
dataPacket.to == DataPacket.ID_BROADCAST,
|
||||
channelName,
|
||||
isSilent,
|
||||
)
|
||||
}
|
||||
|
||||
PortNum.WAYPOINT_APP.value -> {
|
||||
val message = getStringSuspend(Res.string.waypoint_received, dataPacket.waypoint!!.name)
|
||||
notificationManager.dispatch(
|
||||
Notification(
|
||||
title = getSenderName(dataPacket),
|
||||
message = message,
|
||||
category = Notification.Category.Message,
|
||||
contactKey = contactKey,
|
||||
isSilent = isSilent,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
else -> return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.repository.NeighborInfoHandler
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.NeighborInfo
|
||||
@@ -35,7 +34,6 @@ import org.meshtastic.proto.NeighborInfo
|
||||
class NeighborInfoHandlerImpl(
|
||||
private val nodeManager: NodeManager,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val serviceBroadcasts: ServiceBroadcasts,
|
||||
private val nodeRepository: NodeRepository,
|
||||
) : NeighborInfoHandler {
|
||||
|
||||
@@ -59,7 +57,7 @@ class NeighborInfoHandlerImpl(
|
||||
}
|
||||
|
||||
// Update Node DB
|
||||
nodeManager.nodeDBbyNodeNum[from]?.let { serviceBroadcasts.broadcastNodeChange(it) }
|
||||
nodeManager.nodeDBbyNodeNum[from]?.let { /* SDK client.nodes is canonical source */ }
|
||||
|
||||
// Format for UI response
|
||||
val requestId = packet.decoded?.request_id ?: 0
|
||||
|
||||
@@ -41,7 +41,6 @@ import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.data.repository.SdkNodeRepositoryImpl
|
||||
import org.meshtastic.core.repository.Notification
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.getStringSuspend
|
||||
import org.meshtastic.core.resources.new_node_seen
|
||||
@@ -60,7 +59,6 @@ import org.meshtastic.proto.Position as ProtoPosition
|
||||
@Single(binds = [NodeManager::class, NodeIdLookup::class])
|
||||
class NodeManagerImpl(
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val serviceBroadcasts: ServiceBroadcasts,
|
||||
private val notificationManager: NotificationManager,
|
||||
@Named("ServiceScope") private val scope: CoroutineScope,
|
||||
) : NodeManager {
|
||||
@@ -197,9 +195,7 @@ class NodeManagerImpl(
|
||||
scope.handledLaunch { nodeRepository.upsert(result) }
|
||||
}
|
||||
|
||||
if (withBroadcast) {
|
||||
serviceBroadcasts.broadcastNodeChange(result)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun handleReceivedUser(fromNum: Int, p: User, channel: Int, manuallyVerified: Boolean) {
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.asDeferred
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.consumeAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.MeshLog
|
||||
import org.meshtastic.core.model.MessageStatus
|
||||
import org.meshtastic.core.model.RadioNotConnectedException
|
||||
import org.meshtastic.core.model.util.toOneLineString
|
||||
import org.meshtastic.core.model.util.toPIIString
|
||||
import org.meshtastic.core.repository.MeshLogRepository
|
||||
import org.meshtastic.core.repository.PacketHandler
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.proto.FromRadio
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.QueueStatus
|
||||
import org.meshtastic.proto.ToRadio
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@Suppress("TooManyFunctions")
|
||||
@Single
|
||||
class PacketHandlerImpl(
|
||||
private val packetRepository: Lazy<PacketRepository>,
|
||||
private val serviceBroadcasts: ServiceBroadcasts,
|
||||
private val radioInterfaceService: RadioInterfaceService,
|
||||
private val meshLogRepository: Lazy<MeshLogRepository>,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
@Named("ServiceScope") private val scope: CoroutineScope,
|
||||
) : PacketHandler {
|
||||
|
||||
companion object {
|
||||
private val TIMEOUT = 5.seconds
|
||||
}
|
||||
|
||||
private var queueJob: Job? = null
|
||||
|
||||
private val queueMutex = Mutex()
|
||||
private val queuedPackets = mutableListOf<MeshPacket>()
|
||||
|
||||
// Unbounded channel preserves FIFO ordering of fire-and-forget sendToRadio(MeshPacket)
|
||||
// calls. The non-suspend entry point does trySend (always succeeds for UNLIMITED) and
|
||||
// a single consumer coroutine enqueues packets under queueMutex in arrival order.
|
||||
private val outboundChannel = Channel<MeshPacket>(Channel.UNLIMITED)
|
||||
|
||||
// Set to true by stopPacketQueue() under queueMutex. Checked by startPacketQueueLocked()
|
||||
// and the queue processor's finally block to prevent restarting a stopped queue.
|
||||
private var queueStopped = false
|
||||
|
||||
private val responseMutex = Mutex()
|
||||
private val queueResponse = mutableMapOf<Int, CompletableDeferred<Boolean>>()
|
||||
|
||||
init {
|
||||
// Single consumer serializes enqueues from the non-suspend sendToRadio(MeshPacket)
|
||||
// entry point, preserving FIFO across rapid concurrent callers.
|
||||
scope.launch {
|
||||
outboundChannel.consumeAsFlow().collect { packet ->
|
||||
queueMutex.withLock {
|
||||
queueStopped = false // Allow queue to resume after a disconnect/reconnect cycle.
|
||||
queuedPackets.add(packet)
|
||||
startPacketQueueLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendToRadio(p: ToRadio) {
|
||||
Logger.d { "Sending to radio ${p.toPIIString()}" }
|
||||
val b = p.encode()
|
||||
|
||||
radioInterfaceService.sendToRadio(b)
|
||||
p.packet?.id?.let { changeStatus(it, MessageStatus.ENROUTE) }
|
||||
|
||||
val packet = p.packet
|
||||
if (packet?.decoded != null) {
|
||||
val packetToSave =
|
||||
MeshLog(
|
||||
uuid = Uuid.random().toString(),
|
||||
message_type = "Packet",
|
||||
received_date = nowMillis,
|
||||
raw_message = packet.toString(),
|
||||
fromNum = MeshLog.NODE_NUM_LOCAL,
|
||||
portNum = packet.decoded?.portnum?.value ?: 0,
|
||||
fromRadio = FromRadio(packet = packet),
|
||||
)
|
||||
insertMeshLog(packetToSave)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendToRadio(packet: MeshPacket) {
|
||||
// Non-suspend entry point — order-preserving via unbounded channel drained by
|
||||
// a single consumer coroutine. trySend on UNLIMITED never fails for capacity.
|
||||
outboundChannel.trySend(packet)
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
||||
override suspend fun sendToRadioAndAwait(packet: MeshPacket): Boolean {
|
||||
// Pre-register the deferred so the queue processor and QueueStatus handler
|
||||
// can find it immediately — no polling required.
|
||||
val deferred = CompletableDeferred<Boolean>()
|
||||
responseMutex.withLock { queueResponse[packet.id] = deferred }
|
||||
queueMutex.withLock {
|
||||
queueStopped = false // Allow queue to resume after a disconnect/reconnect cycle.
|
||||
queuedPackets.add(packet)
|
||||
startPacketQueueLocked()
|
||||
}
|
||||
return try {
|
||||
withTimeout(TIMEOUT) { deferred.await() }
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
Logger.d { "sendToRadioAndAwait packet id=${packet.id.toUInt()} timeout" }
|
||||
false
|
||||
} catch (e: CancellationException) {
|
||||
throw e // Preserve structured concurrency cancellation propagation.
|
||||
} catch (e: Exception) {
|
||||
Logger.d { "sendToRadioAndAwait packet id=${packet.id.toUInt()} failed: ${e.message}" }
|
||||
false
|
||||
} finally {
|
||||
responseMutex.withLock { queueResponse.remove(packet.id) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun stopPacketQueue() {
|
||||
// Run async so callers (non-suspend) don't block, but all mutations are
|
||||
// serialized under the same mutexes used by the queue processor and senders.
|
||||
scope.launch {
|
||||
Logger.i { "Stopping packet queueJob" }
|
||||
queueMutex.withLock {
|
||||
queueStopped = true
|
||||
queueJob?.cancel()
|
||||
queueJob = null
|
||||
queuedPackets.clear()
|
||||
}
|
||||
responseMutex.withLock {
|
||||
queueResponse.values.forEach { if (!it.isCompleted) it.complete(false) }
|
||||
queueResponse.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleQueueStatus(queueStatus: QueueStatus) {
|
||||
Logger.d { "[queueStatus] ${queueStatus.toOneLineString()}" }
|
||||
val (success, isFull, requestId) = with(queueStatus) { Triple(res == 0, free == 0, mesh_packet_id) }
|
||||
if (success && isFull) return
|
||||
|
||||
scope.launch {
|
||||
responseMutex.withLock {
|
||||
if (requestId != 0) {
|
||||
queueResponse.remove(requestId)?.complete(success)
|
||||
} else {
|
||||
queueResponse.values.firstOrNull { !it.isCompleted }?.complete(success)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun removeResponse(dataRequestId: Int, complete: Boolean) {
|
||||
scope.launch { responseMutex.withLock { queueResponse.remove(dataRequestId)?.complete(complete) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the packet queue processor. Must be called while holding [queueMutex] to ensure the check-then-start is
|
||||
* atomic — preventing two concurrent callers from launching duplicate processors.
|
||||
*/
|
||||
private fun startPacketQueueLocked() {
|
||||
if (queueStopped) return
|
||||
if (queueJob?.isActive == true) return
|
||||
queueJob =
|
||||
scope.handledLaunch {
|
||||
try {
|
||||
while (serviceRepository.connectionState.value == ConnectionState.Connected) {
|
||||
val packet = queueMutex.withLock { queuedPackets.removeFirstOrNull() } ?: break
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException")
|
||||
try {
|
||||
val response = sendPacket(packet)
|
||||
Logger.d { "queueJob packet id=${packet.id.toUInt()} waiting" }
|
||||
val success = withTimeout(TIMEOUT) { response.await() }
|
||||
Logger.d { "queueJob packet id=${packet.id.toUInt()} success $success" }
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
Logger.d { "queueJob packet id=${packet.id.toUInt()} timeout" }
|
||||
// Clean up the deferred for this packet. sendToRadioAndAwait callers
|
||||
// also clean up in their own finally block (idempotent remove).
|
||||
responseMutex.withLock { queueResponse.remove(packet.id) }
|
||||
} catch (e: CancellationException) {
|
||||
throw e // Preserve structured concurrency cancellation propagation.
|
||||
} catch (e: Exception) {
|
||||
Logger.d { "queueJob packet id=${packet.id.toUInt()} failed" }
|
||||
responseMutex.withLock { queueResponse.remove(packet.id) }
|
||||
}
|
||||
// Deferred cleanup is now handled in the catch blocks above.
|
||||
// handleQueueStatus (normal success) and stopPacketQueue (bulk cleanup)
|
||||
// also remove entries, and these removals are idempotent.
|
||||
}
|
||||
} finally {
|
||||
// Hold queueMutex so that clearing queueJob and the restart decision are
|
||||
// atomic with respect to new senders calling startPacketQueueLocked().
|
||||
queueMutex.withLock {
|
||||
queueJob = null
|
||||
if (!queueStopped && queuedPackets.isNotEmpty()) {
|
||||
startPacketQueueLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun changeStatus(packetId: Int, m: MessageStatus) = scope.handledLaunch {
|
||||
if (packetId != 0) {
|
||||
getDataPacketById(packetId)?.let { p ->
|
||||
if (p.status == m) return@handledLaunch
|
||||
packetRepository.value.updateMessageStatus(p, m)
|
||||
serviceBroadcasts.broadcastMessageStatus(packetId, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getDataPacketById(packetId: Int): DataPacket? = withTimeoutOrNull(1.seconds) {
|
||||
var dataPacket: DataPacket? = null
|
||||
while (dataPacket == null) {
|
||||
dataPacket = packetRepository.value.getPacketById(packetId)
|
||||
if (dataPacket == null) delay(100.milliseconds)
|
||||
}
|
||||
dataPacket
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
private suspend fun sendPacket(packet: MeshPacket): Deferred<Boolean> {
|
||||
// Reuse a deferred pre-registered by sendToRadioAndAwait, or create a new one.
|
||||
val deferred = responseMutex.withLock { queueResponse.getOrPut(packet.id) { CompletableDeferred() } }
|
||||
try {
|
||||
if (serviceRepository.connectionState.value != ConnectionState.Connected) {
|
||||
throw RadioNotConnectedException()
|
||||
}
|
||||
sendToRadio(ToRadio(packet = packet))
|
||||
} catch (ex: RadioNotConnectedException) {
|
||||
Logger.w(ex) { "sendToRadio skipped: Not connected to radio" }
|
||||
deferred.complete(false)
|
||||
} catch (ex: Exception) {
|
||||
Logger.e(ex) { "sendToRadio error: ${ex.message}" }
|
||||
deferred.complete(false)
|
||||
}
|
||||
// Return a read-only Deferred view (kotlinx.coroutines 1.11+) so callers can await it
|
||||
// without being able to complete the underlying CompletableDeferred; cancellation is
|
||||
// still exposed via Deferred/Job.
|
||||
return deferred.asDeferred()
|
||||
}
|
||||
|
||||
private fun insertMeshLog(packetToSave: MeshLog) {
|
||||
scope.handledLaunch {
|
||||
Logger.d {
|
||||
"insert: ${packetToSave.message_type} = " +
|
||||
"${packetToSave.raw_message.toOneLineString()} from=${packetToSave.fromNum}"
|
||||
}
|
||||
meshLogRepository.value.insert(packetToSave)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,6 @@ import org.meshtastic.core.repository.HistoryManager
|
||||
import org.meshtastic.core.repository.MeshDataHandler
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
import org.meshtastic.core.repository.StoreForwardPacketHandler
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.PortNum
|
||||
@@ -43,7 +42,6 @@ import kotlin.time.Duration.Companion.milliseconds
|
||||
class StoreForwardPacketHandlerImpl(
|
||||
private val nodeManager: NodeManager,
|
||||
private val packetRepository: Lazy<PacketRepository>,
|
||||
private val serviceBroadcasts: ServiceBroadcasts,
|
||||
private val historyManager: HistoryManager,
|
||||
private val dataHandler: Lazy<MeshDataHandler>,
|
||||
@Named("ServiceScope") private val scope: CoroutineScope,
|
||||
@@ -125,7 +123,6 @@ class StoreForwardPacketHandlerImpl(
|
||||
rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL,
|
||||
myNodeNum = nodeManager.myNodeNum.value ?: 0,
|
||||
)
|
||||
serviceBroadcasts.broadcastMessageStatus(sfpp.encapsulated_id, status)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.util.decodeOrNull
|
||||
import org.meshtastic.core.model.util.toOneLiner
|
||||
import org.meshtastic.core.repository.MeshConnectionManager
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.Notification
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
@@ -48,7 +47,6 @@ import kotlin.time.Duration.Companion.milliseconds
|
||||
@Single
|
||||
class TelemetryPacketHandlerImpl(
|
||||
private val nodeManager: NodeManager,
|
||||
private val connectionManager: Lazy<MeshConnectionManager>,
|
||||
private val notificationManager: NotificationManager,
|
||||
@Named("ServiceScope") private val scope: CoroutineScope,
|
||||
) : TelemetryPacketHandler {
|
||||
@@ -66,9 +64,8 @@ class TelemetryPacketHandlerImpl(
|
||||
Logger.d { "Telemetry from ${packet.from}: ${Telemetry.ADAPTER.toOneLiner(t)}" }
|
||||
val fromNum = packet.from
|
||||
val isRemote = (fromNum != myNodeNum)
|
||||
if (!isRemote) {
|
||||
connectionManager.value.updateTelemetry(t)
|
||||
}
|
||||
// Note: Local telemetry notification update was previously handled by
|
||||
// MeshConnectionManager.updateTelemetry(), now managed via SDK flows.
|
||||
|
||||
nodeManager.updateNode(fromNum) { node: Node ->
|
||||
val metrics = t.device_metrics
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.radio
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.meshtastic.sdk.RadioClient
|
||||
|
||||
/**
|
||||
* Platform-agnostic accessor for the active [RadioClient] instance.
|
||||
*
|
||||
* Implemented by platform-specific providers (Android's `RadioClientProvider`, Desktop's
|
||||
* `DesktopRadioClientProvider`) that handle transport creation and lifecycle. The shared
|
||||
* [SdkRadioController] and [SdkStateBridge] depend on this interface rather than any
|
||||
* concrete provider.
|
||||
*/
|
||||
interface RadioClientAccessor {
|
||||
/** Active [RadioClient], or `null` when disconnected or between connections. */
|
||||
val client: StateFlow<RadioClient?>
|
||||
|
||||
/** Tear down the existing client and rebuild + connect using the current saved address. */
|
||||
fun rebuildAndConnectAsync()
|
||||
|
||||
/** Gracefully disconnect and release the active SDK radio client. */
|
||||
fun disconnect()
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.radio
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.repository.PacketHandler
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.QueueStatus
|
||||
import org.meshtastic.proto.ToRadio
|
||||
|
||||
/**
|
||||
* SDK-backed [PacketHandler] that sends packets through the active [RadioClient].
|
||||
*
|
||||
* Replaces the monolithic [PacketHandlerImpl] which routed through the old
|
||||
* `RadioInterfaceService.sendToRadio()` pipeline. This thin implementation only supports the
|
||||
* `sendToRadio` surface needed by MQTT, XModem, and History managers.
|
||||
*
|
||||
* Queue management (QueueStatus, packet ordering) is handled internally by the SDK engine.
|
||||
*/
|
||||
@Single(binds = [PacketHandler::class])
|
||||
class SdkPacketHandler(
|
||||
private val accessor: RadioClientAccessor,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : PacketHandler {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatchers.io)
|
||||
|
||||
override fun sendToRadio(p: ToRadio) {
|
||||
val packet = p.packet
|
||||
if (packet != null) {
|
||||
// Regular MeshPacket — route through the tracked send path.
|
||||
sendToRadio(packet)
|
||||
return
|
||||
}
|
||||
// Non-packet ToRadio (mqttClientProxyMessage, xmodemPacket) — send as raw frame.
|
||||
val client = accessor.client.value ?: run {
|
||||
Logger.w { "SdkPacketHandler: no client, dropping non-packet ToRadio" }
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
runCatching { client.sendRaw(p) }
|
||||
.onFailure { e -> Logger.w(e) { "SdkPacketHandler: sendRaw(ToRadio) failed" } }
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendToRadio(packet: MeshPacket) {
|
||||
val client = accessor.client.value ?: run {
|
||||
Logger.w { "SdkPacketHandler: no client, dropping packet id=${packet.id}" }
|
||||
return
|
||||
}
|
||||
client.send(packet)
|
||||
}
|
||||
|
||||
override suspend fun sendToRadioAndAwait(packet: MeshPacket): Boolean {
|
||||
val client = accessor.client.value ?: return false
|
||||
return runCatching { client.send(packet) }.isSuccess
|
||||
}
|
||||
|
||||
override fun handleQueueStatus(queueStatus: QueueStatus) {
|
||||
// Queue management is internal to the SDK engine; no-op.
|
||||
}
|
||||
|
||||
override fun removeResponse(dataRequestId: Int, complete: Boolean) {
|
||||
// Response tracking is internal to the SDK engine; no-op.
|
||||
}
|
||||
|
||||
override fun stopPacketQueue() {
|
||||
// Queue management is internal to the SDK engine; no-op.
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,11 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.radio
|
||||
package org.meshtastic.core.data.radio
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.atomicfu.atomic
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
@@ -42,37 +42,35 @@ import org.meshtastic.sdk.AdminResult
|
||||
import org.meshtastic.sdk.ChannelIndex
|
||||
import org.meshtastic.sdk.NodeId
|
||||
import org.meshtastic.sdk.RadioClient
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* [RadioController] implementation that delegates all operations through the meshtastic-sdk.
|
||||
* Shared KMP [RadioController] implementation that delegates all operations through the meshtastic-sdk.
|
||||
*
|
||||
* This replaces [org.meshtastic.core.service.AndroidRadioControllerImpl] in the hard-cutover POC. Feature modules
|
||||
* continue injecting [RadioController] and get SDK-backed behavior without code changes.
|
||||
* Feature modules inject [RadioController] and get SDK-backed behavior without needing platform-specific code.
|
||||
*
|
||||
* **Command dispatch:** All admin, telemetry, and routing operations go through [RadioClient.admin],
|
||||
* [RadioClient.telemetry], and [RadioClient.routing] respectively.
|
||||
*
|
||||
* **State distribution:** Handled separately by [SdkStateBridge], which feeds SDK flows back into
|
||||
* **State distribution:** Handled by [SdkStateBridge], which feeds SDK flows into
|
||||
* [ServiceRepository] and [org.meshtastic.core.repository.NodeManager].
|
||||
*/
|
||||
@Single(binds = [RadioController::class])
|
||||
@Suppress("TooManyFunctions", "LongParameterList")
|
||||
class SdkRadioControllerImpl(
|
||||
private val provider: RadioClientProvider,
|
||||
class SdkRadioController(
|
||||
private val accessor: RadioClientAccessor,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val locationManager: MeshLocationManager,
|
||||
) : RadioController {
|
||||
|
||||
private val packetIdCounter = AtomicInteger(1)
|
||||
private val packetIdCounter = atomic(1)
|
||||
|
||||
private val client: RadioClient?
|
||||
get() = provider.client.value
|
||||
get() = accessor.client.value
|
||||
|
||||
private fun requireClient(): RadioClient {
|
||||
return client ?: run {
|
||||
Logger.w { "SdkRadioControllerImpl: no active RadioClient" }
|
||||
Logger.w { "SdkRadioController: no active RadioClient" }
|
||||
throw IllegalStateException("RadioClient not connected")
|
||||
}
|
||||
}
|
||||
@@ -105,7 +103,7 @@ class SdkRadioControllerImpl(
|
||||
channel = packet.channel,
|
||||
decoded = Data(
|
||||
portnum = PortNum.fromValue(packet.dataType) ?: PortNum.UNKNOWN_APP,
|
||||
payload = packet.bytes ?: ByteString.EMPTY,
|
||||
payload = packet.bytes ?: okio.ByteString.EMPTY,
|
||||
want_response = false,
|
||||
),
|
||||
)
|
||||
@@ -272,7 +270,12 @@ class SdkRadioControllerImpl(
|
||||
if (isLocalNode(destNum)) {
|
||||
c.admin.getCannedMessages()
|
||||
} else {
|
||||
sendRemoteAdmin(c, destNum, AdminMessage(get_canned_message_module_messages_request = true), wantResponse = true)
|
||||
sendRemoteAdmin(
|
||||
c,
|
||||
destNum,
|
||||
AdminMessage(get_canned_message_module_messages_request = true),
|
||||
wantResponse = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,7 +284,12 @@ class SdkRadioControllerImpl(
|
||||
if (isLocalNode(destNum)) {
|
||||
c.admin.getDeviceConnectionStatus()
|
||||
} else {
|
||||
sendRemoteAdmin(c, destNum, AdminMessage(get_device_connection_status_request = true), wantResponse = true)
|
||||
sendRemoteAdmin(
|
||||
c,
|
||||
destNum,
|
||||
AdminMessage(get_device_connection_status_request = true),
|
||||
wantResponse = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,7 +374,6 @@ class SdkRadioControllerImpl(
|
||||
|
||||
override suspend fun requestUserInfo(destNum: Int) {
|
||||
val c = client ?: return
|
||||
// Send an empty NODEINFO_APP packet with want_response to request user info
|
||||
c.send(
|
||||
MeshPacket(
|
||||
to = destNum,
|
||||
@@ -388,7 +395,6 @@ class SdkRadioControllerImpl(
|
||||
override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {
|
||||
val c = requireClient()
|
||||
val node = NodeId(destNum)
|
||||
// TelemetryType enum values: 0=DEVICE, 1=ENVIRONMENT, 2=AIR_QUALITY, 3=POWER, 4=LOCAL_STATS, 5=HEALTH
|
||||
when (typeValue) {
|
||||
0 -> c.telemetry.requestDevice(node)
|
||||
1 -> c.telemetry.requestEnvironment(node)
|
||||
@@ -411,7 +417,6 @@ class SdkRadioControllerImpl(
|
||||
|
||||
override suspend fun beginEditSettings(destNum: Int) {
|
||||
val c = client ?: return
|
||||
// Send raw begin_edit_settings admin message for compatibility with the split begin/commit pattern
|
||||
val msg = AdminMessage(begin_edit_settings = true)
|
||||
val target = if (isLocalNode(destNum)) NodeId(c.ownNode.value?.num ?: 0) else NodeId(destNum)
|
||||
sendRemoteAdmin(c, target.raw, msg)
|
||||
@@ -429,7 +434,7 @@ class SdkRadioControllerImpl(
|
||||
override fun getPacketId(): Int = packetIdCounter.getAndIncrement()
|
||||
|
||||
override fun startProvideLocation() {
|
||||
// Location provision is managed at the app level; no-op until bridge wires it
|
||||
// Location provision is managed at the app level; no-op here
|
||||
}
|
||||
|
||||
override fun stopProvideLocation() {
|
||||
@@ -437,8 +442,7 @@ class SdkRadioControllerImpl(
|
||||
}
|
||||
|
||||
override fun setDeviceAddress(address: String) {
|
||||
// Changing device address requires rebuilding the SDK client connection
|
||||
provider.rebuildAndConnectAsync()
|
||||
accessor.rebuildAndConnectAsync()
|
||||
}
|
||||
|
||||
// ── Private helpers ─────────────────────────────────────────────────────
|
||||
@@ -449,10 +453,6 @@ class SdkRadioControllerImpl(
|
||||
return destNum == ownNum
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a raw admin message to a remote node via the SDK's send path.
|
||||
* Used for remote-admin operations where destNum != local node.
|
||||
*/
|
||||
private suspend fun sendRemoteAdmin(
|
||||
c: RadioClient,
|
||||
destNum: Int,
|
||||
@@ -14,24 +14,29 @@
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.radio
|
||||
package org.meshtastic.core.data.radio
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.ConnectionState as AppConnectionState
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.model.service.ServiceAction
|
||||
import org.meshtastic.core.repository.MeshLocationManager
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.repository.UiPrefs
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
import org.meshtastic.proto.Data
|
||||
@@ -52,28 +57,23 @@ import org.meshtastic.sdk.NodeId
|
||||
* and [NodeManager] so that existing feature-module UI code (which observes those repositories)
|
||||
* continues to work without modification.
|
||||
*
|
||||
* **Node state:** The SDK's [NodeChange] flow provides fully-updated [NodeInfo] instances that
|
||||
* already include position, telemetry, and user changes. No manual packet decoding is needed.
|
||||
*
|
||||
* **Packets:** Raw [MeshPacket]s are forwarded to [ServiceRepository.emitMeshPacket] for
|
||||
* consumers that need them (RadioConfigViewModel admin responses, TAK integration).
|
||||
*
|
||||
* **ServiceActions:** Handled inline via SDK [AdminApi] — eliminates the old
|
||||
* MeshServiceOrchestrator → MeshActionHandler → CommandSender dispatch chain.
|
||||
*
|
||||
* **Lifecycle:** Created as a Koin `@Single`. Automatically subscribes to [RadioClientProvider.client]
|
||||
* **Lifecycle:** Created as a Koin `@Single`. Automatically subscribes to [RadioClientAccessor.client]
|
||||
* and starts/stops collection as clients come and go.
|
||||
*/
|
||||
@Single
|
||||
@Suppress("TooManyFunctions")
|
||||
class SdkStateBridge(
|
||||
private val provider: RadioClientProvider,
|
||||
private val accessor: RadioClientAccessor,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val nodeManager: NodeManager,
|
||||
private val packetRepository: Lazy<PacketRepository>,
|
||||
private val locationManager: MeshLocationManager,
|
||||
private val uiPrefs: UiPrefs,
|
||||
private val radioController: RadioController,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
|
||||
private var locationJob: Job? = null
|
||||
|
||||
init {
|
||||
startBridge()
|
||||
@@ -82,13 +82,13 @@ class SdkStateBridge(
|
||||
private fun startBridge() {
|
||||
|
||||
// ── Connection state ────────────────────────────────────────────────
|
||||
provider.client
|
||||
accessor.client
|
||||
.flatMapLatest { client -> client?.connection ?: flowOf(SdkConnectionState.Disconnected) }
|
||||
.onEach { sdkState -> serviceRepository.setConnectionState(mapConnectionState(sdkState)) }
|
||||
.launchIn(scope)
|
||||
|
||||
// ── Node updates (position, telemetry, user all included in NodeInfo) ─
|
||||
provider.client
|
||||
accessor.client
|
||||
.flatMapLatest { client -> client?.nodes ?: flowOf() }
|
||||
.onEach { change ->
|
||||
when (change) {
|
||||
@@ -107,19 +107,19 @@ class SdkStateBridge(
|
||||
.launchIn(scope)
|
||||
|
||||
// ── Own node identity ───────────────────────────────────────────────
|
||||
provider.client
|
||||
accessor.client
|
||||
.flatMapLatest { client -> client?.ownNode ?: flowOf(null) }
|
||||
.onEach { ownNode -> if (ownNode != null) nodeManager.setMyNodeNum(ownNode.num) }
|
||||
.launchIn(scope)
|
||||
|
||||
// ── Raw packet forward (for RadioConfigViewModel + TAK) ─────────────
|
||||
provider.client
|
||||
accessor.client
|
||||
.flatMapLatest { client -> client?.packets ?: flowOf() }
|
||||
.onEach { packet -> serviceRepository.emitMeshPacket(packet) }
|
||||
.launchIn(scope)
|
||||
|
||||
// ── Events (notifications, security, backpressure) ──────────────────
|
||||
provider.client
|
||||
accessor.client
|
||||
.flatMapLatest { client -> client?.events ?: flowOf() }
|
||||
.onEach { event ->
|
||||
when (event) {
|
||||
@@ -141,13 +141,43 @@ class SdkStateBridge(
|
||||
.onEach { action -> handleServiceAction(action) }
|
||||
.launchIn(scope)
|
||||
|
||||
// ── Location publishing ─────────────────────────────────────────────
|
||||
accessor.client
|
||||
.flatMapLatest { client -> client?.ownNode ?: flowOf(null) }
|
||||
.onEach { ownNode ->
|
||||
locationJob?.cancel()
|
||||
locationJob = null
|
||||
if (ownNode != null) {
|
||||
locationJob = uiPrefs.shouldProvideNodeLocation(ownNode.num)
|
||||
.onEach { shouldProvide ->
|
||||
if (shouldProvide) {
|
||||
locationManager.start(scope) { pos ->
|
||||
scope.launch {
|
||||
val packet = DataPacket(
|
||||
bytes = okio.ByteString.of(
|
||||
*org.meshtastic.proto.Position.ADAPTER.encode(pos),
|
||||
),
|
||||
dataType = PortNum.POSITION_APP.value,
|
||||
)
|
||||
radioController.sendMessage(packet)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
locationManager.stop()
|
||||
}
|
||||
}
|
||||
.launchIn(scope)
|
||||
}
|
||||
}
|
||||
.launchIn(scope)
|
||||
|
||||
Logger.i { "SdkStateBridge started — SDK owns transport + ServiceAction dispatch" }
|
||||
}
|
||||
|
||||
// ── ServiceAction handling ───────────────────────────────────────────────
|
||||
|
||||
private suspend fun handleServiceAction(action: ServiceAction) {
|
||||
val client = provider.client.value
|
||||
val client = accessor.client.value
|
||||
if (client == null) {
|
||||
Logger.w { "[SdkBridge] ServiceAction ${action::class.simpleName} dropped — no client" }
|
||||
if (action is ServiceAction.SendContact) action.result.complete(false)
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.database.DatabaseProvider
|
||||
import org.meshtastic.core.database.entity.NodeMetadataEntity
|
||||
import org.meshtastic.core.repository.AppMetadataRepository
|
||||
import org.meshtastic.core.repository.NodeMetadata
|
||||
|
||||
@Single(binds = [AppMetadataRepository::class])
|
||||
class AppMetadataRepositoryImpl(
|
||||
private val dbManager: DatabaseProvider,
|
||||
) : AppMetadataRepository {
|
||||
|
||||
override val metadataByNum: Flow<Map<Int, NodeMetadata>> =
|
||||
dbManager.currentDb.flatMapLatest { db -> db.nodeMetadataDao().getAllFlow() }
|
||||
.map { list -> list.associate { it.num to it.toModel() } }
|
||||
|
||||
override suspend fun setFavorite(nodeNum: Int, isFavorite: Boolean) {
|
||||
ensureExists(nodeNum)
|
||||
dbManager.withDb { it.nodeMetadataDao().setFavorite(nodeNum, isFavorite) }
|
||||
}
|
||||
|
||||
override suspend fun setIgnored(nodeNum: Int, isIgnored: Boolean) {
|
||||
ensureExists(nodeNum)
|
||||
dbManager.withDb { it.nodeMetadataDao().setIgnored(nodeNum, isIgnored) }
|
||||
}
|
||||
|
||||
override suspend fun setMuted(nodeNum: Int, isMuted: Boolean) {
|
||||
ensureExists(nodeNum)
|
||||
dbManager.withDb { it.nodeMetadataDao().setMuted(nodeNum, isMuted) }
|
||||
}
|
||||
|
||||
override suspend fun setNotes(nodeNum: Int, notes: String) {
|
||||
ensureExists(nodeNum)
|
||||
dbManager.withDb { it.nodeMetadataDao().setNotes(nodeNum, notes) }
|
||||
}
|
||||
|
||||
override suspend fun setManuallyVerified(nodeNum: Int, verified: Boolean) {
|
||||
ensureExists(nodeNum)
|
||||
dbManager.withDb { it.nodeMetadataDao().setManuallyVerified(nodeNum, verified) }
|
||||
}
|
||||
|
||||
override suspend fun delete(nodeNum: Int) {
|
||||
dbManager.withDb { it.nodeMetadataDao().delete(nodeNum) }
|
||||
}
|
||||
|
||||
private suspend fun ensureExists(nodeNum: Int) {
|
||||
dbManager.withDb { db ->
|
||||
if (db.nodeMetadataDao().getByNum(nodeNum) == null) {
|
||||
db.nodeMetadataDao().upsert(NodeMetadataEntity(num = nodeNum))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun NodeMetadataEntity.toModel() = NodeMetadata(
|
||||
num = num,
|
||||
isFavorite = isFavorite,
|
||||
isIgnored = isIgnored,
|
||||
isMuted = isMuted,
|
||||
notes = notes,
|
||||
manuallyVerified = manuallyVerified,
|
||||
)
|
||||
@@ -1,290 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.repository
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.conflate
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.data.datasource.NodeInfoReadDataSource
|
||||
import org.meshtastic.core.data.datasource.NodeInfoWriteDataSource
|
||||
import org.meshtastic.core.database.entity.MetadataEntity
|
||||
import org.meshtastic.core.database.entity.MyNodeEntity
|
||||
import org.meshtastic.core.database.entity.NodeEntity
|
||||
import org.meshtastic.core.datastore.LocalStatsDataSource
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.MeshLog
|
||||
import org.meshtastic.core.model.MyNodeInfo
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.NodeSortOption
|
||||
import org.meshtastic.core.model.util.onlineTimeThreshold
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.proto.DeviceMetadata
|
||||
import org.meshtastic.proto.LocalStats
|
||||
import org.meshtastic.proto.User
|
||||
|
||||
/** Repository for managing node-related data, including hardware info, node database, and identity. */
|
||||
// @Single — Replaced by SdkNodeRepositoryImpl in SDK mode. Kept for reference/desktop fallback.
|
||||
@Suppress("TooManyFunctions")
|
||||
class NodeRepositoryImpl(
|
||||
@Named("ProcessLifecycle") private val processLifecycle: Lifecycle,
|
||||
private val nodeInfoReadDataSource: NodeInfoReadDataSource,
|
||||
private val nodeInfoWriteDataSource: NodeInfoWriteDataSource,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val localStatsDataSource: LocalStatsDataSource,
|
||||
) : NodeRepository {
|
||||
/** Hardware info about our local device (can be null if not connected). */
|
||||
override val myNodeInfo: StateFlow<MyNodeInfo?> =
|
||||
nodeInfoReadDataSource
|
||||
.myNodeInfoFlow()
|
||||
.map { it?.toMyNodeInfo() }
|
||||
.flowOn(dispatchers.io)
|
||||
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, null)
|
||||
|
||||
private val _ourNodeInfo = MutableStateFlow<Node?>(null)
|
||||
|
||||
/** Information about the locally connected node, as seen from the mesh. */
|
||||
override val ourNodeInfo: StateFlow<Node?>
|
||||
get() = _ourNodeInfo
|
||||
|
||||
private val _myId = MutableStateFlow<String?>(null)
|
||||
|
||||
/** The unique userId (hex string) of our local node. */
|
||||
override val myId: StateFlow<String?>
|
||||
get() = _myId
|
||||
|
||||
/** The latest local stats telemetry received from the locally connected node. */
|
||||
override val localStats: StateFlow<LocalStats> =
|
||||
localStatsDataSource.localStatsFlow.stateIn(
|
||||
processLifecycle.coroutineScope,
|
||||
SharingStarted.Eagerly,
|
||||
LocalStats(),
|
||||
)
|
||||
|
||||
/** Update the cached local stats telemetry. */
|
||||
override fun updateLocalStats(stats: LocalStats) {
|
||||
processLifecycle.coroutineScope.launch { localStatsDataSource.setLocalStats(stats) }
|
||||
}
|
||||
|
||||
/** A reactive map from nodeNum to [Node] objects, representing the entire mesh. */
|
||||
override val nodeDBbyNum: StateFlow<Map<Int, Node>> =
|
||||
nodeInfoReadDataSource
|
||||
.nodeDBbyNumFlow()
|
||||
.mapLatest { map -> map.mapValues { (_, it) -> it.toModel() } }
|
||||
.flowOn(dispatchers.io)
|
||||
.conflate()
|
||||
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap())
|
||||
|
||||
init {
|
||||
// Backfill denormalized name columns for existing nodes on startup
|
||||
processLifecycle.coroutineScope.launch {
|
||||
processLifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) {
|
||||
withContext(dispatchers.io) { nodeInfoWriteDataSource.backfillDenormalizedNames() }
|
||||
}
|
||||
}
|
||||
|
||||
// Keep ourNodeInfo and myId correctly updated based on current connection and node DB
|
||||
combine(nodeDBbyNum, nodeInfoReadDataSource.myNodeInfoFlow()) { db, info -> info?.myNodeNum?.let { db[it] } }
|
||||
.onEach { node ->
|
||||
_ourNodeInfo.value = node
|
||||
_myId.value = node?.user?.id
|
||||
}
|
||||
.launchIn(processLifecycle.coroutineScope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the node number used for log queries. Maps [nodeNum] to [MeshLog.NODE_NUM_LOCAL] (0) if it is the locally
|
||||
* connected node.
|
||||
*/
|
||||
override fun effectiveLogNodeId(nodeNum: Int): Flow<Int> = nodeInfoReadDataSource
|
||||
.myNodeInfoFlow()
|
||||
.map { info -> if (nodeNum == info?.myNodeNum) MeshLog.NODE_NUM_LOCAL else nodeNum }
|
||||
.distinctUntilChanged()
|
||||
|
||||
fun getNodeEntityDBbyNumFlow() =
|
||||
nodeInfoReadDataSource.nodeDBbyNumFlow().map { map -> map.mapValues { (_, it) -> it.toEntity() } }
|
||||
|
||||
/** Returns the [Node] associated with a given [userId]. Falls back to a generic node if not found. */
|
||||
override fun getNode(userId: String): Node = nodeDBbyNum.value.values.find { it.user.id == userId }
|
||||
?: Node(num = DataPacket.idToDefaultNodeNum(userId) ?: 0, user = getUser(userId))
|
||||
|
||||
/** Returns the [User] info for a given [nodeNum]. */
|
||||
override fun getUser(nodeNum: Int): User = getUser(DataPacket.nodeNumToDefaultId(nodeNum))
|
||||
|
||||
private val last4 = 4
|
||||
|
||||
/** Returns the [User] info for a given [userId]. Falls back to a generic user if not found. */
|
||||
override fun getUser(userId: String): User {
|
||||
val found = nodeDBbyNum.value.values.find { it.user.id == userId }?.user
|
||||
if (found != null && found.long_name.isNotBlank() && found.short_name.isNotBlank()) {
|
||||
return found
|
||||
}
|
||||
|
||||
val fallbackId = userId.takeLast(last4)
|
||||
val defaultLong =
|
||||
if (userId == DataPacket.ID_LOCAL) {
|
||||
ourNodeInfo.value?.user?.long_name?.takeIf { it.isNotBlank() } ?: "Local"
|
||||
} else {
|
||||
"Meshtastic $fallbackId"
|
||||
}
|
||||
val defaultShort =
|
||||
if (userId == DataPacket.ID_LOCAL) {
|
||||
ourNodeInfo.value?.user?.short_name?.takeIf { it.isNotBlank() } ?: "Local"
|
||||
} else {
|
||||
fallbackId
|
||||
}
|
||||
|
||||
return found?.copy(
|
||||
long_name = found.long_name.takeIf { it.isNotBlank() } ?: defaultLong,
|
||||
short_name = found.short_name.takeIf { it.isNotBlank() } ?: defaultShort,
|
||||
) ?: User(id = userId, long_name = defaultLong, short_name = defaultShort)
|
||||
}
|
||||
|
||||
/** Returns a flow of nodes filtered and sorted according to the parameters. */
|
||||
override fun getNodes(
|
||||
sort: NodeSortOption,
|
||||
filter: String,
|
||||
includeUnknown: Boolean,
|
||||
onlyOnline: Boolean,
|
||||
onlyDirect: Boolean,
|
||||
): Flow<List<Node>> = nodeInfoReadDataSource
|
||||
.getNodesFlow(
|
||||
sort = sort.sqlValue,
|
||||
filter = filter,
|
||||
includeUnknown = includeUnknown,
|
||||
hopsAwayMax = if (onlyDirect) 0 else -1,
|
||||
lastHeardMin = if (onlyOnline) onlineTimeThreshold() else -1,
|
||||
)
|
||||
.mapLatest { list -> list.map { it.toModel() } }
|
||||
.flowOn(dispatchers.io)
|
||||
.conflate()
|
||||
|
||||
/** Upserts a [Node] to the database. */
|
||||
override suspend fun upsert(node: Node) =
|
||||
withContext(dispatchers.io) { nodeInfoWriteDataSource.upsert(node.toEntity()) }
|
||||
|
||||
/** Installs initial configuration data (local info and remote nodes) into the database. */
|
||||
override suspend fun installConfig(mi: MyNodeInfo, nodes: List<Node>) = withContext(dispatchers.io) {
|
||||
nodeInfoWriteDataSource.installConfig(mi.toEntity(), nodes.map { it.toEntity() })
|
||||
}
|
||||
|
||||
/** Deletes all nodes from the database, optionally preserving favorites. */
|
||||
override suspend fun clearNodeDB(preserveFavorites: Boolean) =
|
||||
withContext(dispatchers.io) { nodeInfoWriteDataSource.clearNodeDB(preserveFavorites) }
|
||||
|
||||
/** Clears the local node's connection info. */
|
||||
override suspend fun clearMyNodeInfo() = withContext(dispatchers.io) { nodeInfoWriteDataSource.clearMyNodeInfo() }
|
||||
|
||||
/** Deletes a node and its metadata by [num]. */
|
||||
override suspend fun deleteNode(num: Int) = withContext(dispatchers.io) {
|
||||
nodeInfoWriteDataSource.deleteNode(num)
|
||||
nodeInfoWriteDataSource.deleteMetadata(num)
|
||||
}
|
||||
|
||||
/** Deletes multiple nodes and their metadata. */
|
||||
override suspend fun deleteNodes(nodeNums: List<Int>) = withContext(dispatchers.io) {
|
||||
nodeInfoWriteDataSource.deleteNodes(nodeNums)
|
||||
nodeNums.forEach { nodeInfoWriteDataSource.deleteMetadata(it) }
|
||||
}
|
||||
|
||||
override suspend fun getNodesOlderThan(lastHeard: Int): List<Node> =
|
||||
withContext(dispatchers.io) { nodeInfoReadDataSource.getNodesOlderThan(lastHeard).map { it.toModel() } }
|
||||
|
||||
override suspend fun getUnknownNodes(): List<Node> =
|
||||
withContext(dispatchers.io) { nodeInfoReadDataSource.getUnknownNodes().map { it.toModel() } }
|
||||
|
||||
/** Persists hardware metadata for a node. */
|
||||
override suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) =
|
||||
withContext(dispatchers.io) { nodeInfoWriteDataSource.upsert(MetadataEntity(nodeNum, metadata)) }
|
||||
|
||||
/** Flow emitting the count of nodes currently considered "online". */
|
||||
override val onlineNodeCount: Flow<Int> =
|
||||
nodeInfoReadDataSource
|
||||
.nodeDBbyNumFlow()
|
||||
.mapLatest { map -> map.values.count { it.node.lastHeard > onlineTimeThreshold() } }
|
||||
.flowOn(dispatchers.io)
|
||||
.conflate()
|
||||
|
||||
/** Flow emitting the total number of nodes in the database. */
|
||||
override val totalNodeCount: Flow<Int> =
|
||||
nodeInfoReadDataSource
|
||||
.nodeDBbyNumFlow()
|
||||
.mapLatest { map -> map.values.count() }
|
||||
.flowOn(dispatchers.io)
|
||||
.conflate()
|
||||
|
||||
override suspend fun setNodeNotes(num: Int, notes: String) =
|
||||
withContext(dispatchers.io) { nodeInfoWriteDataSource.setNodeNotes(num, notes) }
|
||||
|
||||
private fun MyNodeInfo.toEntity() = MyNodeEntity(
|
||||
myNodeNum = myNodeNum,
|
||||
model = model,
|
||||
firmwareVersion = firmwareVersion,
|
||||
couldUpdate = couldUpdate,
|
||||
shouldUpdate = shouldUpdate,
|
||||
currentPacketId = currentPacketId,
|
||||
messageTimeoutMsec = messageTimeoutMsec,
|
||||
minAppVersion = minAppVersion,
|
||||
maxChannels = maxChannels,
|
||||
hasWifi = hasWifi,
|
||||
deviceId = deviceId,
|
||||
pioEnv = pioEnv,
|
||||
)
|
||||
|
||||
private fun Node.toEntity() = NodeEntity(
|
||||
num = num,
|
||||
user = user,
|
||||
position = position,
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
snr = snr,
|
||||
rssi = rssi,
|
||||
lastHeard = lastHeard,
|
||||
deviceTelemetry = org.meshtastic.proto.Telemetry(device_metrics = deviceMetrics),
|
||||
channel = channel,
|
||||
viaMqtt = viaMqtt,
|
||||
hopsAway = hopsAway,
|
||||
isFavorite = isFavorite,
|
||||
isIgnored = isIgnored,
|
||||
isMuted = isMuted,
|
||||
environmentTelemetry = org.meshtastic.proto.Telemetry(environment_metrics = environmentMetrics),
|
||||
powerTelemetry = org.meshtastic.proto.Telemetry(power_metrics = powerMetrics),
|
||||
paxcounter = paxcounter,
|
||||
publicKey = publicKey,
|
||||
notes = notes,
|
||||
manuallyVerified = manuallyVerified,
|
||||
nodeStatus = nodeStatus,
|
||||
lastTransport = lastTransport,
|
||||
)
|
||||
}
|
||||
@@ -23,12 +23,15 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.database.DatabaseProvider
|
||||
import org.meshtastic.core.database.entity.NodeMetadataEntity
|
||||
import org.meshtastic.core.datastore.LocalStatsDataSource
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.MeshLog
|
||||
@@ -50,12 +53,13 @@ import org.meshtastic.proto.User
|
||||
* SDK's NodeChange flow (bridged through SdkStateBridge).
|
||||
*
|
||||
* Cold start: nodes are empty until the SDK emits its snapshot from storage (<1s).
|
||||
* Node notes: stored in-memory for this POC (will not survive process death).
|
||||
* Node metadata (favorites, notes, ignored, muted) persists via Room's node_metadata table.
|
||||
*/
|
||||
@Single(binds = [NodeRepository::class])
|
||||
@Suppress("TooManyFunctions")
|
||||
class SdkNodeRepositoryImpl(
|
||||
private val localStatsDataSource: LocalStatsDataSource,
|
||||
private val dbManager: DatabaseProvider,
|
||||
@Named("ServiceScope") private val scope: CoroutineScope,
|
||||
) : NodeRepository {
|
||||
|
||||
@@ -63,8 +67,15 @@ class SdkNodeRepositoryImpl(
|
||||
private val _myNodeInfo = MutableStateFlow<MyNodeInfo?>(null)
|
||||
private val _myNodeNum = MutableStateFlow<Int?>(null)
|
||||
|
||||
// Local-only notes storage (in-memory for POC; does not survive process death)
|
||||
private val nodeNotes = MutableStateFlow<Map<Int, String>>(emptyMap())
|
||||
// Cached metadata from Room (loaded on init, updated on writes)
|
||||
private val _metadataCache = MutableStateFlow<Map<Int, NodeMetadataEntity>>(emptyMap())
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
dbManager.currentDb.flatMapLatest { db -> db.nodeMetadataDao().getAllFlow() }
|
||||
.collect { list -> _metadataCache.value = list.associateBy { it.num } }
|
||||
}
|
||||
}
|
||||
|
||||
override val nodeDBbyNum: StateFlow<Map<Int, Node>> = _nodeDBbyNum
|
||||
|
||||
@@ -151,11 +162,20 @@ class SdkNodeRepositoryImpl(
|
||||
}
|
||||
|
||||
override suspend fun upsert(node: Node) {
|
||||
_nodeDBbyNum.update { map -> map + (node.num to node) }
|
||||
// Also keep _myNodeNum consistent
|
||||
if (node.num == _myNodeNum.value) {
|
||||
// ourNodeInfo will auto-update via combine
|
||||
// Merge persisted metadata with incoming node data
|
||||
val meta = _metadataCache.value[node.num]
|
||||
val enriched = if (meta != null) {
|
||||
node.copy(
|
||||
isFavorite = meta.isFavorite,
|
||||
isIgnored = meta.isIgnored,
|
||||
isMuted = meta.isMuted,
|
||||
notes = meta.notes,
|
||||
manuallyVerified = meta.manuallyVerified,
|
||||
)
|
||||
} else {
|
||||
node
|
||||
}
|
||||
_nodeDBbyNum.update { map -> map + (enriched.num to enriched) }
|
||||
}
|
||||
|
||||
override suspend fun installConfig(mi: MyNodeInfo, nodes: List<Node>) {
|
||||
@@ -178,12 +198,12 @@ class SdkNodeRepositoryImpl(
|
||||
|
||||
override suspend fun deleteNode(num: Int) {
|
||||
_nodeDBbyNum.update { it - num }
|
||||
nodeNotes.update { it - num }
|
||||
dbManager.withDb { it.nodeMetadataDao().delete(num) }
|
||||
}
|
||||
|
||||
override suspend fun deleteNodes(nodeNums: List<Int>) {
|
||||
_nodeDBbyNum.update { map -> map - nodeNums.toSet() }
|
||||
nodeNotes.update { notes -> notes - nodeNums.toSet() }
|
||||
dbManager.withDb { db -> nodeNums.forEach { db.nodeMetadataDao().delete(it) } }
|
||||
}
|
||||
|
||||
override suspend fun getNodesOlderThan(lastHeard: Int): List<Node> =
|
||||
@@ -193,7 +213,8 @@ class SdkNodeRepositoryImpl(
|
||||
_nodeDBbyNum.value.values.filter { it.user.hw_model == HardwareModel.UNSET }
|
||||
|
||||
override suspend fun setNodeNotes(num: Int, notes: String) {
|
||||
nodeNotes.update { it + (num to notes) }
|
||||
ensureMetadataExists(num)
|
||||
dbManager.withDb { it.nodeMetadataDao().setNotes(num, notes) }
|
||||
_nodeDBbyNum.update { map ->
|
||||
val node = map[num] ?: return@update map
|
||||
map + (num to node.copy(notes = notes))
|
||||
@@ -212,6 +233,13 @@ class SdkNodeRepositoryImpl(
|
||||
_myNodeNum.value = num
|
||||
}
|
||||
|
||||
/** Ensures a metadata row exists for the given node, creating a default if needed. */
|
||||
private suspend fun ensureMetadataExists(num: Int) {
|
||||
if (_metadataCache.value[num] == null) {
|
||||
dbManager.withDb { it.nodeMetadataDao().upsert(NodeMetadataEntity(num = num)) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun sortComparator(sort: NodeSortOption): Comparator<Node> = when (sort) {
|
||||
NodeSortOption.LAST_HEARD -> compareByDescending { it.lastHeard }
|
||||
NodeSortOption.ALPHABETICAL -> compareBy(String.CASE_INSENSITIVE_ORDER) { it.user.long_name }
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.mock
|
||||
import dev.mokkery.verify
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.repository.MeshConfigFlowManager
|
||||
import org.meshtastic.core.repository.MeshConfigHandler
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.SessionManager
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
import org.meshtastic.proto.Channel
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.Data
|
||||
import org.meshtastic.proto.DeviceMetadata
|
||||
import org.meshtastic.proto.HardwareModel
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
import org.meshtastic.proto.PortNum
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
|
||||
class AdminPacketHandlerImplTest {
|
||||
|
||||
private val nodeManager = mock<NodeManager>(MockMode.autofill)
|
||||
private val configHandler = mock<MeshConfigHandler>(MockMode.autofill)
|
||||
private val configFlowManager = mock<MeshConfigFlowManager>(MockMode.autofill)
|
||||
private val sessionManager = mock<SessionManager>(MockMode.autofill)
|
||||
|
||||
private lateinit var handler: AdminPacketHandlerImpl
|
||||
|
||||
private val myNodeNum = 12345
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
handler =
|
||||
AdminPacketHandlerImpl(
|
||||
nodeManager = nodeManager,
|
||||
configHandler = lazy { configHandler },
|
||||
configFlowManager = lazy { configFlowManager },
|
||||
sessionManager = sessionManager,
|
||||
)
|
||||
}
|
||||
|
||||
private fun makePacket(from: Int, adminMessage: AdminMessage): MeshPacket {
|
||||
val payload = AdminMessage.ADAPTER.encode(adminMessage).toByteString()
|
||||
return MeshPacket(from = from, decoded = Data(portnum = PortNum.ADMIN_APP, payload = payload))
|
||||
}
|
||||
|
||||
// ---------- Session passkey ----------
|
||||
|
||||
@Test
|
||||
fun `session passkey is updated when present`() {
|
||||
val passkey = ByteString.of(1, 2, 3, 4)
|
||||
val adminMsg = AdminMessage(session_passkey = passkey)
|
||||
val packet = makePacket(myNodeNum, adminMsg)
|
||||
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
|
||||
verify { sessionManager.recordSession(myNodeNum, passkey) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty session passkey does not record refresh`() {
|
||||
val adminMsg = AdminMessage(session_passkey = ByteString.EMPTY)
|
||||
val packet = makePacket(myNodeNum, adminMsg)
|
||||
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
// recordSession should NOT be called for empty passkey
|
||||
}
|
||||
|
||||
// ---------- get_config_response ----------
|
||||
|
||||
@Test
|
||||
fun `get_config_response from own node delegates to configHandler`() {
|
||||
val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT))
|
||||
val adminMsg = AdminMessage(get_config_response = config)
|
||||
val packet = makePacket(myNodeNum, adminMsg)
|
||||
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
|
||||
verify { configHandler.handleDeviceConfig(config) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get_config_response from remote node is ignored`() {
|
||||
val config = Config(device = Config.DeviceConfig())
|
||||
val adminMsg = AdminMessage(get_config_response = config)
|
||||
val packet = makePacket(99999, adminMsg)
|
||||
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
// configHandler.handleDeviceConfig should NOT be called
|
||||
}
|
||||
|
||||
// ---------- get_module_config_response ----------
|
||||
|
||||
@Test
|
||||
fun `get_module_config_response from own node delegates to configHandler`() {
|
||||
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
|
||||
val adminMsg = AdminMessage(get_module_config_response = moduleConfig)
|
||||
val packet = makePacket(myNodeNum, adminMsg)
|
||||
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
|
||||
verify { configHandler.handleModuleConfig(moduleConfig) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get_module_config_response from remote node updates node status`() {
|
||||
val moduleConfig = ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Battery Low"))
|
||||
val adminMsg = AdminMessage(get_module_config_response = moduleConfig)
|
||||
val remoteNode = 99999
|
||||
val packet = makePacket(remoteNode, adminMsg)
|
||||
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
|
||||
verify { nodeManager.updateNodeStatus(remoteNode, "Battery Low") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get_module_config_response from remote without status message does not crash`() {
|
||||
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
|
||||
val adminMsg = AdminMessage(get_module_config_response = moduleConfig)
|
||||
val packet = makePacket(99999, adminMsg)
|
||||
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
// No crash, no updateNodeStatus call
|
||||
}
|
||||
|
||||
// ---------- get_channel_response ----------
|
||||
|
||||
@Test
|
||||
fun `get_channel_response from own node delegates to configHandler`() {
|
||||
val channel = Channel(index = 0)
|
||||
val adminMsg = AdminMessage(get_channel_response = channel)
|
||||
val packet = makePacket(myNodeNum, adminMsg)
|
||||
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
|
||||
verify { configHandler.handleChannel(channel) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get_channel_response from remote node is ignored`() {
|
||||
val channel = Channel(index = 0)
|
||||
val adminMsg = AdminMessage(get_channel_response = channel)
|
||||
val packet = makePacket(99999, adminMsg)
|
||||
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
// configHandler.handleChannel should NOT be called
|
||||
}
|
||||
|
||||
// ---------- get_device_metadata_response ----------
|
||||
|
||||
@Test
|
||||
fun `device metadata from own node delegates to configFlowManager`() {
|
||||
val metadata = DeviceMetadata(firmware_version = "2.6.0", hw_model = HardwareModel.HELTEC_V3)
|
||||
val adminMsg = AdminMessage(get_device_metadata_response = metadata)
|
||||
val packet = makePacket(myNodeNum, adminMsg)
|
||||
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
|
||||
verify { configFlowManager.handleLocalMetadata(metadata) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `device metadata from remote node delegates to nodeManager`() {
|
||||
val metadata = DeviceMetadata(firmware_version = "2.5.0", hw_model = HardwareModel.TBEAM)
|
||||
val adminMsg = AdminMessage(get_device_metadata_response = metadata)
|
||||
val remoteNode = 99999
|
||||
val packet = makePacket(remoteNode, adminMsg)
|
||||
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
|
||||
verify { nodeManager.insertMetadata(remoteNode, metadata) }
|
||||
}
|
||||
|
||||
// ---------- Edge cases ----------
|
||||
|
||||
@Test
|
||||
fun `packet with null decoded payload is ignored`() {
|
||||
val packet = MeshPacket(from = myNodeNum, decoded = null)
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
// No crash
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `packet with empty payload bytes is ignored`() {
|
||||
val packet =
|
||||
MeshPacket(from = myNodeNum, decoded = Data(portnum = PortNum.ADMIN_APP, payload = ByteString.EMPTY))
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
// No crash — decodes as default AdminMessage with no fields set
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `combined admin message with passkey and config response`() {
|
||||
val passkey = ByteString.of(5, 6, 7, 8)
|
||||
val config = Config(lora = Config.LoRaConfig())
|
||||
val adminMsg = AdminMessage(session_passkey = passkey, get_config_response = config)
|
||||
val packet = makePacket(myNodeNum, adminMsg)
|
||||
|
||||
handler.handleAdminMessage(packet, myNodeNum)
|
||||
|
||||
verify { sessionManager.recordSession(myNodeNum, passkey) }
|
||||
verify { configHandler.handleDeviceConfig(config) }
|
||||
}
|
||||
}
|
||||
@@ -1,583 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.answering.returns
|
||||
import dev.mokkery.every
|
||||
import dev.mokkery.everySuspend
|
||||
import dev.mokkery.matcher.any
|
||||
import dev.mokkery.mock
|
||||
import dev.mokkery.verify
|
||||
import dev.mokkery.verify.VerifyMode.Companion.not
|
||||
import dev.mokkery.verifySuspend
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.meshtastic.core.common.database.DatabaseManager
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.MeshUser
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.service.ServiceAction
|
||||
import org.meshtastic.core.repository.CommandSender
|
||||
import org.meshtastic.core.repository.MeshDataHandler
|
||||
import org.meshtastic.core.repository.MeshPrefs
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
import org.meshtastic.core.repository.UiPrefs
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
import org.meshtastic.proto.Channel
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.HardwareModel
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
import org.meshtastic.proto.SharedContact
|
||||
import org.meshtastic.proto.User
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class MeshActionHandlerImplTest {
|
||||
|
||||
private val nodeManager = mock<NodeManager>(MockMode.autofill)
|
||||
private val commandSender = mock<CommandSender>(MockMode.autofill)
|
||||
private val packetRepository = mock<PacketRepository>(MockMode.autofill)
|
||||
private val serviceBroadcasts = mock<ServiceBroadcasts>(MockMode.autofill)
|
||||
private val dataHandler = mock<MeshDataHandler>(MockMode.autofill)
|
||||
private val analytics = mock<PlatformAnalytics>(MockMode.autofill)
|
||||
private val meshPrefs = mock<MeshPrefs>(MockMode.autofill)
|
||||
private val uiPrefs = mock<UiPrefs>(MockMode.autofill)
|
||||
private val databaseManager = mock<DatabaseManager>(MockMode.autofill)
|
||||
private val notificationManager = mock<NotificationManager>(MockMode.autofill)
|
||||
private val radioConfigRepository = mock<RadioConfigRepository>(MockMode.autofill)
|
||||
|
||||
private val myNodeNumFlow = MutableStateFlow<Int?>(MY_NODE_NUM)
|
||||
|
||||
private lateinit var handler: MeshActionHandlerImpl
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
private val testScope = TestScope(testDispatcher)
|
||||
|
||||
companion object {
|
||||
private const val MY_NODE_NUM = 12345
|
||||
private const val REMOTE_NODE_NUM = 67890
|
||||
}
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
every { nodeManager.myNodeNum } returns myNodeNumFlow
|
||||
every { nodeManager.getMyId() } returns "!12345678"
|
||||
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
|
||||
}
|
||||
|
||||
private fun createHandler(scope: CoroutineScope): MeshActionHandlerImpl = MeshActionHandlerImpl(
|
||||
nodeManager = nodeManager,
|
||||
commandSender = commandSender,
|
||||
packetRepository = lazy { packetRepository },
|
||||
serviceBroadcasts = serviceBroadcasts,
|
||||
dataHandler = lazy { dataHandler },
|
||||
analytics = analytics,
|
||||
meshPrefs = meshPrefs,
|
||||
uiPrefs = uiPrefs,
|
||||
databaseManager = databaseManager,
|
||||
notificationManager = notificationManager,
|
||||
radioConfigRepository = radioConfigRepository,
|
||||
scope = scope,
|
||||
)
|
||||
|
||||
// ---- handleUpdateLastAddress (device-switch path — P0 critical) ----
|
||||
|
||||
@Test
|
||||
fun handleUpdateLastAddress_differentAddress_switchesDatabaseAndClearsState() = runTest(testDispatcher) {
|
||||
handler = createHandler(backgroundScope)
|
||||
|
||||
every { meshPrefs.deviceAddress } returns MutableStateFlow("old_addr")
|
||||
everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit
|
||||
|
||||
handler.handleUpdateLastAddress("new_addr")
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { meshPrefs.setDeviceAddress("new_addr") }
|
||||
verify { nodeManager.clear() }
|
||||
verifySuspend { databaseManager.switchActiveDatabase("new_addr") }
|
||||
verify { notificationManager.cancelAll() }
|
||||
verify { nodeManager.loadCachedNodeDB() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleUpdateLastAddress_sameAddress_noOp() = runTest(testDispatcher) {
|
||||
handler = createHandler(backgroundScope)
|
||||
|
||||
every { meshPrefs.deviceAddress } returns MutableStateFlow("same_addr")
|
||||
|
||||
handler.handleUpdateLastAddress("same_addr")
|
||||
advanceUntilIdle()
|
||||
|
||||
verify(not) { meshPrefs.setDeviceAddress(any()) }
|
||||
verify(not) { nodeManager.clear() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleUpdateLastAddress_nullAddress_switchesIfDifferent() = runTest(testDispatcher) {
|
||||
handler = createHandler(backgroundScope)
|
||||
|
||||
every { meshPrefs.deviceAddress } returns MutableStateFlow("old_addr")
|
||||
everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit
|
||||
|
||||
handler.handleUpdateLastAddress(null)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { meshPrefs.setDeviceAddress(null) }
|
||||
verify { nodeManager.clear() }
|
||||
verifySuspend { databaseManager.switchActiveDatabase(null) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleUpdateLastAddress_nullToNull_noOp() = runTest(testDispatcher) {
|
||||
handler = createHandler(backgroundScope)
|
||||
|
||||
every { meshPrefs.deviceAddress } returns MutableStateFlow(null)
|
||||
|
||||
handler.handleUpdateLastAddress(null)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify(not) { meshPrefs.setDeviceAddress(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleUpdateLastAddress_executesStepsInOrder() = runTest(testDispatcher) {
|
||||
handler = createHandler(backgroundScope)
|
||||
|
||||
every { meshPrefs.deviceAddress } returns MutableStateFlow("old")
|
||||
everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit
|
||||
|
||||
handler.handleUpdateLastAddress("new")
|
||||
advanceUntilIdle()
|
||||
|
||||
// Verify critical sequence: clear -> switchDB -> cancelNotifications -> loadCachedNodeDB
|
||||
verify { nodeManager.clear() }
|
||||
verifySuspend { databaseManager.switchActiveDatabase("new") }
|
||||
verify { notificationManager.cancelAll() }
|
||||
verify { nodeManager.loadCachedNodeDB() }
|
||||
}
|
||||
|
||||
// ---- onServiceAction: null myNodeNum early-return ----
|
||||
|
||||
@Test
|
||||
fun onServiceAction_nullMyNodeNum_doesNothing() = runTest(testDispatcher) {
|
||||
handler = createHandler(backgroundScope)
|
||||
myNodeNumFlow.value = null
|
||||
|
||||
val node = createTestNode(REMOTE_NODE_NUM)
|
||||
handler.onServiceAction(ServiceAction.Favorite(node))
|
||||
advanceUntilIdle()
|
||||
|
||||
verify(not) { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- onServiceAction: Favorite ----
|
||||
|
||||
@Test
|
||||
fun onServiceAction_favorite_sendsSetFavoriteWhenNotFavorite() = runTest(testDispatcher) {
|
||||
handler = createHandler(backgroundScope)
|
||||
val node = createTestNode(REMOTE_NODE_NUM, isFavorite = false)
|
||||
|
||||
handler.onServiceAction(ServiceAction.Favorite(node))
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
verify { nodeManager.updateNode(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onServiceAction_favorite_sendsRemoveFavoriteWhenAlreadyFavorite() = runTest(testDispatcher) {
|
||||
handler = createHandler(backgroundScope)
|
||||
val node = createTestNode(REMOTE_NODE_NUM, isFavorite = true)
|
||||
|
||||
handler.onServiceAction(ServiceAction.Favorite(node))
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
verify { nodeManager.updateNode(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- onServiceAction: Ignore ----
|
||||
|
||||
@Test
|
||||
fun onServiceAction_ignore_togglesAndUpdatesFilteredBySender() = runTest(testDispatcher) {
|
||||
handler = createHandler(backgroundScope)
|
||||
val node = createTestNode(REMOTE_NODE_NUM, isIgnored = false)
|
||||
|
||||
handler.onServiceAction(ServiceAction.Ignore(node))
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
verify { nodeManager.updateNode(any(), any(), any(), any()) }
|
||||
verifySuspend { packetRepository.updateFilteredBySender(any(), any()) }
|
||||
}
|
||||
|
||||
// ---- onServiceAction: Mute ----
|
||||
|
||||
@Test
|
||||
fun onServiceAction_mute_togglesMutedState() = runTest(testDispatcher) {
|
||||
handler = createHandler(backgroundScope)
|
||||
val node = createTestNode(REMOTE_NODE_NUM, isMuted = false)
|
||||
|
||||
handler.onServiceAction(ServiceAction.Mute(node))
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
verify { nodeManager.updateNode(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- onServiceAction: GetDeviceMetadata ----
|
||||
|
||||
@Test
|
||||
fun onServiceAction_getDeviceMetadata_sendsAdminRequest() = runTest(testDispatcher) {
|
||||
handler = createHandler(backgroundScope)
|
||||
|
||||
handler.onServiceAction(ServiceAction.GetDeviceMetadata(REMOTE_NODE_NUM))
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- onServiceAction: SendContact ----
|
||||
|
||||
@Test
|
||||
fun onServiceAction_sendContact_completesWithTrueOnSuccess() = runTest(testDispatcher) {
|
||||
handler = createHandler(backgroundScope)
|
||||
everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true
|
||||
|
||||
val action = ServiceAction.SendContact(SharedContact())
|
||||
handler.onServiceAction(action)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(action.result.isCompleted)
|
||||
assertTrue(action.result.await())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onServiceAction_sendContact_completesWithFalseOnFailure() = runTest(testDispatcher) {
|
||||
handler = createHandler(backgroundScope)
|
||||
everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns false
|
||||
|
||||
val action = ServiceAction.SendContact(SharedContact())
|
||||
handler.onServiceAction(action)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(action.result.isCompleted)
|
||||
assertFalse(action.result.await())
|
||||
}
|
||||
|
||||
// ---- onServiceAction: ImportContact ----
|
||||
|
||||
@Test
|
||||
fun onServiceAction_importContact_sendsAdminAndUpdatesNode() = runTest(testDispatcher) {
|
||||
handler = createHandler(backgroundScope)
|
||||
|
||||
val contact =
|
||||
SharedContact(node_num = REMOTE_NODE_NUM, user = User(id = "!abcdef12", long_name = "TestUser"))
|
||||
handler.onServiceAction(ServiceAction.ImportContact(contact))
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
verify { nodeManager.handleReceivedUser(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- handleSetOwner ----
|
||||
|
||||
@Test
|
||||
fun handleSetOwner_sendsAdminAndUpdatesLocalNode() {
|
||||
handler = createHandler(testScope)
|
||||
val meshUser =
|
||||
MeshUser(
|
||||
id = "!12345678",
|
||||
longName = "Test Long",
|
||||
shortName = "TL",
|
||||
hwModel = HardwareModel.UNSET,
|
||||
isLicensed = false,
|
||||
)
|
||||
|
||||
handler.handleSetOwner(meshUser, MY_NODE_NUM)
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
verify { nodeManager.handleReceivedUser(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- handleSend ----
|
||||
|
||||
@Test
|
||||
fun handleSend_sendsDataAndBroadcastsStatus() {
|
||||
handler = createHandler(testScope)
|
||||
val packet = DataPacket(to = "!deadbeef", dataType = 1, bytes = null, channel = 0)
|
||||
|
||||
handler.handleSend(packet, MY_NODE_NUM)
|
||||
|
||||
verify { commandSender.sendData(any()) }
|
||||
verify { serviceBroadcasts.broadcastMessageStatus(any(), any()) }
|
||||
verify { dataHandler.rememberDataPacket(any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- handleRequestPosition: 3 branches ----
|
||||
|
||||
@Test
|
||||
fun handleRequestPosition_sameNode_doesNothing() {
|
||||
handler = createHandler(testScope)
|
||||
|
||||
handler.handleRequestPosition(MY_NODE_NUM, Position(0.0, 0.0, 0), MY_NODE_NUM)
|
||||
|
||||
verify(not) { commandSender.requestPosition(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleRequestPosition_provideLocation_validPosition_usesGivenPosition() {
|
||||
handler = createHandler(testScope)
|
||||
every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true)
|
||||
|
||||
val validPosition = Position(37.7749, -122.4194, 10)
|
||||
handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM)
|
||||
|
||||
verify { commandSender.requestPosition(REMOTE_NODE_NUM, validPosition) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleRequestPosition_provideLocation_invalidPosition_fallsBackToNodeDB() {
|
||||
handler = createHandler(testScope)
|
||||
every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true)
|
||||
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
|
||||
|
||||
val invalidPosition = Position(0.0, 0.0, 0)
|
||||
handler.handleRequestPosition(REMOTE_NODE_NUM, invalidPosition, MY_NODE_NUM)
|
||||
|
||||
// Falls back to Position(0.0, 0.0, 0) when node has no position in DB
|
||||
verify { commandSender.requestPosition(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleRequestPosition_doNotProvide_sendsZeroPosition() {
|
||||
handler = createHandler(testScope)
|
||||
every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(false)
|
||||
|
||||
val validPosition = Position(37.7749, -122.4194, 10)
|
||||
handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM)
|
||||
|
||||
// Should send zero position regardless of valid input
|
||||
verify { commandSender.requestPosition(any(), any()) }
|
||||
}
|
||||
|
||||
// ---- handleSetConfig: optimistic persist ----
|
||||
|
||||
@Test
|
||||
fun handleSetConfig_decodesAndSendsAdmin_thenPersistsLocally() = runTest(testDispatcher) {
|
||||
handler = createHandler(backgroundScope)
|
||||
everySuspend { radioConfigRepository.setLocalConfig(any()) } returns Unit
|
||||
|
||||
val config = Config(lora = Config.LoRaConfig(hop_limit = 5))
|
||||
val payload = Config.ADAPTER.encode(config)
|
||||
|
||||
handler.handleSetConfig(payload, MY_NODE_NUM)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
verifySuspend { radioConfigRepository.setLocalConfig(any()) }
|
||||
}
|
||||
|
||||
// ---- handleSetModuleConfig: conditional persist ----
|
||||
|
||||
@Test
|
||||
fun handleSetModuleConfig_ownNode_persistsLocally() = runTest(testDispatcher) {
|
||||
handler = createHandler(backgroundScope)
|
||||
myNodeNumFlow.value = MY_NODE_NUM
|
||||
everySuspend { radioConfigRepository.setLocalModuleConfig(any()) } returns Unit
|
||||
|
||||
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
|
||||
val payload = ModuleConfig.ADAPTER.encode(moduleConfig)
|
||||
|
||||
handler.handleSetModuleConfig(0, MY_NODE_NUM, payload)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
verifySuspend { radioConfigRepository.setLocalModuleConfig(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleSetModuleConfig_remoteNode_doesNotPersistLocally() = runTest(testDispatcher) {
|
||||
handler = createHandler(backgroundScope)
|
||||
myNodeNumFlow.value = MY_NODE_NUM
|
||||
|
||||
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
|
||||
val payload = ModuleConfig.ADAPTER.encode(moduleConfig)
|
||||
|
||||
handler.handleSetModuleConfig(0, REMOTE_NODE_NUM, payload)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
verifySuspend(not) { radioConfigRepository.setLocalModuleConfig(any()) }
|
||||
}
|
||||
|
||||
// ---- handleSetChannel: null payload guard ----
|
||||
|
||||
@Test
|
||||
fun handleSetChannel_nonNullPayload_decodesAndPersists() = runTest(testDispatcher) {
|
||||
handler = createHandler(backgroundScope)
|
||||
everySuspend { radioConfigRepository.updateChannelSettings(any()) } returns Unit
|
||||
|
||||
val channel = Channel(index = 1)
|
||||
val payload = Channel.ADAPTER.encode(channel)
|
||||
|
||||
handler.handleSetChannel(payload, MY_NODE_NUM)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
verifySuspend { radioConfigRepository.updateChannelSettings(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleSetChannel_nullPayload_doesNothing() {
|
||||
handler = createHandler(testScope)
|
||||
|
||||
handler.handleSetChannel(null, MY_NODE_NUM)
|
||||
|
||||
verify(not) { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- handleRemoveByNodenum ----
|
||||
|
||||
@Test
|
||||
fun handleRemoveByNodenum_removesAndSendsAdmin() {
|
||||
handler = createHandler(testScope)
|
||||
|
||||
handler.handleRemoveByNodenum(REMOTE_NODE_NUM, 99, MY_NODE_NUM)
|
||||
|
||||
verify { nodeManager.removeByNodenum(REMOTE_NODE_NUM) }
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- handleSetRemoteOwner ----
|
||||
|
||||
@Test
|
||||
fun handleSetRemoteOwner_decodesAndSendsAdmin() {
|
||||
handler = createHandler(testScope)
|
||||
|
||||
val user = User(id = "!remote01", long_name = "Remote", short_name = "RM")
|
||||
val payload = User.ADAPTER.encode(user)
|
||||
|
||||
handler.handleSetRemoteOwner(1, REMOTE_NODE_NUM, payload)
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
verify { nodeManager.handleReceivedUser(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- handleGetRemoteConfig: sessionkey vs regular ----
|
||||
|
||||
@Test
|
||||
fun handleGetRemoteConfig_sessionkeyConfig_sendsDeviceMetadataRequest() {
|
||||
handler = createHandler(testScope)
|
||||
|
||||
handler.handleGetRemoteConfig(1, REMOTE_NODE_NUM, AdminMessage.ConfigType.SESSIONKEY_CONFIG.value)
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleGetRemoteConfig_regularConfig_sendsConfigRequest() {
|
||||
handler = createHandler(testScope)
|
||||
|
||||
handler.handleGetRemoteConfig(1, REMOTE_NODE_NUM, AdminMessage.ConfigType.LORA_CONFIG.value)
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- handleSetRemoteChannel: null payload guard ----
|
||||
|
||||
@Test
|
||||
fun handleSetRemoteChannel_nullPayload_doesNothing() {
|
||||
handler = createHandler(testScope)
|
||||
|
||||
handler.handleSetRemoteChannel(1, REMOTE_NODE_NUM, null)
|
||||
|
||||
verify(not) { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleSetRemoteChannel_nonNullPayload_decodesAndSendsAdmin() {
|
||||
handler = createHandler(testScope)
|
||||
|
||||
val channel = Channel(index = 2)
|
||||
val payload = Channel.ADAPTER.encode(channel)
|
||||
|
||||
handler.handleSetRemoteChannel(1, REMOTE_NODE_NUM, payload)
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- handleRequestRebootOta: null hash ----
|
||||
|
||||
@Test
|
||||
fun handleRequestRebootOta_withNullHash_sendsAdmin() {
|
||||
handler = createHandler(testScope)
|
||||
|
||||
handler.handleRequestRebootOta(1, REMOTE_NODE_NUM, 0, null)
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleRequestRebootOta_withHash_sendsAdmin() {
|
||||
handler = createHandler(testScope)
|
||||
|
||||
val hash = byteArrayOf(0x01, 0x02, 0x03)
|
||||
handler.handleRequestRebootOta(1, REMOTE_NODE_NUM, 1, hash)
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- handleRequestNodedbReset ----
|
||||
|
||||
@Test
|
||||
fun handleRequestNodedbReset_sendsAdminWithPreserveFavorites() {
|
||||
handler = createHandler(testScope)
|
||||
|
||||
handler.handleRequestNodedbReset(1, REMOTE_NODE_NUM, preserveFavorites = true)
|
||||
|
||||
verify { commandSender.sendAdmin(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---- Helper ----
|
||||
|
||||
private fun createTestNode(
|
||||
num: Int,
|
||||
isFavorite: Boolean = false,
|
||||
isIgnored: Boolean = false,
|
||||
isMuted: Boolean = false,
|
||||
): Node = Node(
|
||||
num = num,
|
||||
user = User(id = "!${num.toString(16).padStart(8, '0')}", long_name = "Node $num", short_name = "N$num"),
|
||||
isFavorite = isFavorite,
|
||||
isIgnored = isIgnored,
|
||||
isMuted = isMuted,
|
||||
)
|
||||
}
|
||||
@@ -1,471 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.answering.calls
|
||||
import dev.mokkery.answering.returns
|
||||
import dev.mokkery.every
|
||||
import dev.mokkery.matcher.any
|
||||
import dev.mokkery.mock
|
||||
import dev.mokkery.verify
|
||||
import dev.mokkery.verify.VerifyMode
|
||||
import dev.mokkery.verifySuspend
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okio.ByteString.Companion.encodeUtf8
|
||||
import org.meshtastic.core.repository.HandshakeConstants
|
||||
import org.meshtastic.core.repository.MeshConnectionManager
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.NotificationPrefs
|
||||
import org.meshtastic.core.repository.PacketHandler
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.proto.DeviceMetadata
|
||||
import org.meshtastic.proto.FileInfo
|
||||
import org.meshtastic.proto.FirmwareEdition
|
||||
import org.meshtastic.proto.HardwareModel
|
||||
import org.meshtastic.proto.NodeInfo
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MeshConfigFlowManagerImplTest {
|
||||
|
||||
private val nodeManager = mock<NodeManager>(MockMode.autofill)
|
||||
private val connectionManager = mock<MeshConnectionManager>(MockMode.autofill)
|
||||
private val nodeRepository = mock<NodeRepository>(MockMode.autofill)
|
||||
private val radioConfigRepository = mock<RadioConfigRepository>(MockMode.autofill)
|
||||
private val serviceRepository = mock<ServiceRepository>(MockMode.autofill)
|
||||
private val serviceBroadcasts = mock<ServiceBroadcasts>(MockMode.autofill)
|
||||
private val analytics = mock<PlatformAnalytics>(MockMode.autofill)
|
||||
private val packetHandler = mock<PacketHandler>(MockMode.autofill)
|
||||
private val notificationPrefs = mock<NotificationPrefs>(MockMode.autofill)
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val testScope = TestScope(testDispatcher)
|
||||
|
||||
private lateinit var manager: MeshConfigFlowManagerImpl
|
||||
|
||||
private val myNodeNum = 12345
|
||||
|
||||
private val protoMyNodeInfo =
|
||||
ProtoMyNodeInfo(
|
||||
my_node_num = myNodeNum,
|
||||
min_app_version = 30000,
|
||||
device_id = "test-device".encodeUtf8(),
|
||||
pio_env = "",
|
||||
)
|
||||
|
||||
private val metadata =
|
||||
DeviceMetadata(firmware_version = "2.6.0", hw_model = HardwareModel.HELTEC_V3, hasWifi = false)
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
|
||||
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
|
||||
every { nodeManager.myNodeNum } returns MutableStateFlow(null)
|
||||
every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(false)
|
||||
every { notificationPrefs.nodeEventsEnabled } returns MutableStateFlow(true)
|
||||
|
||||
manager =
|
||||
MeshConfigFlowManagerImpl(
|
||||
nodeManager = nodeManager,
|
||||
connectionManager = lazy { connectionManager },
|
||||
nodeRepository = nodeRepository,
|
||||
radioConfigRepository = radioConfigRepository,
|
||||
serviceRepository = serviceRepository,
|
||||
serviceBroadcasts = serviceBroadcasts,
|
||||
analytics = analytics,
|
||||
heartbeatSender = DataLayerHeartbeatSender(packetHandler),
|
||||
notificationPrefs = notificationPrefs,
|
||||
scope = testScope,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- handleMyInfo ----------
|
||||
|
||||
@Test
|
||||
fun `handleMyInfo transitions to ReceivingConfig and sets myNodeNum`() = testScope.runTest {
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { nodeManager.setMyNodeNum(myNodeNum) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleMyInfo clears persisted radio config`() = testScope.runTest {
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { radioConfigRepository.clearChannelSet() }
|
||||
verifySuspend { radioConfigRepository.clearLocalConfig() }
|
||||
verifySuspend { radioConfigRepository.clearLocalModuleConfig() }
|
||||
verifySuspend { radioConfigRepository.clearDeviceUIConfig() }
|
||||
verifySuspend { radioConfigRepository.clearFileManifest() }
|
||||
}
|
||||
|
||||
// ---------- handleLocalMetadata ----------
|
||||
|
||||
@Test
|
||||
fun `handleLocalMetadata persists metadata when in ReceivingConfig state`() = testScope.runTest {
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
|
||||
manager.handleLocalMetadata(metadata)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { nodeRepository.insertMetadata(myNodeNum, metadata) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleLocalMetadata skips empty metadata`() = testScope.runTest {
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Default/empty DeviceMetadata should not trigger insertMetadata
|
||||
manager.handleLocalMetadata(DeviceMetadata())
|
||||
advanceUntilIdle()
|
||||
|
||||
// insertMetadata should only have been called zero times for default metadata
|
||||
// (we just verify no crash occurs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleLocalMetadata ignored outside ReceivingConfig state`() = testScope.runTest {
|
||||
// State is Idle — handleLocalMetadata should be a no-op
|
||||
manager.handleLocalMetadata(metadata)
|
||||
advanceUntilIdle()
|
||||
// No crash, no insertMetadata call
|
||||
}
|
||||
|
||||
// ---------- handleConfigComplete Stage 1 ----------
|
||||
|
||||
@Test
|
||||
fun `Stage 1 complete builds MyNodeInfo and transitions to ReceivingNodeInfo`() = testScope.runTest {
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
manager.handleLocalMetadata(metadata)
|
||||
advanceUntilIdle()
|
||||
|
||||
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { connectionManager.onRadioConfigLoaded() }
|
||||
verify { connectionManager.startNodeInfoOnly() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Stage 1 complete sends heartbeat with non-zero nonce between stages`() = testScope.runTest {
|
||||
val sentPackets = mutableListOf<org.meshtastic.proto.ToRadio>()
|
||||
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } calls
|
||||
{ call ->
|
||||
sentPackets.add(call.arg(0))
|
||||
}
|
||||
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
manager.handleLocalMetadata(metadata)
|
||||
advanceUntilIdle()
|
||||
|
||||
sentPackets.clear() // Clear any packets from prior phases
|
||||
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
|
||||
advanceUntilIdle()
|
||||
|
||||
val heartbeats = sentPackets.filter { it.heartbeat != null }
|
||||
assertEquals(1, heartbeats.size, "Expected exactly one inter-stage heartbeat")
|
||||
assertEquals(
|
||||
true,
|
||||
heartbeats[0].heartbeat!!.nonce != 0,
|
||||
"Inter-stage heartbeat should have a non-zero nonce",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Stage 1 complete with old firmware logs warning but continues handshake`() = testScope.runTest {
|
||||
val oldMetadata =
|
||||
DeviceMetadata(firmware_version = "2.3.0", hw_model = HardwareModel.HELTEC_V3, hasWifi = false)
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
manager.handleLocalMetadata(oldMetadata)
|
||||
advanceUntilIdle()
|
||||
|
||||
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Handshake should still progress despite old firmware
|
||||
verify { connectionManager.onRadioConfigLoaded() }
|
||||
verify { connectionManager.startNodeInfoOnly() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Stage 1 complete without metadata still succeeds with null firmware version`() = testScope.runTest {
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
|
||||
// No metadata provided
|
||||
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { connectionManager.onRadioConfigLoaded() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Stage 1 complete id ignored when not in ReceivingConfig state`() = testScope.runTest {
|
||||
// State is Idle — should be a no-op
|
||||
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
|
||||
advanceUntilIdle()
|
||||
// No crash, no onRadioConfigLoaded
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Duplicate Stage 1 config_complete does not re-trigger`() = testScope.runTest {
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
manager.handleLocalMetadata(metadata)
|
||||
advanceUntilIdle()
|
||||
|
||||
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Now in ReceivingNodeInfo — a second Stage 1 complete should be ignored
|
||||
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
|
||||
advanceUntilIdle()
|
||||
}
|
||||
|
||||
// ---------- handleNodeInfo ----------
|
||||
|
||||
@Test
|
||||
fun `handleNodeInfo accumulates nodes during Stage 2`() = testScope.runTest {
|
||||
// Transition to Stage 2
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
manager.handleLocalMetadata(metadata)
|
||||
advanceUntilIdle()
|
||||
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Now in ReceivingNodeInfo
|
||||
manager.handleNodeInfo(NodeInfo(num = 100))
|
||||
manager.handleNodeInfo(NodeInfo(num = 200))
|
||||
|
||||
assertEquals(2, manager.newNodeCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleNodeInfo ignored outside Stage 2`() = testScope.runTest {
|
||||
// State is Idle
|
||||
manager.handleNodeInfo(NodeInfo(num = 999))
|
||||
|
||||
assertEquals(0, manager.newNodeCount)
|
||||
}
|
||||
|
||||
// ---------- handleConfigComplete Stage 2 ----------
|
||||
|
||||
@Test
|
||||
fun `Stage 2 complete processes nodes and sets Connected state`() = testScope.runTest {
|
||||
val testNode = org.meshtastic.core.testing.TestDataFactory.createTestNode(num = 100)
|
||||
every { nodeManager.nodeDBbyNodeNum } returns mapOf(100 to testNode)
|
||||
|
||||
// Full handshake: MyInfo -> metadata -> Stage 1 complete -> nodes -> Stage 2 complete
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
manager.handleLocalMetadata(metadata)
|
||||
advanceUntilIdle()
|
||||
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
|
||||
advanceUntilIdle()
|
||||
|
||||
manager.handleNodeInfo(NodeInfo(num = 100))
|
||||
manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { nodeManager.installNodeInfo(any(), withBroadcast = false) }
|
||||
verify { nodeManager.setNodeDbReady(true) }
|
||||
verify { nodeManager.setAllowNodeDbWrites(true) }
|
||||
verify { serviceBroadcasts.broadcastConnection() }
|
||||
verify { connectionManager.onNodeDbReady() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Stage 2 complete id ignored when not in ReceivingNodeInfo state`() = testScope.runTest {
|
||||
manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE)
|
||||
advanceUntilIdle()
|
||||
// No crash
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Stage 2 complete with no nodes still transitions to Connected`() = testScope.runTest {
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
manager.handleLocalMetadata(metadata)
|
||||
advanceUntilIdle()
|
||||
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
|
||||
advanceUntilIdle()
|
||||
|
||||
// No handleNodeInfo calls — empty node list
|
||||
manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { nodeManager.setNodeDbReady(true) }
|
||||
verify { connectionManager.onNodeDbReady() }
|
||||
}
|
||||
|
||||
// ---------- Unknown config_complete_id ----------
|
||||
|
||||
@Test
|
||||
fun `Unknown config_complete_id is ignored`() = testScope.runTest {
|
||||
manager.handleConfigComplete(99999)
|
||||
advanceUntilIdle()
|
||||
// No crash
|
||||
}
|
||||
|
||||
// ---------- newNodeCount ----------
|
||||
|
||||
@Test
|
||||
fun `newNodeCount returns 0 when not in ReceivingNodeInfo state`() {
|
||||
assertEquals(0, manager.newNodeCount)
|
||||
}
|
||||
|
||||
// ---------- handleFileInfo ----------
|
||||
|
||||
@Test
|
||||
fun `handleFileInfo delegates to radioConfigRepository`() = testScope.runTest {
|
||||
val fileInfo = FileInfo(file_name = "firmware.bin", size_bytes = 1024)
|
||||
manager.handleFileInfo(fileInfo)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { radioConfigRepository.addFileInfo(fileInfo) }
|
||||
}
|
||||
|
||||
// ---------- triggerWantConfig ----------
|
||||
|
||||
@Test
|
||||
fun `triggerWantConfig delegates to connectionManager startConfigOnly`() {
|
||||
manager.triggerWantConfig()
|
||||
verify { connectionManager.startConfigOnly() }
|
||||
}
|
||||
|
||||
// ---------- Full handshake flow ----------
|
||||
|
||||
@Test
|
||||
fun `Full handshake from Idle to Complete`() = testScope.runTest {
|
||||
val testNode = org.meshtastic.core.testing.TestDataFactory.createTestNode(num = 100)
|
||||
every { nodeManager.nodeDBbyNodeNum } returns mapOf(100 to testNode)
|
||||
|
||||
// Stage 0: Idle -> handleMyInfo
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
verify { nodeManager.setMyNodeNum(myNodeNum) }
|
||||
|
||||
// Receive metadata during Stage 1
|
||||
manager.handleLocalMetadata(metadata)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Stage 1 complete
|
||||
manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
|
||||
advanceUntilIdle()
|
||||
verify { connectionManager.onRadioConfigLoaded() }
|
||||
|
||||
// Receive NodeInfo during Stage 2
|
||||
manager.handleNodeInfo(NodeInfo(num = 100))
|
||||
assertEquals(1, manager.newNodeCount)
|
||||
|
||||
// Stage 2 complete
|
||||
manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { nodeManager.setNodeDbReady(true) }
|
||||
verify { connectionManager.onNodeDbReady() }
|
||||
|
||||
// After complete, newNodeCount should be 0 (state is Complete)
|
||||
assertEquals(0, manager.newNodeCount)
|
||||
}
|
||||
|
||||
// ---------- Interrupted handshake ----------
|
||||
|
||||
@Test
|
||||
fun `handleMyInfo resets stale handshake state`() = testScope.runTest {
|
||||
// Start first handshake
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
manager.handleLocalMetadata(metadata)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Before Stage 1 completes, a new handleMyInfo arrives (device rebooted)
|
||||
val newMyInfo = protoMyNodeInfo.copy(my_node_num = 99999)
|
||||
manager.handleMyInfo(newMyInfo)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { nodeManager.setMyNodeNum(99999) }
|
||||
}
|
||||
|
||||
// ---------- Event firmware notification defaults ----------
|
||||
|
||||
@Test
|
||||
fun `handleMyInfo disables node notifications for event firmware`() = testScope.runTest {
|
||||
every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(false)
|
||||
|
||||
val eventMyInfo = protoMyNodeInfo.copy(firmware_edition = FirmwareEdition.DEFCON)
|
||||
manager.handleMyInfo(eventMyInfo)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { notificationPrefs.setNodeEventsEnabled(false) }
|
||||
verify { notificationPrefs.setNodeEventsAutoDisabledForEvent(true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleMyInfo does not re-disable if already auto-disabled`() = testScope.runTest {
|
||||
every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(true)
|
||||
|
||||
val eventMyInfo = protoMyNodeInfo.copy(firmware_edition = FirmwareEdition.DEFCON)
|
||||
manager.handleMyInfo(eventMyInfo)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify(mode = VerifyMode.not) { notificationPrefs.setNodeEventsEnabled(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleMyInfo re-enables node notifications when vanilla firmware reconnects`() = testScope.runTest {
|
||||
every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(true)
|
||||
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { notificationPrefs.setNodeEventsEnabled(true) }
|
||||
verify { notificationPrefs.setNodeEventsAutoDisabledForEvent(false) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleMyInfo does not touch prefs for vanilla when not previously auto-disabled`() = testScope.runTest {
|
||||
every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(false)
|
||||
|
||||
manager.handleMyInfo(protoMyNodeInfo)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify(mode = VerifyMode.not) { notificationPrefs.setNodeEventsEnabled(any()) }
|
||||
verify(mode = VerifyMode.not) { notificationPrefs.setNodeEventsAutoDisabledForEvent(any()) }
|
||||
}
|
||||
}
|
||||
@@ -1,430 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.answering.calls
|
||||
import dev.mokkery.answering.returns
|
||||
import dev.mokkery.every
|
||||
import dev.mokkery.everySuspend
|
||||
import dev.mokkery.matcher.any
|
||||
import dev.mokkery.mock
|
||||
import dev.mokkery.verify
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.repository.AppWidgetUpdater
|
||||
import org.meshtastic.core.repository.HistoryManager
|
||||
import org.meshtastic.core.repository.MeshLocationManager
|
||||
import org.meshtastic.core.repository.MeshServiceNotifications
|
||||
import org.meshtastic.core.repository.MeshWorkerManager
|
||||
import org.meshtastic.core.repository.MqttManager
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.PacketHandler
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.repository.SessionManager
|
||||
import org.meshtastic.core.repository.UiPrefs
|
||||
import org.meshtastic.core.testing.FakeNodeRepository
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.LocalConfig
|
||||
import org.meshtastic.proto.LocalModuleConfig
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
||||
class MeshConnectionManagerImplTest {
|
||||
private val radioInterfaceService = mock<RadioInterfaceService>(MockMode.autofill)
|
||||
private val serviceRepository = mock<ServiceRepository>(MockMode.autofill)
|
||||
private val serviceBroadcasts = mock<ServiceBroadcasts>(MockMode.autofill)
|
||||
private val serviceNotifications = mock<MeshServiceNotifications>(MockMode.autofill)
|
||||
private val uiPrefs = mock<UiPrefs>(MockMode.autofill)
|
||||
private val packetHandler = mock<PacketHandler>(MockMode.autofill)
|
||||
private val nodeRepository = FakeNodeRepository()
|
||||
private val locationManager = mock<MeshLocationManager>(MockMode.autofill)
|
||||
private val mqttManager = mock<MqttManager>(MockMode.autofill)
|
||||
private val historyManager = mock<HistoryManager>(MockMode.autofill)
|
||||
private val radioConfigRepository = mock<RadioConfigRepository>(MockMode.autofill)
|
||||
private val radioController = mock<RadioController>(MockMode.autofill)
|
||||
private val sessionManager = mock<SessionManager>(MockMode.autofill)
|
||||
private val nodeManager = mock<NodeManager>(MockMode.autofill)
|
||||
private val analytics = mock<PlatformAnalytics>(MockMode.autofill)
|
||||
private val packetRepository = mock<PacketRepository>(MockMode.autofill)
|
||||
private val workerManager = mock<MeshWorkerManager>(MockMode.autofill)
|
||||
private val appWidgetUpdater = mock<AppWidgetUpdater>(MockMode.autofill)
|
||||
|
||||
private val dataPacket = DataPacket(id = 456, time = 0L, to = "0", from = "0", bytes = null, dataType = 0)
|
||||
|
||||
private val radioConnectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
private val connectionStateFlow = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
private val localConfigFlow = MutableStateFlow(LocalConfig())
|
||||
private val moduleConfigFlow = MutableStateFlow(LocalModuleConfig())
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
private lateinit var manager: MeshConnectionManagerImpl
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
every { radioInterfaceService.connectionState } returns radioConnectionState
|
||||
every { radioConfigRepository.localConfigFlow } returns localConfigFlow
|
||||
every { radioConfigRepository.moduleConfigFlow } returns moduleConfigFlow
|
||||
every { serviceRepository.connectionState } returns connectionStateFlow
|
||||
every { serviceRepository.setConnectionState(any()) } calls
|
||||
{ call ->
|
||||
connectionStateFlow.value = call.arg<ConnectionState>(0)
|
||||
}
|
||||
every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
|
||||
every { packetHandler.stopPacketQueue() } returns Unit
|
||||
every { locationManager.stop() } returns Unit
|
||||
every { mqttManager.stop() } returns Unit
|
||||
every { nodeManager.nodeDBbyNodeNum } returns emptyMap<Int, Node>()
|
||||
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } returns Unit
|
||||
}
|
||||
|
||||
private fun createManager(scope: CoroutineScope): MeshConnectionManagerImpl = MeshConnectionManagerImpl(
|
||||
radioInterfaceService,
|
||||
serviceRepository,
|
||||
serviceBroadcasts,
|
||||
serviceNotifications,
|
||||
uiPrefs,
|
||||
packetHandler,
|
||||
nodeRepository,
|
||||
locationManager,
|
||||
mqttManager,
|
||||
historyManager,
|
||||
radioConfigRepository,
|
||||
radioController,
|
||||
sessionManager,
|
||||
nodeManager,
|
||||
analytics,
|
||||
packetRepository,
|
||||
workerManager,
|
||||
appWidgetUpdater,
|
||||
DataLayerHeartbeatSender(packetHandler),
|
||||
scope,
|
||||
)
|
||||
|
||||
@AfterTest fun tearDown() = Unit
|
||||
|
||||
@Test
|
||||
fun `Connected state triggers broadcast and config start`() = runTest(testDispatcher) {
|
||||
manager = createManager(backgroundScope)
|
||||
radioConnectionState.value = ConnectionState.Connected
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(
|
||||
ConnectionState.Connecting,
|
||||
serviceRepository.connectionState.value,
|
||||
"State should be Connecting after radio Connected",
|
||||
)
|
||||
verify { serviceBroadcasts.broadcastConnection() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Connected state sends pre-handshake heartbeat before config request`() = runTest(testDispatcher) {
|
||||
val sentPackets = mutableListOf<org.meshtastic.proto.ToRadio>()
|
||||
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } calls
|
||||
{ call ->
|
||||
sentPackets.add(call.arg(0))
|
||||
}
|
||||
|
||||
manager = createManager(backgroundScope)
|
||||
radioConnectionState.value = ConnectionState.Connected
|
||||
// Advance past PRE_HANDSHAKE_SETTLE_MS (100ms) but NOT the 30s stall guard timeout
|
||||
advanceTimeBy(200)
|
||||
|
||||
// First ToRadio should be a heartbeat, second should be want_config_id
|
||||
assertEquals(2, sentPackets.size, "Expected heartbeat + want_config_id, got ${sentPackets.size} packets")
|
||||
val heartbeat = sentPackets[0]
|
||||
val wantConfig = sentPackets[1]
|
||||
|
||||
assertEquals(true, heartbeat.heartbeat != null, "First packet should be a heartbeat")
|
||||
assertEquals(true, heartbeat.heartbeat!!.nonce != 0, "Heartbeat should have a non-zero nonce")
|
||||
assertEquals(
|
||||
org.meshtastic.core.repository.HandshakeConstants.CONFIG_NONCE,
|
||||
wantConfig.want_config_id,
|
||||
"Second packet should be want_config_id with CONFIG_NONCE",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Disconnect during pre-handshake settle cancels config start`() = runTest(testDispatcher) {
|
||||
val sentPackets = mutableListOf<org.meshtastic.proto.ToRadio>()
|
||||
every { packetHandler.sendToRadio(any<org.meshtastic.proto.ToRadio>()) } calls
|
||||
{ call ->
|
||||
sentPackets.add(call.arg(0))
|
||||
}
|
||||
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
|
||||
|
||||
manager = createManager(backgroundScope)
|
||||
radioConnectionState.value = ConnectionState.Connected
|
||||
// Advance only 50ms — within the 100ms settle window
|
||||
advanceTimeBy(50)
|
||||
|
||||
// Should have sent only the heartbeat so far, not want_config_id
|
||||
assertEquals(1, sentPackets.size, "Only heartbeat should be sent before settle completes")
|
||||
|
||||
// Disconnect before the settle delay completes — should cancel the pending config start
|
||||
radioConnectionState.value = ConnectionState.Disconnected
|
||||
advanceTimeBy(200)
|
||||
|
||||
// The want_config_id should NOT have been sent because the job was cancelled
|
||||
val configPackets = sentPackets.filter { it.want_config_id != null }
|
||||
assertEquals(0, configPackets.size, "want_config_id should not be sent after disconnect")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Disconnected state stops services`() = runTest(testDispatcher) {
|
||||
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
|
||||
manager = createManager(backgroundScope)
|
||||
// Transition to Connected first so that Disconnected actually does something
|
||||
radioConnectionState.value = ConnectionState.Connected
|
||||
advanceUntilIdle()
|
||||
|
||||
radioConnectionState.value = ConnectionState.Disconnected
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(
|
||||
ConnectionState.Disconnected,
|
||||
serviceRepository.connectionState.value,
|
||||
"State should be Disconnected after radio Disconnected",
|
||||
)
|
||||
verify { packetHandler.stopPacketQueue() }
|
||||
verify { locationManager.stop() }
|
||||
verify { mqttManager.stop() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DeviceSleep behavior when power saving is off maps to Disconnected`() = runTest(testDispatcher) {
|
||||
// Power saving disabled + Role CLIENT
|
||||
val config =
|
||||
LocalConfig(
|
||||
power = Config.PowerConfig(is_power_saving = false),
|
||||
device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT),
|
||||
)
|
||||
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
|
||||
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
|
||||
|
||||
manager = createManager(backgroundScope)
|
||||
advanceUntilIdle()
|
||||
|
||||
radioConnectionState.value = ConnectionState.DeviceSleep
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(
|
||||
ConnectionState.Disconnected,
|
||||
serviceRepository.connectionState.value,
|
||||
"State should be Disconnected when power saving is off",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DeviceSleep behavior when power saving is on stays in DeviceSleep`() = runTest(testDispatcher) {
|
||||
// Power saving enabled
|
||||
val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true))
|
||||
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
|
||||
|
||||
manager = createManager(backgroundScope)
|
||||
advanceUntilIdle()
|
||||
|
||||
radioConnectionState.value = ConnectionState.DeviceSleep
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(
|
||||
ConnectionState.DeviceSleep,
|
||||
serviceRepository.connectionState.value,
|
||||
"State should stay in DeviceSleep when power saving is on",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onRadioConfigLoaded enqueues queued packets and sets time`() = runTest(testDispatcher) {
|
||||
manager = createManager(backgroundScope)
|
||||
val packetId = 456
|
||||
everySuspend { packetRepository.getQueuedPackets() } returns listOf(dataPacket)
|
||||
every { workerManager.enqueueSendMessage(any()) } returns Unit
|
||||
|
||||
manager.onRadioConfigLoaded()
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { workerManager.enqueueSendMessage(packetId) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onNodeDbReady starts MQTT and requests history`() = runTest(testDispatcher) {
|
||||
val moduleConfig =
|
||||
LocalModuleConfig(
|
||||
mqtt = ModuleConfig.MQTTConfig(enabled = true, proxy_to_client_enabled = true),
|
||||
store_forward = ModuleConfig.StoreForwardConfig(enabled = true),
|
||||
)
|
||||
moduleConfigFlow.value = moduleConfig
|
||||
every { nodeManager.myNodeNum } returns MutableStateFlow(123)
|
||||
everySuspend { radioController.requestTelemetry(any(), any(), any()) } returns Unit
|
||||
every { mqttManager.startProxy(any(), any()) } returns Unit
|
||||
every { historyManager.requestHistoryReplay(any(), any(), any(), any()) } returns Unit
|
||||
every { nodeManager.getMyNodeInfo() } returns null
|
||||
|
||||
manager = createManager(backgroundScope)
|
||||
manager.onNodeDbReady()
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { mqttManager.startProxy(true, true) }
|
||||
verify { historyManager.requestHistoryReplay(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DeviceSleep timeout is capped at MAX_SLEEP_TIMEOUT_SECONDS for high ls_secs`() = runTest(testDispatcher) {
|
||||
// Router with ls_secs=3600 — previously this created a 3630s timeout.
|
||||
// With the cap, it should be clamped to 300s.
|
||||
val config =
|
||||
LocalConfig(
|
||||
power = Config.PowerConfig(is_power_saving = true, ls_secs = 3600),
|
||||
device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER),
|
||||
)
|
||||
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
|
||||
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
|
||||
|
||||
manager = createManager(backgroundScope)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Transition to Connected then DeviceSleep
|
||||
radioConnectionState.value = ConnectionState.Connected
|
||||
advanceUntilIdle()
|
||||
radioConnectionState.value = ConnectionState.DeviceSleep
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(
|
||||
ConnectionState.DeviceSleep,
|
||||
serviceRepository.connectionState.value,
|
||||
"Should be in DeviceSleep initially",
|
||||
)
|
||||
|
||||
// Advance 300 seconds (the cap) + 1 second to trigger the timeout.
|
||||
advanceTimeBy(301_000L)
|
||||
|
||||
assertEquals(
|
||||
ConnectionState.Disconnected,
|
||||
serviceRepository.connectionState.value,
|
||||
"Should transition to Disconnected after capped timeout (300s), not the raw 3630s",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rapid state transitions are serialized by connectionMutex`() = runTest(testDispatcher) {
|
||||
// Power saving enabled so DeviceSleep is preserved (not mapped to Disconnected)
|
||||
val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true))
|
||||
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
|
||||
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
|
||||
|
||||
// Record every state transition so we can verify ordering
|
||||
val observed = mutableListOf<ConnectionState>()
|
||||
every { serviceRepository.setConnectionState(any()) } calls
|
||||
{ call ->
|
||||
val state = call.arg<ConnectionState>(0)
|
||||
observed.add(state)
|
||||
connectionStateFlow.value = state
|
||||
}
|
||||
|
||||
manager = createManager(backgroundScope)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Rapid-fire: Connected -> DeviceSleep -> Disconnected without yielding between them.
|
||||
// Without the Mutex, the intermediate DeviceSleep could be missed or applied out of order.
|
||||
radioConnectionState.value = ConnectionState.Connected
|
||||
radioConnectionState.value = ConnectionState.DeviceSleep
|
||||
radioConnectionState.value = ConnectionState.Disconnected
|
||||
advanceUntilIdle()
|
||||
|
||||
// Verify final state
|
||||
assertEquals(
|
||||
ConnectionState.Disconnected,
|
||||
serviceRepository.connectionState.value,
|
||||
"Final state should be Disconnected after rapid transitions",
|
||||
)
|
||||
|
||||
// Verify that all intermediate states were observed in correct order.
|
||||
// Connected triggers handleConnected() which sets Connecting (handshake start),
|
||||
// then DeviceSleep, then Disconnected.
|
||||
assertEquals(
|
||||
listOf(ConnectionState.Connecting, ConnectionState.DeviceSleep, ConnectionState.Disconnected),
|
||||
observed,
|
||||
"State transitions should be serialized in order: Connecting -> DeviceSleep -> Disconnected",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `concurrent sleep-timeout and radio state change are serialized`() {
|
||||
val standardDispatcher = StandardTestDispatcher()
|
||||
runTest(standardDispatcher) {
|
||||
// Power saving enabled with a short ls_secs so the sleep timeout fires quickly
|
||||
val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true, ls_secs = 1))
|
||||
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
|
||||
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
|
||||
|
||||
val observed = mutableListOf<ConnectionState>()
|
||||
every { serviceRepository.setConnectionState(any()) } calls
|
||||
{ call ->
|
||||
val state = call.arg<ConnectionState>(0)
|
||||
observed.add(state)
|
||||
connectionStateFlow.value = state
|
||||
}
|
||||
|
||||
manager = createManager(backgroundScope)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Transition to Connected -> DeviceSleep to start the sleep timer
|
||||
radioConnectionState.value = ConnectionState.Connected
|
||||
advanceUntilIdle()
|
||||
radioConnectionState.value = ConnectionState.DeviceSleep
|
||||
advanceUntilIdle()
|
||||
|
||||
observed.clear()
|
||||
|
||||
// Before the sleep timeout fires, emit Connected from the radio (simulating device
|
||||
// waking up). Then let the timeout fire. The mutex ensures they don't race.
|
||||
radioConnectionState.value = ConnectionState.Connected
|
||||
// Advance past the sleep timeout (ls_secs=1 + 30s base = 31s)
|
||||
advanceTimeBy(32_000L)
|
||||
advanceUntilIdle()
|
||||
|
||||
// The Connected transition should have cancelled the sleep timeout, so we should
|
||||
// end up in Connecting (from handleConnected), NOT Disconnected (from timeout).
|
||||
assertEquals(
|
||||
ConnectionState.Connecting,
|
||||
serviceRepository.connectionState.value,
|
||||
"Connected should cancel the sleep timeout; final state should be Connecting",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,706 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.answering.returns
|
||||
import dev.mokkery.every
|
||||
import dev.mokkery.everySuspend
|
||||
import dev.mokkery.matcher.any
|
||||
import dev.mokkery.mock
|
||||
import dev.mokkery.verify
|
||||
import dev.mokkery.verifySuspend
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.model.ContactSettings
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.util.MeshDataMapper
|
||||
import org.meshtastic.core.repository.AdminPacketHandler
|
||||
import org.meshtastic.core.repository.MeshServiceNotifications
|
||||
import org.meshtastic.core.repository.MessageFilter
|
||||
import org.meshtastic.core.repository.NeighborInfoHandler
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.PacketHandler
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.repository.StoreForwardPacketHandler
|
||||
import org.meshtastic.core.repository.TelemetryPacketHandler
|
||||
import org.meshtastic.core.repository.TracerouteHandler
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.Data
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.NeighborInfo
|
||||
import org.meshtastic.proto.Paxcount
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.Position
|
||||
import org.meshtastic.proto.Routing
|
||||
import org.meshtastic.proto.Telemetry
|
||||
import org.meshtastic.proto.User
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MeshDataHandlerTest {
|
||||
|
||||
private lateinit var handler: MeshDataHandlerImpl
|
||||
private val nodeManager: NodeManager = mock(MockMode.autofill)
|
||||
private val packetHandler: PacketHandler = mock(MockMode.autofill)
|
||||
private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
|
||||
private val packetRepository: PacketRepository = mock(MockMode.autofill)
|
||||
private val serviceBroadcasts: ServiceBroadcasts = mock(MockMode.autofill)
|
||||
private val notificationManager: NotificationManager = mock(MockMode.autofill)
|
||||
private val serviceNotifications: MeshServiceNotifications = mock(MockMode.autofill)
|
||||
private val analytics: PlatformAnalytics = mock(MockMode.autofill)
|
||||
private val dataMapper: MeshDataMapper = mock(MockMode.autofill)
|
||||
private val tracerouteHandler: TracerouteHandler = mock(MockMode.autofill)
|
||||
private val neighborInfoHandler: NeighborInfoHandler = mock(MockMode.autofill)
|
||||
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
|
||||
private val messageFilter: MessageFilter = mock(MockMode.autofill)
|
||||
private val storeForwardHandler: StoreForwardPacketHandler = mock(MockMode.autofill)
|
||||
private val telemetryHandler: TelemetryPacketHandler = mock(MockMode.autofill)
|
||||
private val adminPacketHandler: AdminPacketHandler = mock(MockMode.autofill)
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val testScope = TestScope(testDispatcher)
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
handler =
|
||||
MeshDataHandlerImpl(
|
||||
nodeManager = nodeManager,
|
||||
packetHandler = packetHandler,
|
||||
serviceRepository = serviceRepository,
|
||||
packetRepository = lazy { packetRepository },
|
||||
serviceBroadcasts = serviceBroadcasts,
|
||||
notificationManager = notificationManager,
|
||||
serviceNotifications = serviceNotifications,
|
||||
analytics = analytics,
|
||||
dataMapper = dataMapper,
|
||||
tracerouteHandler = tracerouteHandler,
|
||||
neighborInfoHandler = neighborInfoHandler,
|
||||
radioConfigRepository = radioConfigRepository,
|
||||
messageFilter = messageFilter,
|
||||
storeForwardHandler = storeForwardHandler,
|
||||
telemetryHandler = telemetryHandler,
|
||||
adminPacketHandler = adminPacketHandler,
|
||||
scope = testScope,
|
||||
)
|
||||
|
||||
// Default: mapper returns null for empty packets, which is the safe default
|
||||
every { dataMapper.toDataPacket(any()) } returns null
|
||||
// Stub commonly accessed properties to avoid NPE from autofill
|
||||
every { nodeManager.nodeDBbyID } returns emptyMap()
|
||||
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
|
||||
every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInitialization() {
|
||||
assertNotNull(handler)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleReceivedData returns early when dataMapper returns null`() {
|
||||
val packet = MeshPacket()
|
||||
every { dataMapper.toDataPacket(packet) } returns null
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
|
||||
// Should not broadcast if dataMapper returns null
|
||||
verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleReceivedData does not broadcast for position from local node`() {
|
||||
val myNodeNum = 123
|
||||
val position = Position(latitude_i = 450000000, longitude_i = 900000000)
|
||||
val packet =
|
||||
MeshPacket(
|
||||
from = myNodeNum,
|
||||
decoded = Data(portnum = PortNum.POSITION_APP, payload = position.encode().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = DataPacket.nodeNumToDefaultId(myNodeNum),
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = position.encode().toByteString(),
|
||||
dataType = PortNum.POSITION_APP.value,
|
||||
time = 1000L,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
|
||||
handler.handleReceivedData(packet, myNodeNum)
|
||||
|
||||
// Position from local node: shouldBroadcast stays as !fromUs = false
|
||||
verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleReceivedData broadcasts for remote packets`() {
|
||||
val myNodeNum = 123
|
||||
val remoteNum = 456
|
||||
val packet = MeshPacket(from = remoteNum, decoded = Data(portnum = PortNum.PRIVATE_APP))
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = DataPacket.nodeNumToDefaultId(remoteNum),
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = null,
|
||||
dataType = PortNum.PRIVATE_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
|
||||
handler.handleReceivedData(packet, myNodeNum)
|
||||
|
||||
verify { serviceBroadcasts.broadcastReceivedData(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleReceivedData tracks analytics`() {
|
||||
val packet = MeshPacket(from = 456, decoded = Data(portnum = PortNum.PRIVATE_APP))
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = "!other",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = null,
|
||||
dataType = PortNum.PRIVATE_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
|
||||
verify { analytics.track("num_data_receive", any()) }
|
||||
}
|
||||
|
||||
// --- Position handling ---
|
||||
|
||||
@Test
|
||||
fun `position packet delegates to nodeManager`() {
|
||||
val myNodeNum = 123
|
||||
val remoteNum = 456
|
||||
val position = Position(latitude_i = 450000000, longitude_i = 900000000)
|
||||
val packet =
|
||||
MeshPacket(
|
||||
from = remoteNum,
|
||||
decoded = Data(portnum = PortNum.POSITION_APP, payload = position.encode().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = "!remote",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = position.encode().toByteString(),
|
||||
dataType = PortNum.POSITION_APP.value,
|
||||
time = 1000L,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
|
||||
handler.handleReceivedData(packet, myNodeNum)
|
||||
|
||||
verify { nodeManager.handleReceivedPosition(remoteNum, myNodeNum, any(), 1000L) }
|
||||
}
|
||||
|
||||
// --- NodeInfo handling ---
|
||||
|
||||
@Test
|
||||
fun `nodeinfo packet from remote delegates to handleReceivedUser`() {
|
||||
val myNodeNum = 123
|
||||
val remoteNum = 456
|
||||
val user = User(id = "!remote", long_name = "Remote", short_name = "R")
|
||||
val packet =
|
||||
MeshPacket(
|
||||
from = remoteNum,
|
||||
decoded = Data(portnum = PortNum.NODEINFO_APP, payload = user.encode().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = "!remote",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = user.encode().toByteString(),
|
||||
dataType = PortNum.NODEINFO_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
|
||||
handler.handleReceivedData(packet, myNodeNum)
|
||||
|
||||
verify { nodeManager.handleReceivedUser(remoteNum, any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nodeinfo packet from local node is ignored`() {
|
||||
val myNodeNum = 123
|
||||
val user = User(id = "!local", long_name = "Local", short_name = "L")
|
||||
val packet =
|
||||
MeshPacket(
|
||||
from = myNodeNum,
|
||||
decoded = Data(portnum = PortNum.NODEINFO_APP, payload = user.encode().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = "!local",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = user.encode().toByteString(),
|
||||
dataType = PortNum.NODEINFO_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
|
||||
handler.handleReceivedData(packet, myNodeNum)
|
||||
|
||||
verify(mode = dev.mokkery.verify.VerifyMode.not) { nodeManager.handleReceivedUser(any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// --- Paxcounter handling ---
|
||||
|
||||
@Test
|
||||
fun `paxcounter packet delegates to nodeManager`() {
|
||||
val remoteNum = 456
|
||||
val pax = Paxcount(wifi = 10, ble = 5, uptime = 1000)
|
||||
val packet =
|
||||
MeshPacket(
|
||||
from = remoteNum,
|
||||
decoded = Data(portnum = PortNum.PAXCOUNTER_APP, payload = pax.encode().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = "!remote",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = pax.encode().toByteString(),
|
||||
dataType = PortNum.PAXCOUNTER_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
|
||||
verify { nodeManager.handleReceivedPaxcounter(remoteNum, any()) }
|
||||
}
|
||||
|
||||
// --- Traceroute handling ---
|
||||
|
||||
@Test
|
||||
fun `traceroute packet delegates to tracerouteHandler and suppresses broadcast`() {
|
||||
val packet =
|
||||
MeshPacket(
|
||||
from = 456,
|
||||
decoded = Data(portnum = PortNum.TRACEROUTE_APP, payload = byteArrayOf().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = "!remote",
|
||||
to = "!local",
|
||||
bytes = byteArrayOf().toByteString(),
|
||||
dataType = PortNum.TRACEROUTE_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
|
||||
verify { tracerouteHandler.handleTraceroute(packet, any(), any()) }
|
||||
verify(mode = dev.mokkery.verify.VerifyMode.not) { serviceBroadcasts.broadcastReceivedData(any()) }
|
||||
}
|
||||
|
||||
// --- NeighborInfo handling ---
|
||||
|
||||
@Test
|
||||
fun `neighborinfo packet delegates to neighborInfoHandler and broadcasts`() {
|
||||
val ni = NeighborInfo(node_id = 456)
|
||||
val packet =
|
||||
MeshPacket(
|
||||
from = 456,
|
||||
decoded = Data(portnum = PortNum.NEIGHBORINFO_APP, payload = ni.encode().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = "!remote",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = ni.encode().toByteString(),
|
||||
dataType = PortNum.NEIGHBORINFO_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
|
||||
verify { neighborInfoHandler.handleNeighborInfo(packet) }
|
||||
verify { serviceBroadcasts.broadcastReceivedData(any()) }
|
||||
}
|
||||
|
||||
// --- Store-and-Forward handling ---
|
||||
|
||||
@Test
|
||||
fun `store forward packet delegates to storeForwardHandler`() {
|
||||
val packet =
|
||||
MeshPacket(
|
||||
from = 456,
|
||||
decoded = Data(portnum = PortNum.STORE_FORWARD_APP, payload = byteArrayOf().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = "!remote",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = byteArrayOf().toByteString(),
|
||||
dataType = PortNum.STORE_FORWARD_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
|
||||
verify { storeForwardHandler.handleStoreAndForward(packet, any(), 123) }
|
||||
}
|
||||
|
||||
// --- Routing/ACK-NAK handling ---
|
||||
|
||||
@Test
|
||||
fun `routing packet with successful ack broadcasts and removes response`() {
|
||||
val routing = Routing(error_reason = Routing.Error.NONE)
|
||||
val packet =
|
||||
MeshPacket(
|
||||
from = 456,
|
||||
decoded =
|
||||
Data(portnum = PortNum.ROUTING_APP, payload = routing.encode().toByteString(), request_id = 99),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = "!remote",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = routing.encode().toByteString(),
|
||||
dataType = PortNum.ROUTING_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
every { nodeManager.toNodeID(456) } returns "!remote"
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
|
||||
verify { packetHandler.removeResponse(99, complete = true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `routing packet always broadcasts`() {
|
||||
val routing = Routing(error_reason = Routing.Error.NONE)
|
||||
val packet =
|
||||
MeshPacket(
|
||||
from = 456,
|
||||
decoded =
|
||||
Data(portnum = PortNum.ROUTING_APP, payload = routing.encode().toByteString(), request_id = 99),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = "!remote",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = routing.encode().toByteString(),
|
||||
dataType = PortNum.ROUTING_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
every { nodeManager.toNodeID(456) } returns "!remote"
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
|
||||
verify { serviceBroadcasts.broadcastReceivedData(any()) }
|
||||
}
|
||||
|
||||
// --- Telemetry handling ---
|
||||
|
||||
@Test
|
||||
fun `telemetry packet delegates to telemetryHandler`() {
|
||||
val telemetry =
|
||||
Telemetry(
|
||||
time = 2000,
|
||||
device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 80, voltage = 4.0f),
|
||||
)
|
||||
val packet =
|
||||
MeshPacket(
|
||||
from = 456,
|
||||
decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = telemetry.encode().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = "!remote",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = telemetry.encode().toByteString(),
|
||||
dataType = PortNum.TELEMETRY_APP.value,
|
||||
time = 2000000L,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
|
||||
verify { telemetryHandler.handleTelemetry(packet, any(), 123) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `telemetry from local node delegates to telemetryHandler`() {
|
||||
val myNodeNum = 123
|
||||
val telemetry =
|
||||
Telemetry(
|
||||
time = 2000,
|
||||
device_metrics = org.meshtastic.proto.DeviceMetrics(battery_level = 80, voltage = 4.0f),
|
||||
)
|
||||
val packet =
|
||||
MeshPacket(
|
||||
from = myNodeNum,
|
||||
decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = telemetry.encode().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = "!local",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = telemetry.encode().toByteString(),
|
||||
dataType = PortNum.TELEMETRY_APP.value,
|
||||
time = 2000000L,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
|
||||
handler.handleReceivedData(packet, myNodeNum)
|
||||
|
||||
verify { telemetryHandler.handleTelemetry(packet, any(), myNodeNum) }
|
||||
}
|
||||
|
||||
// --- Text message handling ---
|
||||
|
||||
@Test
|
||||
fun `text message is persisted via rememberDataPacket`() = testScope.runTest {
|
||||
val packet =
|
||||
MeshPacket(
|
||||
id = 42,
|
||||
from = 456,
|
||||
decoded =
|
||||
Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
id = 42,
|
||||
from = "!remote",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = "hello".encodeToByteArray().toByteString(),
|
||||
dataType = PortNum.TEXT_MESSAGE_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
everySuspend { packetRepository.findPacketsWithId(42) } returns emptyList()
|
||||
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
|
||||
every { messageFilter.shouldFilter(any(), any()) } returns false
|
||||
// Provide sender node so getSenderName() doesn't fall back to getString (requires Skiko)
|
||||
every { nodeManager.nodeDBbyID } returns
|
||||
mapOf(
|
||||
"!remote" to
|
||||
Node(num = 456, user = User(id = "!remote", long_name = "Remote User", short_name = "RU")),
|
||||
)
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `duplicate text message is not inserted again`() = testScope.runTest {
|
||||
val packet =
|
||||
MeshPacket(
|
||||
id = 42,
|
||||
from = 456,
|
||||
decoded =
|
||||
Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
id = 42,
|
||||
from = "!remote",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = "hello".encodeToByteArray().toByteString(),
|
||||
dataType = PortNum.TEXT_MESSAGE_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
// Return existing packet on duplicate check
|
||||
everySuspend { packetRepository.findPacketsWithId(42) } returns listOf(dataPacket)
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend(mode = dev.mokkery.verify.VerifyMode.not) {
|
||||
packetRepository.insert(any(), any(), any(), any(), any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Reaction handling ---
|
||||
|
||||
@Test
|
||||
fun `text with reply_id and emoji is treated as reaction`() = testScope.runTest {
|
||||
val emojiBytes = "👍".encodeToByteArray()
|
||||
val packet =
|
||||
MeshPacket(
|
||||
id = 99,
|
||||
from = 456,
|
||||
to = 123,
|
||||
decoded =
|
||||
Data(
|
||||
portnum = PortNum.TEXT_MESSAGE_APP,
|
||||
payload = emojiBytes.toByteString(),
|
||||
reply_id = 42,
|
||||
emoji = 1,
|
||||
),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
id = 99,
|
||||
from = "!remote",
|
||||
to = "!local",
|
||||
bytes = emojiBytes.toByteString(),
|
||||
dataType = PortNum.TEXT_MESSAGE_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
every { nodeManager.nodeDBbyNodeNum } returns
|
||||
mapOf(
|
||||
456 to Node(num = 456, user = User(id = "!remote")),
|
||||
123 to Node(num = 123, user = User(id = "!local")),
|
||||
)
|
||||
everySuspend { packetRepository.findReactionsWithId(99) } returns emptyList()
|
||||
every { nodeManager.myNodeNum } returns MutableStateFlow(123)
|
||||
everySuspend { packetRepository.getPacketByPacketId(42) } returns null
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { packetRepository.insertReaction(any(), 123) }
|
||||
}
|
||||
|
||||
// --- Range test / detection sensor handling ---
|
||||
|
||||
@Test
|
||||
fun `range test packet is remembered as text message type`() = testScope.runTest {
|
||||
val packet =
|
||||
MeshPacket(
|
||||
id = 55,
|
||||
from = 456,
|
||||
decoded =
|
||||
Data(portnum = PortNum.RANGE_TEST_APP, payload = "test".encodeToByteArray().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
id = 55,
|
||||
from = "!remote",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = "test".encodeToByteArray().toByteString(),
|
||||
dataType = PortNum.RANGE_TEST_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
everySuspend { packetRepository.findPacketsWithId(55) } returns emptyList()
|
||||
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
|
||||
every { messageFilter.shouldFilter(any(), any()) } returns false
|
||||
every { nodeManager.nodeDBbyID } returns
|
||||
mapOf(
|
||||
"!remote" to
|
||||
Node(num = 456, user = User(id = "!remote", long_name = "Remote User", short_name = "RU")),
|
||||
)
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Range test should be remembered with TEXT_MESSAGE_APP dataType
|
||||
verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// --- Admin message handling ---
|
||||
|
||||
@Test
|
||||
fun `admin message delegates to adminPacketHandler`() {
|
||||
val admin = org.meshtastic.proto.AdminMessage(session_passkey = okio.ByteString.of(1, 2, 3))
|
||||
val packet =
|
||||
MeshPacket(from = 123, decoded = Data(portnum = PortNum.ADMIN_APP, payload = admin.encode().toByteString()))
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
from = "!local",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = admin.encode().toByteString(),
|
||||
dataType = PortNum.ADMIN_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
|
||||
verify { adminPacketHandler.handleAdminMessage(packet, 123) }
|
||||
}
|
||||
|
||||
// --- Message filtering ---
|
||||
|
||||
@Test
|
||||
fun `filtered message is inserted with filtered flag`() = testScope.runTest {
|
||||
val packet =
|
||||
MeshPacket(
|
||||
id = 77,
|
||||
from = 456,
|
||||
decoded =
|
||||
Data(
|
||||
portnum = PortNum.TEXT_MESSAGE_APP,
|
||||
payload = "spam content".encodeToByteArray().toByteString(),
|
||||
),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
id = 77,
|
||||
from = "!remote",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = "spam content".encodeToByteArray().toByteString(),
|
||||
dataType = PortNum.TEXT_MESSAGE_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
everySuspend { packetRepository.findPacketsWithId(77) } returns emptyList()
|
||||
every { nodeManager.nodeDBbyID } returns emptyMap()
|
||||
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
|
||||
every { messageFilter.shouldFilter("spam content", false) } returns true
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Verify insert was called with filtered = true (6th param)
|
||||
verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), filtered = true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message from ignored node is filtered`() = testScope.runTest {
|
||||
val packet =
|
||||
MeshPacket(
|
||||
id = 88,
|
||||
from = 456,
|
||||
decoded =
|
||||
Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hello".encodeToByteArray().toByteString()),
|
||||
)
|
||||
val dataPacket =
|
||||
DataPacket(
|
||||
id = 88,
|
||||
from = "!remote",
|
||||
to = DataPacket.ID_BROADCAST,
|
||||
bytes = "hello".encodeToByteArray().toByteString(),
|
||||
dataType = PortNum.TEXT_MESSAGE_APP.value,
|
||||
)
|
||||
every { dataMapper.toDataPacket(packet) } returns dataPacket
|
||||
everySuspend { packetRepository.findPacketsWithId(88) } returns emptyList()
|
||||
every { nodeManager.nodeDBbyID } returns
|
||||
mapOf("!remote" to Node(num = 456, user = User(id = "!remote"), isIgnored = true))
|
||||
everySuspend { packetRepository.getContactSettings(any()) } returns ContactSettings(contactKey = "test")
|
||||
|
||||
handler.handleReceivedData(packet, 123)
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { packetRepository.insert(any(), 123, any(), any(), any(), filtered = true) }
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,6 @@ import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
import org.meshtastic.proto.DeviceMetrics
|
||||
import org.meshtastic.proto.EnvironmentMetrics
|
||||
import org.meshtastic.proto.HardwareModel
|
||||
@@ -43,7 +42,6 @@ import org.meshtastic.proto.Position as ProtoPosition
|
||||
class NodeManagerImplTest {
|
||||
|
||||
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
|
||||
private val serviceBroadcasts: ServiceBroadcasts = mock(MockMode.autofill)
|
||||
private val notificationManager: NotificationManager = mock(MockMode.autofill)
|
||||
private val testScope = TestScope()
|
||||
|
||||
@@ -51,7 +49,7 @@ class NodeManagerImplTest {
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, notificationManager, testScope)
|
||||
nodeManager = NodeManagerImpl(nodeRepository, notificationManager, testScope)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.manager
|
||||
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.answering.returns
|
||||
import dev.mokkery.every
|
||||
import dev.mokkery.matcher.any
|
||||
import dev.mokkery.mock
|
||||
import dev.mokkery.verify
|
||||
import dev.mokkery.verifySuspend
|
||||
import io.kotest.property.Arb
|
||||
import io.kotest.property.arbitrary.int
|
||||
import io.kotest.property.checkAll
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.repository.MeshLogRepository
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.proto.Data
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.PortNum
|
||||
import org.meshtastic.proto.QueueStatus
|
||||
import org.meshtastic.proto.ToRadio
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
class PacketHandlerImplTest {
|
||||
|
||||
private val packetRepository: PacketRepository = mock(MockMode.autofill)
|
||||
private val serviceBroadcasts: ServiceBroadcasts = mock(MockMode.autofill)
|
||||
private val radioInterfaceService: RadioInterfaceService = mock(MockMode.autofill)
|
||||
private val meshLogRepository: MeshLogRepository = mock(MockMode.autofill)
|
||||
private val serviceRepository: ServiceRepository = mock(MockMode.autofill)
|
||||
|
||||
private val connectionStateFlow = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val testScope = TestScope(testDispatcher)
|
||||
|
||||
private lateinit var handler: PacketHandlerImpl
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
every { serviceRepository.connectionState } returns connectionStateFlow
|
||||
|
||||
handler =
|
||||
PacketHandlerImpl(
|
||||
lazy { packetRepository },
|
||||
serviceBroadcasts,
|
||||
radioInterfaceService,
|
||||
lazy { meshLogRepository },
|
||||
serviceRepository,
|
||||
testScope,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInitialization() {
|
||||
assertNotNull(handler)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sendToRadio with ToRadio sends immediately`() {
|
||||
val toRadio = ToRadio(packet = MeshPacket(id = 123))
|
||||
|
||||
handler.sendToRadio(toRadio)
|
||||
|
||||
verify { radioInterfaceService.sendToRadio(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sendToRadio with MeshPacket queues and sends when connected`() = runTest(testDispatcher) {
|
||||
val packet = MeshPacket(id = 456)
|
||||
connectionStateFlow.value = ConnectionState.Connected
|
||||
|
||||
handler.sendToRadio(packet)
|
||||
testScheduler.runCurrent()
|
||||
|
||||
verify { radioInterfaceService.sendToRadio(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleQueueStatus completes deferred`() = runTest(testDispatcher) {
|
||||
val packet = MeshPacket(id = 789)
|
||||
connectionStateFlow.value = ConnectionState.Connected
|
||||
|
||||
handler.sendToRadio(packet)
|
||||
testScheduler.runCurrent()
|
||||
|
||||
val status =
|
||||
QueueStatus(
|
||||
mesh_packet_id = 789,
|
||||
res = 0, // Success
|
||||
free = 1,
|
||||
)
|
||||
|
||||
handler.handleQueueStatus(status)
|
||||
testScheduler.runCurrent()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleQueueStatus property test`() = runTest(testDispatcher) {
|
||||
checkAll(Arb.int(0, 10), Arb.int(0, 32), Arb.int(0, 100000)) { res, free, packetId ->
|
||||
val status = QueueStatus(res = res, free = free, mesh_packet_id = packetId)
|
||||
|
||||
// Ensure it doesn't crash on any input
|
||||
handler.handleQueueStatus(status)
|
||||
testScheduler.runCurrent()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `outgoing packets are logged with NODE_NUM_LOCAL`() = runTest(testDispatcher) {
|
||||
val packet = MeshPacket(id = 123, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP))
|
||||
val toRadio = ToRadio(packet = packet)
|
||||
|
||||
handler.sendToRadio(toRadio)
|
||||
testScheduler.runCurrent()
|
||||
|
||||
verifySuspend { meshLogRepository.insert(any()) }
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,6 @@ import org.meshtastic.core.repository.HistoryManager
|
||||
import org.meshtastic.core.repository.MeshDataHandler
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
import org.meshtastic.proto.Data
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.meshtastic.proto.PortNum
|
||||
@@ -50,7 +49,6 @@ class StoreForwardPacketHandlerImplTest {
|
||||
|
||||
private val nodeManager = mock<NodeManager>(MockMode.autofill)
|
||||
private val packetRepository = mock<PacketRepository>(MockMode.autofill)
|
||||
private val serviceBroadcasts = mock<ServiceBroadcasts>(MockMode.autofill)
|
||||
private val historyManager = mock<HistoryManager>(MockMode.autofill)
|
||||
private val dataHandler = mock<MeshDataHandler>(MockMode.autofill)
|
||||
|
||||
@@ -69,7 +67,6 @@ class StoreForwardPacketHandlerImplTest {
|
||||
StoreForwardPacketHandlerImpl(
|
||||
nodeManager = nodeManager,
|
||||
packetRepository = lazy { packetRepository },
|
||||
serviceBroadcasts = serviceBroadcasts,
|
||||
historyManager = historyManager,
|
||||
dataHandler = lazy { dataHandler },
|
||||
scope = testScope,
|
||||
@@ -222,7 +219,6 @@ class StoreForwardPacketHandlerImplTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
verifySuspend { packetRepository.updateSFPPStatus(any(), any(), any(), any(), any(), any(), any()) }
|
||||
verify { serviceBroadcasts.broadcastMessageStatus(42, any()) }
|
||||
}
|
||||
|
||||
// ---------- SF++: CANON_ANNOUNCE ----------
|
||||
|
||||
@@ -27,7 +27,6 @@ import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.repository.MeshConnectionManager
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.proto.Data
|
||||
@@ -44,7 +43,6 @@ import kotlin.test.Test
|
||||
class TelemetryPacketHandlerImplTest {
|
||||
|
||||
private val nodeManager = mock<NodeManager>(MockMode.autofill)
|
||||
private val connectionManager = mock<MeshConnectionManager>(MockMode.autofill)
|
||||
private val notificationManager = mock<NotificationManager>(MockMode.autofill)
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
@@ -60,7 +58,6 @@ class TelemetryPacketHandlerImplTest {
|
||||
handler =
|
||||
TelemetryPacketHandlerImpl(
|
||||
nodeManager = nodeManager,
|
||||
connectionManager = lazy { connectionManager },
|
||||
notificationManager = notificationManager,
|
||||
scope = testScope,
|
||||
)
|
||||
@@ -87,7 +84,7 @@ class TelemetryPacketHandlerImplTest {
|
||||
// ---------- Device metrics from local node ----------
|
||||
|
||||
@Test
|
||||
fun `local device metrics updates telemetry on connectionManager`() = testScope.runTest {
|
||||
fun `local device metrics updates node`() = testScope.runTest {
|
||||
val telemetry =
|
||||
Telemetry(time = 1700000000, device_metrics = DeviceMetrics(battery_level = 80, voltage = 4.1f))
|
||||
val packet = makeTelemetryPacket(myNodeNum, telemetry)
|
||||
@@ -96,14 +93,13 @@ class TelemetryPacketHandlerImplTest {
|
||||
handler.handleTelemetry(packet, dataPacket, myNodeNum)
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { connectionManager.updateTelemetry(any()) }
|
||||
verify { nodeManager.updateNode(myNodeNum, any(), any(), any()) }
|
||||
}
|
||||
|
||||
// ---------- Device metrics from remote node ----------
|
||||
|
||||
@Test
|
||||
fun `remote device metrics updates node but not connectionManager`() = testScope.runTest {
|
||||
fun `remote device metrics updates node`() = testScope.runTest {
|
||||
val telemetry =
|
||||
Telemetry(time = 1700000000, device_metrics = DeviceMetrics(battery_level = 90, voltage = 4.2f))
|
||||
val packet = makeTelemetryPacket(remoteNodeNum, telemetry)
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.repository
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.LifecycleRegistry
|
||||
import dev.mokkery.MockMode
|
||||
import dev.mokkery.answering.returns
|
||||
import dev.mokkery.every
|
||||
import dev.mokkery.mock
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.meshtastic.core.data.datasource.NodeInfoReadDataSource
|
||||
import org.meshtastic.core.data.datasource.NodeInfoWriteDataSource
|
||||
import org.meshtastic.core.database.entity.MyNodeEntity
|
||||
import org.meshtastic.core.database.entity.NodeWithRelations
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.MeshLog
|
||||
import org.meshtastic.core.testing.FakeLocalStatsDataSource
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
abstract class CommonNodeRepositoryTest {
|
||||
|
||||
protected lateinit var lifecycleOwner: LifecycleOwner
|
||||
protected lateinit var readDataSource: NodeInfoReadDataSource
|
||||
protected lateinit var writeDataSource: NodeInfoWriteDataSource
|
||||
protected lateinit var localStatsDataSource: FakeLocalStatsDataSource
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
|
||||
|
||||
private val myNodeInfoFlow = MutableStateFlow<MyNodeEntity?>(null)
|
||||
|
||||
protected lateinit var repository: NodeRepositoryImpl
|
||||
|
||||
fun setupRepo() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
lifecycleOwner =
|
||||
object : LifecycleOwner {
|
||||
override val lifecycle = LifecycleRegistry(this)
|
||||
}
|
||||
(lifecycleOwner.lifecycle as LifecycleRegistry).handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
|
||||
|
||||
readDataSource = mock(MockMode.autofill)
|
||||
writeDataSource = mock(MockMode.autofill)
|
||||
localStatsDataSource = FakeLocalStatsDataSource()
|
||||
|
||||
every { readDataSource.myNodeInfoFlow() } returns myNodeInfoFlow
|
||||
every { readDataSource.nodeDBbyNumFlow() } returns MutableStateFlow<Map<Int, NodeWithRelations>>(emptyMap())
|
||||
|
||||
repository =
|
||||
NodeRepositoryImpl(
|
||||
lifecycleOwner.lifecycle,
|
||||
readDataSource,
|
||||
writeDataSource,
|
||||
dispatchers,
|
||||
localStatsDataSource,
|
||||
)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
// Essential to stop background jobs in NodeRepositoryImpl
|
||||
(lifecycleOwner.lifecycle as LifecycleRegistry).handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun createMyNodeEntity(nodeNum: Int) = MyNodeEntity(
|
||||
myNodeNum = nodeNum,
|
||||
model = "model",
|
||||
firmwareVersion = "1.0",
|
||||
couldUpdate = false,
|
||||
shouldUpdate = false,
|
||||
currentPacketId = 0L,
|
||||
messageTimeoutMsec = 0,
|
||||
minAppVersion = 0,
|
||||
maxChannels = 0,
|
||||
hasWifi = false,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `effectiveLogNodeId maps local node number to NODE_NUM_LOCAL`() = runTest(testDispatcher) {
|
||||
val myNodeNum = 12345
|
||||
myNodeInfoFlow.value = createMyNodeEntity(myNodeNum)
|
||||
|
||||
val result = repository.effectiveLogNodeId(myNodeNum).filter { it == MeshLog.NODE_NUM_LOCAL }.first()
|
||||
|
||||
assertEquals(MeshLog.NODE_NUM_LOCAL, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `effectiveLogNodeId preserves remote node numbers`() = runTest(testDispatcher) {
|
||||
val myNodeNum = 12345
|
||||
val remoteNodeNum = 67890
|
||||
myNodeInfoFlow.value = createMyNodeEntity(myNodeNum)
|
||||
|
||||
val result = repository.effectiveLogNodeId(remoteNodeNum).first()
|
||||
|
||||
assertEquals(remoteNodeNum, result)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,11 +23,14 @@ import androidx.room3.DeleteTable
|
||||
import androidx.room3.RoomDatabase
|
||||
import androidx.room3.TypeConverters
|
||||
import androidx.room3.migration.AutoMigrationSpec
|
||||
import androidx.sqlite.SQLiteConnection
|
||||
import androidx.sqlite.execSQL
|
||||
import org.meshtastic.core.common.util.ioDispatcher
|
||||
import org.meshtastic.core.database.dao.DeviceHardwareDao
|
||||
import org.meshtastic.core.database.dao.FirmwareReleaseDao
|
||||
import org.meshtastic.core.database.dao.MeshLogDao
|
||||
import org.meshtastic.core.database.dao.NodeInfoDao
|
||||
import org.meshtastic.core.database.dao.NodeMetadataDao
|
||||
import org.meshtastic.core.database.dao.PacketDao
|
||||
import org.meshtastic.core.database.dao.QuickChatActionDao
|
||||
import org.meshtastic.core.database.dao.TracerouteNodePositionDao
|
||||
@@ -38,6 +41,7 @@ import org.meshtastic.core.database.entity.MeshLog
|
||||
import org.meshtastic.core.database.entity.MetadataEntity
|
||||
import org.meshtastic.core.database.entity.MyNodeEntity
|
||||
import org.meshtastic.core.database.entity.NodeEntity
|
||||
import org.meshtastic.core.database.entity.NodeMetadataEntity
|
||||
import org.meshtastic.core.database.entity.Packet
|
||||
import org.meshtastic.core.database.entity.QuickChatAction
|
||||
import org.meshtastic.core.database.entity.ReactionEntity
|
||||
@@ -48,6 +52,7 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
|
||||
[
|
||||
MyNodeEntity::class,
|
||||
NodeEntity::class,
|
||||
NodeMetadataEntity::class,
|
||||
Packet::class,
|
||||
ContactSettings::class,
|
||||
MeshLog::class,
|
||||
@@ -95,8 +100,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
|
||||
AutoMigration(from = 35, to = 36),
|
||||
AutoMigration(from = 36, to = 37),
|
||||
AutoMigration(from = 37, to = 38),
|
||||
AutoMigration(from = 38, to = 39, spec = AutoMigration38to39::class),
|
||||
],
|
||||
version = 38,
|
||||
version = 39,
|
||||
exportSchema = true,
|
||||
)
|
||||
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)
|
||||
@@ -105,6 +111,8 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
|
||||
abstract class MeshtasticDatabase : RoomDatabase() {
|
||||
abstract fun nodeInfoDao(): NodeInfoDao
|
||||
|
||||
abstract fun nodeMetadataDao(): NodeMetadataDao
|
||||
|
||||
abstract fun packetDao(): PacketDao
|
||||
|
||||
abstract fun meshLogDao(): MeshLogDao
|
||||
@@ -138,3 +146,17 @@ class AutoMigration33to34 : AutoMigrationSpec
|
||||
@DeleteColumn(tableName = "packet", columnName = "retry_count")
|
||||
@DeleteColumn(tableName = "reactions", columnName = "retry_count")
|
||||
class AutoMigration34to35 : AutoMigrationSpec
|
||||
|
||||
/** Copies favorites, notes, ignored, muted, and manuallyVerified from nodes → node_metadata. */
|
||||
class AutoMigration38to39 : AutoMigrationSpec {
|
||||
override suspend fun onPostMigrate(connection: SQLiteConnection) {
|
||||
connection.execSQL(
|
||||
"""
|
||||
INSERT OR IGNORE INTO node_metadata (num, is_favorite, is_ignored, is_muted, notes, manually_verified)
|
||||
SELECT num, is_favorite, is_ignored, is_muted, notes, manually_verified
|
||||
FROM nodes
|
||||
WHERE is_favorite = 1 OR is_ignored = 1 OR is_muted = 1 OR notes != '' OR manually_verified = 1
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.database.dao
|
||||
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.Query
|
||||
import androidx.room3.Upsert
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.meshtastic.core.database.entity.NodeMetadataEntity
|
||||
|
||||
@Dao
|
||||
interface NodeMetadataDao {
|
||||
|
||||
@Upsert
|
||||
suspend fun upsert(metadata: NodeMetadataEntity)
|
||||
|
||||
@Query("SELECT * FROM node_metadata")
|
||||
fun getAllFlow(): Flow<List<NodeMetadataEntity>>
|
||||
|
||||
@Query("SELECT * FROM node_metadata WHERE num = :num")
|
||||
suspend fun getByNum(num: Int): NodeMetadataEntity?
|
||||
|
||||
@Query("UPDATE node_metadata SET is_favorite = :isFavorite WHERE num = :num")
|
||||
suspend fun setFavorite(num: Int, isFavorite: Boolean)
|
||||
|
||||
@Query("UPDATE node_metadata SET is_ignored = :isIgnored WHERE num = :num")
|
||||
suspend fun setIgnored(num: Int, isIgnored: Boolean)
|
||||
|
||||
@Query("UPDATE node_metadata SET is_muted = :isMuted WHERE num = :num")
|
||||
suspend fun setMuted(num: Int, isMuted: Boolean)
|
||||
|
||||
@Query("UPDATE node_metadata SET notes = :notes WHERE num = :num")
|
||||
suspend fun setNotes(num: Int, notes: String)
|
||||
|
||||
@Query("UPDATE node_metadata SET manually_verified = :verified WHERE num = :num")
|
||||
suspend fun setManuallyVerified(num: Int, verified: Boolean)
|
||||
|
||||
@Query("DELETE FROM node_metadata WHERE num = :num")
|
||||
suspend fun delete(num: Int)
|
||||
|
||||
@Query("DELETE FROM node_metadata")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.database.entity
|
||||
|
||||
import androidx.room3.ColumnInfo
|
||||
import androidx.room3.Entity
|
||||
import androidx.room3.PrimaryKey
|
||||
|
||||
/**
|
||||
* Persists app-local node metadata that survives process death.
|
||||
* These fields are user preferences/annotations that the SDK does not manage.
|
||||
*/
|
||||
@Entity(tableName = "node_metadata")
|
||||
data class NodeMetadataEntity(
|
||||
@PrimaryKey val num: Int,
|
||||
@ColumnInfo(name = "is_favorite", defaultValue = "0") val isFavorite: Boolean = false,
|
||||
@ColumnInfo(name = "is_ignored", defaultValue = "0") val isIgnored: Boolean = false,
|
||||
@ColumnInfo(name = "is_muted", defaultValue = "0") val isMuted: Boolean = false,
|
||||
@ColumnInfo(name = "notes", defaultValue = "") val notes: String = "",
|
||||
@ColumnInfo(name = "manually_verified", defaultValue = "0") val manuallyVerified: Boolean = false,
|
||||
)
|
||||
@@ -1,27 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.domain.usecase.settings
|
||||
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.repository.UiPrefs
|
||||
|
||||
@Single
|
||||
open class SetAppIntroCompletedUseCase constructor(private val uiPrefs: UiPrefs) {
|
||||
operator fun invoke(value: Boolean) {
|
||||
uiPrefs.setAppIntroCompleted(value)
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.domain.usecase.settings
|
||||
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.database.DatabaseManager
|
||||
import org.meshtastic.core.database.DatabaseConstants
|
||||
|
||||
/** Use case for setting the database cache limit. */
|
||||
@Single
|
||||
open class SetDatabaseCacheLimitUseCase constructor(private val databaseManager: DatabaseManager) {
|
||||
operator fun invoke(limit: Int) {
|
||||
val clamped = limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
|
||||
databaseManager.setCacheLimit(clamped)
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.domain.usecase.settings
|
||||
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.repository.UiPrefs
|
||||
|
||||
@Single
|
||||
open class SetLocaleUseCase constructor(private val uiPrefs: UiPrefs) {
|
||||
operator fun invoke(value: String) {
|
||||
uiPrefs.setLocale(value)
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.domain.usecase.settings
|
||||
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.repository.NotificationPrefs
|
||||
|
||||
/** Use case for updating application-level notification preferences. */
|
||||
@Single
|
||||
class SetNotificationSettingsUseCase(private val notificationPrefs: NotificationPrefs) {
|
||||
fun setMessagesEnabled(enabled: Boolean) = notificationPrefs.setMessagesEnabled(enabled)
|
||||
|
||||
fun setNodeEventsEnabled(enabled: Boolean) = notificationPrefs.setNodeEventsEnabled(enabled)
|
||||
|
||||
fun setLowBatteryEnabled(enabled: Boolean) = notificationPrefs.setLowBatteryEnabled(enabled)
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.domain.usecase.settings
|
||||
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.repository.UiPrefs
|
||||
|
||||
@Single
|
||||
open class SetProvideLocationUseCase constructor(private val uiPrefs: UiPrefs) {
|
||||
operator fun invoke(myNodeNum: Int, provideLocation: Boolean) {
|
||||
uiPrefs.setShouldProvideNodeLocation(myNodeNum, provideLocation)
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.domain.usecase.settings
|
||||
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.repository.UiPrefs
|
||||
|
||||
@Single
|
||||
open class SetThemeUseCase constructor(private val uiPrefs: UiPrefs) {
|
||||
operator fun invoke(value: Int) {
|
||||
uiPrefs.setTheme(value)
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.domain.usecase.settings
|
||||
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.repository.AnalyticsPrefs
|
||||
|
||||
/** Use case for toggling the analytics preference. */
|
||||
@Single
|
||||
open class ToggleAnalyticsUseCase constructor(private val analyticsPrefs: AnalyticsPrefs) {
|
||||
open operator fun invoke() {
|
||||
analyticsPrefs.setAnalyticsAllowed(!analyticsPrefs.analyticsAllowed.value)
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.domain.usecase.settings
|
||||
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.repository.HomoglyphPrefs
|
||||
|
||||
/** Use case for toggling the homoglyph encoding preference. */
|
||||
@Single
|
||||
open class ToggleHomoglyphEncodingUseCase constructor(private val homoglyphEncodingPrefs: HomoglyphPrefs) {
|
||||
open operator fun invoke() {
|
||||
homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(!homoglyphEncodingPrefs.homoglyphEncodingEnabled.value)
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.domain.usecase.settings
|
||||
|
||||
import dev.mokkery.mock
|
||||
import dev.mokkery.verify
|
||||
import org.meshtastic.core.common.database.DatabaseManager
|
||||
import org.meshtastic.core.database.DatabaseConstants
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
|
||||
class SetDatabaseCacheLimitUseCaseTest {
|
||||
|
||||
private lateinit var databaseManager: DatabaseManager
|
||||
private lateinit var useCase: SetDatabaseCacheLimitUseCase
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
databaseManager = mock(dev.mokkery.MockMode.autofill)
|
||||
useCase = SetDatabaseCacheLimitUseCase(databaseManager)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invoke calls setCacheLimit with clamped value`() {
|
||||
// Act & Assert
|
||||
useCase(0)
|
||||
verify { databaseManager.setCacheLimit(DatabaseConstants.MIN_CACHE_LIMIT) }
|
||||
|
||||
useCase(100)
|
||||
verify { databaseManager.setCacheLimit(DatabaseConstants.MAX_CACHE_LIMIT) }
|
||||
|
||||
useCase(5)
|
||||
verify { databaseManager.setCacheLimit(5) }
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.domain.usecase.settings
|
||||
|
||||
import dev.mokkery.answering.returns
|
||||
import dev.mokkery.every
|
||||
import dev.mokkery.matcher.any
|
||||
import dev.mokkery.mock
|
||||
import dev.mokkery.verify
|
||||
import org.meshtastic.core.repository.NotificationPrefs
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
|
||||
class SetNotificationSettingsUseCaseTest {
|
||||
|
||||
private val notificationPrefs: NotificationPrefs = mock()
|
||||
private lateinit var useCase: SetNotificationSettingsUseCase
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
useCase = SetNotificationSettingsUseCase(notificationPrefs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setMessagesEnabled calls notificationPrefs`() {
|
||||
every { notificationPrefs.setMessagesEnabled(any()) } returns Unit
|
||||
useCase.setMessagesEnabled(true)
|
||||
verify { notificationPrefs.setMessagesEnabled(true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setNodeEventsEnabled calls notificationPrefs`() {
|
||||
every { notificationPrefs.setNodeEventsEnabled(any()) } returns Unit
|
||||
useCase.setNodeEventsEnabled(false)
|
||||
verify { notificationPrefs.setNodeEventsEnabled(false) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setLowBatteryEnabled calls notificationPrefs`() {
|
||||
every { notificationPrefs.setLowBatteryEnabled(any()) } returns Unit
|
||||
useCase.setLowBatteryEnabled(true)
|
||||
verify { notificationPrefs.setLowBatteryEnabled(true) }
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.domain.usecase.settings
|
||||
|
||||
import org.meshtastic.core.testing.FakeAnalyticsPrefs
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class ToggleAnalyticsUseCaseTest {
|
||||
|
||||
private lateinit var analyticsPrefs: FakeAnalyticsPrefs
|
||||
private lateinit var useCase: ToggleAnalyticsUseCase
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
analyticsPrefs = FakeAnalyticsPrefs()
|
||||
useCase = ToggleAnalyticsUseCase(analyticsPrefs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invoke toggles from false to true`() {
|
||||
analyticsPrefs.setAnalyticsAllowed(false)
|
||||
useCase()
|
||||
assertEquals(true, analyticsPrefs.analyticsAllowed.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invoke toggles from true to false`() {
|
||||
analyticsPrefs.setAnalyticsAllowed(true)
|
||||
useCase()
|
||||
assertEquals(false, analyticsPrefs.analyticsAllowed.value)
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.domain.usecase.settings
|
||||
|
||||
import org.meshtastic.core.testing.FakeHomoglyphPrefs
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class ToggleHomoglyphEncodingUseCaseTest {
|
||||
|
||||
private lateinit var homoglyphPrefs: FakeHomoglyphPrefs
|
||||
private lateinit var useCase: ToggleHomoglyphEncodingUseCase
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
homoglyphPrefs = FakeHomoglyphPrefs()
|
||||
useCase = ToggleHomoglyphEncodingUseCase(homoglyphPrefs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invoke toggles from false to true`() {
|
||||
homoglyphPrefs.setHomoglyphEncodingEnabled(false)
|
||||
useCase()
|
||||
assertEquals(true, homoglyphPrefs.homoglyphEncodingEnabled.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invoke toggles from true to false`() {
|
||||
homoglyphPrefs.setHomoglyphEncodingEnabled(true)
|
||||
useCase()
|
||||
assertEquals(false, homoglyphPrefs.homoglyphEncodingEnabled.value)
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.repository
|
||||
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
|
||||
/** Interface for handling admin messages from the mesh (config, metadata, session passkey). */
|
||||
interface AdminPacketHandler {
|
||||
/**
|
||||
* Processes an admin message packet.
|
||||
*
|
||||
* @param packet The received mesh packet.
|
||||
* @param myNodeNum The local node number.
|
||||
*/
|
||||
fun handleAdminMessage(packet: MeshPacket, myNodeNum: Int)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.repository
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* App-local node metadata that persists independently of the SDK's node database.
|
||||
*
|
||||
* This covers user annotations (favorites, notes, mute, ignore) that are NOT synced to the radio.
|
||||
* VMs and feature modules inject this instead of the full [NodeRepository] when they only need
|
||||
* metadata operations.
|
||||
*/
|
||||
interface AppMetadataRepository {
|
||||
|
||||
/** Flow of all node metadata, keyed by node number. */
|
||||
val metadataByNum: Flow<Map<Int, NodeMetadata>>
|
||||
|
||||
suspend fun setFavorite(nodeNum: Int, isFavorite: Boolean)
|
||||
suspend fun setIgnored(nodeNum: Int, isIgnored: Boolean)
|
||||
suspend fun setMuted(nodeNum: Int, isMuted: Boolean)
|
||||
suspend fun setNotes(nodeNum: Int, notes: String)
|
||||
suspend fun setManuallyVerified(nodeNum: Int, verified: Boolean)
|
||||
suspend fun delete(nodeNum: Int)
|
||||
}
|
||||
|
||||
/** Lightweight metadata value object exposed to feature modules. */
|
||||
data class NodeMetadata(
|
||||
val num: Int,
|
||||
val isFavorite: Boolean = false,
|
||||
val isIgnored: Boolean = false,
|
||||
val isMuted: Boolean = false,
|
||||
val notes: String? = null,
|
||||
val manuallyVerified: Boolean = false,
|
||||
)
|
||||
@@ -1,86 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.repository
|
||||
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.proto.AdminMessage
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.LocalConfig
|
||||
|
||||
/** Interface for sending commands and packets to the mesh network. */
|
||||
@Suppress("TooManyFunctions")
|
||||
interface CommandSender {
|
||||
/** Returns the current packet ID. */
|
||||
fun getCurrentPacketId(): Long
|
||||
|
||||
/** Returns the cached local configuration. */
|
||||
fun getCachedLocalConfig(): LocalConfig
|
||||
|
||||
/** Returns the cached channel set. */
|
||||
fun getCachedChannelSet(): ChannelSet
|
||||
|
||||
/** Generates a new unique packet ID. */
|
||||
fun generatePacketId(): Int
|
||||
|
||||
/** Sends a data packet to the mesh. */
|
||||
fun sendData(p: DataPacket)
|
||||
|
||||
/** Sends an admin message to a specific node. */
|
||||
fun sendAdmin(
|
||||
destNum: Int,
|
||||
requestId: Int = generatePacketId(),
|
||||
wantResponse: Boolean = false,
|
||||
initFn: () -> AdminMessage,
|
||||
)
|
||||
|
||||
/**
|
||||
* Sends an admin message and suspends until the radio acknowledges it.
|
||||
*
|
||||
* This is used when the caller needs to guarantee a packet has been accepted by the radio before proceeding, such
|
||||
* as sending a shared contact before the first DM to a node.
|
||||
*
|
||||
* @return `true` if the radio accepted the packet, `false` on timeout or failure.
|
||||
*/
|
||||
suspend fun sendAdminAwait(
|
||||
destNum: Int,
|
||||
requestId: Int = generatePacketId(),
|
||||
wantResponse: Boolean = false,
|
||||
initFn: () -> AdminMessage,
|
||||
): Boolean
|
||||
|
||||
/** Sends our current position to the mesh. */
|
||||
fun sendPosition(pos: org.meshtastic.proto.Position, destNum: Int? = null, wantResponse: Boolean = false)
|
||||
|
||||
/** Requests the position of a specific node. */
|
||||
fun requestPosition(destNum: Int, currentPosition: Position)
|
||||
|
||||
/** Sets a fixed position for a node. */
|
||||
fun setFixedPosition(destNum: Int, pos: Position)
|
||||
|
||||
/** Requests user info from a specific node. */
|
||||
fun requestUserInfo(destNum: Int)
|
||||
|
||||
/** Requests a traceroute to a specific node. */
|
||||
fun requestTraceroute(requestId: Int, destNum: Int)
|
||||
|
||||
/** Requests telemetry from a specific node. */
|
||||
fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int)
|
||||
|
||||
/** Requests neighbor info from a specific node. */
|
||||
fun requestNeighborInfo(requestId: Int, destNum: Int)
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.repository
|
||||
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.MeshUser
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.service.ServiceAction
|
||||
|
||||
/** Interface for handling UI-triggered actions and administrative commands for the mesh. */
|
||||
@Suppress("TooManyFunctions")
|
||||
interface MeshActionHandler {
|
||||
/** Processes a service action from the UI. */
|
||||
suspend fun onServiceAction(action: ServiceAction)
|
||||
|
||||
/** Sets the owner of the local node. */
|
||||
fun handleSetOwner(u: MeshUser, myNodeNum: Int)
|
||||
|
||||
/** Sends a data packet through the mesh. */
|
||||
fun handleSend(p: DataPacket, myNodeNum: Int)
|
||||
|
||||
/** Requests the position of a remote node. */
|
||||
fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int)
|
||||
|
||||
/** Removes a node from the database by its node number. */
|
||||
fun handleRemoveByNodenum(nodeNum: Int, requestId: Int, myNodeNum: Int)
|
||||
|
||||
/** Sets the owner of a remote node. */
|
||||
fun handleSetRemoteOwner(id: Int, destNum: Int, payload: ByteArray)
|
||||
|
||||
/** Gets the owner of a remote node. */
|
||||
fun handleGetRemoteOwner(id: Int, destNum: Int)
|
||||
|
||||
/** Sets the configuration of the local node. */
|
||||
fun handleSetConfig(payload: ByteArray, myNodeNum: Int)
|
||||
|
||||
/** Sets the configuration of a remote node. */
|
||||
fun handleSetRemoteConfig(id: Int, destNum: Int, payload: ByteArray)
|
||||
|
||||
/** Gets the configuration of a remote node. */
|
||||
fun handleGetRemoteConfig(id: Int, destNum: Int, config: Int)
|
||||
|
||||
/** Sets the module configuration of a remote node. */
|
||||
fun handleSetModuleConfig(id: Int, destNum: Int, payload: ByteArray)
|
||||
|
||||
/** Gets the module configuration of a remote node. */
|
||||
fun handleGetModuleConfig(id: Int, destNum: Int, config: Int)
|
||||
|
||||
/** Sets the ringtone of a remote node. */
|
||||
fun handleSetRingtone(destNum: Int, ringtone: String)
|
||||
|
||||
/** Gets the ringtone of a remote node. */
|
||||
fun handleGetRingtone(id: Int, destNum: Int)
|
||||
|
||||
/** Sets canned messages on a remote node. */
|
||||
fun handleSetCannedMessages(destNum: Int, messages: String)
|
||||
|
||||
/** Gets canned messages from a remote node. */
|
||||
fun handleGetCannedMessages(id: Int, destNum: Int)
|
||||
|
||||
/** Sets a channel configuration on the local node. */
|
||||
fun handleSetChannel(payload: ByteArray?, myNodeNum: Int)
|
||||
|
||||
/** Sets a channel configuration on a remote node. */
|
||||
fun handleSetRemoteChannel(id: Int, destNum: Int, payload: ByteArray?)
|
||||
|
||||
/** Gets a channel configuration from a remote node. */
|
||||
fun handleGetRemoteChannel(id: Int, destNum: Int, index: Int)
|
||||
|
||||
/** Requests neighbor information from a remote node. */
|
||||
fun handleRequestNeighborInfo(requestId: Int, destNum: Int)
|
||||
|
||||
/** Begins editing settings on a remote node. */
|
||||
fun handleBeginEditSettings(destNum: Int)
|
||||
|
||||
/** Commits settings edits on a remote node. */
|
||||
fun handleCommitEditSettings(destNum: Int)
|
||||
|
||||
/** Reboots a remote node into DFU mode. */
|
||||
fun handleRebootToDfu(destNum: Int)
|
||||
|
||||
/** Requests telemetry from a remote node. */
|
||||
fun handleRequestTelemetry(requestId: Int, destNum: Int, type: Int)
|
||||
|
||||
/** Requests a remote node to shut down. */
|
||||
fun handleRequestShutdown(requestId: Int, destNum: Int)
|
||||
|
||||
/** Requests a remote node to reboot. */
|
||||
fun handleRequestReboot(requestId: Int, destNum: Int)
|
||||
|
||||
/** Requests a remote node to reboot in OTA mode. */
|
||||
fun handleRequestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?)
|
||||
|
||||
/** Requests a factory reset on a remote node. */
|
||||
fun handleRequestFactoryReset(requestId: Int, destNum: Int)
|
||||
|
||||
/** Requests a node database reset on a remote node. */
|
||||
fun handleRequestNodedbReset(requestId: Int, destNum: Int, preserveFavorites: Boolean)
|
||||
|
||||
/** Gets the connection status of a remote node. */
|
||||
fun handleGetDeviceConnectionStatus(requestId: Int, destNum: Int)
|
||||
|
||||
/** Updates the last used device address. */
|
||||
fun handleUpdateLastAddress(deviceAddr: String?)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.repository
|
||||
|
||||
import org.meshtastic.proto.DeviceMetadata
|
||||
import org.meshtastic.proto.FileInfo
|
||||
import org.meshtastic.proto.MyNodeInfo
|
||||
import org.meshtastic.proto.NodeInfo
|
||||
|
||||
/** Interface for managing the configuration flow, including local node info and metadata. */
|
||||
interface MeshConfigFlowManager {
|
||||
/** Handles received local node information. */
|
||||
fun handleMyInfo(myInfo: MyNodeInfo)
|
||||
|
||||
/** Handles received local device metadata. */
|
||||
fun handleLocalMetadata(metadata: DeviceMetadata)
|
||||
|
||||
/** Handles received node information. */
|
||||
fun handleNodeInfo(info: NodeInfo)
|
||||
|
||||
/**
|
||||
* Handles a [FileInfo] packet received during STATE_SEND_FILEMANIFEST.
|
||||
*
|
||||
* Each packet describes one file available on the device. Accumulated into [RadioConfigRepository.fileManifestFlow]
|
||||
* and cleared at the start of each new handshake.
|
||||
*/
|
||||
fun handleFileInfo(info: FileInfo)
|
||||
|
||||
/** Returns the number of nodes received in the current stage. */
|
||||
val newNodeCount: Int
|
||||
|
||||
/** Handles the completion of a configuration stage. */
|
||||
fun handleConfigComplete(configCompleteId: Int)
|
||||
|
||||
/** Triggers a request for the full device configuration. */
|
||||
fun triggerWantConfig()
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.repository
|
||||
|
||||
import org.meshtastic.proto.Telemetry
|
||||
|
||||
/** Interface for managing the connection lifecycle and status with the mesh radio. */
|
||||
interface MeshConnectionManager {
|
||||
/** Called when the radio configuration has been fully loaded. */
|
||||
fun onRadioConfigLoaded()
|
||||
|
||||
/** Initiates the configuration synchronization stage. */
|
||||
fun startConfigOnly()
|
||||
|
||||
/** Initiates the node information synchronization stage. */
|
||||
fun startNodeInfoOnly()
|
||||
|
||||
/** Called when the node database is ready and fully populated. */
|
||||
fun onNodeDbReady()
|
||||
|
||||
/** Updates the telemetry information for the local node. */
|
||||
fun updateTelemetry(t: Telemetry)
|
||||
|
||||
/** Updates the current status notification. */
|
||||
fun updateStatusNotification(telemetry: Telemetry? = null)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.repository
|
||||
|
||||
/** Interface for the central router that orchestrates specialized mesh packet handlers. */
|
||||
interface MeshRouter {
|
||||
/** Access to the data handler. */
|
||||
val dataHandler: MeshDataHandler
|
||||
|
||||
/** Access to the configuration handler. */
|
||||
val configHandler: MeshConfigHandler
|
||||
|
||||
/** Access to the traceroute handler. */
|
||||
val tracerouteHandler: TracerouteHandler
|
||||
|
||||
/** Access to the neighbor info handler. */
|
||||
val neighborInfoHandler: NeighborInfoHandler
|
||||
|
||||
/** Access to the configuration flow manager. */
|
||||
val configFlowManager: MeshConfigFlowManager
|
||||
|
||||
/** Access to the MQTT manager. */
|
||||
val mqttManager: MqttManager
|
||||
|
||||
/** Access to the action handler. */
|
||||
val actionHandler: MeshActionHandler
|
||||
|
||||
/** Access to the XModem file-transfer manager. */
|
||||
val xmodemManager: XModemManager
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.repository
|
||||
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.MessageStatus
|
||||
import org.meshtastic.core.model.Node
|
||||
|
||||
/** Interface for broadcasting service-level events to the application. */
|
||||
interface ServiceBroadcasts {
|
||||
/** Subscribes a receiver to mesh broadcasts. */
|
||||
fun subscribeReceiver(receiverName: String, packageName: String)
|
||||
|
||||
/** Broadcasts received data to the application. */
|
||||
fun broadcastReceivedData(dataPacket: DataPacket)
|
||||
|
||||
/** Broadcasts that the radio connection state has changed. */
|
||||
fun broadcastConnection()
|
||||
|
||||
/** Broadcasts that node information has changed. */
|
||||
fun broadcastNodeChange(node: Node)
|
||||
|
||||
/** Broadcasts that the status of a message has changed. */
|
||||
fun broadcastMessageStatus(packetId: Int, status: MessageStatus)
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.service
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import co.touchlab.kermit.Severity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.service.ServiceAction
|
||||
import org.meshtastic.core.model.service.TracerouteResponse
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
import org.meshtastic.proto.MeshPacket
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class ServiceBroadcastsTest {
|
||||
|
||||
private lateinit var context: Context
|
||||
private val serviceRepository = FakeServiceRepository()
|
||||
private lateinit var broadcasts: ServiceBroadcasts
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
broadcasts = ServiceBroadcasts(context, serviceRepository)
|
||||
serviceRepository.setConnectionState(ConnectionState.Connected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `broadcastConnection sends uppercase state string for ATAK`() {
|
||||
broadcasts.broadcastConnection()
|
||||
|
||||
val shadowApp = shadowOf(context as Application)
|
||||
val intent = shadowApp.broadcastIntents.find { it.action == ACTION_MESH_CONNECTED }
|
||||
assertEquals("CONNECTED", intent?.getStringExtra(EXTRA_CONNECTED))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `broadcastConnection sends legacy connection intent`() {
|
||||
broadcasts.broadcastConnection()
|
||||
|
||||
val shadowApp = shadowOf(context as Application)
|
||||
val intent = shadowApp.broadcastIntents.find { it.action == ACTION_CONNECTION_CHANGED }
|
||||
assertEquals("CONNECTED", intent?.getStringExtra(EXTRA_CONNECTED))
|
||||
assertEquals(true, intent?.getBooleanExtra("connected", false))
|
||||
}
|
||||
|
||||
private class FakeServiceRepository : ServiceRepository {
|
||||
override val connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
override val clientNotification = MutableStateFlow<ClientNotification?>(null)
|
||||
override val errorMessage = MutableStateFlow<String?>(null)
|
||||
override val connectionProgress = MutableStateFlow<String?>(null)
|
||||
private val meshPackets = MutableSharedFlow<MeshPacket>()
|
||||
override val meshPacketFlow: Flow<MeshPacket> = meshPackets.asFlow()
|
||||
override val tracerouteResponse = MutableStateFlow<TracerouteResponse?>(null)
|
||||
override val neighborInfoResponse = MutableStateFlow<String?>(null)
|
||||
private val serviceActions = MutableSharedFlow<ServiceAction>()
|
||||
override val serviceAction: Flow<ServiceAction> = serviceActions
|
||||
|
||||
override fun setConnectionState(connectionState: ConnectionState) {
|
||||
this.connectionState.value = connectionState
|
||||
}
|
||||
|
||||
override fun setClientNotification(notification: ClientNotification?) {
|
||||
clientNotification.value = notification
|
||||
}
|
||||
|
||||
override fun clearClientNotification() {
|
||||
clientNotification.value = null
|
||||
}
|
||||
|
||||
override fun setErrorMessage(text: String, severity: Severity) {
|
||||
errorMessage.value = text
|
||||
}
|
||||
|
||||
override fun clearErrorMessage() {
|
||||
errorMessage.value = null
|
||||
}
|
||||
|
||||
override fun setConnectionProgress(text: String) {
|
||||
connectionProgress.value = text
|
||||
}
|
||||
|
||||
override suspend fun emitMeshPacket(packet: MeshPacket) {
|
||||
meshPackets.emit(packet)
|
||||
}
|
||||
|
||||
override fun setTracerouteResponse(value: TracerouteResponse?) {
|
||||
tracerouteResponse.value = value
|
||||
}
|
||||
|
||||
override fun clearTracerouteResponse() {
|
||||
tracerouteResponse.value = null
|
||||
}
|
||||
|
||||
override fun setNeighborInfoResponse(value: String?) {
|
||||
neighborInfoResponse.value = value
|
||||
}
|
||||
|
||||
override fun clearNeighborInfoResponse() {
|
||||
neighborInfoResponse.value = null
|
||||
}
|
||||
|
||||
override suspend fun onServiceAction(action: ServiceAction) {
|
||||
serviceActions.emit(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@ const val ACTION_NODE_CHANGE = MeshtasticIntent.ACTION_NODE_CHANGE
|
||||
const val ACTION_MESH_CONNECTED = MeshtasticIntent.ACTION_MESH_CONNECTED
|
||||
const val ACTION_MESH_DISCONNECTED = MeshtasticIntent.ACTION_MESH_DISCONNECTED
|
||||
|
||||
@Suppress("DEPRECATION") // Intentionally re-exported for backward-compat broadcast in ServiceBroadcasts
|
||||
const val ACTION_CONNECTION_CHANGED = MeshtasticIntent.ACTION_CONNECTION_CHANGED
|
||||
const val ACTION_MESSAGE_STATUS = MeshtasticIntent.ACTION_MESSAGE_STATUS
|
||||
|
||||
|
||||
@@ -30,10 +30,10 @@ import org.koin.android.ext.android.inject
|
||||
import org.meshtastic.core.common.hasLocationPermission
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.DeviceVersion
|
||||
import org.meshtastic.core.repository.MeshConnectionManager
|
||||
import org.meshtastic.core.repository.MeshServiceNotifications
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.SERVICE_NOTIFY_ID
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.proto.PortNum
|
||||
|
||||
/**
|
||||
@@ -47,10 +47,10 @@ class MeshService : Service() {
|
||||
|
||||
private val radioInterfaceService: RadioInterfaceService by inject()
|
||||
|
||||
private val connectionManager: MeshConnectionManager by inject()
|
||||
|
||||
private val notifications: MeshServiceNotifications by inject()
|
||||
|
||||
private val serviceRepository: ServiceRepository by inject()
|
||||
|
||||
/** Android-typed accessor for the foreground service notification. */
|
||||
private val androidNotifications: MeshServiceNotificationsImpl
|
||||
get() = notifications as MeshServiceNotificationsImpl
|
||||
@@ -112,7 +112,7 @@ class MeshService : Service() {
|
||||
val a = radioInterfaceService.getDeviceAddress()
|
||||
val wantForeground = a != null && a != "n"
|
||||
|
||||
connectionManager.updateStatusNotification()
|
||||
notifications.updateServiceStateNotification(serviceRepository.connectionState.value, null)
|
||||
val notification = androidNotifications.getServiceNotification()
|
||||
|
||||
val foregroundServiceType =
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.service
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Parcelable
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.MessageStatus
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.NodeInfo
|
||||
import org.meshtastic.core.model.util.toPIIString
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import java.util.Locale
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts as SharedServiceBroadcasts
|
||||
|
||||
@Single
|
||||
class ServiceBroadcasts(private val context: Context, private val serviceRepository: ServiceRepository) :
|
||||
SharedServiceBroadcasts {
|
||||
// A mapping of receiver class name to package name - used for explicit broadcasts.
|
||||
// ConcurrentHashMap because subscribeReceiver() is called from AIDL binder threads
|
||||
// while explicitBroadcast() iterates from coroutine contexts.
|
||||
private val clientPackages = java.util.concurrent.ConcurrentHashMap<String, String>()
|
||||
|
||||
override fun subscribeReceiver(receiverName: String, packageName: String) {
|
||||
clientPackages[receiverName] = packageName
|
||||
}
|
||||
|
||||
/** Broadcast some received data Payload will be a DataPacket */
|
||||
override fun broadcastReceivedData(dataPacket: DataPacket) {
|
||||
val action = MeshService.actionReceived(dataPacket.dataType)
|
||||
explicitBroadcast(Intent(action).putExtra(EXTRA_PAYLOAD, dataPacket))
|
||||
|
||||
// Also broadcast with the numeric port number for backwards compatibility with some apps
|
||||
val numericAction = actionReceived(dataPacket.dataType.toString())
|
||||
if (numericAction != action) {
|
||||
explicitBroadcast(Intent(numericAction).putExtra(EXTRA_PAYLOAD, dataPacket))
|
||||
}
|
||||
}
|
||||
|
||||
override fun broadcastNodeChange(node: Node) {
|
||||
Logger.d { "Broadcasting node change ${node.user.toPIIString()}" }
|
||||
val legacy = node.toLegacy()
|
||||
val intent = Intent(ACTION_NODE_CHANGE).putExtra(EXTRA_NODEINFO, legacy)
|
||||
explicitBroadcast(intent)
|
||||
}
|
||||
|
||||
private fun Node.toLegacy(): NodeInfo = NodeInfo(
|
||||
num = num,
|
||||
user =
|
||||
org.meshtastic.core.model.MeshUser(
|
||||
id = user.id,
|
||||
longName = user.long_name,
|
||||
shortName = user.short_name,
|
||||
hwModel = user.hw_model,
|
||||
role = user.role.value,
|
||||
),
|
||||
position =
|
||||
org.meshtastic.core.model
|
||||
.Position(
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
altitude = position.altitude ?: 0,
|
||||
time = position.time,
|
||||
satellitesInView = position.sats_in_view,
|
||||
groundSpeed = position.ground_speed ?: 0,
|
||||
groundTrack = position.ground_track ?: 0,
|
||||
precisionBits = position.precision_bits,
|
||||
)
|
||||
.takeIf { latitude != 0.0 || longitude != 0.0 },
|
||||
snr = snr,
|
||||
rssi = rssi,
|
||||
lastHeard = lastHeard,
|
||||
deviceMetrics =
|
||||
org.meshtastic.core.model.DeviceMetrics(
|
||||
batteryLevel = deviceMetrics.battery_level ?: 0,
|
||||
voltage = deviceMetrics.voltage ?: 0f,
|
||||
channelUtilization = deviceMetrics.channel_utilization ?: 0f,
|
||||
airUtilTx = deviceMetrics.air_util_tx ?: 0f,
|
||||
uptimeSeconds = deviceMetrics.uptime_seconds ?: 0,
|
||||
),
|
||||
channel = channel,
|
||||
environmentMetrics = org.meshtastic.core.model.EnvironmentMetrics.fromTelemetryProto(environmentMetrics, 0),
|
||||
hopsAway = hopsAway,
|
||||
nodeStatus = nodeStatus,
|
||||
)
|
||||
|
||||
fun broadcastMessageStatus(p: DataPacket) = broadcastMessageStatus(p.id, p.status ?: MessageStatus.UNKNOWN)
|
||||
|
||||
override fun broadcastMessageStatus(packetId: Int, status: MessageStatus) {
|
||||
if (packetId == 0) {
|
||||
Logger.d { "Ignoring anonymous packet status" }
|
||||
} else {
|
||||
// Do not log, contains PII possibly
|
||||
// MeshService.Logger.d { "Broadcasting message status $p" }
|
||||
val intent =
|
||||
Intent(ACTION_MESSAGE_STATUS).apply {
|
||||
putExtra(EXTRA_PACKET_ID, packetId)
|
||||
putExtra(EXTRA_STATUS, status as Parcelable)
|
||||
}
|
||||
explicitBroadcast(intent)
|
||||
}
|
||||
}
|
||||
|
||||
/** Broadcast our current connection status */
|
||||
override fun broadcastConnection() {
|
||||
val connectionState = serviceRepository.connectionState.value
|
||||
// ATAK expects a String: "CONNECTED" or "DISCONNECTED"
|
||||
// It uses equalsIgnoreCase, but we'll use uppercase to be specific.
|
||||
val stateStr = connectionState.toString().uppercase(Locale.ROOT)
|
||||
|
||||
val intent = Intent(ACTION_MESH_CONNECTED).apply { putExtra(EXTRA_CONNECTED, stateStr) }
|
||||
explicitBroadcast(intent)
|
||||
|
||||
if (connectionState == ConnectionState.Disconnected) {
|
||||
explicitBroadcast(Intent(ACTION_MESH_DISCONNECTED))
|
||||
}
|
||||
|
||||
// Restore legacy action for other consumers (e.g. ATAK plugins)
|
||||
val legacyIntent =
|
||||
Intent(ACTION_CONNECTION_CHANGED).apply {
|
||||
putExtra(EXTRA_CONNECTED, stateStr)
|
||||
// Legacy boolean extra often expected by older implementations
|
||||
putExtra("connected", connectionState == ConnectionState.Connected)
|
||||
}
|
||||
explicitBroadcast(legacyIntent)
|
||||
}
|
||||
|
||||
/**
|
||||
* See com.geeksville.mesh broadcast intents.
|
||||
*
|
||||
* RECEIVED_OPAQUE for data received from other nodes
|
||||
* NODE_CHANGE for new IDs appearing or disappearing
|
||||
* ACTION_MESH_CONNECTED for losing/gaining connection to the packet radio
|
||||
* Note: this is not the same as RadioInterfaceService.RADIO_CONNECTED_ACTION,
|
||||
* because it implies we have assembled a valid node db.
|
||||
*/
|
||||
private fun explicitBroadcast(intent: Intent) {
|
||||
context.sendBroadcast(
|
||||
intent,
|
||||
) // We also do a regular (not explicit broadcast) so any context-registered receivers will work
|
||||
clientPackages.forEach {
|
||||
intent.setClassName(it.value, it.key)
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.service
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.model.service.ServiceAction
|
||||
import org.meshtastic.core.repository.CommandSender
|
||||
import org.meshtastic.core.repository.MeshLocationManager
|
||||
import org.meshtastic.core.repository.MeshRouter
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.proto.Channel
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.ModuleConfig
|
||||
import org.meshtastic.proto.SharedContact
|
||||
import org.meshtastic.proto.User
|
||||
|
||||
/**
|
||||
* Platform-agnostic [RadioController] implementation that delegates directly to service-layer handlers.
|
||||
*
|
||||
* Unlike [AndroidRadioControllerImpl], which routes every call through the AIDL [IMeshService] binder, this
|
||||
* implementation talks directly to [CommandSender], [MeshRouter.actionHandler], [ServiceRepository], and [NodeManager].
|
||||
* This is the correct implementation for any target where the service runs in-process (Desktop, iOS, or Android in
|
||||
* single-process mode).
|
||||
*
|
||||
* This eliminates the need for [NoopRadioController] on non-Android targets.
|
||||
*/
|
||||
@Suppress("TooManyFunctions", "LongParameterList")
|
||||
class DirectRadioControllerImpl(
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val commandSender: CommandSender,
|
||||
private val router: MeshRouter,
|
||||
private val nodeManager: NodeManager,
|
||||
private val radioInterfaceService: RadioInterfaceService,
|
||||
private val locationManager: MeshLocationManager,
|
||||
) : RadioController {
|
||||
|
||||
private val actionHandler
|
||||
get() = router.actionHandler
|
||||
|
||||
private val myNodeNum: Int
|
||||
get() = nodeManager.myNodeNum.value ?: 0
|
||||
|
||||
/** Delegates to [ServiceRepository.connectionState] — the canonical app-level source of truth. */
|
||||
override val connectionState: StateFlow<ConnectionState>
|
||||
get() = serviceRepository.connectionState
|
||||
|
||||
override val clientNotification: StateFlow<ClientNotification?>
|
||||
get() = serviceRepository.clientNotification
|
||||
|
||||
override suspend fun sendMessage(packet: DataPacket) {
|
||||
actionHandler.handleSend(packet, myNodeNum)
|
||||
}
|
||||
|
||||
override fun clearClientNotification() {
|
||||
serviceRepository.clearClientNotification()
|
||||
}
|
||||
|
||||
override suspend fun favoriteNode(nodeNum: Int) {
|
||||
val nodeDef = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum))
|
||||
serviceRepository.onServiceAction(ServiceAction.Favorite(nodeDef))
|
||||
}
|
||||
|
||||
override suspend fun sendSharedContact(nodeNum: Int): Boolean {
|
||||
val nodeDef = nodeRepository.getNode(DataPacket.nodeNumToDefaultId(nodeNum))
|
||||
val contact =
|
||||
SharedContact(node_num = nodeDef.num, user = nodeDef.user, manually_verified = nodeDef.manuallyVerified)
|
||||
val action = ServiceAction.SendContact(contact)
|
||||
serviceRepository.onServiceAction(action)
|
||||
return action.result.await()
|
||||
}
|
||||
|
||||
override suspend fun setLocalConfig(config: Config) {
|
||||
actionHandler.handleSetConfig(config.encode(), myNodeNum)
|
||||
}
|
||||
|
||||
override suspend fun setLocalChannel(channel: Channel) {
|
||||
actionHandler.handleSetChannel(channel.encode(), myNodeNum)
|
||||
}
|
||||
|
||||
override suspend fun setOwner(destNum: Int, user: User, packetId: Int) {
|
||||
actionHandler.handleSetRemoteOwner(packetId, destNum, user.encode())
|
||||
}
|
||||
|
||||
override suspend fun setConfig(destNum: Int, config: Config, packetId: Int) {
|
||||
actionHandler.handleSetRemoteConfig(packetId, destNum, config.encode())
|
||||
}
|
||||
|
||||
override suspend fun setModuleConfig(destNum: Int, config: ModuleConfig, packetId: Int) {
|
||||
actionHandler.handleSetModuleConfig(packetId, destNum, config.encode())
|
||||
}
|
||||
|
||||
override suspend fun setRemoteChannel(destNum: Int, channel: Channel, packetId: Int) {
|
||||
actionHandler.handleSetRemoteChannel(packetId, destNum, channel.encode())
|
||||
}
|
||||
|
||||
override suspend fun setFixedPosition(destNum: Int, position: Position) {
|
||||
commandSender.setFixedPosition(destNum, position)
|
||||
}
|
||||
|
||||
override suspend fun setRingtone(destNum: Int, ringtone: String) {
|
||||
actionHandler.handleSetRingtone(destNum, ringtone)
|
||||
}
|
||||
|
||||
override suspend fun setCannedMessages(destNum: Int, messages: String) {
|
||||
actionHandler.handleSetCannedMessages(destNum, messages)
|
||||
}
|
||||
|
||||
override suspend fun getOwner(destNum: Int, packetId: Int) {
|
||||
actionHandler.handleGetRemoteOwner(packetId, destNum)
|
||||
}
|
||||
|
||||
override suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) {
|
||||
actionHandler.handleGetRemoteConfig(packetId, destNum, configType)
|
||||
}
|
||||
|
||||
override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) {
|
||||
actionHandler.handleGetModuleConfig(packetId, destNum, moduleConfigType)
|
||||
}
|
||||
|
||||
override suspend fun getChannel(destNum: Int, index: Int, packetId: Int) {
|
||||
actionHandler.handleGetRemoteChannel(packetId, destNum, index)
|
||||
}
|
||||
|
||||
override suspend fun getRingtone(destNum: Int, packetId: Int) {
|
||||
actionHandler.handleGetRingtone(packetId, destNum)
|
||||
}
|
||||
|
||||
override suspend fun getCannedMessages(destNum: Int, packetId: Int) {
|
||||
actionHandler.handleGetCannedMessages(packetId, destNum)
|
||||
}
|
||||
|
||||
override suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) {
|
||||
actionHandler.handleGetDeviceConnectionStatus(packetId, destNum)
|
||||
}
|
||||
|
||||
override suspend fun reboot(destNum: Int, packetId: Int) {
|
||||
actionHandler.handleRequestReboot(packetId, destNum)
|
||||
}
|
||||
|
||||
override suspend fun rebootToDfu(nodeNum: Int) {
|
||||
actionHandler.handleRebootToDfu(nodeNum)
|
||||
}
|
||||
|
||||
override suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {
|
||||
actionHandler.handleRequestRebootOta(requestId, destNum, mode, hash)
|
||||
}
|
||||
|
||||
override suspend fun shutdown(destNum: Int, packetId: Int) {
|
||||
actionHandler.handleRequestShutdown(packetId, destNum)
|
||||
}
|
||||
|
||||
override suspend fun factoryReset(destNum: Int, packetId: Int) {
|
||||
actionHandler.handleRequestFactoryReset(packetId, destNum)
|
||||
}
|
||||
|
||||
override suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) {
|
||||
actionHandler.handleRequestNodedbReset(packetId, destNum, preserveFavorites)
|
||||
}
|
||||
|
||||
override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) {
|
||||
val myNode = nodeManager.myNodeNum.value
|
||||
if (myNode != null) {
|
||||
actionHandler.handleRemoveByNodenum(nodeNum, packetId, myNode)
|
||||
} else {
|
||||
nodeManager.removeByNodenum(nodeNum)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun requestPosition(destNum: Int, currentPosition: Position) {
|
||||
actionHandler.handleRequestPosition(destNum, currentPosition, myNodeNum)
|
||||
}
|
||||
|
||||
override suspend fun requestUserInfo(destNum: Int) {
|
||||
if (destNum != myNodeNum) {
|
||||
commandSender.requestUserInfo(destNum)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun requestTraceroute(requestId: Int, destNum: Int) {
|
||||
commandSender.requestTraceroute(requestId, destNum)
|
||||
}
|
||||
|
||||
override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {
|
||||
actionHandler.handleRequestTelemetry(requestId, destNum, typeValue)
|
||||
}
|
||||
|
||||
override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) {
|
||||
actionHandler.handleRequestNeighborInfo(requestId, destNum)
|
||||
}
|
||||
|
||||
override suspend fun beginEditSettings(destNum: Int) {
|
||||
actionHandler.handleBeginEditSettings(destNum)
|
||||
}
|
||||
|
||||
override suspend fun commitEditSettings(destNum: Int) {
|
||||
actionHandler.handleCommitEditSettings(destNum)
|
||||
}
|
||||
|
||||
override fun getPacketId(): Int = commandSender.generatePacketId()
|
||||
|
||||
override fun startProvideLocation() {
|
||||
// Location provision requires a scope — typically managed by the orchestrator.
|
||||
// On platforms without GPS hardware (desktop), this is a no-op via the injected locationManager.
|
||||
}
|
||||
|
||||
override fun stopProvideLocation() {
|
||||
locationManager.stop()
|
||||
}
|
||||
|
||||
override fun setDeviceAddress(address: String) {
|
||||
actionHandler.handleUpdateLastAddress(address)
|
||||
radioInterfaceService.setDeviceAddress(address)
|
||||
}
|
||||
}
|
||||
@@ -27,10 +27,11 @@ import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.common.database.DatabaseManager
|
||||
import org.meshtastic.core.common.util.handledLaunch
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.repository.MeshConnectionManager
|
||||
import org.meshtastic.core.repository.AppWidgetUpdater
|
||||
import org.meshtastic.core.repository.MeshServiceNotifications
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.repository.TakPrefs
|
||||
import org.meshtastic.core.takserver.TAKMeshIntegration
|
||||
import org.meshtastic.core.takserver.TAKServerManager
|
||||
@@ -53,7 +54,8 @@ class MeshServiceOrchestrator(
|
||||
private val takMeshIntegration: TAKMeshIntegration,
|
||||
private val takPrefs: TakPrefs,
|
||||
private val databaseManager: DatabaseManager,
|
||||
private val connectionManager: MeshConnectionManager,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val appWidgetUpdater: AppWidgetUpdater,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) {
|
||||
// Per-start coroutine scope. A fresh scope is created on each start() and cancelled on stop(), so all collectors
|
||||
@@ -86,7 +88,21 @@ class MeshServiceOrchestrator(
|
||||
scope = newScope
|
||||
|
||||
serviceNotifications.initChannels()
|
||||
connectionManager.updateStatusNotification()
|
||||
serviceNotifications.updateServiceStateNotification(serviceRepository.connectionState.value, null)
|
||||
|
||||
// Keep notification in sync with connection state changes
|
||||
serviceRepository.connectionState
|
||||
.onEach { state -> serviceNotifications.updateServiceStateNotification(state, null) }
|
||||
.launchIn(newScope)
|
||||
|
||||
// Kickstart app widget
|
||||
newScope.handledLaunch {
|
||||
try {
|
||||
appWidgetUpdater.updateAll()
|
||||
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
|
||||
Logger.e(e) { "Failed to kickstart LocalStatsWidget" }
|
||||
}
|
||||
}
|
||||
|
||||
// Observe TAK server pref to start/stop
|
||||
takPrefs.isTakServerEnabled
|
||||
@@ -107,10 +123,6 @@ class MeshServiceOrchestrator(
|
||||
Logger.i { "Per-device database initialized" }
|
||||
}
|
||||
|
||||
// NOTE: Radio connection, packet routing, and ServiceAction dispatch are now handled
|
||||
// by RadioClientProvider + SdkStateBridge. The old radioInterfaceService.connect() /
|
||||
// receivedData / serviceAction subscription paths are no longer needed.
|
||||
|
||||
nodeManager.loadCachedNodeDB()
|
||||
}
|
||||
|
||||
|
||||
@@ -30,9 +30,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import org.meshtastic.core.common.database.DatabaseManager
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.repository.AppWidgetUpdater
|
||||
import org.meshtastic.core.repository.MeshConfigHandler
|
||||
import org.meshtastic.core.repository.MeshConnectionManager
|
||||
import org.meshtastic.core.repository.MeshServiceNotifications
|
||||
import org.meshtastic.core.repository.NodeManager
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
@@ -55,7 +56,7 @@ class MeshServiceOrchestratorTest {
|
||||
private val takServerManager: TAKServerManager = mock(MockMode.autofill)
|
||||
private val takPrefs: TakPrefs = mock(MockMode.autofill)
|
||||
private val databaseManager: DatabaseManager = mock(MockMode.autofill)
|
||||
private val connectionManager: MeshConnectionManager = mock(MockMode.autofill)
|
||||
private val appWidgetUpdater: AppWidgetUpdater = mock(MockMode.autofill)
|
||||
|
||||
// TAKMeshIntegration deps (final class — constructed directly)
|
||||
private val radioController: RadioController = mock(MockMode.autofill)
|
||||
@@ -80,6 +81,7 @@ class MeshServiceOrchestratorTest {
|
||||
every { meshConfigHandler.moduleConfig } returns MutableStateFlow(LocalModuleConfig())
|
||||
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
|
||||
every { serviceRepository.meshPacketFlow } returns MutableSharedFlow()
|
||||
every { serviceRepository.connectionState } returns MutableStateFlow(ConnectionState.Disconnected)
|
||||
|
||||
val takMeshIntegration = TAKMeshIntegration(
|
||||
takServerManager = takServerManager,
|
||||
@@ -98,7 +100,8 @@ class MeshServiceOrchestratorTest {
|
||||
takMeshIntegration = takMeshIntegration,
|
||||
takPrefs = takPrefs,
|
||||
databaseManager = databaseManager,
|
||||
connectionManager = connectionManager,
|
||||
serviceRepository = serviceRepository,
|
||||
appWidgetUpdater = appWidgetUpdater,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_21)
|
||||
freeCompilerArgs.add("-jvm-default=no-compatibility")
|
||||
freeCompilerArgs.add("-Xskip-prerelease-check")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,6 +263,12 @@ dependencies {
|
||||
implementation(projects.core.proto)
|
||||
implementation(projects.core.ble)
|
||||
|
||||
// Meshtastic SDK (composite build — TCP, Serial transports + storage)
|
||||
implementation(libs.sdk.core)
|
||||
implementation(libs.sdk.transport.tcp)
|
||||
implementation(libs.sdk.transport.serial)
|
||||
implementation(libs.sdk.storage.sqldelight)
|
||||
|
||||
// Feature modules (JVM variants for real composable wiring)
|
||||
implementation(projects.feature.settings)
|
||||
implementation(projects.feature.node)
|
||||
|
||||
@@ -39,7 +39,7 @@ import org.meshtastic.core.data.datasource.FirmwareReleaseJsonDataSource
|
||||
import org.meshtastic.core.model.BootloaderOtaQuirk
|
||||
import org.meshtastic.core.model.NetworkDeviceHardware
|
||||
import org.meshtastic.core.model.NetworkFirmwareReleases
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.data.radio.RadioClientAccessor
|
||||
import org.meshtastic.core.network.HttpClientDefaults
|
||||
import org.meshtastic.core.network.KermitHttpLogger
|
||||
import org.meshtastic.core.network.repository.MQTTRepository
|
||||
@@ -54,9 +54,8 @@ import org.meshtastic.core.repository.MessageQueue
|
||||
import org.meshtastic.core.repository.NotificationManager
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import org.meshtastic.core.repository.RadioTransportFactory
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
import org.meshtastic.core.repository.ServiceRepository
|
||||
import org.meshtastic.core.service.DirectRadioControllerImpl
|
||||
import org.meshtastic.core.service.SdkClientLifecycle
|
||||
import org.meshtastic.core.service.ServiceRepositoryImpl
|
||||
import org.meshtastic.desktop.DesktopBuildConfig
|
||||
import org.meshtastic.desktop.DesktopNotificationManager
|
||||
@@ -67,6 +66,7 @@ import org.meshtastic.desktop.notification.MacOSNotificationSender
|
||||
import org.meshtastic.desktop.notification.NativeNotificationSender
|
||||
import org.meshtastic.desktop.notification.WindowsNotificationSender
|
||||
import org.meshtastic.desktop.radio.DesktopMessageQueue
|
||||
import org.meshtastic.desktop.radio.DesktopRadioClientProvider
|
||||
import org.meshtastic.desktop.radio.DesktopRadioTransportFactory
|
||||
import org.meshtastic.desktop.stub.NoopAppWidgetUpdater
|
||||
import org.meshtastic.desktop.stub.NoopCompassHeadingProvider
|
||||
@@ -77,7 +77,6 @@ import org.meshtastic.desktop.stub.NoopMeshLocationManager
|
||||
import org.meshtastic.desktop.stub.NoopMeshWorkerManager
|
||||
import org.meshtastic.desktop.stub.NoopPhoneLocationProvider
|
||||
import org.meshtastic.desktop.stub.NoopPlatformAnalytics
|
||||
import org.meshtastic.desktop.stub.NoopServiceBroadcasts
|
||||
import org.meshtastic.feature.node.compass.CompassHeadingProvider
|
||||
import org.meshtastic.feature.node.compass.MagneticFieldProvider
|
||||
import org.meshtastic.feature.node.compass.PhoneLocationProvider
|
||||
@@ -159,17 +158,10 @@ private fun desktopPlatformStubsModule() = module {
|
||||
connectionFactory = get(),
|
||||
)
|
||||
}
|
||||
single<RadioController> {
|
||||
DirectRadioControllerImpl(
|
||||
serviceRepository = get(),
|
||||
nodeRepository = get(),
|
||||
commandSender = get(),
|
||||
router = get(),
|
||||
nodeManager = get(),
|
||||
radioInterfaceService = get(),
|
||||
locationManager = get(),
|
||||
)
|
||||
}
|
||||
// SDK-backed RadioClient lifecycle — replaces DirectRadioControllerImpl
|
||||
single { DesktopRadioClientProvider(radioPrefs = get()) }
|
||||
single<RadioClientAccessor> { get<DesktopRadioClientProvider>() }
|
||||
single<SdkClientLifecycle> { get<DesktopRadioClientProvider>() }
|
||||
single<NativeNotificationSender> {
|
||||
when (DesktopOS.current()) {
|
||||
DesktopOS.Linux -> LinuxNotificationSender()
|
||||
@@ -181,7 +173,6 @@ private fun desktopPlatformStubsModule() = module {
|
||||
single<NotificationManager> { get<DesktopNotificationManager>() }
|
||||
single<MeshServiceNotifications> { DesktopMeshServiceNotifications(notificationManager = get()) }
|
||||
single<PlatformAnalytics> { NoopPlatformAnalytics() }
|
||||
single<ServiceBroadcasts> { NoopServiceBroadcasts() }
|
||||
single<AppWidgetUpdater> { NoopAppWidgetUpdater() }
|
||||
single<MeshWorkerManager> { NoopMeshWorkerManager() }
|
||||
single<MessageQueue> { DesktopMessageQueue(packetRepository = get(), radioController = get(), dispatchers = get()) }
|
||||
|
||||
@@ -46,9 +46,7 @@ class DesktopMessageQueue(
|
||||
|
||||
// Verify we are connected before attempting to send to avoid unnecessary Exception bubbling
|
||||
if (radioController.connectionState.value != ConnectionState.Connected) {
|
||||
// In a real desktop environment, we might want a background loop to retry queued messages.
|
||||
// For now, it will retry when connection is re-established (handled by
|
||||
// MeshConnectionManager.onRadioConfigLoaded).
|
||||
// Queued messages will be retried when connection is re-established.
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.desktop.radio
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.meshtastic.core.data.radio.RadioClientAccessor
|
||||
import org.meshtastic.core.model.InterfaceId
|
||||
import org.meshtastic.core.repository.RadioPrefs
|
||||
import org.meshtastic.core.service.SdkClientLifecycle
|
||||
import org.meshtastic.sdk.AutoReconnectConfig
|
||||
import org.meshtastic.sdk.RadioClient
|
||||
import org.meshtastic.sdk.RadioTransport
|
||||
import org.meshtastic.sdk.storage.sqldelight.SqlDelightStorageProvider
|
||||
import org.meshtastic.sdk.transport.serial.JvmSerialPorts
|
||||
import org.meshtastic.sdk.transport.tcp.TcpTransport
|
||||
|
||||
/**
|
||||
* Desktop (JVM) implementation of [RadioClientAccessor].
|
||||
*
|
||||
* Supports BLE (Kable JVM — macOS/Windows/Linux), TCP, and Serial (jSerialComm) transports.
|
||||
* Storage uses file-system backed SqlDelightStorageProvider with a platform-appropriate data dir.
|
||||
*
|
||||
* Registered manually in [desktopPlatformStubsModule] — do NOT add @Single to avoid
|
||||
* double-registration with the @ComponentScan in DesktopDiModule.
|
||||
*/
|
||||
class DesktopRadioClientProvider(
|
||||
private val radioPrefs: RadioPrefs,
|
||||
) : RadioClientAccessor, SdkClientLifecycle {
|
||||
|
||||
private val _client = MutableStateFlow<RadioClient?>(null)
|
||||
override val client: StateFlow<RadioClient?> = _client.asStateFlow()
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val mutex = Mutex()
|
||||
|
||||
/**
|
||||
* Tear down the existing client (if any) and build + connect a new one using the current
|
||||
* saved radio address from [RadioPrefs].
|
||||
*
|
||||
* Supports BLE (`x` prefix), TCP (`t` prefix, format `tHOST:PORT`), and Serial (`s` prefix).
|
||||
*/
|
||||
suspend fun rebuildAndConnect() = mutex.withLock {
|
||||
val rawAddress = radioPrefs.devAddr.value
|
||||
?: run {
|
||||
Logger.w { "DesktopRadioClientProvider: no saved device address — skipping connect" }
|
||||
return@withLock
|
||||
}
|
||||
|
||||
val interfaceChar = rawAddress.firstOrNull() ?: run {
|
||||
Logger.w { "DesktopRadioClientProvider: empty address — skipping connect" }
|
||||
return@withLock
|
||||
}
|
||||
val addressPayload = rawAddress.substring(1)
|
||||
|
||||
val transport: RadioTransport = when (InterfaceId.forIdChar(interfaceChar)) {
|
||||
InterfaceId.BLUETOOTH -> {
|
||||
// BLE on Desktop requires a Kable Peripheral (obtained via Scanner in the connections UI).
|
||||
// Direct MAC-address construction is Android-only. Desktop BLE is handled by the
|
||||
// connections feature via DesktopRadioTransportFactory; skip SDK client for BLE for now.
|
||||
Logger.w { "DesktopRadioClientProvider: BLE not yet supported via SDK — use connections UI" }
|
||||
return@withLock
|
||||
}
|
||||
|
||||
InterfaceId.TCP -> {
|
||||
val (host, port) = parseTcpAddress(addressPayload)
|
||||
Logger.i { "DesktopRadioClientProvider: building TCP transport for $host:$port" }
|
||||
TcpTransport(host, port)
|
||||
}
|
||||
|
||||
InterfaceId.SERIAL -> {
|
||||
Logger.i { "DesktopRadioClientProvider: building Serial transport for $addressPayload" }
|
||||
JvmSerialPorts.open(addressPayload)
|
||||
}
|
||||
|
||||
InterfaceId.MOCK, InterfaceId.NOP, null -> {
|
||||
Logger.w { "DesktopRadioClientProvider: unsupported transport '$interfaceChar' ($rawAddress)" }
|
||||
return@withLock
|
||||
}
|
||||
}
|
||||
|
||||
val old = _client.value
|
||||
_client.value = null
|
||||
old?.let { runCatching { it.disconnect() }.onFailure { e -> Logger.w(e) { "disconnect old" } } }
|
||||
|
||||
val newClient = RadioClient.Builder()
|
||||
.transport(transport)
|
||||
.storage(SqlDelightStorageProvider(baseDir = storageDir()))
|
||||
.autoReconnect(AutoReconnectConfig())
|
||||
.build()
|
||||
|
||||
_client.value = newClient
|
||||
newClient.connect()
|
||||
|
||||
Logger.i { "DesktopRadioClientProvider: connected via ${InterfaceId.forIdChar(interfaceChar)}" }
|
||||
}
|
||||
|
||||
override fun rebuildAndConnectAsync() {
|
||||
scope.launch {
|
||||
runCatching { rebuildAndConnect() }
|
||||
.onFailure { e -> Logger.e(e) { "DesktopRadioClientProvider: connect failed" } }
|
||||
}
|
||||
}
|
||||
|
||||
override fun disconnect() {
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
val c = _client.value ?: return@withLock
|
||||
_client.value = null
|
||||
runCatching { c.disconnect() }.onFailure { e -> Logger.w(e) { "disconnect" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_TCP_PORT = 4403
|
||||
|
||||
private fun parseTcpAddress(payload: String): Pair<String, Int> {
|
||||
val parts = payload.split(":")
|
||||
val host = parts[0]
|
||||
val port = parts.getOrNull(1)?.toIntOrNull() ?: DEFAULT_TCP_PORT
|
||||
return host to port
|
||||
}
|
||||
|
||||
/** Platform-appropriate storage directory for SDK state (channels, nodeDB, etc.). */
|
||||
private fun storageDir(): String {
|
||||
val os = System.getProperty("os.name", "").lowercase()
|
||||
val home = System.getProperty("user.home", ".")
|
||||
return when {
|
||||
os.contains("mac") -> "$home/Library/Application Support/Meshtastic/sdk"
|
||||
os.contains("win") -> "${System.getenv("APPDATA") ?: home}/Meshtastic/sdk"
|
||||
else -> "${System.getenv("XDG_DATA_HOME") ?: "$home/.local/share"}/meshtastic/sdk"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,12 +28,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.DeviceType
|
||||
import org.meshtastic.core.model.InterfaceId
|
||||
import org.meshtastic.core.model.MeshActivity
|
||||
import org.meshtastic.core.model.MessageStatus
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.network.repository.MQTTRepository
|
||||
import org.meshtastic.core.repository.AppWidgetUpdater
|
||||
import org.meshtastic.core.repository.DataPair
|
||||
@@ -43,7 +40,6 @@ import org.meshtastic.core.repository.MeshLocationManager
|
||||
import org.meshtastic.core.repository.MeshWorkerManager
|
||||
import org.meshtastic.core.repository.PlatformAnalytics
|
||||
import org.meshtastic.core.repository.RadioInterfaceService
|
||||
import org.meshtastic.core.repository.ServiceBroadcasts
|
||||
import org.meshtastic.proto.MqttClientProxyMessage
|
||||
import org.meshtastic.mqtt.ConnectionState as MqttConnectionState
|
||||
import org.meshtastic.proto.Position as ProtoPosition
|
||||
@@ -122,18 +118,6 @@ class NoopPlatformAnalytics : PlatformAnalytics {
|
||||
override val isPlatformServicesAvailable: Boolean = false
|
||||
}
|
||||
|
||||
class NoopServiceBroadcasts : ServiceBroadcasts {
|
||||
override fun subscribeReceiver(receiverName: String, packageName: String) {}
|
||||
|
||||
override fun broadcastReceivedData(dataPacket: DataPacket) {}
|
||||
|
||||
override fun broadcastConnection() {}
|
||||
|
||||
override fun broadcastNodeChange(node: Node) {}
|
||||
|
||||
override fun broadcastMessageStatus(packetId: Int, status: MessageStatus) {}
|
||||
}
|
||||
|
||||
class NoopAppWidgetUpdater : AppWidgetUpdater {
|
||||
override suspend fun updateAll() {}
|
||||
}
|
||||
|
||||
@@ -29,16 +29,11 @@ import org.koin.core.annotation.KoinViewModel
|
||||
import org.meshtastic.core.common.BuildConfigProvider
|
||||
import org.meshtastic.core.common.database.DatabaseManager
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.database.DatabaseConstants
|
||||
import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.SetLocaleUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.SetNotificationSettingsUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.MyNodeInfo
|
||||
import org.meshtastic.core.model.Node
|
||||
@@ -64,13 +59,7 @@ class SettingsViewModel(
|
||||
private val databaseManager: DatabaseManager,
|
||||
private val meshLogPrefs: MeshLogPrefs,
|
||||
private val notificationPrefs: NotificationPrefs,
|
||||
private val setThemeUseCase: SetThemeUseCase,
|
||||
private val setLocaleUseCase: SetLocaleUseCase,
|
||||
private val setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase,
|
||||
private val setProvideLocationUseCase: SetProvideLocationUseCase,
|
||||
private val setDatabaseCacheLimitUseCase: SetDatabaseCacheLimitUseCase,
|
||||
private val setMeshLogSettingsUseCase: SetMeshLogSettingsUseCase,
|
||||
private val setNotificationSettingsUseCase: SetNotificationSettingsUseCase,
|
||||
private val meshLocationUseCase: MeshLocationUseCase,
|
||||
private val exportDataUseCase: ExportDataUseCase,
|
||||
private val isOtaCapableUseCase: IsOtaCapableUseCase,
|
||||
@@ -123,7 +112,7 @@ class SettingsViewModel(
|
||||
val dbCacheLimit: StateFlow<Int> = databaseManager.cacheLimit
|
||||
|
||||
fun setDbCacheLimit(limit: Int) {
|
||||
setDatabaseCacheLimitUseCase(limit)
|
||||
databaseManager.setCacheLimit(limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT))
|
||||
}
|
||||
|
||||
// Notifications
|
||||
@@ -131,11 +120,11 @@ class SettingsViewModel(
|
||||
val nodeEventsEnabled = notificationPrefs.nodeEventsEnabled
|
||||
val lowBatteryEnabled = notificationPrefs.lowBatteryEnabled
|
||||
|
||||
fun setMessagesEnabled(enabled: Boolean) = setNotificationSettingsUseCase.setMessagesEnabled(enabled)
|
||||
fun setMessagesEnabled(enabled: Boolean) = notificationPrefs.setMessagesEnabled(enabled)
|
||||
|
||||
fun setNodeEventsEnabled(enabled: Boolean) = setNotificationSettingsUseCase.setNodeEventsEnabled(enabled)
|
||||
fun setNodeEventsEnabled(enabled: Boolean) = notificationPrefs.setNodeEventsEnabled(enabled)
|
||||
|
||||
fun setLowBatteryEnabled(enabled: Boolean) = setNotificationSettingsUseCase.setLowBatteryEnabled(enabled)
|
||||
fun setLowBatteryEnabled(enabled: Boolean) = notificationPrefs.setLowBatteryEnabled(enabled)
|
||||
|
||||
// MeshLog retention period (bounded by MeshLogPrefsImpl constants)
|
||||
private val _meshLogRetentionDays = MutableStateFlow(meshLogPrefs.retentionDays.value)
|
||||
@@ -155,20 +144,20 @@ class SettingsViewModel(
|
||||
}
|
||||
|
||||
fun setProvideLocation(value: Boolean) {
|
||||
myNodeNum?.let { setProvideLocationUseCase(it, value) }
|
||||
myNodeNum?.let { uiPrefs.setShouldProvideNodeLocation(it, value) }
|
||||
}
|
||||
|
||||
fun setTheme(theme: Int) {
|
||||
setThemeUseCase(theme)
|
||||
uiPrefs.setTheme(theme)
|
||||
}
|
||||
|
||||
/** Set the application locale. Empty string means system default. */
|
||||
fun setLocale(languageTag: String) {
|
||||
setLocaleUseCase(languageTag)
|
||||
uiPrefs.setLocale(languageTag)
|
||||
}
|
||||
|
||||
fun showAppIntro() {
|
||||
setAppIntroCompletedUseCase(false)
|
||||
uiPrefs.setAppIntroCompleted(false)
|
||||
}
|
||||
|
||||
fun unlockExcludedModules() {
|
||||
|
||||
@@ -44,8 +44,6 @@ import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.RadioResponseResult
|
||||
import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.MqttConnectionState
|
||||
import org.meshtastic.core.model.MqttProbeStatus
|
||||
@@ -121,8 +119,6 @@ open class RadioConfigViewModel(
|
||||
private val mapConsentPrefs: MapConsentPrefs,
|
||||
private val analyticsPrefs: AnalyticsPrefs,
|
||||
private val homoglyphEncodingPrefs: HomoglyphPrefs,
|
||||
private val toggleAnalyticsUseCase: ToggleAnalyticsUseCase,
|
||||
private val toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase,
|
||||
protected val importProfileUseCase: ImportProfileUseCase,
|
||||
protected val exportProfileUseCase: ExportProfileUseCase,
|
||||
protected val exportSecurityConfigUseCase: ExportSecurityConfigUseCase,
|
||||
@@ -137,13 +133,13 @@ open class RadioConfigViewModel(
|
||||
val analyticsAllowedFlow = analyticsPrefs.analyticsAllowed
|
||||
|
||||
fun toggleAnalyticsAllowed() {
|
||||
toggleAnalyticsUseCase()
|
||||
analyticsPrefs.setAnalyticsAllowed(!analyticsPrefs.analyticsAllowed.value)
|
||||
}
|
||||
|
||||
val homoglyphEncodingEnabledFlow = homoglyphEncodingPrefs.homoglyphEncodingEnabled
|
||||
|
||||
fun toggleHomoglyphCharactersEncodingEnabled() {
|
||||
toggleHomoglyphEncodingUseCase()
|
||||
homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(!homoglyphEncodingPrefs.homoglyphEncodingEnabled.value)
|
||||
}
|
||||
|
||||
/** MQTT proxy connection state for the settings UI. */
|
||||
|
||||
@@ -39,13 +39,7 @@ import org.meshtastic.core.common.BuildConfigProvider
|
||||
import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.SetLocaleUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.SetNotificationSettingsUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.repository.FileService
|
||||
import org.meshtastic.core.repository.RadioConfigRepository
|
||||
@@ -95,13 +89,7 @@ class SettingsViewModelTest {
|
||||
every { isOtaCapableUseCase() } returns flowOf(true)
|
||||
|
||||
val uiPrefs = appPreferences.ui
|
||||
val setThemeUseCase = SetThemeUseCase(uiPrefs)
|
||||
val setLocaleUseCase = SetLocaleUseCase(uiPrefs)
|
||||
val setAppIntroCompletedUseCase = SetAppIntroCompletedUseCase(uiPrefs)
|
||||
val setProvideLocationUseCase = SetProvideLocationUseCase(uiPrefs)
|
||||
val setDatabaseCacheLimitUseCase = SetDatabaseCacheLimitUseCase(databaseManager)
|
||||
val setMeshLogSettingsUseCase = SetMeshLogSettingsUseCase(meshLogRepository, appPreferences.meshLog)
|
||||
val setNotificationSettingsUseCase = SetNotificationSettingsUseCase(notificationPrefs)
|
||||
val meshLocationUseCase = MeshLocationUseCase(radioController)
|
||||
val exportDataUseCase = ExportDataUseCase(nodeRepository, meshLogRepository)
|
||||
|
||||
@@ -115,13 +103,7 @@ class SettingsViewModelTest {
|
||||
databaseManager = databaseManager,
|
||||
meshLogPrefs = appPreferences.meshLog,
|
||||
notificationPrefs = notificationPrefs,
|
||||
setThemeUseCase = setThemeUseCase,
|
||||
setLocaleUseCase = setLocaleUseCase,
|
||||
setAppIntroCompletedUseCase = setAppIntroCompletedUseCase,
|
||||
setProvideLocationUseCase = setProvideLocationUseCase,
|
||||
setDatabaseCacheLimitUseCase = setDatabaseCacheLimitUseCase,
|
||||
setMeshLogSettingsUseCase = setMeshLogSettingsUseCase,
|
||||
setNotificationSettingsUseCase = setNotificationSettingsUseCase,
|
||||
meshLocationUseCase = meshLocationUseCase,
|
||||
exportDataUseCase = exportDataUseCase,
|
||||
isOtaCapableUseCase = isOtaCapableUseCase,
|
||||
|
||||
@@ -44,8 +44,6 @@ import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.RadioResponseResult
|
||||
import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase
|
||||
import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.repository.AnalyticsPrefs
|
||||
import org.meshtastic.core.repository.FileService
|
||||
@@ -89,8 +87,6 @@ class RadioConfigViewModelTest {
|
||||
private val analyticsPrefs: AnalyticsPrefs = mock(MockMode.autofill)
|
||||
private val homoglyphEncodingPrefs: HomoglyphPrefs = mock(MockMode.autofill)
|
||||
|
||||
private val toggleAnalyticsUseCase: ToggleAnalyticsUseCase = mock(MockMode.autofill)
|
||||
private val toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase = mock(MockMode.autofill)
|
||||
private val importProfileUseCase: ImportProfileUseCase = mock(MockMode.autofill)
|
||||
private val exportProfileUseCase: ExportProfileUseCase = mock(MockMode.autofill)
|
||||
private val exportSecurityConfigUseCase: ExportSecurityConfigUseCase = mock(MockMode.autofill)
|
||||
@@ -146,8 +142,6 @@ class RadioConfigViewModelTest {
|
||||
mapConsentPrefs = mapConsentPrefs,
|
||||
analyticsPrefs = analyticsPrefs,
|
||||
homoglyphEncodingPrefs = homoglyphEncodingPrefs,
|
||||
toggleAnalyticsUseCase = toggleAnalyticsUseCase,
|
||||
toggleHomoglyphEncodingUseCase = toggleHomoglyphEncodingUseCase,
|
||||
importProfileUseCase = importProfileUseCase,
|
||||
exportProfileUseCase = exportProfileUseCase,
|
||||
exportSecurityConfigUseCase = exportSecurityConfigUseCase,
|
||||
@@ -181,21 +175,21 @@ class RadioConfigViewModelTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggleAnalyticsAllowed calls useCase`() {
|
||||
every { toggleAnalyticsUseCase() } returns Unit
|
||||
fun `toggleAnalyticsAllowed updates prefs`() {
|
||||
every { analyticsPrefs.setAnalyticsAllowed(any()) } returns Unit
|
||||
|
||||
viewModel.toggleAnalyticsAllowed()
|
||||
|
||||
verify { toggleAnalyticsUseCase() }
|
||||
verify { analyticsPrefs.setAnalyticsAllowed(true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggleHomoglyphCharactersEncodingEnabled calls useCase`() {
|
||||
every { toggleHomoglyphEncodingUseCase() } returns Unit
|
||||
fun `toggleHomoglyphCharactersEncodingEnabled updates prefs`() {
|
||||
every { homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(any()) } returns Unit
|
||||
|
||||
viewModel.toggleHomoglyphCharactersEncodingEnabled()
|
||||
|
||||
verify { toggleHomoglyphEncodingUseCase() }
|
||||
verify { homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user