feat/decoupling (#4685)

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

View file

@ -1,25 +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
/**
* Interface for enqueuing background work for transmitting messages. This allows the domain layer to trigger durable
* transmission without depending on Android-specific WorkManager.
*/
interface MessageQueue {
suspend fun enqueue(packetId: Int)
}

View file

@ -1,139 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase
import co.touchlab.kermit.Logger
import org.meshtastic.core.common.util.HomoglyphCharacterStringTransformer
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.domain.MessageQueue
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
import org.meshtastic.proto.Config
import javax.inject.Inject
import kotlin.math.abs
import kotlin.random.Random
/**
* Use case for sending a message. This component handles message transformation, persistence, and enqueuing for durable
* delivery.
*/
@Suppress("TooGenericExceptionCaught")
class SendMessageUseCase
@Inject
constructor(
private val nodeRepository: NodeRepository,
private val packetRepository: PacketRepository,
private val radioController: RadioController,
private val homoglyphEncodingPrefs: HomoglyphPrefs,
private val messageQueue: MessageQueue,
) {
@Suppress("NestedBlockDepth", "LongMethod", "CyclomaticComplexMethod")
suspend operator fun invoke(
text: String,
contactKey: String = "0${DataPacket.ID_BROADCAST}",
replyId: Int? = null,
) {
val channel = contactKey[0].digitToIntOrNull()
val dest = if (channel != null) contactKey.substring(1) else contactKey
val ourNode = nodeRepository.ourNodeInfo.value
val fromId = ourNode?.user?.id ?: DataPacket.ID_LOCAL
// logic for direct messages
if (channel == null) {
val destNode = nodeRepository.getNode(dest)
val fwVersion = ourNode?.metadata?.firmware_version
val isClientBase = ourNode?.user?.role == Config.DeviceConfig.Role.CLIENT_BASE
val capabilities = Capabilities(fwVersion)
if (capabilities.canSendVerifiedContacts) {
sendSharedContact(destNode)
} else {
if (!destNode.isFavorite && !isClientBase) {
favoriteNode(destNode)
}
}
}
// Apply homoglyph encoding
val finalMessageText =
if (homoglyphEncodingPrefs.homoglyphEncodingEnabled) {
HomoglyphCharacterStringTransformer.optimizeUtf8StringWithHomoglyphs(text)
} else {
text
}
val packetId = abs(Random.nextInt())
val packet =
DataPacket(dest, channel ?: 0, finalMessageText, replyId).apply {
from = fromId
id = packetId
status = MessageStatus.QUEUED
}
val packetToSave =
Packet(
uuid = 0L,
myNodeNum = ourNode?.num ?: 0,
packetId = packetId,
port_num = packet.dataType,
contact_key = contactKey,
received_time = nowMillis,
read = true,
data = packet,
snr = packet.snr,
rssi = packet.rssi,
hopsAway = packet.hopsAway,
filtered = false,
)
try {
// Write to the DB to immediately reflect the queued state on the UI
packetRepository.insert(packetToSave)
// Enqueue for durable transmission via the platform-specific queue
messageQueue.enqueue(packetId)
} catch (ex: Exception) {
Logger.e(ex) { "Failed to enqueue message packet" }
}
}
private suspend fun favoriteNode(node: Node) {
try {
radioController.favoriteNode(node.num)
} catch (ex: Exception) {
Logger.e(ex) { "Favorite node error" }
}
}
private suspend fun sendSharedContact(node: Node) {
try {
radioController.sendSharedContact(node.num)
} catch (ex: Exception) {
Logger.e(ex) { "Send shared contact error" }
}
}
}

View file

@ -16,11 +16,16 @@
*/
package org.meshtastic.core.domain.usecase.settings
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.NodeRepository
import javax.inject.Inject
/** Use case for performing administrative actions on the radio. */
/**
* Use case for performing administrative and destructive actions on mesh nodes.
*
* This component provides methods for rebooting, shutting down, or resetting nodes within the mesh. It also handles
* local database synchronization when these actions are performed on the locally connected device.
*/
open class AdminActionsUseCase
@Inject
constructor(

View file

@ -16,14 +16,14 @@
*/
package org.meshtastic.core.domain.usecase.settings
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.NodeRepository
import javax.inject.Inject
import kotlin.time.Duration.Companion.days
/** Use case for cleaning up nodes from the database. */
class CleanNodeDatabaseUseCase
open class CleanNodeDatabaseUseCase
@Inject
constructor(
private val nodeRepository: NodeRepository,
@ -43,11 +43,9 @@ constructor(
nodeRepository.getNodesOlderThan(olderThanTimestamp.toInt())
}
return nodesToConsider
.filterNot { node ->
(node.hasPKC && node.lastHeard >= sevenDaysAgoSeconds) || node.isIgnored || node.isFavorite
}
.map { it.toModel() }
return nodesToConsider.filterNot { node ->
(node.hasPKC && node.lastHeard >= sevenDaysAgoSeconds) || node.isIgnored || node.isFavorite
}
}
/** Performs the cleanup of specified nodes. */

View file

@ -19,9 +19,9 @@ package org.meshtastic.core.domain.usecase.settings
import android.icu.text.SimpleDateFormat
import kotlinx.coroutines.flow.first
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.positionToMeter
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.proto.PortNum
import java.io.BufferedWriter
import java.util.Locale
@ -30,7 +30,7 @@ import kotlin.math.roundToInt
import org.meshtastic.proto.Position as ProtoPosition
/** Use case for exporting persisted packet data to a CSV format. */
class ExportDataUseCase
open class ExportDataUseCase
@Inject
constructor(
private val nodeRepository: NodeRepository,

View file

@ -21,7 +21,7 @@ import java.io.OutputStream
import javax.inject.Inject
/** Use case for exporting a device profile to an output stream. */
class ExportProfileUseCase @Inject constructor() {
open class ExportProfileUseCase @Inject constructor() {
/**
* Exports the provided [DeviceProfile] to the given [OutputStream].
*

View file

@ -24,7 +24,7 @@ import java.io.OutputStream
import javax.inject.Inject
/** Use case for exporting security configuration to a JSON format. */
class ExportSecurityConfigUseCase @Inject constructor() {
open class ExportSecurityConfigUseCase @Inject constructor() {
/**
* Exports the provided [Config.SecurityConfig] as a JSON string to the given [OutputStream].
*

View file

@ -21,7 +21,7 @@ import java.io.InputStream
import javax.inject.Inject
/** Use case for importing a device profile from an input stream. */
class ImportProfileUseCase @Inject constructor() {
open class ImportProfileUseCase @Inject constructor() {
/**
* Imports a [DeviceProfile] from the provided [InputStream].
*

View file

@ -27,7 +27,7 @@ import org.meshtastic.proto.User
import javax.inject.Inject
/** Use case for installing a device profile onto a radio. */
class InstallProfileUseCase @Inject constructor(private val radioController: RadioController) {
open class InstallProfileUseCase @Inject constructor(private val radioController: RadioController) {
/**
* Installs the provided [DeviceProfile] onto the radio at [destNum].
*

View file

@ -20,19 +20,19 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import org.meshtastic.core.data.repository.DeviceHardwareRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.prefs.radio.RadioPrefs
import org.meshtastic.core.prefs.radio.isBle
import org.meshtastic.core.prefs.radio.isSerial
import org.meshtastic.core.prefs.radio.isTcp
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.NodeRepository
import javax.inject.Inject
/** Use case to determine if the currently connected device is capable of over-the-air (OTA) updates. */
class IsOtaCapableUseCase
open class IsOtaCapableUseCase
@Inject
constructor(
private val nodeRepository: NodeRepository,

View file

@ -20,7 +20,7 @@ import org.meshtastic.core.model.RadioController
import javax.inject.Inject
/** Use case for controlling location sharing with the mesh. */
class MeshLocationUseCase @Inject constructor(private val radioController: RadioController) {
open class MeshLocationUseCase @Inject constructor(private val radioController: RadioController) {
/** Starts providing the phone's location to the mesh. */
fun startProvidingLocation() {
radioController.startProvideLocation()

View file

@ -17,7 +17,7 @@
package org.meshtastic.core.domain.usecase.settings
import co.touchlab.kermit.Logger
import org.meshtastic.core.database.model.getStringResFrom
import org.meshtastic.core.model.getStringResFrom
import org.meshtastic.core.resources.UiText
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
@ -54,7 +54,7 @@ sealed class RadioResponseResult {
}
/** Use case for processing incoming [MeshPacket]s that are responses to admin requests. */
class ProcessRadioResponseUseCase @Inject constructor() {
open class ProcessRadioResponseUseCase @Inject constructor() {
/**
* Decodes and processes the provided [packet].
*

View file

@ -20,7 +20,11 @@ import org.meshtastic.core.datastore.UiPreferencesDataSource
import javax.inject.Inject
/** Use case for setting whether the application intro has been completed. */
class SetAppIntroCompletedUseCase @Inject constructor(private val uiPreferencesDataSource: UiPreferencesDataSource) {
open class SetAppIntroCompletedUseCase
@Inject
constructor(
private val uiPreferencesDataSource: UiPreferencesDataSource,
) {
operator fun invoke(completed: Boolean) {
uiPreferencesDataSource.setAppIntroCompleted(completed)
}

View file

@ -17,11 +17,11 @@
package org.meshtastic.core.domain.usecase.settings
import org.meshtastic.core.database.DatabaseConstants
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.repository.DatabaseManager
import javax.inject.Inject
/** Use case for setting the database cache limit. */
class SetDatabaseCacheLimitUseCase @Inject constructor(private val databaseManager: DatabaseManager) {
open class SetDatabaseCacheLimitUseCase @Inject constructor(private val databaseManager: DatabaseManager) {
operator fun invoke(limit: Int) {
val clamped = limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
databaseManager.setCacheLimit(clamped)

View file

@ -21,7 +21,7 @@ import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import javax.inject.Inject
/** Use case for managing mesh log settings. */
class SetMeshLogSettingsUseCase
open class SetMeshLogSettingsUseCase
@Inject
constructor(
private val meshLogRepository: MeshLogRepository,

View file

@ -20,7 +20,7 @@ import org.meshtastic.core.prefs.ui.UiPrefs
import javax.inject.Inject
/** Use case for setting whether to provide the node location to the mesh. */
class SetProvideLocationUseCase @Inject constructor(private val uiPrefs: UiPrefs) {
open class SetProvideLocationUseCase @Inject constructor(private val uiPrefs: UiPrefs) {
operator fun invoke(myNodeNum: Int, provideLocation: Boolean) {
uiPrefs.setShouldProvideNodeLocation(myNodeNum, provideLocation)
}

View file

@ -20,7 +20,7 @@ import org.meshtastic.core.datastore.UiPreferencesDataSource
import javax.inject.Inject
/** Use case for setting the application theme. */
class SetThemeUseCase @Inject constructor(private val uiPreferencesDataSource: UiPreferencesDataSource) {
open class SetThemeUseCase @Inject constructor(private val uiPreferencesDataSource: UiPreferencesDataSource) {
operator fun invoke(themeMode: Int) {
uiPreferencesDataSource.setTheme(themeMode)
}

View file

@ -20,7 +20,7 @@ import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
import javax.inject.Inject
/** Use case for toggling the analytics preference. */
class ToggleAnalyticsUseCase @Inject constructor(private val analyticsPrefs: AnalyticsPrefs) {
open class ToggleAnalyticsUseCase @Inject constructor(private val analyticsPrefs: AnalyticsPrefs) {
operator fun invoke() {
analyticsPrefs.analyticsAllowed = !analyticsPrefs.analyticsAllowed
}

View file

@ -20,7 +20,7 @@ import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
import javax.inject.Inject
/** Use case for toggling the homoglyph encoding preference. */
class ToggleHomoglyphEncodingUseCase @Inject constructor(private val homoglyphEncodingPrefs: HomoglyphPrefs) {
open class ToggleHomoglyphEncodingUseCase @Inject constructor(private val homoglyphEncodingPrefs: HomoglyphPrefs) {
operator fun invoke() {
homoglyphEncodingPrefs.homoglyphEncodingEnabled = !homoglyphEncodingPrefs.homoglyphEncodingEnabled
}

View file

@ -53,6 +53,10 @@ class FakeRadioController : RadioController {
sentSharedContacts.add(nodeNum)
}
override suspend fun setLocalConfig(config: org.meshtastic.proto.Config) {}
override suspend fun setLocalChannel(channel: org.meshtastic.proto.Channel) {}
override suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int) {}
override suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int) {}
@ -83,6 +87,10 @@ class FakeRadioController : RadioController {
override suspend fun reboot(destNum: Int, packetId: Int) {}
override suspend fun rebootToDfu(nodeNum: Int) {}
override suspend fun requestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {}
override suspend fun shutdown(destNum: Int, packetId: Int) {}
override suspend fun factoryReset(destNum: Int, packetId: Int) {}
@ -91,6 +99,16 @@ class FakeRadioController : RadioController {
override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) {}
override suspend fun requestPosition(destNum: Int, currentPosition: org.meshtastic.core.model.Position) {}
override suspend fun requestUserInfo(destNum: Int) {}
override suspend fun requestTraceroute(requestId: Int, destNum: Int) {}
override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {}
override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) {}
override suspend fun beginEditSettings(destNum: Int) {}
override suspend fun commitEditSettings(destNum: Int) {}
@ -101,6 +119,8 @@ class FakeRadioController : RadioController {
override fun stopProvideLocation() {}
override fun setDeviceAddress(address: String) {}
// --- Helper methods for testing ---
fun setConnectionState(state: ConnectionState) {

View file

@ -29,15 +29,15 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.domain.FakeRadioController
import org.meshtastic.core.domain.MessageQueue
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.HomoglyphPrefs
import org.meshtastic.core.repository.MessageQueue
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.usecase.SendMessageUseCase
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetadata
@ -90,7 +90,7 @@ class SendMessageUseCaseTest {
assertEquals(0, radioController.favoritedNodes.size)
assertEquals(0, radioController.sentSharedContacts.size)
coVerify { packetRepository.insert(any<Packet>()) }
coVerify { packetRepository.savePacket(any(), any(), any(), any()) }
coVerify { messageQueue.enqueue(any()) }
}
@ -120,7 +120,7 @@ class SendMessageUseCaseTest {
assertEquals(1, radioController.favoritedNodes.size)
assertEquals(12345, radioController.favoritedNodes[0])
coVerify { packetRepository.insert(any<Packet>()) }
coVerify { packetRepository.savePacket(any(), any(), any(), any()) }
coVerify { messageQueue.enqueue(any()) }
}
@ -149,7 +149,7 @@ class SendMessageUseCaseTest {
assertEquals(1, radioController.sentSharedContacts.size)
assertEquals(67890, radioController.sentSharedContacts[0])
coVerify { packetRepository.insert(any<Packet>()) }
coVerify { packetRepository.savePacket(any(), any(), any(), any()) }
coVerify { messageQueue.enqueue(any()) }
}
@ -166,9 +166,9 @@ class SendMessageUseCaseTest {
useCase(originalText, "0${DataPacket.ID_BROADCAST}", null)
// Assert
val packetSlot = slot<Packet>()
coVerify { packetRepository.insert(capture(packetSlot)) }
assertTrue(packetSlot.captured.data?.text?.contains("Apple") == true)
val packetSlot = slot<DataPacket>()
coVerify { packetRepository.savePacket(any(), any(), capture(packetSlot), any()) }
assertTrue(packetSlot.captured.text?.contains("Apple") == true)
coVerify { messageQueue.enqueue(any()) }
}
}

View file

@ -23,8 +23,8 @@ import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.NodeRepository
class AdminActionsUseCaseTest {

View file

@ -23,9 +23,9 @@ import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.domain.FakeRadioController
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.NodeRepository
import kotlin.time.Duration.Companion.days
class CleanNodeDatabaseUseCaseTest {
@ -47,9 +47,9 @@ class CleanNodeDatabaseUseCaseTest {
val currentTime = 1000000L
val olderThanTimestamp = currentTime - 30.days.inWholeSeconds
val oldNode = NodeEntity(num = 1, lastHeard = (olderThanTimestamp - 1).toInt())
val newNode = NodeEntity(num = 2, lastHeard = (currentTime - 1).toInt())
val ignoredNode = NodeEntity(num = 3, lastHeard = (olderThanTimestamp - 1).toInt(), isIgnored = true)
val oldNode = Node(num = 1, lastHeard = (olderThanTimestamp - 1).toInt())
val newNode = Node(num = 2, lastHeard = (currentTime - 1).toInt())
val ignoredNode = Node(num = 3, lastHeard = (olderThanTimestamp - 1).toInt(), isIgnored = true)
coEvery { nodeRepository.getNodesOlderThan(any()) } returns listOf(oldNode, ignoredNode)

View file

@ -27,9 +27,9 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.proto.Data
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
@ -63,7 +63,6 @@ class ExportDataUseCaseTest {
val nodes = mapOf(senderNodeNum to senderNode)
val stateFlow = MutableStateFlow(nodes)
every { nodeRepository.nodeDBbyNum } returns stateFlow
every { nodeRepository.getNodeEntityDBbyNumFlow() } returns flowOf(emptyMap())
val meshPacket =
MeshPacket(

View file

@ -26,12 +26,12 @@ import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.data.repository.DeviceHardwareRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.prefs.radio.RadioPrefs
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.NodeRepository
class IsOtaCapableUseCaseTest {

View file

@ -21,7 +21,7 @@ import io.mockk.verify
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.database.DatabaseConstants
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.repository.DatabaseManager
class SetDatabaseCacheLimitUseCaseTest {