feat: settings rework part 2, domain and usecase abstraction, tests (#4680)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-02 12:15:33 -06:00 committed by GitHub
parent 5f31df96d8
commit 8c6bd8ab7a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
121 changed files with 5245 additions and 1332 deletions

View file

@ -0,0 +1,87 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.util
/**
* This util class allows you to optimize the binary size of the transmitted text message strings. It replaces certain
* characters from national alphabets with the characters from the latin alphabet that have an identical appearance
* (homoglyphs), for example: cyrillic "А", "С", "у" -> latin "A", "C", "y", etc. According to statistics, such letters
* can make up about 20-25% of the total number of letters in the average text. Replacing them with Latin characters
* reduces the binary size of the transmitted message. The average transmitted message volume can then fit around
* ~140-145 characters instead of ~115-120
*/
object HomoglyphCharacterStringTransformer {
/**
* Unicode characters from the basic cyrillic block (U+0400-U+04FF), each of which occupies 2 bytes
* https://www.compart.com/en/unicode/block/U+0400 Mapped with the corresponding similarly written latin characters,
* each of which occupies 1 byte
*
* Please note that only 100% "reliable", completely visually identical characters are presented will here The
* characters that look like latin but contain various descenders, hooks, strokes, etc are not replaced with
* "simplified" latin appearance and will remain 2 byte unicode, as usual
*/
private val homoglyphCharactersSubstitutionMapping: Map<Char, Char> =
mapOf(
'\u0405' to 'S', // https://www.compart.com/en/unicode/U+0405 - Cyrillic Capital Letter Dze
'\u0406' to
'I', // https://www.compart.com/en/unicode/U+0406 - Cyrillic Capital Letter Byelorussian-Ukrainian I
'\u0408' to 'J', // https://www.compart.com/en/unicode/U+0408 - Cyrillic Capital Letter Je
'\u0410' to 'A', // https://www.compart.com/en/unicode/U+0410 - Cyrillic Capital Letter A
'\u0412' to 'B', // https://www.compart.com/en/unicode/U+0412 - Cyrillic Capital Letter Ve
'\u0415' to 'E', // https://www.compart.com/en/unicode/U+0415 - Cyrillic Capital Letter Ie
'\u041A' to 'K', // https://www.compart.com/en/unicode/U+041A - Cyrillic Capital Letter Ka
'\u041C' to 'M', // https://www.compart.com/en/unicode/U+041C - Cyrillic Capital Letter Em
'\u041D' to 'H', // https://www.compart.com/en/unicode/U+041D - Cyrillic Capital Letter En
'\u041E' to 'O', // https://www.compart.com/en/unicode/U+041E - Cyrillic Capital Letter O
'\u0420' to 'P', // https://www.compart.com/en/unicode/U+0420 - Cyrillic Capital Letter Er
'\u0421' to 'C', // https://www.compart.com/en/unicode/U+0421 - Cyrillic Capital Letter Es
'\u0422' to 'T', // https://www.compart.com/en/unicode/U+0422 - Cyrillic Capital Letter Te
'\u0425' to 'X', // https://www.compart.com/en/unicode/U+0425 - Cyrillic Capital Letter Ha
'\u0430' to 'a', // https://www.compart.com/en/unicode/U+0430 - Cyrillic Small Letter A
'\u0435' to 'e', // https://www.compart.com/en/unicode/U+0435 - Cyrillic Small Letter Ie
'\u043E' to 'o', // https://www.compart.com/en/unicode/U+043E - Cyrillic Small Letter O
'\u0440' to 'p', // https://www.compart.com/en/unicode/U+0440 - Cyrillic Small Letter Er
'\u0441' to 'c', // https://www.compart.com/en/unicode/U+0441 - Cyrillic Small Letter Es
'\u0443' to 'y', // https://www.compart.com/en/unicode/U+0443 - Cyrillic Small Letter U
'\u0445' to 'x', // https://www.compart.com/en/unicode/U+0445 - Cyrillic Small Letter Ha
'\u0455' to 's', // https://www.compart.com/en/unicode/U+0455 - Cyrillic Small Letter Dze
'\u0456' to
'i', // https://www.compart.com/en/unicode/U+0456 - Cyrillic Small Letter Byelorussian-Ukrainian I
'\u0458' to 'j', // https://www.compart.com/en/unicode/U+0458 - Cyrillic Small Letter Je
'\u04AE' to 'Y', // https://www.compart.com/en/unicode/U+04AE - Cyrillic Capital Letter Straight U
'\u0417' to '3', // https://www.compart.com/en/unicode/U+0417 - Cyrillic Capital Letter Ze
// Note that capital "ze" here is a bit special - it technically transforms to a digit "three"
// The visuals are all the same, across the different fonts etc& The core idea is the same:
// We are still replacing 2-byte unicode letter with a digit character that occupies 1 byte in Unicode
// But I have to point it out to avoid confusion
)
/**
* Returns the transformed optimized [String] value, in which some characters of the national alphabets are replaced
* with identical Latin characters so that the text takes up fewer bytes and is more compact for transmission.
*
* @param value original string value.
* @return optimized string value.
*/
fun optimizeUtf8StringWithHomoglyphs(value: String): String {
val stringBuilder = StringBuilder()
for (c in value.toCharArray()) stringBuilder.append(homoglyphCharactersSubstitutionMapping.getOrDefault(c, c))
return stringBuilder.toString()
}
}

View file

@ -25,8 +25,9 @@ import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
/**
* A helper class that manages a single [Job]. When a new job is launched, the previous one is cancelled. This is useful
* for ensuring that only one operation of a certain type is running at a time.
* A helper class that manages a single [Job]. When a new job is launched, any previous job is cancelled. This is useful
* for ensuring that only the latest operation of a certain type is running at a time (e.g. for search or settings
* updates).
*/
class SequentialJob @Inject constructor() {
private val job = AtomicReference<Job?>()

View file

@ -56,7 +56,7 @@ import javax.inject.Singleton
/** Repository for managing node-related data, including hardware info, node database, and identity. */
@Singleton
@Suppress("TooManyFunctions")
class NodeRepository
open class NodeRepository
@Inject
constructor(
@ProcessLifecycle private val processLifecycle: Lifecycle,
@ -66,7 +66,7 @@ constructor(
private val localStatsDataSource: LocalStatsDataSource,
) {
/** Hardware info about our local device (can be null if not connected). */
val myNodeInfo: StateFlow<MyNodeEntity?> =
open val myNodeInfo: StateFlow<MyNodeEntity?> =
nodeInfoReadDataSource
.myNodeInfoFlow()
.flowOn(dispatchers.io)
@ -75,7 +75,7 @@ constructor(
private val _ourNodeInfo = MutableStateFlow<Node?>(null)
/** Information about the locally connected node, as seen from the mesh. */
val ourNodeInfo: StateFlow<Node?>
open val ourNodeInfo: StateFlow<Node?>
get() = _ourNodeInfo
private val _myId = MutableStateFlow<String?>(null)
@ -131,7 +131,7 @@ constructor(
.map { info -> if (nodeNum == info?.myNodeNum) MeshLog.NODE_NUM_LOCAL else nodeNum }
.distinctUntilChanged()
fun getNodeDBbyNum() =
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. */

View file

@ -36,7 +36,7 @@ import javax.inject.Inject
* Class responsible for radio configuration data. Combines access to [nodeDB], [ChannelSet], [LocalConfig] &
* [LocalModuleConfig].
*/
class RadioConfigRepository
open class RadioConfigRepository
@Inject
constructor(
private val nodeDB: NodeRepository,
@ -68,7 +68,7 @@ constructor(
suspend fun updateChannelSettings(channel: Channel) = channelSetDataSource.updateChannelSettings(channel)
/** Flow representing the [LocalConfig] data store. */
val localConfigFlow: Flow<LocalConfig> = localConfigDataSource.localConfigFlow
open val localConfigFlow: Flow<LocalConfig> = localConfigDataSource.localConfigFlow
/** Clears the [LocalConfig] data in the data store. */
suspend fun clearLocalConfig() {

View file

@ -46,7 +46,12 @@ import javax.inject.Singleton
@Singleton
@Suppress("TooManyFunctions")
@OptIn(ExperimentalCoroutinesApi::class)
class DatabaseManager @Inject constructor(private val app: Application, private val dispatchers: CoroutineDispatchers) {
open class DatabaseManager
@Inject
constructor(
private val app: Application,
private val dispatchers: CoroutineDispatchers,
) {
val prefs: SharedPreferences = app.getSharedPreferences("db-manager-prefs", Context.MODE_PRIVATE)
private val managerScope = CoroutineScope(SupervisorJob() + dispatchers.default)
@ -54,7 +59,7 @@ class DatabaseManager @Inject constructor(private val app: Application, private
// Expose the DB cache limit as a reactive stream so UI can observe changes.
private val _cacheLimit = MutableStateFlow(getCacheLimit())
val cacheLimit: StateFlow<Int> = _cacheLimit
open val cacheLimit: StateFlow<Int> = _cacheLimit
// Keep cache-limit StateFlow in sync if some other component updates SharedPreferences.
private val prefsListener =

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -40,4 +40,4 @@ plugins {
configure<LibraryExtension> { namespace = "org.meshtastic.core.di" }
dependencies {}
dependencies { implementation(libs.androidx.work.runtime.ktx) }

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,14 +14,17 @@
* 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.di
import android.content.Context
import androidx.work.WorkManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.Dispatchers
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
@ -30,4 +33,8 @@ object AppModule {
@Provides
fun provideCoroutineDispatchers(): CoroutineDispatchers =
CoroutineDispatchers(io = Dispatchers.IO, main = Dispatchers.Main, default = Dispatchers.Default)
@Provides
@Singleton
fun provideWorkManager(@ApplicationContext context: Context): WorkManager = WorkManager.getInstance(context)
}

View file

@ -0,0 +1,44 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.android.library.flavors)
alias(libs.plugins.meshtastic.hilt)
}
android { namespace = "org.meshtastic.core.domain" }
dependencies {
implementation(projects.core.model)
implementation(projects.core.proto)
implementation(projects.core.common)
implementation(projects.core.database)
implementation(projects.core.prefs)
implementation(projects.core.data)
implementation(projects.core.datastore)
implementation(projects.core.resources)
implementation(libs.kermit)
implementation(libs.compose.multiplatform.resources)
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.robolectric)
testImplementation(libs.turbine)
testImplementation(libs.kotlinx.coroutines.test)
}

View file

@ -0,0 +1,25 @@
/*
* 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

@ -0,0 +1,139 @@
/*
* 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

@ -0,0 +1,92 @@
/*
* 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.settings
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.model.RadioController
import javax.inject.Inject
/** Use case for performing administrative actions on the radio. */
open class AdminActionsUseCase
@Inject
constructor(
private val radioController: RadioController,
private val nodeRepository: NodeRepository,
) {
/**
* Reboots the radio.
*
* @param destNum The node number to reboot.
* @return The packet ID of the request.
*/
suspend fun reboot(destNum: Int): Int {
val packetId = radioController.getPacketId()
radioController.reboot(destNum, packetId)
return packetId
}
/**
* Shuts down the radio.
*
* @param destNum The node number to shut down.
* @return The packet ID of the request.
*/
suspend fun shutdown(destNum: Int): Int {
val packetId = radioController.getPacketId()
radioController.shutdown(destNum, packetId)
return packetId
}
/**
* Factory resets the radio.
*
* @param destNum The node number to reset.
* @param isLocal Whether the reset is being performed on the locally connected node.
* @return The packet ID of the request.
*/
suspend fun factoryReset(destNum: Int, isLocal: Boolean): Int {
val packetId = radioController.getPacketId()
radioController.factoryReset(destNum, packetId)
if (isLocal) {
// If it's the local node, we should also clear the phone's node database as it will be out of sync.
nodeRepository.clearNodeDB()
}
return packetId
}
/**
* Resets the NodeDB on the radio.
*
* @param destNum The node number to reset.
* @param preserveFavorites Whether to keep favorite nodes in the database.
* @param isLocal Whether the reset is being performed on the locally connected node.
* @return The packet ID of the request.
*/
suspend fun nodedbReset(destNum: Int, preserveFavorites: Boolean, isLocal: Boolean): Int {
val packetId = radioController.getPacketId()
radioController.nodedbReset(destNum, packetId, preserveFavorites)
if (isLocal) {
// If it's the local node, we should also clear the phone's node database.
nodeRepository.clearNodeDB(preserveFavorites)
}
return packetId
}
}

View file

@ -0,0 +1,63 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.settings
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.RadioController
import javax.inject.Inject
import kotlin.time.Duration.Companion.days
/** Use case for cleaning up nodes from the database. */
class CleanNodeDatabaseUseCase
@Inject
constructor(
private val nodeRepository: NodeRepository,
private val radioController: RadioController,
) {
/** Identifies nodes that match the cleanup criteria. */
suspend fun getNodesToClean(olderThanDays: Float, onlyUnknownNodes: Boolean, currentTimeSeconds: Long): List<Node> {
val sevenDaysAgoSeconds = currentTimeSeconds - 7.days.inWholeSeconds
val olderThanTimestamp = currentTimeSeconds - olderThanDays.toInt().days.inWholeSeconds
val nodesToConsider =
if (onlyUnknownNodes) {
val olderNodes = nodeRepository.getNodesOlderThan(olderThanTimestamp.toInt())
val unknownNodes = nodeRepository.getUnknownNodes()
olderNodes.filter { itNode -> unknownNodes.any { it.num == itNode.num } }
} else {
nodeRepository.getNodesOlderThan(olderThanTimestamp.toInt())
}
return nodesToConsider
.filterNot { node ->
(node.hasPKC && node.lastHeard >= sevenDaysAgoSeconds) || node.isIgnored || node.isFavorite
}
.map { it.toModel() }
}
/** Performs the cleanup of specified nodes. */
suspend fun cleanNodes(nodeNums: List<Int>) {
if (nodeNums.isEmpty()) return
nodeRepository.deleteNodes(nodeNums)
val packetId = radioController.getPacketId()
for (nodeNum in nodeNums) {
radioController.removeByNodenum(packetId, nodeNum)
}
}
}

View file

@ -0,0 +1,122 @@
/*
* 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.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.proto.PortNum
import java.io.BufferedWriter
import java.util.Locale
import javax.inject.Inject
import kotlin.math.roundToInt
import org.meshtastic.proto.Position as ProtoPosition
/** Use case for exporting persisted packet data to a CSV format. */
class ExportDataUseCase
@Inject
constructor(
private val nodeRepository: NodeRepository,
private val meshLogRepository: MeshLogRepository,
) {
/**
* Writes all persisted packet data to the provided [BufferedWriter].
*
* @param writer The writer to output the CSV data to.
* @param myNodeNum The node number of the current device.
* @param filterPortnum If provided, only packets with this port number will be exported.
*/
@Suppress("detekt:CyclomaticComplexMethod", "detekt:LongMethod", "detekt:NestedBlockDepth")
suspend operator fun invoke(writer: BufferedWriter, myNodeNum: Int, filterPortnum: Int? = null) {
val nodes = nodeRepository.nodeDBbyNum.value
val positionToPos: (ProtoPosition?) -> Position? = { meshPosition ->
meshPosition?.let { Position(it) }?.takeIf { it.isValid() }
}
val nodePositions = mutableMapOf<Int, ProtoPosition?>()
@Suppress("MaxLineLength")
writer.appendLine(
"\"date\",\"time\",\"from\",\"sender name\",\"sender lat\",\"sender long\",\"rx lat\",\"rx long\",\"rx elevation\",\"rx snr\",\"distance(m)\",\"hop limit\",\"payload\"",
)
val dateFormat = SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault())
meshLogRepository.getAllLogsInReceiveOrder(Int.MAX_VALUE).first().forEach { packet ->
packet.nodeInfo?.let { nodeInfo ->
positionToPos.invoke(nodeInfo.position)?.let { nodePositions[nodeInfo.num] = nodeInfo.position }
}
packet.meshPacket?.let { proto ->
packet.position?.let { position ->
positionToPos.invoke(position)?.let {
nodePositions[proto.from.takeIf { it != 0 } ?: myNodeNum] = position
}
}
if (
(filterPortnum == null || (proto.decoded?.portnum?.value ?: 0) == filterPortnum) &&
proto.rx_snr != 0.0f
) {
val rxDateTime = dateFormat.format(packet.received_date)
val rxFrom = proto.from.toUInt()
val senderName = nodes[proto.from]?.user?.long_name ?: ""
val senderPosition = nodePositions[proto.from]
val senderPos = positionToPos.invoke(senderPosition)
val senderLat = senderPos?.latitude ?: ""
val senderLong = senderPos?.longitude ?: ""
val rxPosition = nodePositions[myNodeNum]
val rxPos = positionToPos.invoke(rxPosition)
val rxLat = rxPos?.latitude ?: ""
val rxLong = rxPos?.longitude ?: ""
val rxAlt = rxPos?.altitude ?: ""
val rxSnr = proto.rx_snr
val dist =
if (senderPos == null || rxPos == null) {
""
} else {
positionToMeter(Position(rxPosition!!), Position(senderPosition!!)).roundToInt().toString()
}
val hopLimit = proto.hop_limit
val decoded = proto.decoded
val encrypted = proto.encrypted
val payload =
when {
(decoded?.portnum?.value ?: 0) !in
setOf(PortNum.TEXT_MESSAGE_APP.value, PortNum.RANGE_TEST_APP.value) ->
"<${decoded?.portnum}>"
decoded != null -> decoded.payload.utf8().replace("\"", "\"\"")
encrypted != null -> "${encrypted.size} encrypted bytes"
else -> ""
}
@Suppress("MaxLineLength")
writer.appendLine(
"$rxDateTime,\"$rxFrom\",\"$senderName\",\"$senderLat\",\"$senderLong\",\"$rxLat\",\"$rxLong\",\"$rxAlt\",\"$rxSnr\",\"$dist\",\"$hopLimit\",\"$payload\"",
)
}
}
}
}
}

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.settings
import org.meshtastic.proto.DeviceProfile
import java.io.OutputStream
import javax.inject.Inject
/** Use case for exporting a device profile to an output stream. */
class ExportProfileUseCase @Inject constructor() {
/**
* Exports the provided [DeviceProfile] to the given [OutputStream].
*
* @param outputStream The stream to write the profile to.
* @param profile The device profile to export.
* @return A [Result] indicating success or failure.
*/
operator fun invoke(outputStream: OutputStream, profile: DeviceProfile): Result<Unit> = runCatching {
outputStream.write(profile.encode())
}
}

View file

@ -0,0 +1,58 @@
/*
* 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.settings
import android.util.Base64
import org.json.JSONObject
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.proto.Config
import java.io.OutputStream
import javax.inject.Inject
/** Use case for exporting security configuration to a JSON format. */
class ExportSecurityConfigUseCase @Inject constructor() {
/**
* Exports the provided [Config.SecurityConfig] as a JSON string to the given [OutputStream].
*
* @param outputStream The stream to write the JSON to.
* @param securityConfig The security configuration to export.
* @return A [Result] indicating success or failure.
*/
operator fun invoke(outputStream: OutputStream, securityConfig: Config.SecurityConfig): Result<Unit> = runCatching {
val publicKeyBytes = securityConfig.public_key.toByteArray()
val privateKeyBytes = securityConfig.private_key.toByteArray()
// Convert byte arrays to Base64 strings
val publicKeyBase64 = Base64.encodeToString(publicKeyBytes, Base64.NO_WRAP)
val privateKeyBase64 = Base64.encodeToString(privateKeyBytes, Base64.NO_WRAP)
// Create a JSON object
val jsonObject =
JSONObject().apply {
put("timestamp", nowMillis)
put("public_key", publicKeyBase64)
put("private_key", privateKeyBase64)
}
val jsonString = jsonObject.toString(JSON_INDENT_SPACES)
outputStream.write(jsonString.toByteArray(Charsets.UTF_8))
}
private companion object {
private const val JSON_INDENT_SPACES = 4
}
}

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.settings
import org.meshtastic.proto.DeviceProfile
import java.io.InputStream
import javax.inject.Inject
/** Use case for importing a device profile from an input stream. */
class ImportProfileUseCase @Inject constructor() {
/**
* Imports a [DeviceProfile] from the provided [InputStream].
*
* @param inputStream The stream to read the profile from.
* @return A [Result] containing the imported [DeviceProfile] or an error.
*/
operator fun invoke(inputStream: InputStream): Result<DeviceProfile> = runCatching {
val bytes = inputStream.readBytes()
DeviceProfile.ADAPTER.decode(bytes)
}
}

View file

@ -0,0 +1,153 @@
/*
* 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.settings
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.RadioController
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
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) {
/**
* Installs the provided [DeviceProfile] onto the radio at [destNum].
*
* @param destNum The destination node number.
* @param profile The device profile to install.
* @param currentUser The current user configuration of the destination node (to preserve names if not in profile).
*/
suspend operator fun invoke(destNum: Int, profile: DeviceProfile, currentUser: User?) {
radioController.beginEditSettings(destNum)
installOwner(destNum, profile, currentUser)
installConfig(destNum, profile.config)
installFixedPosition(destNum, profile.fixed_position)
installModuleConfig(destNum, profile.module_config)
radioController.commitEditSettings(destNum)
}
private suspend fun installOwner(destNum: Int, profile: DeviceProfile, currentUser: User?) {
if (profile.long_name != null || profile.short_name != null) {
currentUser?.let {
val user =
it.copy(
long_name = profile.long_name ?: it.long_name,
short_name = profile.short_name ?: it.short_name,
)
radioController.setOwner(destNum, user, radioController.getPacketId())
}
}
}
private suspend fun installConfig(destNum: Int, config: LocalConfig?) {
config?.let { lc ->
lc.device?.let { radioController.setConfig(destNum, Config(device = it), radioController.getPacketId()) }
lc.position?.let {
radioController.setConfig(destNum, Config(position = it), radioController.getPacketId())
}
lc.power?.let { radioController.setConfig(destNum, Config(power = it), radioController.getPacketId()) }
lc.network?.let { radioController.setConfig(destNum, Config(network = it), radioController.getPacketId()) }
lc.display?.let { radioController.setConfig(destNum, Config(display = it), radioController.getPacketId()) }
lc.lora?.let { radioController.setConfig(destNum, Config(lora = it), radioController.getPacketId()) }
lc.bluetooth?.let {
radioController.setConfig(destNum, Config(bluetooth = it), radioController.getPacketId())
}
lc.security?.let {
radioController.setConfig(destNum, Config(security = it), radioController.getPacketId())
}
}
}
private suspend fun installFixedPosition(destNum: Int, fixedPosition: org.meshtastic.proto.Position?) {
if (fixedPosition != null) {
radioController.setFixedPosition(destNum, Position(fixedPosition))
}
}
private suspend fun installModuleConfig(destNum: Int, moduleConfig: LocalModuleConfig?) {
moduleConfig?.let { lmc ->
installModuleConfigPart1(destNum, lmc)
installModuleConfigPart2(destNum, lmc)
}
}
private suspend fun installModuleConfigPart1(destNum: Int, lmc: LocalModuleConfig) {
lmc.mqtt?.let {
radioController.setModuleConfig(destNum, ModuleConfig(mqtt = it), radioController.getPacketId())
}
lmc.serial?.let {
radioController.setModuleConfig(destNum, ModuleConfig(serial = it), radioController.getPacketId())
}
lmc.external_notification?.let {
radioController.setModuleConfig(
destNum,
ModuleConfig(external_notification = it),
radioController.getPacketId(),
)
}
lmc.store_forward?.let {
radioController.setModuleConfig(destNum, ModuleConfig(store_forward = it), radioController.getPacketId())
}
lmc.range_test?.let {
radioController.setModuleConfig(destNum, ModuleConfig(range_test = it), radioController.getPacketId())
}
lmc.telemetry?.let {
radioController.setModuleConfig(destNum, ModuleConfig(telemetry = it), radioController.getPacketId())
}
lmc.canned_message?.let {
radioController.setModuleConfig(destNum, ModuleConfig(canned_message = it), radioController.getPacketId())
}
lmc.audio?.let {
radioController.setModuleConfig(destNum, ModuleConfig(audio = it), radioController.getPacketId())
}
}
private suspend fun installModuleConfigPart2(destNum: Int, lmc: LocalModuleConfig) {
lmc.remote_hardware?.let {
radioController.setModuleConfig(destNum, ModuleConfig(remote_hardware = it), radioController.getPacketId())
}
lmc.neighbor_info?.let {
radioController.setModuleConfig(destNum, ModuleConfig(neighbor_info = it), radioController.getPacketId())
}
lmc.ambient_lighting?.let {
radioController.setModuleConfig(destNum, ModuleConfig(ambient_lighting = it), radioController.getPacketId())
}
lmc.detection_sensor?.let {
radioController.setModuleConfig(destNum, ModuleConfig(detection_sensor = it), radioController.getPacketId())
}
lmc.paxcounter?.let {
radioController.setModuleConfig(destNum, ModuleConfig(paxcounter = it), radioController.getPacketId())
}
lmc.statusmessage?.let {
radioController.setModuleConfig(destNum, ModuleConfig(statusmessage = it), radioController.getPacketId())
}
lmc.traffic_management?.let {
radioController.setModuleConfig(
destNum,
ModuleConfig(traffic_management = it),
radioController.getPacketId(),
)
}
lmc.tak?.let { radioController.setModuleConfig(destNum, ModuleConfig(tak = it), radioController.getPacketId()) }
}
}

View file

@ -0,0 +1,65 @@
/*
* 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.settings
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.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 javax.inject.Inject
/** Use case to determine if the currently connected device is capable of over-the-air (OTA) updates. */
class IsOtaCapableUseCase
@Inject
constructor(
private val nodeRepository: NodeRepository,
private val radioController: RadioController,
private val radioPrefs: RadioPrefs,
private val deviceHardwareRepository: DeviceHardwareRepository,
) {
operator fun invoke(): Flow<Boolean> = combine(nodeRepository.ourNodeInfo, radioController.connectionState) {
node: Node?,
connectionState: ConnectionState,
->
node to connectionState
}
.flatMapLatest { (node, connectionState) ->
if (node == null || connectionState != ConnectionState.Connected) {
flowOf(false)
} else if (radioPrefs.isBle() || radioPrefs.isSerial() || radioPrefs.isTcp()) {
val hwModel = node.user.hw_model.value
val hw = deviceHardwareRepository.getDeviceHardwareByModel(hwModel).getOrNull()
// ESP32 Unified OTA is only supported via BLE or WiFi (TCP), not USB Serial.
// TODO: Re-enable when supportsUnifiedOta is added to DeviceHardware
val isEsp32OtaSupported = false
flowOf(hw?.requiresDfu == true || isEsp32OtaSupported)
} else {
flowOf(false)
}
}
}

View file

@ -0,0 +1,33 @@
/*
* 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.settings
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) {
/** Starts providing the phone's location to the mesh. */
fun startProvidingLocation() {
radioController.startProvideLocation()
}
/** Stops providing the phone's location to the mesh. */
fun stopProvidingLocation() {
radioController.stopProvideLocation()
}
}

View file

@ -0,0 +1,127 @@
/*
* 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.settings
import co.touchlab.kermit.Logger
import org.meshtastic.core.database.model.getStringResFrom
import org.meshtastic.core.resources.UiText
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Data
import org.meshtastic.proto.DeviceConnectionStatus
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Routing
import org.meshtastic.proto.User
import javax.inject.Inject
/** Sealed class representing the result of processing a radio response packet. */
sealed class RadioResponseResult {
data class Metadata(val metadata: DeviceMetadata) : RadioResponseResult()
data class ChannelResponse(val channel: Channel) : RadioResponseResult()
data class Owner(val user: User) : RadioResponseResult()
data class ConfigResponse(val config: org.meshtastic.proto.Config) : RadioResponseResult()
data class ModuleConfigResponse(val config: org.meshtastic.proto.ModuleConfig) : RadioResponseResult()
data class CannedMessages(val messages: String) : RadioResponseResult()
data class Ringtone(val ringtone: String) : RadioResponseResult()
data class ConnectionStatus(val status: DeviceConnectionStatus) : RadioResponseResult()
data class Error(val message: UiText) : RadioResponseResult()
data object Success : RadioResponseResult()
}
/** Use case for processing incoming [MeshPacket]s that are responses to admin requests. */
class ProcessRadioResponseUseCase @Inject constructor() {
/**
* Decodes and processes the provided [packet].
*
* @param packet The mesh packet received from the radio.
* @param destNum The node number that the response is expected from.
* @param requestIds The set of active request IDs.
* @return A [RadioResponseResult] if the packet matches a request, or null otherwise.
*/
@Suppress("CyclomaticComplexMethod", "NestedBlockDepth")
operator fun invoke(packet: MeshPacket, destNum: Int, requestIds: Set<Int>): RadioResponseResult? {
val data = packet.decoded
if (data == null || data.request_id !in requestIds) {
return null
}
return when (data.portnum) {
PortNum.ROUTING_APP -> processRoutingResponse(packet, data, destNum)
PortNum.ADMIN_APP -> processAdminResponse(packet, data, destNum)
else -> null
}
}
private fun processRoutingResponse(packet: MeshPacket, data: Data, destNum: Int): RadioResponseResult? {
val parsed = Routing.ADAPTER.decode(data.payload)
return when {
parsed.error_reason != Routing.Error.NONE ->
RadioResponseResult.Error(UiText.Resource(getStringResFrom(parsed.error_reason?.value ?: 0)))
packet.from == destNum -> RadioResponseResult.Success
else -> null
}
}
private fun processAdminResponse(packet: MeshPacket, data: Data, destNum: Int): RadioResponseResult {
if (destNum != packet.from) {
return RadioResponseResult.Error(
UiText.DynamicString("Unexpected sender: ${packet.from.toUInt()} instead of ${destNum.toUInt()}."),
)
}
val parsed = AdminMessage.ADAPTER.decode(data.payload)
return processAdminMessage(parsed)
}
private fun processAdminMessage(parsed: AdminMessage): RadioResponseResult = when {
parsed.get_device_metadata_response != null ->
RadioResponseResult.Metadata(parsed.get_device_metadata_response!!)
parsed.get_channel_response != null -> RadioResponseResult.ChannelResponse(parsed.get_channel_response!!)
parsed.get_owner_response != null -> RadioResponseResult.Owner(parsed.get_owner_response!!)
parsed.get_config_response != null -> RadioResponseResult.ConfigResponse(parsed.get_config_response!!)
parsed.get_module_config_response != null ->
RadioResponseResult.ModuleConfigResponse(parsed.get_module_config_response!!)
parsed.get_canned_message_module_messages_response != null ->
RadioResponseResult.CannedMessages(parsed.get_canned_message_module_messages_response!!)
parsed.get_ringtone_response != null -> RadioResponseResult.Ringtone(parsed.get_ringtone_response!!)
parsed.get_device_connection_status_response != null ->
RadioResponseResult.ConnectionStatus(parsed.get_device_connection_status_response!!)
else -> {
Logger.d { "No custom processing needed for $parsed" }
RadioResponseResult.Success
}
}
}

View file

@ -0,0 +1,187 @@
/*
* 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.settings
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.RadioController
import org.meshtastic.proto.Config
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.User
import javax.inject.Inject
/** Use case for interacting with radio configuration components. */
@Suppress("TooManyFunctions")
open class RadioConfigUseCase @Inject constructor(private val radioController: RadioController) {
/**
* Updates the owner information on the radio.
*
* @param destNum The node number to update.
* @param user The new user configuration.
* @return The packet ID of the request.
*/
suspend fun setOwner(destNum: Int, user: User): Int {
val packetId = radioController.getPacketId()
radioController.setOwner(destNum, user, packetId)
return packetId
}
/**
* Requests the owner information from the radio.
*
* @param destNum The node number to query.
* @return The packet ID of the request.
*/
suspend fun getOwner(destNum: Int): Int {
val packetId = radioController.getPacketId()
radioController.getOwner(destNum, packetId)
return packetId
}
/**
* Updates a configuration section on the radio.
*
* @param destNum The node number to update.
* @param config The new configuration.
* @return The packet ID of the request.
*/
suspend fun setConfig(destNum: Int, config: Config): Int {
val packetId = radioController.getPacketId()
radioController.setConfig(destNum, config, packetId)
return packetId
}
/**
* Requests a configuration section from the radio.
*
* @param destNum The node number to query.
* @param configType The type of configuration to request (from [org.meshtastic.proto.AdminMessage.ConfigType]).
* @return The packet ID of the request.
*/
suspend fun getConfig(destNum: Int, configType: Int): Int {
val packetId = radioController.getPacketId()
radioController.getConfig(destNum, configType, packetId)
return packetId
}
/**
* Updates a module configuration section on the radio.
*
* @param destNum The node number to update.
* @param config The new module configuration.
* @return The packet ID of the request.
*/
suspend fun setModuleConfig(destNum: Int, config: ModuleConfig): Int {
val packetId = radioController.getPacketId()
radioController.setModuleConfig(destNum, config, packetId)
return packetId
}
/**
* Requests a module configuration section from the radio.
*
* @param destNum The node number to query.
* @param moduleConfigType The type of module configuration to request.
* @return The packet ID of the request.
*/
suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int): Int {
val packetId = radioController.getPacketId()
radioController.getModuleConfig(destNum, moduleConfigType, packetId)
return packetId
}
/**
* Requests a channel from the radio.
*
* @param destNum The node number to query.
* @param index The index of the channel to request.
* @return The packet ID of the request.
*/
suspend fun getChannel(destNum: Int, index: Int): Int {
val packetId = radioController.getPacketId()
radioController.getChannel(destNum, index, packetId)
return packetId
}
/**
* Updates a channel on the radio.
*
* @param destNum The node number to update.
* @param channel The new channel configuration.
* @return The packet ID of the request.
*/
suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel): Int {
val packetId = radioController.getPacketId()
radioController.setRemoteChannel(destNum, channel, packetId)
return packetId
}
/** Updates the fixed position on the radio. */
suspend fun setFixedPosition(destNum: Int, position: Position) {
radioController.setFixedPosition(destNum, position)
}
/** Removes the fixed position on the radio. */
suspend fun removeFixedPosition(destNum: Int) {
radioController.setFixedPosition(destNum, Position(0.0, 0.0, 0))
}
/** Sets the ringtone on the radio. */
suspend fun setRingtone(destNum: Int, ringtone: String) {
radioController.setRingtone(destNum, ringtone)
}
/**
* Requests the ringtone from the radio.
*
* @param destNum The node number to query.
* @return The packet ID of the request.
*/
suspend fun getRingtone(destNum: Int): Int {
val packetId = radioController.getPacketId()
radioController.getRingtone(destNum, packetId)
return packetId
}
/** Sets the canned messages on the radio. */
suspend fun setCannedMessages(destNum: Int, messages: String) {
radioController.setCannedMessages(destNum, messages)
}
/**
* Requests the canned messages from the radio.
*
* @param destNum The node number to query.
* @return The packet ID of the request.
*/
suspend fun getCannedMessages(destNum: Int): Int {
val packetId = radioController.getPacketId()
radioController.getCannedMessages(destNum, packetId)
return packetId
}
/**
* Requests the device connection status from the radio.
*
* @param destNum The node number to query.
* @return The packet ID of the request.
*/
suspend fun getDeviceConnectionStatus(destNum: Int): Int {
val packetId = radioController.getPacketId()
radioController.getDeviceConnectionStatus(destNum, packetId)
return packetId
}
}

View file

@ -0,0 +1,27 @@
/*
* 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.settings
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) {
operator fun invoke(completed: Boolean) {
uiPreferencesDataSource.setAppIntroCompleted(completed)
}
}

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.settings
import org.meshtastic.core.database.DatabaseConstants
import org.meshtastic.core.database.DatabaseManager
import javax.inject.Inject
/** Use case for setting the database cache limit. */
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

@ -0,0 +1,54 @@
/*
* 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.settings
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import javax.inject.Inject
/** Use case for managing mesh log settings. */
class SetMeshLogSettingsUseCase
@Inject
constructor(
private val meshLogRepository: MeshLogRepository,
private val meshLogPrefs: MeshLogPrefs,
) {
/**
* Sets the retention period for mesh logs.
*
* @param days The number of days to retain logs.
*/
suspend fun setRetentionDays(days: Int) {
val clamped = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS)
meshLogPrefs.retentionDays = clamped
meshLogRepository.deleteLogsOlderThan(clamped)
}
/**
* Enables or disables mesh logging.
*
* @param enabled True to enable logging, false to disable.
*/
suspend fun setLoggingEnabled(enabled: Boolean) {
meshLogPrefs.loggingEnabled = enabled
if (!enabled) {
meshLogRepository.deleteAll()
} else {
meshLogRepository.deleteLogsOlderThan(meshLogPrefs.retentionDays)
}
}
}

View file

@ -0,0 +1,27 @@
/*
* 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.settings
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) {
operator fun invoke(myNodeNum: Int, provideLocation: Boolean) {
uiPrefs.setShouldProvideNodeLocation(myNodeNum, provideLocation)
}
}

View file

@ -0,0 +1,27 @@
/*
* 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.settings
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) {
operator fun invoke(themeMode: Int) {
uiPreferencesDataSource.setTheme(themeMode)
}
}

View file

@ -0,0 +1,27 @@
/*
* 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.settings
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) {
operator fun invoke() {
analyticsPrefs.analyticsAllowed = !analyticsPrefs.analyticsAllowed
}
}

View file

@ -0,0 +1,27 @@
/*
* 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.settings
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) {
operator fun invoke() {
homoglyphEncodingPrefs.homoglyphEncodingEnabled = !homoglyphEncodingPrefs.homoglyphEncodingEnabled
}
}

View file

@ -0,0 +1,109 @@
/*
* 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
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.RadioController
import org.meshtastic.proto.ClientNotification
class FakeRadioController : RadioController {
// Mutable state flows so we can manipulate them in our tests
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Connected)
override val connectionState: StateFlow<ConnectionState> = _connectionState
private val _clientNotification = MutableStateFlow<ClientNotification?>(null)
override val clientNotification: StateFlow<ClientNotification?> = _clientNotification
// Track sent packets to assert in tests
val sentPackets = mutableListOf<DataPacket>()
val favoritedNodes = mutableListOf<Int>()
val sentSharedContacts = mutableListOf<Int>()
override suspend fun sendMessage(packet: DataPacket) {
sentPackets.add(packet)
}
override fun clearClientNotification() {
_clientNotification.value = null
}
override suspend fun favoriteNode(nodeNum: Int) {
favoritedNodes.add(nodeNum)
}
override suspend fun sendSharedContact(nodeNum: Int) {
sentSharedContacts.add(nodeNum)
}
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) {}
override suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int) {}
override suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int) {}
override suspend fun setFixedPosition(destNum: Int, position: org.meshtastic.core.model.Position) {}
override suspend fun setRingtone(destNum: Int, ringtone: String) {}
override suspend fun setCannedMessages(destNum: Int, messages: String) {}
override suspend fun getOwner(destNum: Int, packetId: Int) {}
override suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) {}
override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) {}
override suspend fun getChannel(destNum: Int, index: Int, packetId: Int) {}
override suspend fun getRingtone(destNum: Int, packetId: Int) {}
override suspend fun getCannedMessages(destNum: Int, packetId: Int) {}
override suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) {}
override suspend fun reboot(destNum: Int, packetId: Int) {}
override suspend fun shutdown(destNum: Int, packetId: Int) {}
override suspend fun factoryReset(destNum: Int, packetId: Int) {}
override suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) {}
override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) {}
override suspend fun beginEditSettings(destNum: Int) {}
override suspend fun commitEditSettings(destNum: Int) {}
override fun getPacketId(): Int = 1
override fun startProvideLocation() {}
override fun stopProvideLocation() {}
// --- Helper methods for testing ---
fun setConnectionState(state: ConnectionState) {
_connectionState.value = state
}
}

View file

@ -0,0 +1,174 @@
/*
* 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 io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.slot
import io.mockk.unmockkAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.After
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.proto.Config
import org.meshtastic.proto.DeviceMetadata
class SendMessageUseCaseTest {
private lateinit var nodeRepository: NodeRepository
private lateinit var packetRepository: PacketRepository
private lateinit var radioController: FakeRadioController
private lateinit var homoglyphEncodingPrefs: HomoglyphPrefs
private lateinit var messageQueue: MessageQueue
private lateinit var useCase: SendMessageUseCase
@Before
fun setUp() {
nodeRepository = mockk(relaxed = true)
packetRepository = mockk(relaxed = true)
radioController = FakeRadioController()
homoglyphEncodingPrefs = mockk(relaxed = true)
messageQueue = mockk(relaxed = true)
useCase =
SendMessageUseCase(
nodeRepository = nodeRepository,
packetRepository = packetRepository,
radioController = radioController,
homoglyphEncodingPrefs = homoglyphEncodingPrefs,
messageQueue = messageQueue,
)
mockkConstructor(Capabilities::class)
}
@After
fun tearDown() {
unmockkAll()
}
@Test
fun `invoke with broadcast message simply sends data packet`() = runTest {
// Arrange
val ourNode = mockk<Node>(relaxed = true)
every { ourNode.user.id } returns "!1234"
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
// Act
useCase("Hello broadcast", "0${DataPacket.ID_BROADCAST}", null)
// Assert
assertEquals(0, radioController.favoritedNodes.size)
assertEquals(0, radioController.sentSharedContacts.size)
coVerify { packetRepository.insert(any<Packet>()) }
coVerify { messageQueue.enqueue(any()) }
}
@Test
fun `invoke with direct message to older firmware triggers favoriteNode`() = runTest {
// Arrange
val ourNode = mockk<Node>(relaxed = true)
val metadata = mockk<DeviceMetadata>(relaxed = true)
every { ourNode.user.id } returns "!local"
every { ourNode.user.role } returns Config.DeviceConfig.Role.CLIENT
every { ourNode.metadata } returns metadata
every { metadata.firmware_version } returns "2.0.0" // Older firmware
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
val destNode = mockk<Node>(relaxed = true)
every { destNode.isFavorite } returns false
every { destNode.num } returns 12345
every { nodeRepository.getNode("!dest") } returns destNode
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
every { anyConstructed<Capabilities>().canSendVerifiedContacts } returns false
// Act
useCase("Direct message", "!dest", null)
// Assert
assertEquals(1, radioController.favoritedNodes.size)
assertEquals(12345, radioController.favoritedNodes[0])
coVerify { packetRepository.insert(any<Packet>()) }
coVerify { messageQueue.enqueue(any()) }
}
@Test
fun `invoke with direct message to new firmware triggers sendSharedContact`() = runTest {
// Arrange
val ourNode = mockk<Node>(relaxed = true)
val metadata = mockk<DeviceMetadata>(relaxed = true)
every { ourNode.user.id } returns "!local"
every { ourNode.user.role } returns Config.DeviceConfig.Role.CLIENT
every { ourNode.metadata } returns metadata
every { metadata.firmware_version } returns "2.7.12" // Newer firmware
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
val destNode = mockk<Node>(relaxed = true)
every { destNode.num } returns 67890
every { nodeRepository.getNode("!dest") } returns destNode
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
every { anyConstructed<Capabilities>().canSendVerifiedContacts } returns true
// Act
useCase("Direct message", "!dest", null)
// Assert
assertEquals(1, radioController.sentSharedContacts.size)
assertEquals(67890, radioController.sentSharedContacts[0])
coVerify { packetRepository.insert(any<Packet>()) }
coVerify { messageQueue.enqueue(any()) }
}
@Test
fun `invoke with homoglyph enabled transforms text`() = runTest {
// Arrange
val ourNode = mockk<Node>(relaxed = true)
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns true
val originalText = "\u0410pple" // Cyrillic A
// Act
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)
coVerify { messageQueue.enqueue(any()) }
}
}

View file

@ -0,0 +1,72 @@
/*
* 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.settings
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
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
class AdminActionsUseCaseTest {
private lateinit var radioController: RadioController
private lateinit var nodeRepository: NodeRepository
private lateinit var useCase: AdminActionsUseCase
@Before
fun setUp() {
radioController = mockk(relaxed = true)
nodeRepository = mockk(relaxed = true)
useCase = AdminActionsUseCase(radioController, nodeRepository)
every { radioController.getPacketId() } returns 42
}
@Test
fun `reboot calls radioController and returns packetId`() = runTest {
val result = useCase.reboot(123)
coVerify { radioController.reboot(123, 42) }
assertEquals(42, result)
}
@Test
fun `shutdown calls radioController and returns packetId`() = runTest {
val result = useCase.shutdown(123)
coVerify { radioController.shutdown(123, 42) }
assertEquals(42, result)
}
@Test
fun `factoryReset calls radioController and clears DB if local`() = runTest {
val result = useCase.factoryReset(123, isLocal = true)
coVerify { radioController.factoryReset(123, 42) }
coVerify { nodeRepository.clearNodeDB() }
assertEquals(42, result)
}
@Test
fun `nodedbReset calls radioController and clears DB if local`() = runTest {
val result = useCase.nodedbReset(123, preserveFavorites = true, isLocal = true)
coVerify { radioController.nodedbReset(123, 42, true) }
coVerify { nodeRepository.clearNodeDB(true) }
assertEquals(42, result)
}
}

View file

@ -0,0 +1,73 @@
/*
* 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.settings
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
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 kotlin.time.Duration.Companion.days
class CleanNodeDatabaseUseCaseTest {
private lateinit var nodeRepository: NodeRepository
private lateinit var radioController: FakeRadioController
private lateinit var useCase: CleanNodeDatabaseUseCase
@Before
fun setUp() {
nodeRepository = mockk(relaxed = true)
radioController = FakeRadioController()
useCase = CleanNodeDatabaseUseCase(nodeRepository, radioController)
}
@Test
fun `getNodesToClean filters nodes correctly`() = runTest {
// Arrange
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)
coEvery { nodeRepository.getNodesOlderThan(any()) } returns listOf(oldNode, ignoredNode)
// Act
val result = useCase.getNodesToClean(30f, false, currentTime)
// Assert
assertEquals(1, result.size)
assertEquals(1, result[0].num)
}
@Test
fun `cleanNodes calls repository and controller`() = runTest {
// Act
useCase.cleanNodes(listOf(1, 2))
// Assert
coVerify { nodeRepository.deleteNodes(listOf(1, 2)) }
// Note: we can't easily verify removeByNodenum on FakeRadioController without adding tracking
}
}

View file

@ -0,0 +1,99 @@
/*
* 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 io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.encodeUtf8
import org.junit.Assert.assertTrue
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.proto.Data
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.User
import org.robolectric.RobolectricTestRunner
import java.io.BufferedWriter
import java.io.StringWriter
@RunWith(RobolectricTestRunner::class)
class ExportDataUseCaseTest {
private lateinit var nodeRepository: NodeRepository
private lateinit var meshLogRepository: MeshLogRepository
private lateinit var useCase: ExportDataUseCase
@Before
fun setUp() {
nodeRepository = mockk(relaxed = true)
meshLogRepository = mockk(relaxed = true)
useCase = ExportDataUseCase(nodeRepository, meshLogRepository)
}
@Test
fun `invoke writes header and log data`() = runTest {
// Arrange
val myNodeNum = 123
val senderNodeNum = 456
val senderNode = Node(num = senderNodeNum, user = User(long_name = "Sender Name"))
val nodes = mapOf(senderNodeNum to senderNode)
val stateFlow = MutableStateFlow(nodes)
every { nodeRepository.nodeDBbyNum } returns stateFlow
every { nodeRepository.getNodeEntityDBbyNumFlow() } returns flowOf(emptyMap())
val meshPacket =
MeshPacket(
from = senderNodeNum,
rx_snr = 5.5f,
decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "Hello".encodeUtf8()),
)
val meshLog =
MeshLog(
uuid = "uuid-1",
message_type = "Packet",
received_date = 1700000000000L,
raw_message = "",
fromNum = senderNodeNum,
portNum = PortNum.TEXT_MESSAGE_APP.value,
fromRadio = FromRadio(packet = meshPacket),
)
every { meshLogRepository.getAllLogsInReceiveOrder(any()) } returns flowOf(listOf(meshLog))
val stringWriter = StringWriter()
val bufferedWriter = BufferedWriter(stringWriter)
// Act
useCase(bufferedWriter, myNodeNum)
bufferedWriter.flush()
// Assert
val output = stringWriter.toString()
assertTrue("Header should be present", output.contains("\"date\",\"time\",\"from\",\"sender name\""))
assertTrue("Sender name should be present", output.contains("Sender Name"))
assertTrue("Payload should be present", output.contains("Hello"))
}
}

View file

@ -0,0 +1,48 @@
/*
* 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.settings
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.meshtastic.proto.DeviceProfile
import java.io.ByteArrayOutputStream
class ExportProfileUseCaseTest {
private lateinit var useCase: ExportProfileUseCase
@Before
fun setUp() {
useCase = ExportProfileUseCase()
}
@Test
fun `invoke writes encoded profile to output stream`() {
// Arrange
val profile = DeviceProfile(long_name = "Export Node")
val outputStream = ByteArrayOutputStream()
// Act
val result = useCase(outputStream, profile)
// Assert
assertTrue(result.isSuccess)
assertArrayEquals(profile.encode(), outputStream.toByteArray())
}
}

View file

@ -0,0 +1,61 @@
/*
* 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.settings
import okio.ByteString.Companion.toByteString
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.proto.Config
import org.robolectric.RobolectricTestRunner
import java.io.ByteArrayOutputStream
@RunWith(RobolectricTestRunner::class)
class ExportSecurityConfigUseCaseTest {
private lateinit var useCase: ExportSecurityConfigUseCase
@Before
fun setUp() {
useCase = ExportSecurityConfigUseCase()
}
@Test
fun `invoke writes valid JSON to output stream`() {
// Arrange
val publicKey = byteArrayOf(1, 2, 3).toByteString()
val privateKey = byteArrayOf(4, 5, 6).toByteString()
val config = Config.SecurityConfig(public_key = publicKey, private_key = privateKey)
val outputStream = ByteArrayOutputStream()
// Act
val result = useCase(outputStream, config)
// Assert
assertTrue(result.isSuccess)
val json = JSONObject(outputStream.toString())
assertTrue(json.has("timestamp"))
assertTrue(json.has("public_key"))
assertTrue(json.has("private_key"))
// Check base64 values
assertEquals("AQID", json.getString("public_key"))
assertEquals("BAUG", json.getString("private_key"))
}
}

View file

@ -0,0 +1,60 @@
/*
* 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.settings
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.meshtastic.proto.DeviceProfile
import java.io.ByteArrayInputStream
class ImportProfileUseCaseTest {
private lateinit var useCase: ImportProfileUseCase
@Before
fun setUp() {
useCase = ImportProfileUseCase()
}
@Test
fun `invoke with valid data returns profile`() {
// Arrange
val profile = DeviceProfile(long_name = "Test Node")
val inputStream = ByteArrayInputStream(profile.encode())
// Act
val result = useCase(inputStream)
// Assert
assertTrue(result.isSuccess)
assertEquals("Test Node", result.getOrNull()?.long_name)
}
@Test
fun `invoke with invalid data returns failure`() {
// Arrange
val inputStream = ByteArrayInputStream(byteArrayOf(1, 2, 3))
// Act
val result = useCase(inputStream)
// Assert
assertTrue(result.isFailure)
}
}

View file

@ -0,0 +1,98 @@
/*
* 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 io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.model.RadioController
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.User
class InstallProfileUseCaseTest {
private lateinit var radioController: RadioController
private lateinit var useCase: InstallProfileUseCase
@Before
fun setUp() {
radioController = mockk(relaxed = true)
useCase = InstallProfileUseCase(radioController)
every { radioController.getPacketId() } returns 1
}
@Test
fun `invoke with names updates owner`() = runTest {
// Arrange
val profile = DeviceProfile(long_name = "New Long", short_name = "NL")
val currentUser = User(long_name = "Old Long", short_name = "OL")
// Act
useCase(123, profile, currentUser)
// Assert
coVerify { radioController.beginEditSettings(123) }
coVerify { radioController.setOwner(123, match { it.long_name == "New Long" && it.short_name == "NL" }, 1) }
coVerify { radioController.commitEditSettings(123) }
}
@Test
fun `invoke with config sets config`() = runTest {
// Arrange
val loraConfig = Config.LoRaConfig(region = Config.LoRaConfig.RegionCode.US)
val profile = DeviceProfile(config = LocalConfig(lora = loraConfig))
// Act
useCase(456, profile, null)
// Assert
coVerify { radioController.setConfig(456, match { it.lora == loraConfig }, 1) }
}
@Test
fun `invoke with module_config sets module config`() = runTest {
// Arrange
val mqttConfig = ModuleConfig.MQTTConfig(enabled = true, address = "broker.local")
val profile = DeviceProfile(module_config = LocalModuleConfig(mqtt = mqttConfig))
// Act
useCase(789, profile, null)
// Assert
coVerify { radioController.setModuleConfig(789, match { it.mqtt == mqttConfig }, 1) }
}
@Test
fun `invoke with module_config part 2 sets module config`() = runTest {
// Arrange
val neighborInfoConfig = ModuleConfig.NeighborInfoConfig(enabled = true)
val profile = DeviceProfile(module_config = LocalModuleConfig(neighbor_info = neighborInfoConfig))
// Act
useCase(789, profile, null)
// Assert
coVerify { radioController.setModuleConfig(789, match { it.neighbor_info == neighborInfoConfig }, 1) }
}
}

View file

@ -0,0 +1,124 @@
/*
* 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.settings
import app.cash.turbine.test
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
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.RadioController
import org.meshtastic.core.prefs.radio.RadioPrefs
class IsOtaCapableUseCaseTest {
private lateinit var nodeRepository: NodeRepository
private lateinit var radioController: RadioController
private lateinit var radioPrefs: RadioPrefs
private lateinit var deviceHardwareRepository: DeviceHardwareRepository
private lateinit var useCase: IsOtaCapableUseCase
private val ourNodeInfoFlow = MutableStateFlow<Node?>(null)
private val connectionStateFlow = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
@Before
fun setUp() {
nodeRepository = mockk { every { ourNodeInfo } returns ourNodeInfoFlow }
radioController = mockk { every { connectionState } returns connectionStateFlow }
radioPrefs = mockk(relaxed = true)
deviceHardwareRepository = mockk(relaxed = true)
useCase = IsOtaCapableUseCase(nodeRepository, radioController, radioPrefs, deviceHardwareRepository)
}
@Test
fun `returns false when node is null`() = runTest {
ourNodeInfoFlow.value = null
connectionStateFlow.value = ConnectionState.Connected
useCase().test {
assertFalse(awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `returns false when not connected`() = runTest {
val node = mockk<Node>(relaxed = true)
ourNodeInfoFlow.value = node
connectionStateFlow.value = ConnectionState.Disconnected
useCase().test {
assertFalse(awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `returns false when radio is not BLE, Serial, or TCP`() = runTest {
val node = mockk<Node>(relaxed = true)
ourNodeInfoFlow.value = node
connectionStateFlow.value = ConnectionState.Connected
every { radioPrefs.devAddr } returns "m123" // Mock
useCase().test {
assertFalse(awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `returns true when hw requires Dfu`() = runTest {
val node = mockk<Node>(relaxed = true)
ourNodeInfoFlow.value = node
connectionStateFlow.value = ConnectionState.Connected
every { radioPrefs.devAddr } returns "x123" // BLE
val hw = mockk<org.meshtastic.core.model.DeviceHardware> { every { requiresDfu } returns true }
coEvery { deviceHardwareRepository.getDeviceHardwareByModel(any()) } returns Result.success(hw)
useCase().test {
assertTrue(awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `returns false when hw does not require Dfu and isEsp32OtaSupported is false`() = runTest {
val node = mockk<Node>(relaxed = true)
ourNodeInfoFlow.value = node
connectionStateFlow.value = ConnectionState.Connected
every { radioPrefs.devAddr } returns "x123" // BLE
val hw = mockk<org.meshtastic.core.model.DeviceHardware> { every { requiresDfu } returns false }
coEvery { deviceHardwareRepository.getDeviceHardwareByModel(any()) } returns Result.success(hw)
useCase().test {
assertFalse(awaitItem())
cancelAndIgnoreRemainingEvents()
}
}
}

View file

@ -0,0 +1,47 @@
/*
* 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.settings
import io.mockk.mockk
import io.mockk.verify
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.model.RadioController
class MeshLocationUseCaseTest {
private lateinit var radioController: RadioController
private lateinit var useCase: MeshLocationUseCase
@Before
fun setUp() {
radioController = mockk(relaxed = true)
useCase = MeshLocationUseCase(radioController)
}
@Test
fun `startProvidingLocation calls radioController`() {
useCase.startProvidingLocation()
verify { radioController.startProvideLocation() }
}
@Test
fun `stopProvidingLocation calls radioController`() {
useCase.stopProvidingLocation()
verify { radioController.stopProvideLocation() }
}
}

View file

@ -0,0 +1,106 @@
/*
* 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.settings
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Data
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Routing
class ProcessRadioResponseUseCaseTest {
private lateinit var useCase: ProcessRadioResponseUseCase
@Before
fun setUp() {
useCase = ProcessRadioResponseUseCase()
}
@Test
fun `invoke with routing error returns error result`() {
// Arrange
val packet =
MeshPacket(
from = 123,
decoded =
Data(
portnum = PortNum.ROUTING_APP,
request_id = 42,
payload = Routing(error_reason = Routing.Error.NO_ROUTE).encode().toByteString(),
),
)
// Act
val result = useCase(packet, 123, setOf(42))
// Assert
assertTrue(result is RadioResponseResult.Error)
}
@Test
fun `invoke with metadata response returns metadata result`() {
// Arrange
val metadata = DeviceMetadata(firmware_version = "2.5.0")
val adminMsg = AdminMessage(get_device_metadata_response = metadata)
val packet =
MeshPacket(
from = 123,
decoded = Data(
portnum = PortNum.ADMIN_APP,
request_id = 42,
payload = adminMsg.encode().toByteString(),
),
)
// Act
val result = useCase(packet, 123, setOf(42))
// Assert
assertTrue(result is RadioResponseResult.Metadata)
assertEquals("2.5.0", (result as RadioResponseResult.Metadata).metadata.firmware_version)
}
@Test
fun `invoke with canned messages response returns canned messages result`() {
// Arrange
val adminMsg = AdminMessage(get_canned_message_module_messages_response = "Hello World")
val packet =
MeshPacket(
from = 123,
decoded = Data(
portnum = PortNum.ADMIN_APP,
request_id = 42,
payload = adminMsg.encode().toByteString(),
),
)
// Act
val result = useCase(packet, 123, setOf(42))
// Assert
assertTrue(result is RadioResponseResult.CannedMessages)
assertEquals("Hello World", (result as RadioResponseResult.CannedMessages).messages)
}
private fun ByteArray.toByteString() = okio.ByteString.of(*this)
}

View file

@ -0,0 +1,160 @@
/*
* 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.settings
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.RadioController
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.User
class RadioConfigUseCaseTest {
private lateinit var radioController: RadioController
private lateinit var useCase: RadioConfigUseCase
@Before
fun setUp() {
radioController = mockk(relaxed = true)
useCase = RadioConfigUseCase(radioController)
every { radioController.getPacketId() } returns 42
}
@Test
fun `setOwner calls radioController and returns packetId`() = runTest {
val user = User(long_name = "New Name")
val result = useCase.setOwner(123, user)
coVerify { radioController.setOwner(123, user, 42) }
assertEquals(42, result)
}
@Test
fun `getOwner calls radioController and returns packetId`() = runTest {
val result = useCase.getOwner(123)
coVerify { radioController.getOwner(123, 42) }
assertEquals(42, result)
}
@Test
fun `setConfig calls radioController and returns packetId`() = runTest {
val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT))
val result = useCase.setConfig(123, config)
coVerify { radioController.setConfig(123, config, 42) }
assertEquals(42, result)
}
@Test
fun `getConfig calls radioController and returns packetId`() = runTest {
val result = useCase.getConfig(123, 1)
coVerify { radioController.getConfig(123, 1, 42) }
assertEquals(42, result)
}
@Test
fun `setModuleConfig calls radioController and returns packetId`() = runTest {
val config = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
val result = useCase.setModuleConfig(123, config)
coVerify { radioController.setModuleConfig(123, config, 42) }
assertEquals(42, result)
}
@Test
fun `getModuleConfig calls radioController and returns packetId`() = runTest {
val result = useCase.getModuleConfig(123, 2)
coVerify { radioController.getModuleConfig(123, 2, 42) }
assertEquals(42, result)
}
@Test
fun `getChannel calls radioController and returns packetId`() = runTest {
val result = useCase.getChannel(123, 0)
coVerify { radioController.getChannel(123, 0, 42) }
assertEquals(42, result)
}
@Test
fun `setRemoteChannel calls radioController and returns packetId`() = runTest {
val channel = Channel(index = 0)
val result = useCase.setRemoteChannel(123, channel)
coVerify { radioController.setRemoteChannel(123, channel, 42) }
assertEquals(42, result)
}
@Test
fun `setFixedPosition calls radioController`() = runTest {
val pos = Position(1.0, 2.0, 3)
useCase.setFixedPosition(123, pos)
coVerify { radioController.setFixedPosition(123, pos) }
}
@Test
fun `removeFixedPosition calls radioController with zero position`() = runTest {
useCase.removeFixedPosition(123)
coVerify { radioController.setFixedPosition(123, any()) }
}
@Test
fun `setRingtone calls radioController`() = runTest {
useCase.setRingtone(123, "ring")
coVerify { radioController.setRingtone(123, "ring") }
}
@Test
fun `getRingtone calls radioController and returns packetId`() = runTest {
val result = useCase.getRingtone(123)
coVerify { radioController.getRingtone(123, 42) }
assertEquals(42, result)
}
@Test
fun `setCannedMessages calls radioController`() = runTest {
useCase.setCannedMessages(123, "msg")
coVerify { radioController.setCannedMessages(123, "msg") }
}
@Test
fun `getCannedMessages calls radioController and returns packetId`() = runTest {
val result = useCase.getCannedMessages(123)
coVerify { radioController.getCannedMessages(123, 42) }
assertEquals(42, result)
}
@Test
fun `getDeviceConnectionStatus calls radioController and returns packetId`() = runTest {
val result = useCase.getDeviceConnectionStatus(123)
coVerify { radioController.getDeviceConnectionStatus(123, 42) }
assertEquals(42, result)
}
}

View file

@ -0,0 +1,44 @@
/*
* 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.settings
import io.mockk.mockk
import io.mockk.verify
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.datastore.UiPreferencesDataSource
class SetAppIntroCompletedUseCaseTest {
private lateinit var uiPreferencesDataSource: UiPreferencesDataSource
private lateinit var useCase: SetAppIntroCompletedUseCase
@Before
fun setUp() {
uiPreferencesDataSource = mockk(relaxed = true)
useCase = SetAppIntroCompletedUseCase(uiPreferencesDataSource)
}
@Test
fun `invoke calls setAppIntroCompleted on data source`() {
// Act
useCase(true)
// Assert
verify { uiPreferencesDataSource.setAppIntroCompleted(true) }
}
}

View file

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

View file

@ -0,0 +1,74 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.domain.usecase.settings
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
class SetMeshLogSettingsUseCaseTest {
private lateinit var meshLogRepository: MeshLogRepository
private lateinit var meshLogPrefs: MeshLogPrefs
private lateinit var useCase: SetMeshLogSettingsUseCase
@Before
fun setUp() {
meshLogRepository = mockk(relaxed = true)
meshLogPrefs = mockk(relaxed = true)
useCase = SetMeshLogSettingsUseCase(meshLogRepository, meshLogPrefs)
}
@Test
fun `setRetentionDays clamps and updates prefs and repository`() = runTest {
// Act
useCase.setRetentionDays(MeshLogPrefs.MIN_RETENTION_DAYS - 1)
// Assert
verify { meshLogPrefs.retentionDays = MeshLogPrefs.MIN_RETENTION_DAYS }
coVerify { meshLogRepository.deleteLogsOlderThan(MeshLogPrefs.MIN_RETENTION_DAYS) }
}
@Test
fun `setLoggingEnabled true triggers cleanup`() = runTest {
// Arrange
every { meshLogPrefs.retentionDays } returns 30
// Act
useCase.setLoggingEnabled(true)
// Assert
verify { meshLogPrefs.loggingEnabled = true }
coVerify { meshLogRepository.deleteLogsOlderThan(30) }
}
@Test
fun `setLoggingEnabled false triggers deletion`() = runTest {
// Act
useCase.setLoggingEnabled(false)
// Assert
verify { meshLogPrefs.loggingEnabled = false }
coVerify { meshLogRepository.deleteAll() }
}
}

View file

@ -0,0 +1,44 @@
/*
* 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.settings
import io.mockk.mockk
import io.mockk.verify
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.prefs.ui.UiPrefs
class SetProvideLocationUseCaseTest {
private lateinit var uiPrefs: UiPrefs
private lateinit var useCase: SetProvideLocationUseCase
@Before
fun setUp() {
uiPrefs = mockk(relaxed = true)
useCase = SetProvideLocationUseCase(uiPrefs)
}
@Test
fun `invoke calls setShouldProvideNodeLocation on uiPrefs`() {
// Act
useCase(1234, true)
// Assert
verify { uiPrefs.setShouldProvideNodeLocation(1234, true) }
}
}

View file

@ -0,0 +1,44 @@
/*
* 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.settings
import io.mockk.mockk
import io.mockk.verify
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.datastore.UiPreferencesDataSource
class SetThemeUseCaseTest {
private lateinit var uiPreferencesDataSource: UiPreferencesDataSource
private lateinit var useCase: SetThemeUseCase
@Before
fun setUp() {
uiPreferencesDataSource = mockk(relaxed = true)
useCase = SetThemeUseCase(uiPreferencesDataSource)
}
@Test
fun `invoke calls setTheme on data source`() {
// Act
useCase(1)
// Assert
verify { uiPreferencesDataSource.setTheme(1) }
}
}

View file

@ -0,0 +1,60 @@
/*
* 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.settings
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
class ToggleAnalyticsUseCaseTest {
private lateinit var analyticsPrefs: AnalyticsPrefs
private lateinit var useCase: ToggleAnalyticsUseCase
@Before
fun setUp() {
analyticsPrefs = mockk(relaxed = true)
useCase = ToggleAnalyticsUseCase(analyticsPrefs)
}
@Test
fun `invoke toggles analytics from false to true`() {
// Arrange
every { analyticsPrefs.analyticsAllowed } returns false
// Act
useCase()
// Assert
verify { analyticsPrefs.analyticsAllowed = true }
}
@Test
fun `invoke toggles analytics from true to false`() {
// Arrange
every { analyticsPrefs.analyticsAllowed } returns true
// Act
useCase()
// Assert
verify { analyticsPrefs.analyticsAllowed = false }
}
}

View file

@ -0,0 +1,60 @@
/*
* 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.settings
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
class ToggleHomoglyphEncodingUseCaseTest {
private lateinit var homoglyphEncodingPrefs: HomoglyphPrefs
private lateinit var useCase: ToggleHomoglyphEncodingUseCase
@Before
fun setUp() {
homoglyphEncodingPrefs = mockk(relaxed = true)
useCase = ToggleHomoglyphEncodingUseCase(homoglyphEncodingPrefs)
}
@Test
fun `invoke toggles homoglyph encoding from false to true`() {
// Arrange
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
// Act
useCase()
// Assert
verify { homoglyphEncodingPrefs.homoglyphEncodingEnabled = true }
}
@Test
fun `invoke toggles homoglyph encoding from true to false`() {
// Arrange
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns true
// Act
useCase()
// Assert
verify { homoglyphEncodingPrefs.homoglyphEncodingEnabled = false }
}
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* 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
@ -14,8 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.service
package org.meshtastic.core.model
sealed class ConnectionState {
/** We are disconnected from the device, and we should be trying to reconnect. */

View file

@ -0,0 +1,90 @@
/*
* 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.model
import kotlinx.coroutines.flow.StateFlow
import org.meshtastic.proto.ClientNotification
@Suppress("TooManyFunctions")
interface RadioController {
val connectionState: StateFlow<ConnectionState>
val clientNotification: StateFlow<ClientNotification?>
suspend fun sendMessage(packet: DataPacket)
fun clearClientNotification()
// Abstracted ServiceActions
suspend fun favoriteNode(nodeNum: Int)
suspend fun sendSharedContact(nodeNum: Int)
// Radio configuration
suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int)
suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int)
suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int)
suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int)
suspend fun setFixedPosition(destNum: Int, position: Position)
suspend fun setRingtone(destNum: Int, ringtone: String)
suspend fun setCannedMessages(destNum: Int, messages: String)
// Admin get operations
suspend fun getOwner(destNum: Int, packetId: Int)
suspend fun getConfig(destNum: Int, configType: Int, packetId: Int)
suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int)
suspend fun getChannel(destNum: Int, index: Int, packetId: Int)
suspend fun getRingtone(destNum: Int, packetId: Int)
suspend fun getCannedMessages(destNum: Int, packetId: Int)
suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int)
// Admin operations
suspend fun reboot(destNum: Int, packetId: Int)
suspend fun shutdown(destNum: Int, packetId: Int)
suspend fun factoryReset(destNum: Int, packetId: Int)
suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean)
suspend fun removeByNodenum(packetId: Int, nodeNum: Int)
// Batch editing
suspend fun beginEditSettings(destNum: Int)
suspend fun commitEditSettings(destNum: Int)
// Helpers
fun getPacketId(): Int
/** Starts providing the phone's location to the mesh. */
fun startProvideLocation()
/** Stops providing the phone's location to the mesh. */
fun stopProvideLocation()
}

View file

@ -16,7 +16,10 @@
*/
import com.android.build.api.dsl.LibraryExtension
plugins { alias(libs.plugins.meshtastic.android.library) }
plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.hilt)
}
configure<LibraryExtension> {
buildFeatures { aidl = true }
@ -28,6 +31,7 @@ configure<LibraryExtension> {
dependencies {
api(projects.core.api)
implementation(projects.core.common)
implementation(projects.core.data)
implementation(projects.core.database)
implementation(projects.core.model)
implementation(projects.core.prefs)
@ -39,4 +43,5 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk)
testImplementation(libs.turbine)
}

View file

@ -0,0 +1,161 @@
/*
* 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.data.repository.NodeRepository
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.RadioController
import org.meshtastic.proto.ClientNotification
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
@Suppress("TooManyFunctions")
class AndroidRadioControllerImpl
@Inject
constructor(
private val serviceRepository: ServiceRepository,
private val nodeRepository: NodeRepository,
) : RadioController {
override val connectionState: StateFlow<ConnectionState>
get() = serviceRepository.connectionState
override val clientNotification: StateFlow<ClientNotification?>
get() = serviceRepository.clientNotification
override suspend fun sendMessage(packet: DataPacket) {
// Bridging to the existing flow via IMeshService
serviceRepository.meshService?.send(packet)
}
override fun clearClientNotification() {
serviceRepository.clearClientNotification()
}
override suspend fun favoriteNode(nodeNum: Int) {
val nodeDef = nodeRepository.getNode(nodeNum.toString())
serviceRepository.onServiceAction(ServiceAction.Favorite(nodeDef))
}
override suspend fun sendSharedContact(nodeNum: Int) {
val nodeDef = nodeRepository.getNode(nodeNum.toString())
val contact =
org.meshtastic.proto.SharedContact(
node_num = nodeDef.num,
user = nodeDef.user,
manually_verified = nodeDef.manuallyVerified,
)
serviceRepository.onServiceAction(ServiceAction.SendContact(contact))
}
override suspend fun setOwner(destNum: Int, user: org.meshtastic.proto.User, packetId: Int) {
serviceRepository.meshService?.setRemoteOwner(packetId, destNum, user.encode())
}
override suspend fun setConfig(destNum: Int, config: org.meshtastic.proto.Config, packetId: Int) {
serviceRepository.meshService?.setRemoteConfig(packetId, destNum, config.encode())
}
override suspend fun setModuleConfig(destNum: Int, config: org.meshtastic.proto.ModuleConfig, packetId: Int) {
serviceRepository.meshService?.setModuleConfig(packetId, destNum, config.encode())
}
override suspend fun setRemoteChannel(destNum: Int, channel: org.meshtastic.proto.Channel, packetId: Int) {
serviceRepository.meshService?.setRemoteChannel(packetId, destNum, channel.encode())
}
override suspend fun setFixedPosition(destNum: Int, position: org.meshtastic.core.model.Position) {
serviceRepository.meshService?.setFixedPosition(destNum, position)
}
override suspend fun setRingtone(destNum: Int, ringtone: String) {
serviceRepository.meshService?.setRingtone(destNum, ringtone)
}
override suspend fun setCannedMessages(destNum: Int, messages: String) {
serviceRepository.meshService?.setCannedMessages(destNum, messages)
}
override suspend fun getOwner(destNum: Int, packetId: Int) {
serviceRepository.meshService?.getRemoteOwner(packetId, destNum)
}
override suspend fun getConfig(destNum: Int, configType: Int, packetId: Int) {
serviceRepository.meshService?.getRemoteConfig(packetId, destNum, configType)
}
override suspend fun getModuleConfig(destNum: Int, moduleConfigType: Int, packetId: Int) {
serviceRepository.meshService?.getModuleConfig(packetId, destNum, moduleConfigType)
}
override suspend fun getChannel(destNum: Int, index: Int, packetId: Int) {
serviceRepository.meshService?.getRemoteChannel(packetId, destNum, index)
}
override suspend fun getRingtone(destNum: Int, packetId: Int) {
serviceRepository.meshService?.getRingtone(packetId, destNum)
}
override suspend fun getCannedMessages(destNum: Int, packetId: Int) {
serviceRepository.meshService?.getCannedMessages(packetId, destNum)
}
override suspend fun getDeviceConnectionStatus(destNum: Int, packetId: Int) {
serviceRepository.meshService?.getDeviceConnectionStatus(packetId, destNum)
}
override suspend fun reboot(destNum: Int, packetId: Int) {
serviceRepository.meshService?.requestReboot(packetId, destNum)
}
override suspend fun shutdown(destNum: Int, packetId: Int) {
serviceRepository.meshService?.requestShutdown(packetId, destNum)
}
override suspend fun factoryReset(destNum: Int, packetId: Int) {
serviceRepository.meshService?.requestFactoryReset(packetId, destNum)
}
override suspend fun nodedbReset(destNum: Int, packetId: Int, preserveFavorites: Boolean) {
serviceRepository.meshService?.requestNodedbReset(packetId, destNum, preserveFavorites)
}
override suspend fun removeByNodenum(packetId: Int, nodeNum: Int) {
serviceRepository.meshService?.removeByNodenum(packetId, nodeNum)
}
override suspend fun beginEditSettings(destNum: Int) {
serviceRepository.meshService?.beginEditSettings(destNum)
}
override suspend fun commitEditSettings(destNum: Int) {
serviceRepository.meshService?.commitEditSettings(destNum)
}
override fun getPacketId(): Int = serviceRepository.meshService?.getPacketId() ?: 0
override fun startProvideLocation() {
serviceRepository.meshService?.startProvideLocation()
}
override fun stopProvideLocation() {
serviceRepository.meshService?.stopProvideLocation()
}
}

View file

@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.MeshPacket
import javax.inject.Inject
@ -44,7 +45,7 @@ data class TracerouteResponse(
/** Repository class for managing the [IMeshService] instance and connection state */
@Suppress("TooManyFunctions")
@Singleton
class ServiceRepository @Inject constructor() {
open class ServiceRepository @Inject constructor() {
var meshService: IMeshService? = null
private set
@ -54,7 +55,7 @@ class ServiceRepository @Inject constructor() {
// Connection state to our radio device
private val _connectionState: MutableStateFlow<ConnectionState> = MutableStateFlow(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState>
open val connectionState: StateFlow<ConnectionState>
get() = _connectionState
fun setConnectionState(connectionState: ConnectionState) {

View file

@ -0,0 +1,31 @@
/*
* 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.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.service.AndroidRadioControllerImpl
@Module
@InstallIn(SingletonComponent::class)
abstract class ServiceModule {
@Binds abstract fun bindRadioController(impl: AndroidRadioControllerImpl): RadioController
}