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

@ -198,7 +198,7 @@
<intent-filter android:autoVerify="true">
<!--
The QR codes to share channel settings and contacts are shared as meshtastic URLS.
We also support NFC NDEF Discovery for the same URLs.
We also support NFC NDEF Discovery for the same URLS.
An approximate example:
https://meshtastic.org/e/YXNkZnF3ZXJhc2RmcXdlcmFzZGZxd2Vy
@ -252,9 +252,9 @@
android:path="com.geeksville.mesh" /> -->
</intent-filter>
</receiver>
<receiver android:name="com.geeksville.mesh.service.ReplyReceiver"/>
<receiver android:name="com.geeksville.mesh.service.MarkAsReadReceiver"/>
<receiver android:name="com.geeksville.mesh.service.ReactionReceiver"/>
<receiver android:name="com.geeksville.mesh.service.ReplyReceiver" android:exported="false" />
<receiver android:name="com.geeksville.mesh.service.MarkAsReadReceiver" android:exported="false" />
<receiver android:name="com.geeksville.mesh.service.ReactionReceiver" android:exported="false" />
<receiver
android:name="com.geeksville.mesh.widget.LocalStatsWidgetReceiver"
@ -277,6 +277,17 @@
</intent-filter>
</activity>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
</application>
</manifest>

View file

@ -208,6 +208,10 @@ constructor(
onDisconnected(state)
}
}
.catch { e ->
Logger.w(e) { "[$address] bleConnection.connectionState flow crashed!" }
service.onDisconnect(BleError.from(e))
}
.launchIn(connectionScope)
val p = retryBleOperation(tag = address) { findPeripheral() }

View file

@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@ -47,9 +48,9 @@ import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.toRemoteExceptions
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.di.ProcessLifecycle
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.prefs.radio.RadioPrefs
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.ToRadio
import javax.inject.Inject
@ -127,6 +128,7 @@ constructor(
stopInterface()
}
}
.catch { Logger.e(it) { "bluetoothRepository.state flow crashed!" } }
.launchIn(processLifecycle.coroutineScope)
networkRepository.networkAvailable
@ -137,6 +139,7 @@ constructor(
stopInterface()
}
}
.catch { Logger.e(it) { "networkRepository.networkAvailable flow crashed!" } }
.launchIn(processLifecycle.coroutineScope)
}
}

View file

@ -18,7 +18,7 @@ package com.geeksville.mesh.service
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.model.ConnectionState
import javax.inject.Inject
import javax.inject.Singleton

View file

@ -30,12 +30,12 @@ import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.isWithinSizeLimit
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Constants
@ -47,7 +47,6 @@ import org.meshtastic.proto.NeighborInfo
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
@ -68,7 +67,6 @@ constructor(
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val currentPacketId = AtomicLong(java.util.Random(nowMillis).nextLong().absoluteValue)
private val sessionPasskey = AtomicReference(ByteString.EMPTY)
private val offlineSentPackets = CopyOnWriteArrayList<DataPacket>()
val tracerouteStartTimes = ConcurrentHashMap<Int, Long>()
val neighborInfoStartTimes = ConcurrentHashMap<Int, Long>()
@ -77,17 +75,6 @@ constructor(
@Volatile var lastNeighborInfo: NeighborInfo? = null
private val rememberDataType =
setOf(
PortNum.TEXT_MESSAGE_APP.value,
PortNum.ALERT_APP.value,
PortNum.WAYPOINT_APP.value,
PortNum.ATAK_PLUGIN.value,
PortNum.ATAK_FORWARDER.value,
PortNum.DETECTION_SENSOR_APP.value,
PortNum.PRIVATE_APP.value,
)
fun start(scope: CoroutineScope) {
this.scope = scope
radioConfigRepository?.localConfigFlow?.onEach { localConfig.value = it }?.launchIn(scope)
@ -154,14 +141,9 @@ constructor(
}
if (connectionStateHolder?.connectionState?.value == ConnectionState.Connected) {
try {
sendNow(p)
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
Logger.e(ex) { "Error sending message, so enqueueing" }
enqueueForSending(p)
}
sendNow(p)
} else {
enqueueForSending(p)
error("Radio is not connected")
}
}
@ -185,25 +167,6 @@ constructor(
packetHandler?.sendToRadio(meshPacket)
}
private fun enqueueForSending(p: DataPacket) {
if (p.dataType in rememberDataType) {
offlineSentPackets.add(p)
}
}
fun processQueuedPackets() {
val sentPackets = mutableListOf<DataPacket>()
offlineSentPackets.forEach { p ->
try {
sendNow(p)
sentPackets.add(p)
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
Logger.e(ex) { "Error sending queued message:" }
}
}
offlineSentPackets.removeAll(sentPackets)
}
fun sendAdmin(
destNum: Int,
requestId: Int = generatePacketId(),

View file

@ -27,7 +27,7 @@ import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Heartbeat

View file

@ -19,6 +19,10 @@ package com.geeksville.mesh.service
import android.app.Notification
import android.content.Context
import androidx.glance.appwidget.updateAll
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.workDataOf
import co.touchlab.kermit.Logger
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.widget.LocalStatsWidget
@ -40,7 +44,9 @@ import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.resources.Res
@ -50,8 +56,8 @@ import org.meshtastic.core.resources.device_sleeping
import org.meshtastic.core.resources.disconnected
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.meshtastic_app_name
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.feature.messaging.domain.worker.SendMessageWorker
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Config
import org.meshtastic.proto.Telemetry
@ -82,6 +88,8 @@ constructor(
private val commandSender: MeshCommandSender,
private val nodeManager: MeshNodeManager,
private val analytics: PlatformAnalytics,
private val packetRepository: PacketRepository,
private val workManager: WorkManager,
) {
private var scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var sleepTimeout: Job? = null
@ -255,7 +263,25 @@ constructor(
}
fun onRadioConfigLoaded() {
commandSender.processQueuedPackets()
scope.handledLaunch {
val queuedPackets = packetRepository.getQueuedPackets() ?: emptyList()
queuedPackets.forEach { packet ->
try {
val workRequest =
OneTimeWorkRequestBuilder<SendMessageWorker>()
.setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packet.id))
.build()
workManager.enqueueUniqueWork(
"${SendMessageWorker.WORK_NAME_PREFIX}${packet.id}",
ExistingWorkPolicy.REPLACE,
workRequest,
)
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
Logger.e(e) { "Failed to enqueue queued packet worker" }
}
}
}
val myNodeNum = nodeManager.myNodeNum ?: 0
// Set time

View file

@ -60,7 +60,7 @@ constructor(
private val logInsertJobByPacketId = ConcurrentHashMap<Int, Job>()
private val earlyReceivedPackets = ArrayDeque<MeshPacket>()
private val maxEarlyPacketBuffer = 128
private val maxEarlyPacketBuffer = 10240
fun clearEarlyPackets() {
synchronized(earlyReceivedPackets) { earlyReceivedPackets.clear() }

View file

@ -78,7 +78,7 @@ constructor(
fun loadCachedNodeDB() {
scope.handledLaunch {
val nodes = nodeRepository?.getNodeDBbyNum()?.first() ?: emptyMap()
val nodes = nodeRepository?.getNodeEntityDBbyNumFlow()?.first() ?: emptyMap()
nodeDBbyNodeNum.putAll(nodes)
nodes.values.forEach { nodeDBbyID[it.user.id] = it }
myNodeNum = nodeRepository?.myNodeInfo?.value?.myNodeNum

View file

@ -21,11 +21,11 @@ import android.content.Intent
import android.os.Parcelable
import co.touchlab.kermit.Logger
import dagger.hilt.android.qualifiers.ApplicationContext
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.NodeInfo
import org.meshtastic.core.model.util.toPIIString
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.ServiceRepository
import java.util.Locale
import javax.inject.Inject

View file

@ -309,7 +309,7 @@ constructor(
if (myNodeNum != null) {
// We use runBlocking here because this is called from MeshConnectionManager's synchronous methods,
// and we only do this once if the cache is empty.
val nodes = runBlocking { repo.getNodeDBbyNum().first() }
val nodes = runBlocking { repo.getNodeEntityDBbyNumFlow().first() }
nodes[myNodeNum]?.let { entity ->
if (cachedDeviceMetrics == null) {
cachedDeviceMetrics = entity.deviceTelemetry.device_metrics

View file

@ -32,11 +32,11 @@ import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.util.toOneLineString
import org.meshtastic.core.model.util.toPIIString
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.QueueStatus

View file

@ -96,6 +96,7 @@ import no.nordicsemi.android.common.permissions.notification.RequestNotification
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.navigation.ConnectionsRoutes
import org.meshtastic.core.navigation.ContactsRoutes
@ -123,7 +124,6 @@ import org.meshtastic.core.resources.should_update
import org.meshtastic.core.resources.should_update_firmware
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.resources.view_on_map
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.icon.Conversations

View file

@ -59,6 +59,7 @@ import com.geeksville.mesh.ui.connections.components.UsbDevices
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.resources.Res
@ -71,7 +72,6 @@ import org.meshtastic.core.resources.must_set_region
import org.meshtastic.core.resources.no_device_selected
import org.meshtastic.core.resources.not_connected
import org.meshtastic.core.resources.set_your_region
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.TitledCard

View file

@ -37,9 +37,9 @@ import no.nordicsemi.android.common.scanner.view.ScannerView
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.ble.MeshtasticBleConstants.BLE_NAME_PATTERN
import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.bluetooth_available_devices
import org.meshtastic.core.service.ConnectionState
/**
* Composable that displays a list of Bluetooth Low Energy (BLE) devices and allows scanning. It handles Bluetooth

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,7 +14,6 @@
* 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 com.geeksville.mesh.ui.connections.components
import androidx.compose.animation.Crossfade
@ -39,7 +38,7 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.geeksville.mesh.ui.connections.DeviceType
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.ui.icon.Device
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.NoDevice

View file

@ -56,12 +56,12 @@ import com.geeksville.mesh.model.DeviceListEntry
import kotlinx.coroutines.delay
import no.nordicsemi.android.common.ui.view.RssiIcon
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add
import org.meshtastic.core.resources.bluetooth
import org.meshtastic.core.resources.network
import org.meshtastic.core.resources.serial
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.ui.component.NodeChip
private const val RSSI_UPDATE_RATE_MS = 2000L

View file

@ -28,7 +28,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.model.DeviceListEntry
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.model.ConnectionState
@Composable
fun List<DeviceListEntry>.DeviceListSection(

View file

@ -53,6 +53,7 @@ import com.geeksville.mesh.ui.connections.ScannerViewModel
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.isValidAddress
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_network_device
import org.meshtastic.core.resources.address
@ -63,7 +64,6 @@ import org.meshtastic.core.resources.forget_connection
import org.meshtastic.core.resources.ip_port
import org.meshtastic.core.resources.no_network_devices
import org.meshtastic.core.resources.recent_network_devices
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.ui.component.MeshtasticResourceDialog
import org.meshtastic.core.ui.theme.AppTheme

View file

@ -27,9 +27,9 @@ import androidx.compose.ui.unit.dp
import com.geeksville.mesh.model.DeviceListEntry
import com.geeksville.mesh.ui.connections.ScannerViewModel
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.no_usb_devices
import org.meshtastic.core.service.ConnectionState
@Composable
fun UsbDevices(

View file

@ -71,6 +71,7 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.toPlatformUri
import org.meshtastic.core.model.Channel
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.util.getChannelUrl
import org.meshtastic.core.model.util.qrCode
import org.meshtastic.core.navigation.Route
@ -88,7 +89,6 @@ import org.meshtastic.core.resources.replace
import org.meshtastic.core.resources.reset
import org.meshtastic.core.resources.reset_to_defaults
import org.meshtastic.core.resources.share_channels_qr
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.ui.component.AdaptiveTwoPane
import org.meshtastic.core.ui.component.ChannelSelection
import org.meshtastic.core.ui.component.MainAppBar

View file

@ -69,6 +69,7 @@ import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.air_utilization
@ -92,7 +93,6 @@ import org.meshtastic.core.resources.powered
import org.meshtastic.core.resources.refresh
import org.meshtastic.core.resources.updated
import org.meshtastic.core.resources.uptime
import org.meshtastic.core.service.ConnectionState
class LocalStatsWidget : GlanceAppWidget() {

View file

@ -30,8 +30,8 @@ import kotlinx.coroutines.flow.stateIn
import org.meshtastic.core.common.util.nowMillis
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.util.onlineTimeThreshold
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.LocalStats
import javax.inject.Inject

View file

@ -30,8 +30,8 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.MeshPacket

View file

@ -1,122 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.service
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import okio.ByteString
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.proto.PortNum
class MeshCommandSenderQueueTest {
private val packetHandler = mockk<PacketHandler>(relaxed = true)
private val connectionStateHandler = mockk<ConnectionStateHandler>(relaxed = true)
private val connectionStateFlow = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
private lateinit var commandSender: MeshCommandSender
@Before
fun setUp() {
every { connectionStateHandler.connectionState } returns connectionStateFlow.asStateFlow()
commandSender = MeshCommandSender(packetHandler, null, connectionStateHandler, null)
}
@Test
fun `sendData queues TEXT_MESSAGE_APP when disconnected`() {
val packet = DataPacket(dataType = PortNum.TEXT_MESSAGE_APP.value, bytes = ByteString.EMPTY)
commandSender.sendData(packet)
verify(exactly = 0) { packetHandler.sendToRadio(any<org.meshtastic.proto.MeshPacket>()) }
connectionStateFlow.value = ConnectionState.Connected
commandSender.processQueuedPackets()
verify(exactly = 1) { packetHandler.sendToRadio(any<org.meshtastic.proto.MeshPacket>()) }
}
@Test
fun `sendData queues ATAK_PLUGIN when disconnected`() {
val packet = DataPacket(dataType = PortNum.ATAK_PLUGIN.value, bytes = ByteString.EMPTY)
commandSender.sendData(packet)
verify(exactly = 0) { packetHandler.sendToRadio(any<org.meshtastic.proto.MeshPacket>()) }
connectionStateFlow.value = ConnectionState.Connected
commandSender.processQueuedPackets()
verify(exactly = 1) { packetHandler.sendToRadio(any<org.meshtastic.proto.MeshPacket>()) }
}
@Test
fun `sendData queues ATAK_FORWARDER when disconnected`() {
val packet = DataPacket(dataType = PortNum.ATAK_FORWARDER.value, bytes = ByteString.EMPTY)
commandSender.sendData(packet)
verify(exactly = 0) { packetHandler.sendToRadio(any<org.meshtastic.proto.MeshPacket>()) }
connectionStateFlow.value = ConnectionState.Connected
commandSender.processQueuedPackets()
verify(exactly = 1) { packetHandler.sendToRadio(any<org.meshtastic.proto.MeshPacket>()) }
}
@Test
fun `sendData queues DETECTION_SENSOR_APP when disconnected`() {
val packet = DataPacket(dataType = PortNum.DETECTION_SENSOR_APP.value, bytes = ByteString.EMPTY)
commandSender.sendData(packet)
verify(exactly = 0) { packetHandler.sendToRadio(any<org.meshtastic.proto.MeshPacket>()) }
connectionStateFlow.value = ConnectionState.Connected
commandSender.processQueuedPackets()
verify(exactly = 1) { packetHandler.sendToRadio(any<org.meshtastic.proto.MeshPacket>()) }
}
@Test
fun `sendData queues PRIVATE_APP when disconnected`() {
val packet = DataPacket(dataType = PortNum.PRIVATE_APP.value, bytes = ByteString.EMPTY)
commandSender.sendData(packet)
verify(exactly = 0) { packetHandler.sendToRadio(any<org.meshtastic.proto.MeshPacket>()) }
connectionStateFlow.value = ConnectionState.Connected
commandSender.processQueuedPackets()
verify(exactly = 1) { packetHandler.sendToRadio(any<org.meshtastic.proto.MeshPacket>()) }
}
@Test
fun `sendData does NOT queue IP_TUNNEL_APP when disconnected`() {
val packet = DataPacket(dataType = PortNum.IP_TUNNEL_APP.value, bytes = ByteString.EMPTY)
commandSender.sendData(packet)
verify(exactly = 0) { packetHandler.sendToRadio(any<org.meshtastic.proto.MeshPacket>()) }
connectionStateFlow.value = ConnectionState.Connected
commandSender.processQueuedPackets()
verify(exactly = 0) { packetHandler.sendToRadio(any<org.meshtastic.proto.MeshPacket>()) }
}
}

View file

@ -19,6 +19,9 @@ package com.geeksville.mesh.service
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
import androidx.glance.appwidget.updateAll
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import io.mockk.coEvery
import io.mockk.every
@ -37,12 +40,15 @@ import org.junit.Before
import org.junit.Test
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.feature.messaging.domain.worker.SendMessageWorker
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
@ -67,6 +73,8 @@ class MeshConnectionManagerTest {
private val commandSender: MeshCommandSender = mockk(relaxed = true)
private val nodeManager: MeshNodeManager = mockk(relaxed = true)
private val analytics: PlatformAnalytics = mockk(relaxed = true)
private val packetRepository: PacketRepository = mockk(relaxed = true)
private val workManager: WorkManager = mockk(relaxed = true)
private val radioConnectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
private val localConfigFlow = MutableStateFlow(LocalConfig())
private val moduleConfigFlow = MutableStateFlow(LocalModuleConfig())
@ -107,6 +115,8 @@ class MeshConnectionManagerTest {
commandSender,
nodeManager,
analytics,
packetRepository,
workManager,
)
}
@ -194,10 +204,23 @@ class MeshConnectionManagerTest {
}
@Test
fun `onRadioConfigLoaded processes queued packets and sets time`() = runTest(testDispatcher) {
manager.onRadioConfigLoaded()
fun `onRadioConfigLoaded enqueues queued packets and sets time`() = runTest(testDispatcher) {
manager.start(backgroundScope)
val packetId = 456
val dataPacket = mockk<DataPacket>(relaxed = true)
every { dataPacket.id } returns packetId
coEvery { packetRepository.getQueuedPackets() } returns listOf(dataPacket)
verify { commandSender.processQueuedPackets() }
manager.onRadioConfigLoaded()
advanceUntilIdle()
verify {
workManager.enqueueUniqueWork(
match<String> { it.startsWith(SendMessageWorker.WORK_NAME_PREFIX) },
any<ExistingWorkPolicy>(),
any<OneTimeWorkRequest>(),
)
}
verify { commandSender.sendAdmin(any(), initFn = any()) }
}

View file

@ -24,7 +24,7 @@ import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.service.ServiceRepository
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf

View file

@ -30,7 +30,7 @@ import org.junit.Test
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2026 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.messaging
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
@ -24,7 +24,7 @@ package org.meshtastic.feature.messaging
* reduces the binary size of the transmitted message. The average transmitted message volume can then fit around
* ~140-145 characters instead of ~115-120
*/
internal object HomoglyphCharacterStringTransformer {
object HomoglyphCharacterStringTransformer {
/**
* Unicode characters from the basic cyrillic block (U+0400-U+04FF), each of which occupies 2 bytes

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

@ -14,28 +14,39 @@
* 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.feature.messaging.domain.usecase
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.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.feature.messaging.HomoglyphCharacterStringTransformer
import org.meshtastic.proto.Config
import org.meshtastic.proto.SharedContact
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 serviceRepository: ServiceRepository,
private val packetRepository: PacketRepository,
private val radioController: RadioController,
private val homoglyphEncodingPrefs: HomoglyphPrefs,
private val messageQueue: MessageQueue,
) {
@Suppress("NestedBlockDepth", "LongMethod", "CyclomaticComplexMethod")
@ -74,18 +85,45 @@ constructor(
text
}
val packet = DataPacket(dest, channel ?: 0, finalMessageText, replyId).apply { from = fromId }
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 {
serviceRepository.meshService?.send(packet)
// 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 send data packet" }
Logger.e(ex) { "Failed to enqueue message packet" }
}
}
private suspend fun favoriteNode(node: Node) {
try {
serviceRepository.onServiceAction(ServiceAction.Favorite(node))
radioController.favoriteNode(node.num)
} catch (ex: Exception) {
Logger.e(ex) { "Favorite node error" }
}
@ -93,9 +131,7 @@ constructor(
private suspend fun sendSharedContact(node: Node) {
try {
val contact =
SharedContact(node_num = node.num, user = node.user, manually_verified = node.manuallyVerified)
serviceRepository.onServiceAction(ServiceAction.SendContact(contact = contact))
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

@ -14,49 +14,67 @@
* 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.feature.messaging.domain.usecase
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.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetadata
class SendMessageUseCaseTest {
private lateinit var nodeRepository: NodeRepository
private lateinit var serviceRepository: ServiceRepository
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)
serviceRepository = mockk(relaxed = true)
packetRepository = mockk(relaxed = true)
radioController = FakeRadioController()
homoglyphEncodingPrefs = mockk(relaxed = true)
messageQueue = mockk(relaxed = true)
useCase =
SendMessageUseCase(
nodeRepository = nodeRepository,
serviceRepository = serviceRepository,
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
@ -69,8 +87,11 @@ class SendMessageUseCaseTest {
useCase("Hello broadcast", "0${DataPacket.ID_BROADCAST}", null)
// Assert
coVerify(exactly = 0) { serviceRepository.onServiceAction(any()) }
coVerify(exactly = 1) { serviceRepository.meshService?.send(any()) }
assertEquals(0, radioController.favoritedNodes.size)
assertEquals(0, radioController.sentSharedContacts.size)
coVerify { packetRepository.insert(any<Packet>()) }
coVerify { messageQueue.enqueue(any()) }
}
@Test
@ -86,18 +107,21 @@ class SendMessageUseCaseTest {
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
coVerify(exactly = 1) { serviceRepository.onServiceAction(match { it is ServiceAction.Favorite }) }
coVerify(exactly = 1) { serviceRepository.meshService?.send(any()) }
assertEquals(1, radioController.favoritedNodes.size)
assertEquals(12345, radioController.favoritedNodes[0])
coVerify { packetRepository.insert(any<Packet>()) }
coVerify { messageQueue.enqueue(any()) }
}
@Test
@ -112,18 +136,21 @@ class SendMessageUseCaseTest {
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
coVerify(exactly = 1) { serviceRepository.onServiceAction(match { it is ServiceAction.SendContact }) }
coVerify(exactly = 1) { serviceRepository.meshService?.send(any()) }
assertEquals(1, radioController.sentSharedContacts.size)
assertEquals(67890, radioController.sentSharedContacts[0])
coVerify { packetRepository.insert(any<Packet>()) }
coVerify { messageQueue.enqueue(any()) }
}
@Test
@ -133,14 +160,15 @@ class SendMessageUseCaseTest {
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns true
// Let's use a cyrillic character 'A' (U+0410) that will be mapped to Latin 'A'
val originalText = "\u0410pple"
val originalText = "\u0410pple" // Cyrillic A
// Act
useCase(originalText, "0${DataPacket.ID_BROADCAST}", null)
// Assert
// We verify that send was called with the transformed text (Latin 'A'pple)
coVerify(exactly = 1) { serviceRepository.meshService?.send(match { it.text?.contains("Apple") == true }) }
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
}

View file

@ -44,6 +44,7 @@ import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.FirmwareReleaseType
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.datastore.BootloaderWarningDataSource
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.prefs.radio.RadioPrefs
import org.meshtastic.core.prefs.radio.isBle
@ -71,7 +72,6 @@ import org.meshtastic.core.resources.firmware_update_unknown_hardware
import org.meshtastic.core.resources.firmware_update_updating
import org.meshtastic.core.resources.firmware_update_validating
import org.meshtastic.core.resources.unknown
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.ServiceRepository
import java.io.File
import javax.inject.Inject

View file

@ -36,7 +36,7 @@ import com.google.accompanist.permissions.rememberPermissionState
*/
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun AppIntroductionScreen(onDone: () -> Unit, @Suppress("unused") viewModel: IntroViewModel = hiltViewModel()) {
fun AppIntroductionScreen(onDone: () -> Unit, viewModel: IntroViewModel = hiltViewModel()) {
val notificationPermissionState: PermissionState? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)

View file

@ -44,9 +44,9 @@ import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.prefs.map.GoogleMapsPrefs
import org.meshtastic.core.prefs.map.MapPrefs
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.ServiceRepository
import org.robolectric.RobolectricTestRunner

View file

@ -26,8 +26,10 @@ configure<LibraryExtension> { namespace = "org.meshtastic.feature.messaging" }
dependencies {
implementation(projects.core.analytics)
implementation(projects.core.common)
implementation(projects.core.data)
implementation(projects.core.database)
implementation(projects.core.domain)
implementation(projects.core.model)
implementation(projects.core.navigation)
implementation(projects.core.prefs)
@ -50,6 +52,9 @@ dependencies {
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.paging.compose)
implementation(libs.kermit)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.hilt.work)
ksp(libs.androidx.hilt.compiler)
debugImplementation(libs.androidx.compose.ui.test.manifest)
@ -59,4 +64,8 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.turbine)
testImplementation(libs.androidx.work.testing)
testImplementation(libs.androidx.test.core)
testImplementation(libs.robolectric)
}

View file

@ -100,6 +100,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.HomoglyphCharacterStringTransformer
import org.meshtastic.core.database.entity.QuickChatAction
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node

View file

@ -39,6 +39,7 @@ import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.domain.usecase.SendMessageUseCase
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.prefs.emoji.CustomEmojiPrefs
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
@ -47,7 +48,6 @@ import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.messaging.domain.usecase.SendMessageUseCase
import org.meshtastic.proto.ChannelSet
import javax.inject.Inject

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.feature.messaging.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.domain.MessageQueue
import org.meshtastic.feature.messaging.domain.worker.WorkManagerMessageQueue
@Module
@InstallIn(SingletonComponent::class)
abstract class MessagingModule {
@Binds abstract fun bindMessageQueue(impl: WorkManagerMessageQueue): MessageQueue
}

View file

@ -0,0 +1,70 @@
/*
* 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.feature.messaging.domain.worker
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.RadioController
@HiltWorker
class SendMessageWorker
@AssistedInject
constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val packetRepository: PacketRepository,
private val radioController: RadioController,
) : CoroutineWorker(context, params) {
@Suppress("TooGenericExceptionCaught", "SwallowedException", "ReturnCount")
override suspend fun doWork(): Result {
val packetId = inputData.getInt(KEY_PACKET_ID, 0)
if (packetId == 0) return Result.failure()
// Verify we are connected before attempting to send to avoid unnecessary Exception bubbling
if (radioController.connectionState.value != ConnectionState.Connected) {
return Result.retry()
}
val packetEntity =
packetRepository.getPacketByPacketId(packetId)
?: return Result.failure() // Packet no longer exists in DB? Do not retry.
val packetData = packetEntity.packet.data
return try {
radioController.sendMessage(packetData)
packetRepository.updateMessageStatus(packetData, MessageStatus.ENROUTE)
Result.success()
} catch (e: Exception) {
packetRepository.updateMessageStatus(packetData, MessageStatus.ERROR)
Result.retry()
}
}
companion object {
const val KEY_PACKET_ID = "packet_id"
const val WORK_NAME_PREFIX = "send_message_"
}
}

View file

@ -0,0 +1,43 @@
/*
* 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.feature.messaging.domain.worker
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.workDataOf
import org.meshtastic.core.domain.MessageQueue
import javax.inject.Inject
import javax.inject.Singleton
/** Android implementation of [MessageQueue] that uses [WorkManager] for reliable background transmission. */
@Singleton
class WorkManagerMessageQueue @Inject constructor(private val workManager: WorkManager) : MessageQueue {
override suspend fun enqueue(packetId: Int) {
val workRequest =
OneTimeWorkRequestBuilder<SendMessageWorker>()
.setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packetId))
.build()
workManager.enqueueUniqueWork(
"${SendMessageWorker.WORK_NAME_PREFIX}$packetId",
ExistingWorkPolicy.REPLACE,
workRequest,
)
}
}

View file

@ -19,6 +19,7 @@ package org.meshtastic.feature.messaging
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.meshtastic.core.common.util.HomoglyphCharacterStringTransformer
class HomoglyphCharacterTransformTest {

View file

@ -0,0 +1,159 @@
/*
* 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.feature.messaging.domain.worker
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.work.ListenableWorker
import androidx.work.WorkerParameters
import androidx.work.testing.TestListenableWorkerBuilder
import androidx.work.workDataOf
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.entity.PacketEntity
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.RadioController
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class SendMessageWorkerTest {
private lateinit var context: Context
private lateinit var packetRepository: PacketRepository
private lateinit var radioController: RadioController
@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
packetRepository = mockk(relaxed = true)
radioController = mockk(relaxed = true)
every { radioController.connectionState } returns MutableStateFlow(ConnectionState.Connected)
}
@Test
fun `doWork returns success when packet is sent successfully`() = runTest {
// Arrange
val packetId = 12345
val dataPacket = DataPacket("dest", 0, "Hello")
val packet = mockk<Packet>(relaxed = true)
val packetEntity = PacketEntity(packet = packet)
every { packet.data } returns dataPacket
coEvery { packetRepository.getPacketByPacketId(packetId) } returns packetEntity
every { radioController.connectionState } returns MutableStateFlow(ConnectionState.Connected)
coEvery { radioController.sendMessage(any()) } just Runs
coEvery { packetRepository.updateMessageStatus(any(), any()) } just Runs
val worker =
TestListenableWorkerBuilder<SendMessageWorker>(context)
.setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packetId))
.setWorkerFactory(
object : androidx.work.WorkerFactory() {
override fun createWorker(
appContext: Context,
workerClassName: String,
workerParameters: WorkerParameters,
): ListenableWorker? =
SendMessageWorker(appContext, workerParameters, packetRepository, radioController)
},
)
.build()
// Act
val result = worker.doWork()
// Assert
assertEquals(ListenableWorker.Result.success(), result)
coVerify { radioController.sendMessage(dataPacket) }
coVerify { packetRepository.updateMessageStatus(dataPacket, MessageStatus.ENROUTE) }
}
@Test
fun `doWork returns retry when radio is disconnected`() = runTest {
// Arrange
val packetId = 12345
val dataPacket = DataPacket("dest", 0, "Hello")
val packet = mockk<Packet>(relaxed = true)
val packetEntity = PacketEntity(packet = packet)
every { packet.data } returns dataPacket
coEvery { packetRepository.getPacketByPacketId(packetId) } returns packetEntity
every { radioController.connectionState } returns MutableStateFlow(ConnectionState.Disconnected)
val worker =
TestListenableWorkerBuilder<SendMessageWorker>(context)
.setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packetId))
.setWorkerFactory(
object : androidx.work.WorkerFactory() {
override fun createWorker(
appContext: Context,
workerClassName: String,
workerParameters: WorkerParameters,
): ListenableWorker? =
SendMessageWorker(appContext, workerParameters, packetRepository, radioController)
},
)
.build()
// Act
val result = worker.doWork()
// Assert
assertEquals(ListenableWorker.Result.retry(), result)
coVerify(exactly = 0) { radioController.sendMessage(any()) }
}
@Test
fun `doWork returns failure when packet is missing`() = runTest {
// Arrange
val packetId = 999
coEvery { packetRepository.getPacketByPacketId(packetId) } returns null
val worker =
TestListenableWorkerBuilder<SendMessageWorker>(context)
.setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packetId))
.setWorkerFactory(
object : androidx.work.WorkerFactory() {
override fun createWorker(
appContext: Context,
workerClassName: String,
workerParameters: WorkerParameters,
): ListenableWorker? =
SendMessageWorker(appContext, workerParameters, packetRepository, radioController)
},
)
.build()
// Act
val result = worker.doWork()
// Assert
assertEquals(ListenableWorker.Result.failure(), result)
}
}

View file

@ -53,6 +53,7 @@ import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.isUnmessageableRole
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.resources.Res
@ -63,7 +64,6 @@ import org.meshtastic.core.resources.elevation_suffix
import org.meshtastic.core.resources.signal_quality
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.resources.voltage
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.ui.component.AirQualityInfo
import org.meshtastic.core.ui.component.ChannelInfo
import org.meshtastic.core.ui.component.DistanceInfo

View file

@ -37,6 +37,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting
@ -46,7 +47,6 @@ import org.meshtastic.core.resources.favorite
import org.meshtastic.core.resources.mute_always
import org.meshtastic.core.resources.unmessageable
import org.meshtastic.core.resources.unmonitored_or_infrastructure
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.ui.icon.CloudDone
import org.meshtastic.core.ui.icon.CloudOffTwoTone
import org.meshtastic.core.ui.icon.CloudSync

View file

@ -68,6 +68,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_favorite
import org.meshtastic.core.resources.channel_invalid
@ -79,7 +80,6 @@ import org.meshtastic.core.resources.remove
import org.meshtastic.core.resources.remove_favorite
import org.meshtastic.core.resources.remove_ignored
import org.meshtastic.core.resources.unmute
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticImportFAB
import org.meshtastic.core.ui.component.ScrollToTopEvent

View file

@ -33,6 +33,7 @@ dependencies {
implementation(projects.core.data)
implementation(projects.core.database)
implementation(projects.core.datastore)
implementation(projects.core.domain)
implementation(projects.core.model)
implementation(projects.core.navigation)
implementation(projects.core.nfc)
@ -57,7 +58,10 @@ dependencies {
implementation(libs.nordic.common.permissions.ble)
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.robolectric)
testImplementation(libs.turbine)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation(libs.androidx.test.ext.junit)

View file

@ -47,8 +47,8 @@ import org.meshtastic.core.resources.preserve_favorites
import org.meshtastic.core.resources.remotely_administrating
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.radio.AdminRoute
import org.meshtastic.feature.settings.radio.ExpressiveSection
import org.meshtastic.feature.settings.radio.RadioConfigState
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.ResponseState

View file

@ -35,8 +35,8 @@ import org.meshtastic.core.resources.device_configuration
import org.meshtastic.core.resources.remotely_administrating
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.radio.ExpressiveSection
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
@Composable

View file

@ -36,8 +36,8 @@ import org.meshtastic.core.resources.module_settings
import org.meshtastic.core.resources.remotely_administrating
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.navigation.ModuleRoute
import org.meshtastic.feature.settings.radio.ExpressiveSection
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
@Composable

Some files were not shown because too many files have changed in this diff Show more