diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3c0e623aa..383ee77f1 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -198,7 +198,7 @@
-
-
-
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt
index 3ab5b5300..19e047139 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/NordicBleInterface.kt
@@ -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() }
diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt
index 0e7215d5c..f7cf8fbd5 100644
--- a/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt
+++ b/app/src/main/java/com/geeksville/mesh/repository/radio/RadioInterfaceService.kt
@@ -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)
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/service/ConnectionStateHandler.kt b/app/src/main/java/com/geeksville/mesh/service/ConnectionStateHandler.kt
index 4db868f22..a9f1cf014 100644
--- a/app/src/main/java/com/geeksville/mesh/service/ConnectionStateHandler.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/ConnectionStateHandler.kt
@@ -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
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt
index 3b36c9e19..6e98b253e 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshCommandSender.kt
@@ -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()
val tracerouteStartTimes = ConcurrentHashMap()
val neighborInfoStartTimes = ConcurrentHashMap()
@@ -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()
- 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(),
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt
index ad3f64d34..1d666ca2d 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshConfigFlowManager.kt
@@ -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
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt
index bd777c538..eeb4882dc 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshConnectionManager.kt
@@ -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()
+ .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
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt b/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt
index f1da54dd7..7ed7980c3 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshMessageProcessor.kt
@@ -60,7 +60,7 @@ constructor(
private val logInsertJobByPacketId = ConcurrentHashMap()
private val earlyReceivedPackets = ArrayDeque()
- private val maxEarlyPacketBuffer = 128
+ private val maxEarlyPacketBuffer = 10240
fun clearEarlyPackets() {
synchronized(earlyReceivedPackets) { earlyReceivedPackets.clear() }
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt
index ce6d4431c..1f284c7a7 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt
@@ -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
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt
index e0215bc15..34ce09dec 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceBroadcasts.kt
@@ -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
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt
index 6128caaf6..babdc5565 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt
@@ -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
diff --git a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt b/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt
index 2de292491..d85edd7ad 100644
--- a/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/PacketHandler.kt
@@ -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
diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt
index 8a31155eb..f28f98114 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt
@@ -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
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt
index a7b34c125..27cb87e24 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/ConnectionsScreen.kt
@@ -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
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt
index ed0a540bb..2ea0bda92 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/BLEDevices.kt
@@ -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
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/ConnectionsNavIcon.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/ConnectionsNavIcon.kt
index a99053754..03be8458b 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/ConnectionsNavIcon.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/ConnectionsNavIcon.kt
@@ -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 .
*/
-
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
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt
index 78e64088c..0ab39bbe7 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListItem.kt
@@ -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
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListSection.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListSection.kt
index 1915cfff3..2381d4f97 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListSection.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/DeviceListSection.kt
@@ -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.DeviceListSection(
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt
index cc0f8af7a..8cda4687c 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/NetworkDevices.kt
@@ -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
diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt
index fe7aa4d70..9669e83c8 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/connections/components/UsbDevices.kt
@@ -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(
diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt
index 693dbf61f..24bcff02f 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt
@@ -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
diff --git a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt
index 7de8359eb..1e7f58323 100644
--- a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt
+++ b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidget.kt
@@ -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() {
diff --git a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt
index 75dc02cd1..eafbe38a2 100644
--- a/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt
+++ b/app/src/main/java/com/geeksville/mesh/widget/LocalStatsWidgetState.kt
@@ -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
diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt
index 9fcb5ab91..c7f2e2e87 100644
--- a/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt
+++ b/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderHopLimitTest.kt
@@ -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
diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderQueueTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderQueueTest.kt
deleted file mode 100644
index e1c0cca2f..000000000
--- a/app/src/test/java/com/geeksville/mesh/service/MeshCommandSenderQueueTest.kt
+++ /dev/null
@@ -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 .
- */
-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(relaxed = true)
- private val connectionStateHandler = mockk(relaxed = true)
- private val connectionStateFlow = MutableStateFlow(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()) }
-
- connectionStateFlow.value = ConnectionState.Connected
- commandSender.processQueuedPackets()
-
- verify(exactly = 1) { packetHandler.sendToRadio(any()) }
- }
-
- @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()) }
-
- connectionStateFlow.value = ConnectionState.Connected
- commandSender.processQueuedPackets()
-
- verify(exactly = 1) { packetHandler.sendToRadio(any()) }
- }
-
- @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()) }
-
- connectionStateFlow.value = ConnectionState.Connected
- commandSender.processQueuedPackets()
-
- verify(exactly = 1) { packetHandler.sendToRadio(any()) }
- }
-
- @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()) }
-
- connectionStateFlow.value = ConnectionState.Connected
- commandSender.processQueuedPackets()
-
- verify(exactly = 1) { packetHandler.sendToRadio(any()) }
- }
-
- @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()) }
-
- connectionStateFlow.value = ConnectionState.Connected
- commandSender.processQueuedPackets()
-
- verify(exactly = 1) { packetHandler.sendToRadio(any()) }
- }
-
- @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()) }
-
- connectionStateFlow.value = ConnectionState.Connected
- commandSender.processQueuedPackets()
-
- verify(exactly = 0) { packetHandler.sendToRadio(any()) }
- }
-}
diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt
index c7e002ec0..cefdb7b61 100644
--- a/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt
+++ b/app/src/test/java/com/geeksville/mesh/service/MeshConnectionManagerTest.kt
@@ -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.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(relaxed = true)
+ every { dataPacket.id } returns packetId
+ coEvery { packetRepository.getQueuedPackets() } returns listOf(dataPacket)
- verify { commandSender.processQueuedPackets() }
+ manager.onRadioConfigLoaded()
+ advanceUntilIdle()
+
+ verify {
+ workManager.enqueueUniqueWork(
+ match { it.startsWith(SendMessageWorker.WORK_NAME_PREFIX) },
+ any(),
+ any(),
+ )
+ }
verify { commandSender.sendAdmin(any(), initFn = any()) }
}
diff --git a/app/src/test/java/com/geeksville/mesh/service/MeshServiceBroadcastsTest.kt b/app/src/test/java/com/geeksville/mesh/service/MeshServiceBroadcastsTest.kt
index 1e9db9ba9..88cee4a4b 100644
--- a/app/src/test/java/com/geeksville/mesh/service/MeshServiceBroadcastsTest.kt
+++ b/app/src/test/java/com/geeksville/mesh/service/MeshServiceBroadcastsTest.kt
@@ -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
diff --git a/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt b/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt
index 0ad9629f2..bd3ddc0b9 100644
--- a/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt
+++ b/app/src/test/java/com/geeksville/mesh/service/PacketHandlerTest.kt
@@ -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
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/HomoglyphCharacterStringTransformer.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt
similarity index 97%
rename from feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/HomoglyphCharacterStringTransformer.kt
rename to core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt
index 9aebea8a0..d91c02b7e 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/HomoglyphCharacterStringTransformer.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt
@@ -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 .
*/
-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
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SequentialJob.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SequentialJob.kt
index 564c66515..6046c68b6 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SequentialJob.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/SequentialJob.kt
@@ -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()
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt
index 8ea4e70be..53729ce48 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/NodeRepository.kt
@@ -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 =
+ open val myNodeInfo: StateFlow =
nodeInfoReadDataSource
.myNodeInfoFlow()
.flowOn(dispatchers.io)
@@ -75,7 +75,7 @@ constructor(
private val _ourNodeInfo = MutableStateFlow(null)
/** Information about the locally connected node, as seen from the mesh. */
- val ourNodeInfo: StateFlow
+ open val ourNodeInfo: StateFlow
get() = _ourNodeInfo
private val _myId = MutableStateFlow(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. */
diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/RadioConfigRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/RadioConfigRepository.kt
index a22b001e4..1e4067f80 100644
--- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/RadioConfigRepository.kt
+++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/RadioConfigRepository.kt
@@ -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 = localConfigDataSource.localConfigFlow
+ open val localConfigFlow: Flow = localConfigDataSource.localConfigFlow
/** Clears the [LocalConfig] data in the data store. */
suspend fun clearLocalConfig() {
diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt
index 3ae7d49f7..fe90c72e3 100644
--- a/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt
+++ b/core/database/src/main/kotlin/org/meshtastic/core/database/DatabaseManager.kt
@@ -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 = _cacheLimit
+ open val cacheLimit: StateFlow = _cacheLimit
// Keep cache-limit StateFlow in sync if some other component updates SharedPreferences.
private val prefsListener =
diff --git a/core/di/build.gradle.kts b/core/di/build.gradle.kts
index ef82c29a4..d968dda63 100644
--- a/core/di/build.gradle.kts
+++ b/core/di/build.gradle.kts
@@ -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 { namespace = "org.meshtastic.core.di" }
-dependencies {}
+dependencies { implementation(libs.androidx.work.runtime.ktx) }
diff --git a/core/di/src/main/kotlin/org/meshtastic/core/di/AppModule.kt b/core/di/src/main/kotlin/org/meshtastic/core/di/AppModule.kt
index 4c834d897..0dfe5764a 100644
--- a/core/di/src/main/kotlin/org/meshtastic/core/di/AppModule.kt
+++ b/core/di/src/main/kotlin/org/meshtastic/core/di/AppModule.kt
@@ -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 .
*/
-
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)
}
diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts
new file mode 100644
index 000000000..60226b661
--- /dev/null
+++ b/core/domain/build.gradle.kts
@@ -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 .
+ */
+
+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)
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/MessageQueue.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/MessageQueue.kt
new file mode 100644
index 000000000..5142c89f9
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/MessageQueue.kt
@@ -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 .
+ */
+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)
+}
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/usecase/SendMessageUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCase.kt
similarity index 60%
rename from feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/usecase/SendMessageUseCase.kt
rename to core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCase.kt
index 1c9863015..ca2cf3f77 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/usecase/SendMessageUseCase.kt
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCase.kt
@@ -14,28 +14,39 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-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" }
}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt
new file mode 100644
index 000000000..728a209e4
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCase.kt
@@ -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 .
+ */
+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
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt
new file mode 100644
index 000000000..6a32f1131
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCase.kt
@@ -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 .
+ */
+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 {
+ 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) {
+ if (nodeNums.isEmpty()) return
+
+ nodeRepository.deleteNodes(nodeNums)
+ val packetId = radioController.getPacketId()
+ for (nodeNum in nodeNums) {
+ radioController.removeByNodenum(packetId, nodeNum)
+ }
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCase.kt
new file mode 100644
index 000000000..c8bcdf699
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCase.kt
@@ -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 .
+ */
+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()
+
+ @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\"",
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCase.kt
new file mode 100644
index 000000000..8a9905975
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCase.kt
@@ -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 .
+ */
+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 = runCatching {
+ outputStream.write(profile.encode())
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCase.kt
new file mode 100644
index 000000000..2e32ed868
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCase.kt
@@ -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 .
+ */
+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 = 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
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt
new file mode 100644
index 000000000..7dc1a9745
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt
@@ -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 .
+ */
+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 = runCatching {
+ val bytes = inputStream.readBytes()
+ DeviceProfile.ADAPTER.decode(bytes)
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt
new file mode 100644
index 000000000..20b59f452
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCase.kt
@@ -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 .
+ */
+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()) }
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCase.kt
new file mode 100644
index 000000000..0e18a33a7
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCase.kt
@@ -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 .
+ */
+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 = 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)
+ }
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCase.kt
new file mode 100644
index 000000000..f03f89e23
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCase.kt
@@ -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 .
+ */
+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()
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt
new file mode 100644
index 000000000..e208a5435
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCase.kt
@@ -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 .
+ */
+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): 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
+ }
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt
new file mode 100644
index 000000000..a65b75209
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCase.kt
@@ -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 .
+ */
+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
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt
new file mode 100644
index 000000000..04462c0f9
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCase.kt
@@ -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 .
+ */
+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)
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCase.kt
new file mode 100644
index 000000000..4153ad934
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCase.kt
@@ -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 .
+ */
+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)
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCase.kt
new file mode 100644
index 000000000..360c72bcd
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCase.kt
@@ -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 .
+ */
+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)
+ }
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt
new file mode 100644
index 000000000..fa8daee9e
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCase.kt
@@ -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 .
+ */
+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)
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt
new file mode 100644
index 000000000..437e39604
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCase.kt
@@ -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 .
+ */
+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)
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCase.kt
new file mode 100644
index 000000000..0682c4da2
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCase.kt
@@ -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 .
+ */
+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
+ }
+}
diff --git a/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCase.kt b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCase.kt
new file mode 100644
index 000000000..1c83d6886
--- /dev/null
+++ b/core/domain/src/main/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCase.kt
@@ -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 .
+ */
+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
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/FakeRadioController.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/FakeRadioController.kt
new file mode 100644
index 000000000..69ec2022a
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/FakeRadioController.kt
@@ -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 .
+ */
+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.Connected)
+ override val connectionState: StateFlow = _connectionState
+
+ private val _clientNotification = MutableStateFlow(null)
+ override val clientNotification: StateFlow = _clientNotification
+
+ // Track sent packets to assert in tests
+ val sentPackets = mutableListOf()
+ val favoritedNodes = mutableListOf()
+ val sentSharedContacts = mutableListOf()
+
+ 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
+ }
+}
diff --git a/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/domain/usecase/SendMessageUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCaseTest.kt
similarity index 69%
rename from feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/domain/usecase/SendMessageUseCaseTest.kt
rename to core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCaseTest.kt
index 42adf05a8..6c0d0fe6e 100644
--- a/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/domain/usecase/SendMessageUseCaseTest.kt
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/SendMessageUseCaseTest.kt
@@ -14,49 +14,67 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-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()) }
+ coVerify { messageQueue.enqueue(any()) }
}
@Test
@@ -86,18 +107,21 @@ class SendMessageUseCaseTest {
val destNode = mockk(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().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()) }
+ coVerify { messageQueue.enqueue(any()) }
}
@Test
@@ -112,18 +136,21 @@ class SendMessageUseCaseTest {
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
val destNode = mockk(relaxed = true)
+ every { destNode.num } returns 67890
every { nodeRepository.getNode("!dest") } returns destNode
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
-
every { anyConstructed().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()) }
+ 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()
+ coVerify { packetRepository.insert(capture(packetSlot)) }
+ assertTrue(packetSlot.captured.data?.text?.contains("Apple") == true)
+ coVerify { messageQueue.enqueue(any()) }
}
}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCaseTest.kt
new file mode 100644
index 000000000..e423ca882
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/AdminActionsUseCaseTest.kt
@@ -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 .
+ */
+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)
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCaseTest.kt
new file mode 100644
index 000000000..001c0a5fe
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/CleanNodeDatabaseUseCaseTest.kt
@@ -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 .
+ */
+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
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCaseTest.kt
new file mode 100644
index 000000000..32dcff37f
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCaseTest.kt
@@ -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 .
+ */
+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"))
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCaseTest.kt
new file mode 100644
index 000000000..e2e26f4f2
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportProfileUseCaseTest.kt
@@ -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 .
+ */
+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())
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCaseTest.kt
new file mode 100644
index 000000000..b86569cd0
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCaseTest.kt
@@ -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 .
+ */
+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"))
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCaseTest.kt
new file mode 100644
index 000000000..7b41a67f8
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCaseTest.kt
@@ -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 .
+ */
+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)
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt
new file mode 100644
index 000000000..411d47a92
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/InstallProfileUseCaseTest.kt
@@ -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 .
+ */
+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) }
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt
new file mode 100644
index 000000000..41db758c7
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/IsOtaCapableUseCaseTest.kt
@@ -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 .
+ */
+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(null)
+ private val connectionStateFlow = MutableStateFlow(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(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(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(relaxed = true)
+ ourNodeInfoFlow.value = node
+ connectionStateFlow.value = ConnectionState.Connected
+ every { radioPrefs.devAddr } returns "x123" // BLE
+
+ val hw = mockk { 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(relaxed = true)
+ ourNodeInfoFlow.value = node
+ connectionStateFlow.value = ConnectionState.Connected
+ every { radioPrefs.devAddr } returns "x123" // BLE
+
+ val hw = mockk { every { requiresDfu } returns false }
+ coEvery { deviceHardwareRepository.getDeviceHardwareByModel(any()) } returns Result.success(hw)
+
+ useCase().test {
+ assertFalse(awaitItem())
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCaseTest.kt
new file mode 100644
index 000000000..95910cc78
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/MeshLocationUseCaseTest.kt
@@ -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 .
+ */
+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() }
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCaseTest.kt
new file mode 100644
index 000000000..9489a804e
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ProcessRadioResponseUseCaseTest.kt
@@ -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 .
+ */
+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)
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCaseTest.kt
new file mode 100644
index 000000000..29e26406c
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/RadioConfigUseCaseTest.kt
@@ -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 .
+ */
+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)
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCaseTest.kt
new file mode 100644
index 000000000..08e485c9a
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetAppIntroCompletedUseCaseTest.kt
@@ -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 .
+ */
+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) }
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCaseTest.kt
new file mode 100644
index 000000000..1551ab32d
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetDatabaseCacheLimitUseCaseTest.kt
@@ -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 .
+ */
+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) }
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCaseTest.kt
new file mode 100644
index 000000000..748587b6a
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetMeshLogSettingsUseCaseTest.kt
@@ -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 .
+ */
+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() }
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCaseTest.kt
new file mode 100644
index 000000000..240b07876
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetProvideLocationUseCaseTest.kt
@@ -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 .
+ */
+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) }
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCaseTest.kt
new file mode 100644
index 000000000..7d04ce7bc
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/SetThemeUseCaseTest.kt
@@ -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 .
+ */
+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) }
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCaseTest.kt
new file mode 100644
index 000000000..63fbf2b2a
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleAnalyticsUseCaseTest.kt
@@ -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 .
+ */
+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 }
+ }
+}
diff --git a/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCaseTest.kt b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCaseTest.kt
new file mode 100644
index 000000000..f8cf978af
--- /dev/null
+++ b/core/domain/src/test/kotlin/org/meshtastic/core/domain/usecase/settings/ToggleHomoglyphEncodingUseCaseTest.kt
@@ -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 .
+ */
+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 }
+ }
+}
diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/ConnectionState.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt
similarity index 94%
rename from core/service/src/main/kotlin/org/meshtastic/core/service/ConnectionState.kt
rename to core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt
index 0e8beedae..0af5a0efd 100644
--- a/core/service/src/main/kotlin/org/meshtastic/core/service/ConnectionState.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt
@@ -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 .
*/
-
-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. */
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt
new file mode 100644
index 000000000..286f32ddb
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt
@@ -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 .
+ */
+package org.meshtastic.core.model
+
+import kotlinx.coroutines.flow.StateFlow
+import org.meshtastic.proto.ClientNotification
+
+@Suppress("TooManyFunctions")
+interface RadioController {
+ val connectionState: StateFlow
+ val clientNotification: StateFlow
+
+ 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()
+}
diff --git a/core/service/build.gradle.kts b/core/service/build.gradle.kts
index a71e0ec3a..8245b887e 100644
--- a/core/service/build.gradle.kts
+++ b/core/service/build.gradle.kts
@@ -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 {
buildFeatures { aidl = true }
@@ -28,6 +31,7 @@ configure {
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)
}
diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt
new file mode 100644
index 000000000..ae582faa3
--- /dev/null
+++ b/core/service/src/main/kotlin/org/meshtastic/core/service/AndroidRadioControllerImpl.kt
@@ -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 .
+ */
+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
+ get() = serviceRepository.connectionState
+
+ override val clientNotification: StateFlow
+ 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()
+ }
+}
diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt
index 77f2b49c0..858e1695b 100644
--- a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt
+++ b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt
@@ -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 = MutableStateFlow(ConnectionState.Disconnected)
- val connectionState: StateFlow
+ open val connectionState: StateFlow
get() = _connectionState
fun setConnectionState(connectionState: ConnectionState) {
diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/di/ServiceModule.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/di/ServiceModule.kt
new file mode 100644
index 000000000..0df2b76e5
--- /dev/null
+++ b/core/service/src/main/kotlin/org/meshtastic/core/service/di/ServiceModule.kt
@@ -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 .
+ */
+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
+}
diff --git a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
index 81d60db69..84a5e9538 100644
--- a/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
+++ b/feature/firmware/src/main/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
@@ -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
diff --git a/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt b/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt
index 5147bef41..7a4215220 100644
--- a/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt
+++ b/feature/intro/src/main/kotlin/org/meshtastic/feature/intro/AppIntroductionScreen.kt
@@ -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)
diff --git a/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt b/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt
index 571f3ac0d..10972edb3 100644
--- a/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt
+++ b/feature/map/src/testGoogle/kotlin/org/meshtastic/feature/map/MapViewModelTest.kt
@@ -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
diff --git a/feature/messaging/build.gradle.kts b/feature/messaging/build.gradle.kts
index 36cbbe824..97b81c776 100644
--- a/feature/messaging/build.gradle.kts
+++ b/feature/messaging/build.gradle.kts
@@ -26,8 +26,10 @@ configure { 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)
}
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt
index 2c2835c75..91bda8f2e 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/Message.kt
@@ -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
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
index 774faac4a..174b48588 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
@@ -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
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/di/MessagingModule.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/di/MessagingModule.kt
new file mode 100644
index 000000000..616765d1d
--- /dev/null
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/di/MessagingModule.kt
@@ -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 .
+ */
+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
+}
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorker.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorker.kt
new file mode 100644
index 000000000..49d11fa10
--- /dev/null
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorker.kt
@@ -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 .
+ */
+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_"
+ }
+}
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/WorkManagerMessageQueue.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/WorkManagerMessageQueue.kt
new file mode 100644
index 000000000..a7b829be0
--- /dev/null
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/domain/worker/WorkManagerMessageQueue.kt
@@ -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 .
+ */
+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()
+ .setInputData(workDataOf(SendMessageWorker.KEY_PACKET_ID to packetId))
+ .build()
+
+ workManager.enqueueUniqueWork(
+ "${SendMessageWorker.WORK_NAME_PREFIX}$packetId",
+ ExistingWorkPolicy.REPLACE,
+ workRequest,
+ )
+ }
+}
diff --git a/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/HomoglyphCharacterTransformTest.kt b/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/HomoglyphCharacterTransformTest.kt
index f521c5e07..b5b634d6a 100644
--- a/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/HomoglyphCharacterTransformTest.kt
+++ b/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/HomoglyphCharacterTransformTest.kt
@@ -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 {
diff --git a/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorkerTest.kt b/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorkerTest.kt
new file mode 100644
index 000000000..48abe99de
--- /dev/null
+++ b/feature/messaging/src/test/kotlin/org/meshtastic/feature/messaging/domain/worker/SendMessageWorkerTest.kt
@@ -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 .
+ */
+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(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(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(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(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(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)
+ }
+}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
index 755c68175..f8b895552 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
@@ -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
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt
index 919515426..5546b3cbe 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt
@@ -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
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
index 6b95f55fa..f2a823296 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
@@ -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
diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts
index 9a7de65a8..5c02a427e 100644
--- a/feature/settings/build.gradle.kts
+++ b/feature/settings/build.gradle.kts
@@ -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)
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt
index 1d5c16f4e..d5fbcc31f 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/AdministrationScreen.kt
@@ -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
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt
index 77dc42419..61d551d8e 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt
@@ -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
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt
index 630d19c0b..788292573 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt
@@ -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
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
index bd5ebc655..d24a6c1cd 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
@@ -16,101 +16,54 @@
*/
package org.meshtastic.feature.settings
-import android.Manifest
import android.app.Activity
import android.content.Intent
-import android.net.Uri
-import android.os.Build
-import android.provider.Settings
-import android.provider.Settings.ACTION_APP_LOCALE_SETTINGS
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
-import androidx.annotation.VisibleForTesting
-import androidx.appcompat.app.AppCompatActivity.RESULT_OK
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
-import androidx.compose.material.icons.filled.Abc
-import androidx.compose.material.icons.filled.BugReport
-import androidx.compose.material.icons.rounded.AppSettingsAlt
-import androidx.compose.material.icons.rounded.FormatPaint
-import androidx.compose.material.icons.rounded.Info
-import androidx.compose.material.icons.rounded.Language
-import androidx.compose.material.icons.rounded.LocationOn
-import androidx.compose.material.icons.rounded.Memory
-import androidx.compose.material.icons.rounded.Output
-import androidx.compose.material.icons.rounded.WavingHand
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalConfiguration
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
-import androidx.core.net.toUri
-import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
-import com.google.accompanist.permissions.rememberMultiplePermissionsState
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.common.gpsDisabled
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
-import org.meshtastic.core.database.DatabaseConstants
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.acknowledgements
-import org.meshtastic.core.resources.analytics_okay
-import org.meshtastic.core.resources.app_settings
-import org.meshtastic.core.resources.app_version
import org.meshtastic.core.resources.bottom_nav_settings
import org.meshtastic.core.resources.choose_theme
-import org.meshtastic.core.resources.device_db_cache_limit
-import org.meshtastic.core.resources.device_db_cache_limit_summary
import org.meshtastic.core.resources.dynamic
import org.meshtastic.core.resources.export_configuration
-import org.meshtastic.core.resources.export_data_csv
import org.meshtastic.core.resources.import_configuration
-import org.meshtastic.core.resources.intro_show
-import org.meshtastic.core.resources.location_disabled
-import org.meshtastic.core.resources.modules_already_unlocked
-import org.meshtastic.core.resources.modules_unlocked
import org.meshtastic.core.resources.preferences_language
-import org.meshtastic.core.resources.provide_location_to_mesh
import org.meshtastic.core.resources.remotely_administrating
-import org.meshtastic.core.resources.save_rangetest
-import org.meshtastic.core.resources.system_settings
-import org.meshtastic.core.resources.theme
import org.meshtastic.core.resources.theme_dark
import org.meshtastic.core.resources.theme_light
import org.meshtastic.core.resources.theme_system
-import org.meshtastic.core.resources.use_homoglyph_characters_encoding
-import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
-import org.meshtastic.core.ui.component.SwitchListItem
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
-import org.meshtastic.core.ui.util.showToast
+import org.meshtastic.feature.settings.component.AppInfoSection
+import org.meshtastic.feature.settings.component.AppearanceSection
+import org.meshtastic.feature.settings.component.PersistenceSection
+import org.meshtastic.feature.settings.component.PrivacySection
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.ModuleRoute
-import org.meshtastic.feature.settings.radio.ExpressiveSection
import org.meshtastic.feature.settings.radio.RadioConfigItemList
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.EditDeviceProfileDialog
@@ -119,7 +72,6 @@ import org.meshtastic.feature.settings.util.LanguageUtils.languageMap
import org.meshtastic.proto.DeviceProfile
import java.text.SimpleDateFormat
import java.util.Locale
-import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalPermissionsApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@@ -259,226 +211,37 @@ fun SettingsScreen(
onNavigate = onNavigate,
)
- val context = LocalContext.current
+ PrivacySection(
+ analyticsAvailable = state.analyticsAvailable,
+ analyticsEnabled = viewModel.analyticsAllowedFlow.collectAsStateWithLifecycle(false).value,
+ onToggleAnalytics = { viewModel.toggleAnalyticsAllowed() },
+ provideLocation = settingsViewModel.provideLocation.collectAsStateWithLifecycle().value,
+ onToggleLocation = { settingsViewModel.setProvideLocation(it) },
+ homoglyphEnabled = viewModel.homoglyphEncodingEnabledFlow.collectAsStateWithLifecycle(false).value,
+ onToggleHomoglyph = { viewModel.toggleHomoglyphCharactersEncodingEnabled() },
+ startProvideLocation = { settingsViewModel.startProvidingLocation() },
+ stopProvideLocation = { settingsViewModel.stopProvidingLocation() },
+ )
- ExpressiveSection(title = stringResource(Res.string.app_settings)) {
- if (state.analyticsAvailable) {
- val allowed by viewModel.analyticsAllowedFlow.collectAsStateWithLifecycle(false)
- SwitchListItem(
- text = stringResource(Res.string.analytics_okay),
- checked = allowed,
- leadingIcon = Icons.Default.BugReport,
- onClick = { viewModel.toggleAnalyticsAllowed() },
- )
- }
+ AppearanceSection(
+ onShowLanguagePicker = { showLanguagePickerDialog = true },
+ onShowThemePicker = { showThemePickerDialog = true },
+ )
- val locationPermissionsState =
- rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
- val isGpsDisabled = context.gpsDisabled()
- val provideLocation by settingsViewModel.provideLocation.collectAsStateWithLifecycle()
+ PersistenceSection(
+ cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value,
+ onSetCacheLimit = { settingsViewModel.setDbCacheLimit(it) },
+ nodeShortName = ourNode?.user?.short_name ?: "",
+ onExportData = { settingsViewModel.saveDataCsv(it) },
+ )
- LaunchedEffect(provideLocation, locationPermissionsState.allPermissionsGranted, isGpsDisabled) {
- if (provideLocation) {
- if (locationPermissionsState.allPermissionsGranted) {
- if (!isGpsDisabled) {
- settingsViewModel.meshService?.startProvideLocation()
- } else {
- context.showToast(Res.string.location_disabled)
- }
- } else {
- // Request permissions if not granted and user wants to provide location
- locationPermissionsState.launchMultiplePermissionRequest()
- }
- } else {
- settingsViewModel.meshService?.stopProvideLocation()
- }
- }
-
- SwitchListItem(
- text = stringResource(Res.string.provide_location_to_mesh),
- leadingIcon = Icons.Rounded.LocationOn,
- enabled = !isGpsDisabled,
- checked = provideLocation,
- onClick = { settingsViewModel.setProvideLocation(!provideLocation) },
- )
-
- val homoglyphEncodingEnabled by
- viewModel.homoglyphEncodingEnabledFlow.collectAsStateWithLifecycle(false)
-
- HomoglyphSetting(
- homoglyphEncodingEnabled = homoglyphEncodingEnabled,
- onToggle = { viewModel.toggleHomoglyphCharactersEncodingEnabled() },
- )
-
- val settingsLauncher =
- rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {}
-
- // On Android 12 and below, system app settings for language are not available. Use the in-app language
- // picker for these devices.
- val useInAppLangPicker = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
- ListItem(
- text = stringResource(Res.string.preferences_language),
- leadingIcon = Icons.Rounded.Language,
- trailingIcon = if (useInAppLangPicker) null else Icons.AutoMirrored.Rounded.KeyboardArrowRight,
- ) {
- if (useInAppLangPicker) {
- showLanguagePickerDialog = true
- } else {
- val intent = Intent(ACTION_APP_LOCALE_SETTINGS, "package:${context.packageName}".toUri())
- if (intent.resolveActivity(context.packageManager) != null) {
- settingsLauncher.launch(intent)
- } else {
- // Fall back to the in-app picker
- showLanguagePickerDialog = true
- }
- }
- }
-
- ListItem(
- text = stringResource(Res.string.theme),
- leadingIcon = Icons.Rounded.FormatPaint,
- trailingIcon = null,
- ) {
- showThemePickerDialog = true
- }
-
- // Node DB cache limit (App setting)
- val cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value
- val cacheItems = remember {
- (DatabaseConstants.MIN_CACHE_LIMIT..DatabaseConstants.MAX_CACHE_LIMIT).map {
- it.toLong() to it.toString()
- }
- }
- DropDownPreference(
- title = stringResource(Res.string.device_db_cache_limit),
- enabled = true,
- items = cacheItems,
- selectedItem = cacheLimit.toLong(),
- onItemSelected = { selected -> settingsViewModel.setDbCacheLimit(selected.toInt()) },
- summary = stringResource(Res.string.device_db_cache_limit_summary),
- )
-
- val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(nowMillis.toInstant().toDate())
- val nodeName = ourNode?.user?.short_name ?: ""
-
- val exportRangeTestLauncher =
- rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
- if (it.resultCode == RESULT_OK) {
- it.data?.data?.let { uri -> settingsViewModel.saveDataCsv(uri) }
- }
- }
- ListItem(
- text = stringResource(Res.string.save_rangetest),
- leadingIcon = Icons.Rounded.Output,
- trailingIcon = null,
- ) {
- val intent =
- Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
- addCategory(Intent.CATEGORY_OPENABLE)
- type = "application/csv"
- putExtra(Intent.EXTRA_TITLE, "Meshtastic_rangetest_${nodeName}_$timestamp.csv")
- }
- exportRangeTestLauncher.launch(intent)
- }
-
- val exportDataLauncher =
- rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
- if (it.resultCode == RESULT_OK) {
- it.data?.data?.let { uri -> settingsViewModel.saveDataCsv(uri) }
- }
- }
- ListItem(
- text = stringResource(Res.string.export_data_csv),
- leadingIcon = Icons.Rounded.Output,
- trailingIcon = null,
- ) {
- val intent =
- Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
- addCategory(Intent.CATEGORY_OPENABLE)
- type = "application/csv"
- putExtra(Intent.EXTRA_TITLE, "Meshtastic_datalog_${nodeName}_$timestamp.csv")
- }
- exportDataLauncher.launch(intent)
- }
-
- ListItem(
- text = stringResource(Res.string.intro_show),
- leadingIcon = Icons.Rounded.WavingHand,
- trailingIcon = null,
- ) {
- settingsViewModel.showAppIntro()
- }
-
- ListItem(
- text = stringResource(Res.string.system_settings),
- leadingIcon = Icons.Rounded.AppSettingsAlt,
- trailingIcon = null,
- ) {
- val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
- intent.data = Uri.fromParts("package", context.packageName, null)
- settingsLauncher.launch(intent)
- }
-
- ListItem(
- text = stringResource(Res.string.acknowledgements),
- leadingIcon = Icons.Rounded.Info,
- trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
- ) {
- onNavigate(SettingsRoutes.About)
- }
-
- AppVersionButton(
- excludedModulesUnlocked = excludedModulesUnlocked,
- appVersionName = settingsViewModel.appVersionName,
- ) {
- settingsViewModel.unlockExcludedModules()
- }
- }
- }
- }
-}
-
-private const val UNLOCK_CLICK_COUNT = 5 // Number of clicks required to unlock excluded modules.
-private const val UNLOCKED_CLICK_COUNT = 3 // Number of clicks before we toast that modules are already unlocked.
-private const val UNLOCK_TIMEOUT_SECONDS = 1 // Timeout in seconds to reset the click counter.
-
-/** A button to display the app version. Clicking it 5 times will unlock the excluded modules. */
-@Composable
-private fun AppVersionButton(
- excludedModulesUnlocked: Boolean,
- appVersionName: String,
- onUnlockExcludedModules: () -> Unit,
-) {
- val scope = rememberCoroutineScope()
- val context = LocalContext.current
- var clickCount by remember { mutableIntStateOf(0) }
-
- LaunchedEffect(clickCount) {
- if (clickCount in 1.. {
- clickCount = 0
- scope.launch { context.showToast(Res.string.modules_already_unlocked) }
- }
-
- clickCount == UNLOCK_CLICK_COUNT -> {
- clickCount = 0
- onUnlockExcludedModules()
- scope.launch { context.showToast(Res.string.modules_unlocked) }
- }
+ AppInfoSection(
+ appVersionName = settingsViewModel.appVersionName,
+ excludedModulesUnlocked = excludedModulesUnlocked,
+ onUnlockExcludedModules = { settingsViewModel.unlockExcludedModules() },
+ onShowAppIntro = { settingsViewModel.showAppIntro() },
+ onNavigateToAbout = { onNavigate(SettingsRoutes.About) },
+ )
}
}
}
@@ -525,18 +288,3 @@ private fun ThemePickerDialog(onClickTheme: (Int) -> Unit, onDismiss: () -> Unit
},
)
}
-
-@VisibleForTesting
-@Composable
-fun HomoglyphSetting(homoglyphEncodingEnabled: Boolean, onToggle: () -> Unit) {
- val currentLocale = ConfigurationCompat.getLocales(LocalConfiguration.current).get(0)
- val supportedLanguages = listOf("ru", "uk", "be", "bg", "sr", "mk", "kk", "ky", "tg", "mn")
- if (currentLocale?.language in supportedLanguages) {
- SwitchListItem(
- text = stringResource(Res.string.use_homoglyph_characters_encoding),
- checked = homoglyphEncodingEnabled,
- leadingIcon = Icons.Default.Abc,
- onClick = onToggle,
- )
- }
-}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt
index 9ed773068..a75296c13 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt
@@ -16,8 +16,6 @@
*/
package org.meshtastic.feature.settings
-import android.app.Application
-import android.icu.text.SimpleDateFormat
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -27,64 +25,57 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
-import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.meshtastic.core.common.BuildConfigProvider
-import org.meshtastic.core.data.repository.DeviceHardwareRepository
-import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
-import org.meshtastic.core.database.DatabaseConstants
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.model.Node
-import org.meshtastic.core.datastore.UiPreferencesDataSource
-import org.meshtastic.core.model.Capabilities
-import org.meshtastic.core.model.Position
-import org.meshtastic.core.model.util.positionToMeter
+import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase
+import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase
+import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase
+import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase
+import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase
+import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase
+import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase
+import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase
+import org.meshtastic.core.model.RadioController
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
-import org.meshtastic.core.prefs.radio.RadioPrefs
-import org.meshtastic.core.prefs.radio.isBle
-import org.meshtastic.core.prefs.radio.isSerial
-import org.meshtastic.core.prefs.radio.isTcp
import org.meshtastic.core.prefs.ui.UiPrefs
-import org.meshtastic.core.service.IMeshService
-import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.LocalConfig
-import org.meshtastic.proto.PortNum
import java.io.BufferedWriter
import java.io.FileNotFoundException
import java.io.FileWriter
-import java.util.Locale
import javax.inject.Inject
-import kotlin.math.roundToInt
-import org.meshtastic.proto.Position as ProtoPosition
-@Suppress("LongParameterList")
+@Suppress("LongParameterList", "TooManyFunctions")
@HiltViewModel
class SettingsViewModel
@Inject
constructor(
- private val app: Application,
+ private val app: android.app.Application,
radioConfigRepository: RadioConfigRepository,
- private val serviceRepository: ServiceRepository,
+ private val radioController: RadioController,
private val nodeRepository: NodeRepository,
- private val meshLogRepository: MeshLogRepository,
private val uiPrefs: UiPrefs,
- private val uiPreferencesDataSource: UiPreferencesDataSource,
private val buildConfigProvider: BuildConfigProvider,
private val databaseManager: DatabaseManager,
- private val deviceHardwareRepository: DeviceHardwareRepository,
- private val radioPrefs: RadioPrefs,
private val meshLogPrefs: MeshLogPrefs,
+ private val setThemeUseCase: SetThemeUseCase,
+ private val setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase,
+ private val setProvideLocationUseCase: SetProvideLocationUseCase,
+ private val setDatabaseCacheLimitUseCase: SetDatabaseCacheLimitUseCase,
+ private val setMeshLogSettingsUseCase: SetMeshLogSettingsUseCase,
+ private val meshLocationUseCase: MeshLocationUseCase,
+ private val exportDataUseCase: ExportDataUseCase,
+ private val isOtaCapableUseCase: IsOtaCapableUseCase,
) : ViewModel() {
val myNodeInfo: StateFlow = nodeRepository.myNodeInfo
@@ -94,14 +85,11 @@ constructor(
val ourNodeInfo: StateFlow = nodeRepository.ourNodeInfo
val isConnected =
- serviceRepository.connectionState.map { it.isConnected() }.stateInWhileSubscribed(initialValue = false)
+ radioController.connectionState.map { it.isConnected() }.stateInWhileSubscribed(initialValue = false)
val localConfig: StateFlow =
radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig())
- val meshService: IMeshService?
- get() = serviceRepository.meshService
-
val provideLocation: StateFlow =
myNodeInfo
.flatMapLatest { myNodeEntity ->
@@ -114,41 +102,27 @@ constructor(
}
.stateInWhileSubscribed(initialValue = false)
+ fun startProvidingLocation() {
+ meshLocationUseCase.startProvidingLocation()
+ }
+
+ fun stopProvidingLocation() {
+ meshLocationUseCase.stopProvidingLocation()
+ }
+
private val _excludedModulesUnlocked = MutableStateFlow(false)
val excludedModulesUnlocked: StateFlow = _excludedModulesUnlocked.asStateFlow()
val appVersionName
get() = buildConfigProvider.versionName
- val isOtaCapable: StateFlow =
- combine(ourNodeInfo, serviceRepository.connectionState) { node, connectionState -> Pair(node, connectionState) }
- .flatMapLatest { (node, connectionState) ->
- if (node == null || !connectionState.isConnected()) {
- flowOf(false)
- } else if (radioPrefs.isBle() || radioPrefs.isSerial() || radioPrefs.isTcp()) {
- val hwModel = node.user.hw_model.value
- val hw = deviceHardwareRepository.getDeviceHardwareByModel(hwModel).getOrNull()
- // Support both Nordic DFU (requiresDfu) and ESP32 Unified OTA (supportsUnifiedOta)
- val capabilities = Capabilities(node.metadata?.firmware_version)
-
- // 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
- // hw?.supportsUnifiedOta == true && capabilities.supportsEsp32Ota && !radioPrefs.isSerial()
-
- flow { emit(hw?.requiresDfu == true || isEsp32OtaSupported) }
- } else {
- flowOf(false)
- }
- }
- .stateInWhileSubscribed(initialValue = false)
+ val isOtaCapable: StateFlow = isOtaCapableUseCase().stateInWhileSubscribed(initialValue = false)
// Device DB cache limit (bounded by DatabaseConstants)
val dbCacheLimit: StateFlow = databaseManager.cacheLimit
fun setDbCacheLimit(limit: Int) {
- val clamped = limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
- databaseManager.setCacheLimit(clamped)
+ setDatabaseCacheLimitUseCase(limit)
}
// MeshLog retention period (bounded by MeshLogPrefsImpl constants)
@@ -159,32 +133,25 @@ constructor(
val meshLogLoggingEnabled: StateFlow = _meshLogLoggingEnabled.asStateFlow()
fun setMeshLogRetentionDays(days: Int) {
- val clamped = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS)
- meshLogPrefs.retentionDays = clamped
- _meshLogRetentionDays.value = clamped
- viewModelScope.launch { meshLogRepository.deleteLogsOlderThan(clamped) }
+ viewModelScope.launch { setMeshLogSettingsUseCase.setRetentionDays(days) }
+ _meshLogRetentionDays.value = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS)
}
fun setMeshLogLoggingEnabled(enabled: Boolean) {
- meshLogPrefs.loggingEnabled = enabled
+ viewModelScope.launch { setMeshLogSettingsUseCase.setLoggingEnabled(enabled) }
_meshLogLoggingEnabled.value = enabled
- if (!enabled) {
- viewModelScope.launch { meshLogRepository.deleteAll() }
- } else {
- viewModelScope.launch { meshLogRepository.deleteLogsOlderThan(meshLogPrefs.retentionDays) }
- }
}
fun setProvideLocation(value: Boolean) {
- myNodeNum?.let { uiPrefs.setShouldProvideNodeLocation(it, value) }
+ myNodeNum?.let { setProvideLocationUseCase(it, value) }
}
fun setTheme(theme: Int) {
- uiPreferencesDataSource.setTheme(theme)
+ setThemeUseCase(theme)
}
fun showAppIntro() {
- uiPreferencesDataSource.setAppIntroCompleted(false)
+ setAppIntroCompletedUseCase(false)
}
fun unlockExcludedModules() {
@@ -204,112 +171,8 @@ constructor(
@Suppress("detekt:CyclomaticComplexMethod", "detekt:LongMethod")
fun saveDataCsv(uri: Uri, filterPortnum: Int? = null) {
viewModelScope.launch(Dispatchers.Main) {
- // Extract distances to this device from position messages and put (node,SNR,distance)
- // in the file_uri
val myNodeNum = myNodeNum ?: return@launch
-
- // Capture the current node value while we're still on main thread
- val nodes = nodeRepository.nodeDBbyNum.value
-
- // Converts a ProtoPosition (nullable) to a Position, but only if it's valid, otherwise returns null.
- // The returned Position is guaranteed to be non-null and valid, or null if the input was null or invalid.
- val positionToPos: (ProtoPosition?) -> Position? = { meshPosition ->
- meshPosition?.let { Position(it) }?.takeIf { it.isValid() }
- }
-
- writeToUri(uri) { writer ->
- val nodePositions = mutableMapOf()
-
- @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\"",
- )
-
- // Packets are ordered by time, we keep most recent position of
- // our device in localNodePosition.
- val dateFormat = SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault())
- meshLogRepository.getAllLogsInReceiveOrder(Int.MAX_VALUE).first().forEach { packet ->
- // If we get a NodeInfo packet, use it to update our position data (if valid)
- packet.nodeInfo?.let { nodeInfo ->
- positionToPos.invoke(nodeInfo.position)?.let { nodePositions[nodeInfo.num] = nodeInfo.position }
- }
-
- packet.meshPacket?.let { proto ->
- // If the packet contains position data then use it to update, if valid
- packet.position?.let { position ->
- positionToPos.invoke(position)?.let {
- nodePositions[
- proto.from.takeIf { it != 0 } ?: myNodeNum,
- ] = position
- }
- }
-
- // packets must have rxSNR, and optionally match the filter given as a param.
- if (
- (filterPortnum == null || (proto.decoded?.portnum?.value ?: 0) == filterPortnum) &&
- (proto.rx_snr ?: 0f) != 0.0f
- ) {
- val rxDateTime = dateFormat.format(packet.received_date)
- val rxFrom = proto.from.toUInt()
- val senderName = nodes[proto.from]?.user?.long_name ?: ""
-
- // sender lat & long
- val senderPosition = nodePositions[proto.from]
- val senderPos = positionToPos.invoke(senderPosition)
- val senderLat = senderPos?.latitude ?: ""
- val senderLong = senderPos?.longitude ?: ""
-
- // rx lat, long, and elevation
- 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
-
- // Calculate the distance if both positions are valid
-
- val dist =
- if (senderPos == null || rxPos == null) {
- ""
- } else {
- positionToMeter(
- Position(rxPosition!!), // Use rxPosition but only if rxPos was
- // valid
- Position(senderPosition!!), // Use senderPosition but only if
- // senderPos was valid
- )
- .roundToInt()
- .toString()
- }
-
- val hopLimit = proto.hop_limit ?: 0
-
- 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 -> ""
- }
-
- // date,time,from,sender name,sender lat,sender long,rx lat,rx long,rx
- // elevation,rx
- // snr,distance,hop limit,payload
- @Suppress("MaxLineLength")
- writer.appendLine(
- "$rxDateTime,\"$rxFrom\",\"$senderName\",\"$senderLat\",\"$senderLong\",\"$rxLat\",\"$rxLong\",\"$rxAlt\",\"$rxSnr\",\"$dist\",\"$hopLimit\",\"$payload\"",
- )
- }
- }
- }
- }
+ writeToUri(uri) { writer -> exportDataUseCase(writer, myNodeNum, filterPortnum) }
}
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/AppInfoSection.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/AppInfoSection.kt
new file mode 100644
index 000000000..cb6ef918b
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/AppInfoSection.kt
@@ -0,0 +1,159 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.feature.settings.component
+
+import android.content.Intent
+import android.net.Uri
+import android.provider.Settings
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
+import androidx.compose.material.icons.rounded.AppSettingsAlt
+import androidx.compose.material.icons.rounded.Info
+import androidx.compose.material.icons.rounded.Memory
+import androidx.compose.material.icons.rounded.WavingHand
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.tooling.preview.Preview
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.acknowledgements
+import org.meshtastic.core.resources.app_version
+import org.meshtastic.core.resources.info
+import org.meshtastic.core.resources.intro_show
+import org.meshtastic.core.resources.modules_already_unlocked
+import org.meshtastic.core.resources.modules_unlocked
+import org.meshtastic.core.resources.system_settings
+import org.meshtastic.core.ui.component.ListItem
+import org.meshtastic.core.ui.theme.AppTheme
+import org.meshtastic.core.ui.util.showToast
+import kotlin.time.Duration.Companion.seconds
+
+/** Section displaying application information and related actions. */
+@Composable
+fun AppInfoSection(
+ appVersionName: String,
+ excludedModulesUnlocked: Boolean,
+ onUnlockExcludedModules: () -> Unit,
+ onShowAppIntro: () -> Unit,
+ onNavigateToAbout: () -> Unit,
+) {
+ val context = LocalContext.current
+ val settingsLauncher =
+ rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {}
+
+ ExpressiveSection(title = stringResource(Res.string.info)) {
+ ListItem(
+ text = stringResource(Res.string.intro_show),
+ leadingIcon = Icons.Rounded.WavingHand,
+ trailingIcon = null,
+ ) {
+ onShowAppIntro()
+ }
+
+ ListItem(
+ text = stringResource(Res.string.system_settings),
+ leadingIcon = Icons.Rounded.AppSettingsAlt,
+ trailingIcon = null,
+ ) {
+ val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
+ intent.data = Uri.fromParts("package", context.packageName, null)
+ settingsLauncher.launch(intent)
+ }
+
+ ListItem(
+ text = stringResource(Res.string.acknowledgements),
+ leadingIcon = Icons.Rounded.Info,
+ trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
+ ) {
+ onNavigateToAbout()
+ }
+
+ AppVersionButton(
+ excludedModulesUnlocked = excludedModulesUnlocked,
+ appVersionName = appVersionName,
+ onUnlockExcludedModules = onUnlockExcludedModules,
+ )
+ }
+}
+
+private const val UNLOCK_CLICK_COUNT = 5 // Number of clicks required to unlock excluded modules.
+private const val UNLOCKED_CLICK_COUNT = 3 // Number of clicks before we toast that modules are already unlocked.
+private const val UNLOCK_TIMEOUT_SECONDS = 1 // Timeout in seconds to reset the click counter.
+
+@Composable
+private fun AppVersionButton(
+ excludedModulesUnlocked: Boolean,
+ appVersionName: String,
+ onUnlockExcludedModules: () -> Unit,
+) {
+ val scope = rememberCoroutineScope()
+ val context = LocalContext.current
+ var clickCount by remember { mutableIntStateOf(0) }
+
+ LaunchedEffect(clickCount) {
+ if (clickCount in 1.. {
+ clickCount = 0
+ scope.launch { context.showToast(Res.string.modules_already_unlocked) }
+ }
+
+ clickCount == UNLOCK_CLICK_COUNT -> {
+ clickCount = 0
+ onUnlockExcludedModules()
+ scope.launch { context.showToast(Res.string.modules_unlocked) }
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun AppInfoSectionPreview() {
+ AppTheme {
+ AppInfoSection(
+ appVersionName = "2.5.0",
+ excludedModulesUnlocked = false,
+ onUnlockExcludedModules = {},
+ onShowAppIntro = {},
+ onNavigateToAbout = {},
+ )
+ }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt
new file mode 100644
index 000000000..48807d8fa
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/AppearanceSection.kt
@@ -0,0 +1,84 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.feature.settings.component
+
+import android.content.Intent
+import android.os.Build
+import android.provider.Settings
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
+import androidx.compose.material.icons.rounded.FormatPaint
+import androidx.compose.material.icons.rounded.Language
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.core.net.toUri
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.app_settings
+import org.meshtastic.core.resources.preferences_language
+import org.meshtastic.core.resources.theme
+import org.meshtastic.core.ui.component.ListItem
+import org.meshtastic.core.ui.theme.AppTheme
+
+/** Section for app appearance settings like language and theme. */
+@Composable
+fun AppearanceSection(onShowLanguagePicker: () -> Unit, onShowThemePicker: () -> Unit) {
+ val context = LocalContext.current
+ val settingsLauncher =
+ rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {}
+
+ // On Android 12 and below, system app settings for language are not available. Use the in-app language
+ // picker for these devices.
+ val useInAppLangPicker = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
+
+ ExpressiveSection(title = stringResource(Res.string.app_settings)) {
+ ListItem(
+ text = stringResource(Res.string.preferences_language),
+ leadingIcon = Icons.Rounded.Language,
+ trailingIcon = if (useInAppLangPicker) null else Icons.AutoMirrored.Rounded.KeyboardArrowRight,
+ ) {
+ if (useInAppLangPicker) {
+ onShowLanguagePicker()
+ } else {
+ val intent = Intent(Settings.ACTION_APP_LOCALE_SETTINGS, "package:${context.packageName}".toUri())
+ if (intent.resolveActivity(context.packageManager) != null) {
+ settingsLauncher.launch(intent)
+ } else {
+ // Fall back to the in-app picker
+ onShowLanguagePicker()
+ }
+ }
+ }
+
+ ListItem(
+ text = stringResource(Res.string.theme),
+ leadingIcon = Icons.Rounded.FormatPaint,
+ trailingIcon = null,
+ ) {
+ onShowThemePicker()
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun AppearanceSectionPreview() {
+ AppTheme { AppearanceSection(onShowLanguagePicker = {}, onShowThemePicker = {}) }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/ExpressiveSection.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/ExpressiveSection.kt
new file mode 100644
index 000000000..49dbe2252
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/ExpressiveSection.kt
@@ -0,0 +1,56 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.feature.settings.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+
+/** A styled section container for settings screens. */
+@Composable
+fun ExpressiveSection(
+ title: String,
+ modifier: Modifier = Modifier,
+ titleColor: Color = MaterialTheme.colorScheme.primary,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = title,
+ modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth(),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = titleColor,
+ )
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
+ content = content,
+ )
+ }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/HomoglyphSetting.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/HomoglyphSetting.kt
new file mode 100644
index 000000000..161367ee2
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/HomoglyphSetting.kt
@@ -0,0 +1,41 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.feature.settings.component
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Abc
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.core.os.ConfigurationCompat
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.use_homoglyph_characters_encoding
+import org.meshtastic.core.ui.component.SwitchListItem
+
+@Composable
+fun HomoglyphSetting(homoglyphEncodingEnabled: Boolean, onToggle: () -> Unit) {
+ val currentLocale = ConfigurationCompat.getLocales(LocalConfiguration.current).get(0)
+ val supportedLanguages = listOf("ru", "uk", "be", "bg", "sr", "mk", "kk", "ky", "tg", "mn")
+ if (currentLocale?.language in supportedLanguages) {
+ SwitchListItem(
+ text = stringResource(Res.string.use_homoglyph_characters_encoding),
+ checked = homoglyphEncodingEnabled,
+ leadingIcon = Icons.Default.Abc,
+ onClick = onToggle,
+ )
+ }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/PersistenceSection.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/PersistenceSection.kt
new file mode 100644
index 000000000..c22235bd2
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/PersistenceSection.kt
@@ -0,0 +1,116 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.feature.settings.component
+
+import android.content.Intent
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity.RESULT_OK
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Output
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.tooling.preview.Preview
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.common.util.toDate
+import org.meshtastic.core.common.util.toInstant
+import org.meshtastic.core.database.DatabaseConstants
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.app_settings
+import org.meshtastic.core.resources.device_db_cache_limit
+import org.meshtastic.core.resources.device_db_cache_limit_summary
+import org.meshtastic.core.resources.export_data_csv
+import org.meshtastic.core.resources.save_rangetest
+import org.meshtastic.core.ui.component.DropDownPreference
+import org.meshtastic.core.ui.component.ListItem
+import org.meshtastic.core.ui.theme.AppTheme
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+/** Section for settings related to data persistence and exports. */
+@Composable
+fun PersistenceSection(
+ cacheLimit: Int,
+ onSetCacheLimit: (Int) -> Unit,
+ nodeShortName: String,
+ onExportData: (android.net.Uri) -> Unit,
+) {
+ val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(nowMillis.toInstant().toDate())
+
+ val exportRangeTestLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ if (it.resultCode == RESULT_OK) {
+ it.data?.data?.let { uri -> onExportData(uri) }
+ }
+ }
+
+ val exportDataLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
+ if (it.resultCode == RESULT_OK) {
+ it.data?.data?.let { uri -> onExportData(uri) }
+ }
+ }
+
+ ExpressiveSection(title = stringResource(Res.string.app_settings)) {
+ val cacheItems = remember {
+ (DatabaseConstants.MIN_CACHE_LIMIT..DatabaseConstants.MAX_CACHE_LIMIT).map { it.toLong() to it.toString() }
+ }
+ DropDownPreference(
+ title = stringResource(Res.string.device_db_cache_limit),
+ enabled = true,
+ items = cacheItems,
+ selectedItem = cacheLimit.toLong(),
+ onItemSelected = { selected -> onSetCacheLimit(selected.toInt()) },
+ summary = stringResource(Res.string.device_db_cache_limit_summary),
+ )
+
+ ListItem(
+ text = stringResource(Res.string.save_rangetest),
+ leadingIcon = Icons.Rounded.Output,
+ trailingIcon = null,
+ ) {
+ val intent =
+ Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = "application/csv"
+ putExtra(Intent.EXTRA_TITLE, "Meshtastic_rangetest_${nodeShortName}_$timestamp.csv")
+ }
+ exportRangeTestLauncher.launch(intent)
+ }
+
+ ListItem(
+ text = stringResource(Res.string.export_data_csv),
+ leadingIcon = Icons.Rounded.Output,
+ trailingIcon = null,
+ ) {
+ val intent =
+ Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = "application/csv"
+ putExtra(Intent.EXTRA_TITLE, "Meshtastic_datalog_${nodeShortName}_$timestamp.csv")
+ }
+ exportDataLauncher.launch(intent)
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun PersistenceSectionPreview() {
+ AppTheme { PersistenceSection(cacheLimit = 100, onSetCacheLimit = {}, nodeShortName = "TEST", onExportData = {}) }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt
new file mode 100644
index 000000000..cecdc27b8
--- /dev/null
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/component/PrivacySection.kt
@@ -0,0 +1,113 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.settings.component
+
+import android.Manifest
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.BugReport
+import androidx.compose.material.icons.rounded.LocationOn
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.tooling.preview.Preview
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.rememberMultiplePermissionsState
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.common.gpsDisabled
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.analytics_okay
+import org.meshtastic.core.resources.app_settings
+import org.meshtastic.core.resources.location_disabled
+import org.meshtastic.core.resources.provide_location_to_mesh
+import org.meshtastic.core.ui.component.SwitchListItem
+import org.meshtastic.core.ui.theme.AppTheme
+import org.meshtastic.core.ui.util.showToast
+
+/** Section managing privacy settings like analytics and location sharing. */
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+fun PrivacySection(
+ analyticsAvailable: Boolean,
+ analyticsEnabled: Boolean,
+ onToggleAnalytics: () -> Unit,
+ provideLocation: Boolean,
+ onToggleLocation: (Boolean) -> Unit,
+ homoglyphEnabled: Boolean,
+ onToggleHomoglyph: () -> Unit,
+ startProvideLocation: () -> Unit,
+ stopProvideLocation: () -> Unit,
+) {
+ val context = LocalContext.current
+ val locationPermissionsState =
+ rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
+ val isGpsDisabled = context.gpsDisabled()
+
+ LaunchedEffect(provideLocation, locationPermissionsState.allPermissionsGranted, isGpsDisabled) {
+ if (provideLocation) {
+ if (locationPermissionsState.allPermissionsGranted) {
+ if (!isGpsDisabled) {
+ startProvideLocation()
+ } else {
+ context.showToast(Res.string.location_disabled)
+ }
+ } else {
+ locationPermissionsState.launchMultiplePermissionRequest()
+ }
+ } else {
+ stopProvideLocation()
+ }
+ }
+
+ ExpressiveSection(title = stringResource(Res.string.app_settings)) {
+ if (analyticsAvailable) {
+ SwitchListItem(
+ text = stringResource(Res.string.analytics_okay),
+ checked = analyticsEnabled,
+ leadingIcon = Icons.Default.BugReport,
+ onClick = onToggleAnalytics,
+ )
+ }
+
+ SwitchListItem(
+ text = stringResource(Res.string.provide_location_to_mesh),
+ leadingIcon = Icons.Rounded.LocationOn,
+ enabled = !isGpsDisabled,
+ checked = provideLocation,
+ onClick = { onToggleLocation(!provideLocation) },
+ )
+
+ HomoglyphSetting(homoglyphEncodingEnabled = homoglyphEnabled, onToggle = onToggleHomoglyph)
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun PrivacySectionPreview() {
+ AppTheme {
+ PrivacySection(
+ analyticsAvailable = true,
+ analyticsEnabled = true,
+ onToggleAnalytics = {},
+ provideLocation = true,
+ onToggleLocation = {},
+ homoglyphEnabled = false,
+ onToggleHomoglyph = {},
+ startProvideLocation = {},
+ stopProvideLocation = {},
+ )
+ }
+}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt
index 51ca46704..db9cd8fd5 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseScreen.kt
@@ -40,7 +40,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.database.entity.NodeEntity
+import org.meshtastic.core.database.model.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.clean_node_database_description
import org.meshtastic.core.resources.clean_node_database_title
@@ -150,7 +150,7 @@ private fun UnknownNodesFilter(onlyUnknownNodes: Boolean, onCheckedChanged: (Boo
* @param nodesToDelete The list of nodes to be deleted.
*/
@Composable
-private fun NodesDeletionPreview(nodesToDelete: List) {
+private fun NodesDeletionPreview(nodesToDelete: List) {
Text(
stringResource(Res.string.nodes_queued_for_deletion, nodesToDelete.size),
modifier = Modifier.padding(bottom = 16.dp),
@@ -160,8 +160,6 @@ private fun NodesDeletionPreview(nodesToDelete: List) {
horizontalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.Center,
) {
- nodesToDelete.forEach { node ->
- NodeChip(node = node.toModel(), modifier = Modifier.padding(end = 8.dp, bottom = 8.dp))
- }
+ nodesToDelete.forEach { node -> NodeChip(node = node, modifier = Modifier.padding(end = 8.dp, bottom = 8.dp)) }
}
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt
index 344ee0890..d17df93ff 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModel.kt
@@ -24,16 +24,14 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.common.util.nowSeconds
-import org.meshtastic.core.data.repository.NodeRepository
-import org.meshtastic.core.database.entity.NodeEntity
+import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.domain.usecase.settings.CleanNodeDatabaseUseCase
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.are_you_sure
import org.meshtastic.core.resources.clean_node_database_confirmation
import org.meshtastic.core.resources.clean_now
-import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.util.AlertManager
import javax.inject.Inject
-import kotlin.time.Duration.Companion.days
private const val MIN_DAYS_THRESHOLD = 7f
@@ -45,8 +43,7 @@ private const val MIN_DAYS_THRESHOLD = 7f
class CleanNodeDatabaseViewModel
@Inject
constructor(
- private val nodeRepository: NodeRepository,
- private val serviceRepository: ServiceRepository,
+ private val cleanNodeDatabaseUseCase: CleanNodeDatabaseUseCase,
private val alertManager: AlertManager,
) : ViewModel() {
private val _olderThanDays = MutableStateFlow(30f)
@@ -55,7 +52,7 @@ constructor(
private val _onlyUnknownNodes = MutableStateFlow(false)
val onlyUnknownNodes = _onlyUnknownNodes.asStateFlow()
- private val _nodesToDelete = MutableStateFlow>(emptyList())
+ private val _nodesToDelete = MutableStateFlow>(emptyList())
val nodesToDelete = _nodesToDelete.asStateFlow()
fun onOlderThanDaysChanged(value: Float) {
@@ -69,40 +66,15 @@ constructor(
}
}
- /**
- * Updates the list of nodes to be deleted based on the current filter criteria. The logic is as follows:
- * - The "older than X days" filter (controlled by the slider) is always active.
- * - If "only unknown nodes" is also enabled, nodes that are BOTH unknown AND older than X days are selected.
- * - If "only unknown nodes" is not enabled, all nodes older than X days are selected.
- * - Nodes with an associated public key (PKI) heard from within the last 7 days are always excluded from deletion.
- * - Nodes marked as ignored or favorite are always excluded from deletion.
- */
+ /** Updates the list of nodes to be deleted based on the current filter criteria. */
fun getNodesToDelete() {
viewModelScope.launch {
- val onlyUnknownEnabled = _onlyUnknownNodes.value
- val currentTimeSeconds = nowSeconds
- val sevenDaysAgoSeconds = currentTimeSeconds - 7.days.inWholeSeconds
- val olderThanTimestamp = currentTimeSeconds - _olderThanDays.value.toInt().days.inWholeSeconds
-
- val initialNodesToConsider =
- if (onlyUnknownEnabled) {
- // Both "older than X days" and "only unknown nodes" filters apply
- val olderNodes = nodeRepository.getNodesOlderThan(olderThanTimestamp.toInt())
- val unknownNodes = nodeRepository.getUnknownNodes()
- olderNodes.filter { itNode -> unknownNodes.any { unknownNode -> itNode.num == unknownNode.num } }
- } else {
- // Only "older than X days" filter applies
- nodeRepository.getNodesOlderThan(olderThanTimestamp.toInt())
- }
-
_nodesToDelete.value =
- initialNodesToConsider.filterNot { node ->
- // Exclude nodes with PKI heard in the last 7 days
- (node.hasPKC && node.lastHeard >= sevenDaysAgoSeconds) ||
- // Exclude ignored or favorite nodes
- node.isIgnored ||
- node.isFavorite
- }
+ cleanNodeDatabaseUseCase.getNodesToClean(
+ olderThanDays = _olderThanDays.value,
+ onlyUnknownNodes = _onlyUnknownNodes.value,
+ currentTimeSeconds = nowSeconds,
+ )
}
}
@@ -126,16 +98,7 @@ constructor(
fun cleanNodes() {
viewModelScope.launch {
val nodeNums = _nodesToDelete.value.map { it.num }
- if (nodeNums.isNotEmpty()) {
- nodeRepository.deleteNodes(nodeNums)
-
- val service = serviceRepository.meshService
- if (service != null) {
- for (nodeNum in nodeNums) {
- service.removeByNodenum(service.packetId, nodeNum)
- }
- }
- }
+ cleanNodeDatabaseUseCase.cleanNodes(nodeNums)
// Clear the list after deletion or if it was empty
_nodesToDelete.value = emptyList()
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
index b87987539..3bae7ef2b 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt
@@ -18,8 +18,6 @@ package org.meshtastic.feature.settings.radio
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ColumnScope
-import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
@@ -35,16 +33,11 @@ import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Storage
import androidx.compose.material.icons.rounded.SystemUpdate
import androidx.compose.material.icons.rounded.Upload
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
@@ -72,10 +65,9 @@ import org.meshtastic.core.resources.shutdown
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
+import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.navigation.ConfigRoute
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
-@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun RadioConfigItemList(
state: RadioConfigState,
@@ -89,130 +81,135 @@ fun RadioConfigItemList(
val enabled = state.connected && !state.responseState.isWaiting() && !isManaged
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
- ExpressiveSection(title = stringResource(Res.string.radio_configuration)) {
- if (isManaged) {
- ManagedMessage()
- }
- ConfigRoute.radioConfigRoutes.forEach {
- ListItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) { onRouteClick(it) }
- }
- }
-
- ExpressiveSection(title = stringResource(Res.string.device_configuration)) {
- if (isManaged) {
- ManagedMessage()
- }
- ListItem(
- text = stringResource(Res.string.device_configuration),
- leadingIcon = Icons.Rounded.AppSettingsAlt,
- trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
- enabled = enabled,
- ) {
- onNavigate(SettingsRoutes.DeviceConfiguration)
- }
- }
-
- ExpressiveSection(title = stringResource(Res.string.module_settings)) {
- if (isManaged) {
- ManagedMessage()
- }
- ListItem(
- text = stringResource(Res.string.module_settings),
- leadingIcon = Icons.Rounded.Settings,
- trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
- enabled = enabled,
- ) {
- onNavigate(SettingsRoutes.ModuleConfiguration)
- }
- }
+ RadioConfigSection(isManaged, enabled, onRouteClick)
+ DeviceConfigSection(isManaged, enabled, onNavigate)
+ ModuleSettingsSection(isManaged, enabled, onNavigate)
if (state.isLocal) {
- ExpressiveSection(title = stringResource(Res.string.backup_restore)) {
- if (isManaged) {
- ManagedMessage()
- }
-
- ListItem(
- text = stringResource(Res.string.import_configuration),
- leadingIcon = Icons.Rounded.Download,
- enabled = enabled,
- onClick = onImport,
- )
- ListItem(
- text = stringResource(Res.string.export_configuration),
- leadingIcon = Icons.Rounded.Upload,
- enabled = enabled,
- onClick = onExport,
- )
- }
+ BackupRestoreSection(isManaged, enabled, onImport, onExport)
}
- ExpressiveSection(title = stringResource(Res.string.administration)) {
- ListItem(
- text = stringResource(Res.string.administration),
- leadingIcon = Icons.Rounded.AdminPanelSettings,
- trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
- leadingIconTint = MaterialTheme.colorScheme.error,
- textColor = MaterialTheme.colorScheme.error,
- trailingIconTint = MaterialTheme.colorScheme.error,
- enabled = enabled,
- ) {
- onNavigate(SettingsRoutes.Administration)
- }
- }
+ AdministrationSection(enabled, onNavigate)
if (state.isLocal) {
- ExpressiveSection(title = stringResource(Res.string.advanced_title)) {
- if (isManaged) {
- ManagedMessage()
- }
-
- if (isOtaCapable) {
- ListItem(
- text = stringResource(Res.string.firmware_update_title),
- leadingIcon = Icons.Rounded.SystemUpdate,
- enabled = enabled,
- onClick = { onNavigate(FirmwareRoutes.FirmwareUpdate) },
- )
- }
-
- ListItem(
- text = stringResource(Res.string.clean_node_database_title),
- leadingIcon = Icons.Rounded.CleaningServices,
- enabled = enabled,
- onClick = { onNavigate(SettingsRoutes.CleanNodeDb) },
- )
-
- ListItem(
- text = stringResource(Res.string.debug_panel),
- leadingIcon = Icons.Rounded.BugReport,
- enabled = enabled,
- onClick = { onNavigate(SettingsRoutes.DebugPanel) },
- )
- }
+ AdvancedSection(isManaged, isOtaCapable, enabled, onNavigate)
}
}
}
@Composable
-fun ExpressiveSection(
- title: String,
- modifier: Modifier = Modifier,
- titleColor: Color = MaterialTheme.colorScheme.primary,
- content: @Composable ColumnScope.() -> Unit,
-) {
- Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
- Text(
- text = title,
- modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth(),
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold,
- color = titleColor,
+private fun RadioConfigSection(isManaged: Boolean, enabled: Boolean, onRouteClick: (Enum<*>) -> Unit) {
+ ExpressiveSection(title = stringResource(Res.string.radio_configuration)) {
+ if (isManaged) {
+ ManagedMessage()
+ }
+ ConfigRoute.radioConfigRoutes.forEach {
+ ListItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) { onRouteClick(it) }
+ }
+ }
+}
+
+@Composable
+private fun DeviceConfigSection(isManaged: Boolean, enabled: Boolean, onNavigate: (Route) -> Unit) {
+ ExpressiveSection(title = stringResource(Res.string.device_configuration)) {
+ if (isManaged) {
+ ManagedMessage()
+ }
+ ListItem(
+ text = stringResource(Res.string.device_configuration),
+ leadingIcon = Icons.Rounded.AppSettingsAlt,
+ trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
+ enabled = enabled,
+ ) {
+ onNavigate(SettingsRoutes.DeviceConfiguration)
+ }
+ }
+}
+
+@Composable
+private fun ModuleSettingsSection(isManaged: Boolean, enabled: Boolean, onNavigate: (Route) -> Unit) {
+ ExpressiveSection(title = stringResource(Res.string.module_settings)) {
+ if (isManaged) {
+ ManagedMessage()
+ }
+ ListItem(
+ text = stringResource(Res.string.module_settings),
+ leadingIcon = Icons.Rounded.Settings,
+ trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
+ enabled = enabled,
+ ) {
+ onNavigate(SettingsRoutes.ModuleConfiguration)
+ }
+ }
+}
+
+@Composable
+private fun BackupRestoreSection(isManaged: Boolean, enabled: Boolean, onImport: () -> Unit, onExport: () -> Unit) {
+ ExpressiveSection(title = stringResource(Res.string.backup_restore)) {
+ if (isManaged) {
+ ManagedMessage()
+ }
+
+ ListItem(
+ text = stringResource(Res.string.import_configuration),
+ leadingIcon = Icons.Rounded.Download,
+ enabled = enabled,
+ onClick = onImport,
)
- Card(
- modifier = Modifier.fillMaxWidth(),
- colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
- content = content,
+ ListItem(
+ text = stringResource(Res.string.export_configuration),
+ leadingIcon = Icons.Rounded.Upload,
+ enabled = enabled,
+ onClick = onExport,
+ )
+ }
+}
+
+@Composable
+private fun AdministrationSection(enabled: Boolean, onNavigate: (Route) -> Unit) {
+ ExpressiveSection(title = stringResource(Res.string.administration)) {
+ ListItem(
+ text = stringResource(Res.string.administration),
+ leadingIcon = Icons.Rounded.AdminPanelSettings,
+ trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
+ leadingIconTint = MaterialTheme.colorScheme.error,
+ textColor = MaterialTheme.colorScheme.error,
+ trailingIconTint = MaterialTheme.colorScheme.error,
+ enabled = enabled,
+ ) {
+ onNavigate(SettingsRoutes.Administration)
+ }
+ }
+}
+
+@Composable
+private fun AdvancedSection(isManaged: Boolean, isOtaCapable: Boolean, enabled: Boolean, onNavigate: (Route) -> Unit) {
+ ExpressiveSection(title = stringResource(Res.string.advanced_title)) {
+ if (isManaged) {
+ ManagedMessage()
+ }
+
+ if (isOtaCapable) {
+ ListItem(
+ text = stringResource(Res.string.firmware_update_title),
+ leadingIcon = Icons.Rounded.SystemUpdate,
+ enabled = enabled,
+ onClick = { onNavigate(FirmwareRoutes.FirmwareUpdate) },
+ )
+ }
+
+ ListItem(
+ text = stringResource(Res.string.clean_node_database_title),
+ leadingIcon = Icons.Rounded.CleaningServices,
+ enabled = enabled,
+ onClick = { onNavigate(SettingsRoutes.CleanNodeDb) },
+ )
+
+ ListItem(
+ text = stringResource(Res.string.debug_panel),
+ leadingIcon = Icons.Rounded.BugReport,
+ enabled = enabled,
+ onClick = { onNavigate(SettingsRoutes.DebugPanel) },
)
}
}
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
index 2cb947c8f..bc61b70c4 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
@@ -21,8 +21,6 @@ import android.app.Application
import android.content.pm.PackageManager
import android.location.Location
import android.net.Uri
-import android.os.RemoteException
-import android.util.Base64
import androidx.annotation.RequiresPermission
import androidx.core.content.ContextCompat
import androidx.lifecycle.SavedStateHandle
@@ -44,15 +42,23 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.StringResource
-import org.json.JSONObject
-import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.LocationRepository
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.database.model.getStringResFrom
+import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase
+import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase
+import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase
+import org.meshtastic.core.domain.usecase.settings.ImportProfileUseCase
+import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase
+import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase
+import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase
+import org.meshtastic.core.domain.usecase.settings.RadioResponseResult
+import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase
+import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase
+import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.Position
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
@@ -61,8 +67,6 @@ import org.meshtastic.core.prefs.map.MapConsentPrefs
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.cant_shutdown
-import org.meshtastic.core.service.ConnectionState
-import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.util.getChannelList
import org.meshtastic.feature.settings.navigation.ConfigRoute
@@ -79,8 +83,6 @@ import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.ModuleConfig
-import org.meshtastic.proto.PortNum
-import org.meshtastic.proto.Routing
import org.meshtastic.proto.User
import java.io.FileOutputStream
import javax.inject.Inject
@@ -119,20 +121,26 @@ constructor(
private val mapConsentPrefs: MapConsentPrefs,
private val analyticsPrefs: AnalyticsPrefs,
private val homoglyphEncodingPrefs: HomoglyphPrefs,
+ private val toggleAnalyticsUseCase: ToggleAnalyticsUseCase,
+ private val toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase,
+ private val importProfileUseCase: ImportProfileUseCase,
+ private val exportProfileUseCase: ExportProfileUseCase,
+ private val exportSecurityConfigUseCase: ExportSecurityConfigUseCase,
+ private val installProfileUseCase: InstallProfileUseCase,
+ private val radioConfigUseCase: RadioConfigUseCase,
+ private val adminActionsUseCase: AdminActionsUseCase,
+ private val processRadioResponseUseCase: ProcessRadioResponseUseCase,
) : ViewModel() {
- private val meshService: IMeshService?
- get() = serviceRepository.meshService
-
var analyticsAllowedFlow = analyticsPrefs.getAnalyticsAllowedChangesFlow()
fun toggleAnalyticsAllowed() {
- analyticsPrefs.analyticsAllowed = !analyticsPrefs.analyticsAllowed
+ toggleAnalyticsUseCase()
}
val homoglyphEncodingEnabledFlow = homoglyphEncodingPrefs.getHomoglyphEncodingEnabledChangesFlow()
fun toggleHomoglyphCharactersEncodingEnabled() {
- homoglyphEncodingPrefs.homoglyphEncodingEnabled = !homoglyphEncodingPrefs.homoglyphEncodingEnabled
+ toggleHomoglyphEncodingUseCase()
}
private val destNum =
@@ -234,52 +242,30 @@ constructor(
Logger.d { "RadioConfigViewModel cleared" }
}
- private fun request(destNum: Int, requestAction: suspend (IMeshService, Int, Int) -> Unit, errorMessage: String) =
- viewModelScope.launch {
- meshService?.let { service ->
- val packetId = service.getPacketId()
- try {
- requestAction(service, packetId, destNum)
- requestIds.update { it.apply { add(packetId) } }
- _radioConfigState.update { state ->
- if (state.responseState is ResponseState.Loading) {
- val total = maxOf(requestIds.value.size, state.responseState.total)
- state.copy(responseState = state.responseState.copy(total = total))
- } else {
- state.copy(
- route = "", // setter (response is PortNum.ROUTING_APP)
- responseState = ResponseState.Loading(),
- )
- }
- }
- } catch (ex: RemoteException) {
- Logger.e { "$errorMessage: ${ex.message}" }
- }
- }
- }
-
fun setOwner(user: User) {
- setRemoteOwner(destNode.value?.num ?: return, user)
+ val destNum = destNode.value?.num ?: return
+ viewModelScope.launch {
+ _radioConfigState.update { it.copy(userConfig = user) }
+ val packetId = radioConfigUseCase.setOwner(destNum, user)
+ registerRequestId(packetId)
+ }
}
- private fun setRemoteOwner(destNum: Int, user: User) = request(
- destNum,
- { service, packetId, _ ->
- _radioConfigState.update { it.copy(userConfig = user) }
- service.setRemoteOwner(packetId, destNum, user.encode())
- },
- "Request setOwner error",
- )
-
- private fun getOwner(destNum: Int) = request(
- destNum,
- { service, packetId, dest -> service.getRemoteOwner(packetId, dest) },
- "Request getOwner error",
- )
+ private fun getOwner(destNum: Int) {
+ viewModelScope.launch {
+ val packetId = radioConfigUseCase.getOwner(destNum)
+ registerRequestId(packetId)
+ }
+ }
fun updateChannels(new: List, old: List) {
val destNum = destNode.value?.num ?: return
- getChannelList(new, old).forEach { setRemoteChannel(destNum, it) }
+ getChannelList(new, old).forEach { channel ->
+ viewModelScope.launch {
+ val packetId = radioConfigUseCase.setRemoteChannel(destNum, channel)
+ registerRequestId(packetId)
+ }
+ }
if (destNum == myNodeNum) {
viewModelScope.launch {
@@ -290,25 +276,16 @@ constructor(
_radioConfigState.update { it.copy(channelList = new) }
}
- private fun setRemoteChannel(destNum: Int, channel: Channel) = request(
- destNum,
- { service, packetId, dest -> service.setRemoteChannel(packetId, dest, channel.encode()) },
- "Request setRemoteChannel error",
- )
-
- private fun getChannel(destNum: Int, index: Int) = request(
- destNum,
- { service, packetId, dest -> service.getRemoteChannel(packetId, dest, index) },
- "Request getChannel error",
- )
-
- fun setConfig(config: Config) {
- setRemoteConfig(destNode.value?.num ?: return, config)
+ private fun getChannel(destNum: Int, index: Int) {
+ viewModelScope.launch {
+ val packetId = radioConfigUseCase.getChannel(destNum, index)
+ registerRequestId(packetId)
+ }
}
- private fun setRemoteConfig(destNum: Int, config: Config) = request(
- destNum,
- { service, packetId, dest ->
+ fun setConfig(config: Config) {
+ val destNum = destNode.value?.num ?: return
+ viewModelScope.launch {
_radioConfigState.update { state ->
state.copy(
radioConfig =
@@ -324,24 +301,22 @@ constructor(
),
)
}
- service.setRemoteConfig(packetId, dest, config.encode())
- },
- "Request setConfig error",
- )
-
- private fun getConfig(destNum: Int, configType: Int) = request(
- destNum,
- { service, packetId, dest -> service.getRemoteConfig(packetId, dest, configType) },
- "Request getConfig error",
- )
-
- fun setModuleConfig(config: ModuleConfig) {
- setRemoteModuleConfig(destNode.value?.num ?: return, config)
+ val packetId = radioConfigUseCase.setConfig(destNum, config)
+ registerRequestId(packetId)
+ }
}
- private fun setRemoteModuleConfig(destNum: Int, config: ModuleConfig) = request(
- destNum,
- { service, packetId, dest ->
+ private fun getConfig(destNum: Int, configType: Int) {
+ viewModelScope.launch {
+ val packetId = radioConfigUseCase.getConfig(destNum, configType)
+ registerRequestId(packetId)
+ }
+ }
+
+ @Suppress("CyclomaticComplexMethod")
+ fun setModuleConfig(config: ModuleConfig) {
+ val destNum = destNode.value?.num ?: return
+ viewModelScope.launch {
_radioConfigState.update { state ->
state.copy(
moduleConfig =
@@ -366,97 +341,78 @@ constructor(
),
)
}
- service.setModuleConfig(packetId, dest, config.encode())
- },
- "Request setModuleConfig error",
- )
+ val packetId = radioConfigUseCase.setModuleConfig(destNum, config)
+ registerRequestId(packetId)
+ }
+ }
- private fun getModuleConfig(destNum: Int, configType: Int) = request(
- destNum,
- { service, packetId, dest -> service.getModuleConfig(packetId, dest, configType) },
- "Request getModuleConfig error",
- )
+ private fun getModuleConfig(destNum: Int, configType: Int) {
+ viewModelScope.launch {
+ val packetId = radioConfigUseCase.getModuleConfig(destNum, configType)
+ registerRequestId(packetId)
+ }
+ }
fun setRingtone(ringtone: String) {
val destNum = destNode.value?.num ?: return
_radioConfigState.update { it.copy(ringtone = ringtone) }
- try {
- meshService?.setRingtone(destNum, ringtone)
- } catch (ex: RemoteException) {
- Logger.e { "Set ringtone error: ${ex.message}" }
- }
+ viewModelScope.launch { radioConfigUseCase.setRingtone(destNum, ringtone) }
}
- private fun getRingtone(destNum: Int) = request(
- destNum,
- { service, packetId, dest -> service.getRingtone(packetId, dest) },
- "Request getRingtone error",
- )
+ private fun getRingtone(destNum: Int) {
+ viewModelScope.launch {
+ val packetId = radioConfigUseCase.getRingtone(destNum)
+ registerRequestId(packetId)
+ }
+ }
fun setCannedMessages(messages: String) {
val destNum = destNode.value?.num ?: return
_radioConfigState.update { it.copy(cannedMessageMessages = messages) }
- try {
- meshService?.setCannedMessages(destNum, messages)
- } catch (ex: RemoteException) {
- Logger.e { "Set canned messages error: ${ex.message}" }
+ viewModelScope.launch { radioConfigUseCase.setCannedMessages(destNum, messages) }
+ }
+
+ private fun getCannedMessages(destNum: Int) {
+ viewModelScope.launch {
+ val packetId = radioConfigUseCase.getCannedMessages(destNum)
+ registerRequestId(packetId)
}
}
- private fun getCannedMessages(destNum: Int) = request(
- destNum,
- { service, packetId, dest -> service.getCannedMessages(packetId, dest) },
- "Request getCannedMessages error",
- )
+ private fun getDeviceConnectionStatus(destNum: Int) {
+ viewModelScope.launch {
+ val packetId = radioConfigUseCase.getDeviceConnectionStatus(destNum)
+ registerRequestId(packetId)
+ }
+ }
- private fun getDeviceConnectionStatus(destNum: Int) = request(
- destNum,
- { service, packetId, dest -> service.getDeviceConnectionStatus(packetId, dest) },
- "Request getDeviceConnectionStatus error",
- )
+ private fun requestShutdown(destNum: Int) {
+ viewModelScope.launch {
+ val packetId = adminActionsUseCase.shutdown(destNum)
+ registerRequestId(packetId)
+ }
+ }
- private fun requestShutdown(destNum: Int) = request(
- destNum,
- { service, packetId, dest -> service.requestShutdown(packetId, dest) },
- "Request shutdown error",
- )
-
- private fun requestReboot(destNum: Int) =
- request(destNum, { service, packetId, dest -> service.requestReboot(packetId, dest) }, "Request reboot error")
+ private fun requestReboot(destNum: Int) {
+ viewModelScope.launch {
+ val packetId = adminActionsUseCase.reboot(destNum)
+ registerRequestId(packetId)
+ }
+ }
private fun requestFactoryReset(destNum: Int) {
- request(
- destNum,
- { service, packetId, dest -> service.requestFactoryReset(packetId, dest) },
- "Request factory reset error",
- )
- if (destNum == myNodeNum) {
- viewModelScope.launch {
- // Clear the service's in-memory node cache first so screens refresh immediately.
- val existingNodeNums = nodeRepository.getNodeDBbyNum().firstOrNull()?.keys?.toList().orEmpty()
- meshService?.let { service ->
- existingNodeNums.forEach { service.removeByNodenum(service.getPacketId(), it) }
- }
- nodeRepository.clearNodeDB()
- }
+ viewModelScope.launch {
+ val isLocal = (destNum == myNodeNum)
+ val packetId = adminActionsUseCase.factoryReset(destNum, isLocal)
+ registerRequestId(packetId)
}
}
private fun requestNodedbReset(destNum: Int, preserveFavorites: Boolean) {
- request(
- destNum,
- { service, packetId, dest -> service.requestNodedbReset(packetId, dest, preserveFavorites) },
- "Request NodeDB reset error",
- )
- if (destNum == myNodeNum) {
- viewModelScope.launch {
- // Clear the service's in-memory node cache as well so UI updates immediately.
- val existingNodeNums = nodeRepository.getNodeDBbyNum().firstOrNull()?.keys?.toList().orEmpty()
- meshService?.let { service ->
- existingNodeNums.forEach { service.removeByNodenum(service.getPacketId(), it) }
- }
- nodeRepository.clearNodeDB(preserveFavorites)
- }
+ viewModelScope.launch {
+ val isLocal = (destNum == myNodeNum)
+ val packetId = adminActionsUseCase.nodedbReset(destNum, preserveFavorites, isLocal)
+ registerRequestId(packetId)
}
}
@@ -484,21 +440,18 @@ constructor(
fun setFixedPosition(position: Position) {
val destNum = destNode.value?.num ?: return
- try {
- meshService?.setFixedPosition(destNum, position)
- } catch (ex: RemoteException) {
- Logger.e { "Set fixed position error: ${ex.message}" }
- }
+ viewModelScope.launch { radioConfigUseCase.setFixedPosition(destNum, position) }
}
- fun removeFixedPosition() = setFixedPosition(Position(0.0, 0.0, 0))
+ fun removeFixedPosition() {
+ val destNum = destNode.value?.num ?: return
+ viewModelScope.launch { radioConfigUseCase.removeFixedPosition(destNum) }
+ }
fun importProfile(uri: Uri, onResult: (DeviceProfile) -> Unit) = viewModelScope.launch(Dispatchers.IO) {
try {
- app.contentResolver.openInputStream(uri).use { inputStream ->
- val bytes = inputStream?.readBytes() ?: ByteArray(0)
- val protobuf = DeviceProfile.ADAPTER.decode(bytes)
- onResult(protobuf)
+ app.contentResolver.openInputStream(uri)?.use { inputStream ->
+ importProfileUseCase(inputStream).onSuccess(onResult).onFailure { throw it }
}
} catch (ex: Exception) {
Logger.e { "Import DeviceProfile error: ${ex.message}" }
@@ -506,104 +459,44 @@ constructor(
}
}
- fun exportProfile(uri: Uri, profile: DeviceProfile) = viewModelScope.launch { writeToUri(uri, profile) }
-
- private suspend fun writeToUri(uri: Uri, message: com.squareup.wire.Message<*, *>) = withContext(Dispatchers.IO) {
- try {
- app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
- FileOutputStream(parcelFileDescriptor.fileDescriptor).use { outputStream ->
- outputStream.write(message.encode())
+ fun exportProfile(uri: Uri, profile: DeviceProfile) = viewModelScope.launch {
+ withContext(Dispatchers.IO) {
+ try {
+ app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
+ FileOutputStream(parcelFileDescriptor.fileDescriptor).use { outputStream ->
+ exportProfileUseCase(outputStream, profile)
+ .onSuccess { setResponseStateSuccess() }
+ .onFailure { throw it }
+ }
}
+ } catch (ex: Exception) {
+ Logger.e { "Can't write file error: ${ex.message}" }
+ sendError(ex.customMessage)
}
- setResponseStateSuccess()
- } catch (ex: Exception) {
- Logger.e { "Can't write file error: ${ex.message}" }
- sendError(ex.customMessage)
}
}
- fun exportSecurityConfig(uri: Uri, securityConfig: Config.SecurityConfig) =
- viewModelScope.launch { writeSecurityKeysJsonToUri(uri, securityConfig) }
-
- private val indentSpaces = 4
-
- private suspend fun writeSecurityKeysJsonToUri(uri: Uri, securityConfig: Config.SecurityConfig) =
+ fun exportSecurityConfig(uri: Uri, securityConfig: Config.SecurityConfig) = viewModelScope.launch {
withContext(Dispatchers.IO) {
try {
- val publicKeyBytes = securityConfig.public_key.toByteArray()
- val privateKeyBytes = securityConfig.private_key.toByteArray()
-
- // Convert byte arrays to Base64 strings for human readability in JSON
- 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)
- }
-
- // Convert JSON object to a string
- val jsonString = jsonObject.toString(indentSpaces)
-
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
FileOutputStream(parcelFileDescriptor.fileDescriptor).use { outputStream ->
- outputStream.write(jsonString.toByteArray(Charsets.UTF_8))
+ exportSecurityConfigUseCase(outputStream, securityConfig)
+ .onSuccess { setResponseStateSuccess() }
+ .onFailure { throw it }
}
}
- setResponseStateSuccess()
} catch (ex: Exception) {
val errorMessage = "Can't write security keys JSON error: ${ex.message}"
Logger.e { errorMessage }
sendError(ex.customMessage)
}
}
+ }
fun installProfile(protobuf: DeviceProfile) {
val destNum = destNode.value?.num ?: return
- with(protobuf) {
- meshService?.beginEditSettings(destNum)
- if (long_name != null || short_name != null) {
- destNode.value?.user?.let {
- val user = it.copy(long_name = long_name ?: it.long_name, short_name = short_name ?: it.short_name)
- setOwner(user)
- }
- }
- config?.let { lc ->
- lc.device?.let { setConfig(Config(device = it)) }
- lc.position?.let { setConfig(Config(position = it)) }
- lc.power?.let { setConfig(Config(power = it)) }
- lc.network?.let { setConfig(Config(network = it)) }
- lc.display?.let { setConfig(Config(display = it)) }
- lc.lora?.let { setConfig(Config(lora = it)) }
- lc.bluetooth?.let { setConfig(Config(bluetooth = it)) }
- lc.security?.let { setConfig(Config(security = it)) }
- }
- if (fixed_position != null) {
- setFixedPosition(Position(fixed_position!!))
- }
- module_config?.let { lmc ->
- lmc.mqtt?.let { setModuleConfig(ModuleConfig(mqtt = it)) }
- lmc.serial?.let { setModuleConfig(ModuleConfig(serial = it)) }
- lmc.external_notification?.let { setModuleConfig(ModuleConfig(external_notification = it)) }
- lmc.store_forward?.let { setModuleConfig(ModuleConfig(store_forward = it)) }
- lmc.range_test?.let { setModuleConfig(ModuleConfig(range_test = it)) }
- lmc.telemetry?.let { setModuleConfig(ModuleConfig(telemetry = it)) }
- lmc.canned_message?.let { setModuleConfig(ModuleConfig(canned_message = it)) }
- lmc.audio?.let { setModuleConfig(ModuleConfig(audio = it)) }
- lmc.remote_hardware?.let { setModuleConfig(ModuleConfig(remote_hardware = it)) }
- lmc.neighbor_info?.let { setModuleConfig(ModuleConfig(neighbor_info = it)) }
- lmc.ambient_lighting?.let { setModuleConfig(ModuleConfig(ambient_lighting = it)) }
- lmc.detection_sensor?.let { setModuleConfig(ModuleConfig(detection_sensor = it)) }
- lmc.paxcounter?.let { setModuleConfig(ModuleConfig(paxcounter = it)) }
- lmc.statusmessage?.let { setModuleConfig(ModuleConfig(statusmessage = it)) }
- lmc.traffic_management?.let { setModuleConfig(ModuleConfig(traffic_management = it)) }
- lmc.tak?.let { setModuleConfig(ModuleConfig(tak = it)) }
- }
- meshService?.commitEditSettings(destNum)
- }
+ viewModelScope.launch { installProfileUseCase(destNum, protobuf, destNode.value?.user) }
}
fun clearPacketResponse() {
@@ -686,6 +579,8 @@ constructor(
private fun sendError(id: StringResource) = setResponseStateError(UiText.Resource(id))
+ private fun sendError(error: UiText) = setResponseStateError(error)
+
private fun setResponseStateError(error: UiText) {
_radioConfigState.update { it.copy(responseState = ResponseState.Error(error)) }
}
@@ -701,171 +596,156 @@ constructor(
}
}
- private fun processPacketResponse(packet: MeshPacket) {
- val data = packet.decoded ?: return
- if (data.request_id !in requestIds.value) return
- val route = radioConfigState.value.route
-
- val destNum = destNode.value?.num ?: return
- val debugMsg = "requestId: ${data.request_id.toUInt()} to: ${destNum.toUInt()} received %s"
-
- if (data.portnum == PortNum.ROUTING_APP) {
- val parsed = Routing.ADAPTER.decode(data.payload)
- Logger.d { debugMsg.format(parsed.error_reason?.name) }
- if (parsed.error_reason != Routing.Error.NONE) {
- sendError(getStringResFrom(parsed.error_reason?.value ?: 0))
- } else if (packet.from == destNum && route.isEmpty()) {
- requestIds.update { it.apply { remove(data.request_id) } }
- if (requestIds.value.isEmpty()) {
- setResponseStateSuccess()
- } else {
- incrementCompleted()
- }
+ private fun registerRequestId(packetId: Int) {
+ requestIds.update { it.apply { add(packetId) } }
+ _radioConfigState.update { state ->
+ if (state.responseState is ResponseState.Loading) {
+ val total = maxOf(requestIds.value.size, state.responseState.total)
+ state.copy(responseState = state.responseState.copy(total = total))
+ } else {
+ state.copy(
+ route = "", // setter (response is PortNum.ROUTING_APP)
+ responseState = ResponseState.Loading(),
+ )
}
}
- if (data.portnum == PortNum.ADMIN_APP) {
- val parsed = AdminMessage.ADAPTER.decode(data.payload)
- // Explicitly log the non-null field name for clarity
- val variant =
- when {
- parsed.get_device_metadata_response != null -> "get_device_metadata_response"
- parsed.get_channel_response != null -> "get_channel_response"
- parsed.get_owner_response != null -> "get_owner_response"
- parsed.get_config_response != null -> "get_config_response"
- parsed.get_module_config_response != null -> "get_module_config_response"
- parsed.get_canned_message_module_messages_response != null ->
- "get_canned_message_module_messages_response"
- parsed.get_ringtone_response != null -> "get_ringtone_response"
- parsed.get_device_connection_status_response != null -> "get_device_connection_status_response"
- else -> "unknown"
- }
- Logger.d { debugMsg.format(variant) }
- if (destNum != packet.from) {
- sendError("Unexpected sender: ${packet.from.toUInt()} instead of ${destNum.toUInt()}.")
- return
- }
- when {
- parsed.get_device_metadata_response != null -> {
- _radioConfigState.update { it.copy(metadata = parsed.get_device_metadata_response) }
- incrementCompleted()
- }
+ }
- parsed.get_channel_response != null -> {
- val response = parsed.get_channel_response!!
- // Stop once we get to the first disabled entry
- if (response.role != Channel.Role.DISABLED) {
- _radioConfigState.update { state ->
- state.copy(
- channelList =
- state.channelList.toMutableList().apply {
- val index = response.index
- val settings = response.settings ?: ChannelSettings()
- // Make sure list is large enough
- while (size <= index) add(ChannelSettings())
- set(index, settings)
- },
- )
- }
- incrementCompleted()
- val index = response.index
- if (index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) {
- // Not done yet, request next channel
- getChannel(destNum, index + 1)
- }
+ private fun processPacketResponse(packet: MeshPacket) {
+ val destNum = destNode.value?.num ?: return
+ val result = processRadioResponseUseCase(packet, destNum, requestIds.value) ?: return
+ val route = radioConfigState.value.route
+
+ when (result) {
+ is RadioResponseResult.Error -> sendError(result.message)
+ is RadioResponseResult.Success -> {
+ if (route.isEmpty()) {
+ val data = packet.decoded!!
+ requestIds.update { it.apply { remove(data.request_id) } }
+ if (requestIds.value.isEmpty()) {
+ setResponseStateSuccess()
} else {
- // Received last channel, update total and start channel editor
- setResponseStateTotal(response.index + 1)
+ incrementCompleted()
}
}
+ }
- parsed.get_owner_response != null -> {
- _radioConfigState.update { it.copy(userConfig = parsed.get_owner_response!!) }
- incrementCompleted()
- }
+ is RadioResponseResult.Metadata -> {
+ _radioConfigState.update { it.copy(metadata = result.metadata) }
+ incrementCompleted()
+ }
- parsed.get_config_response != null -> {
- val response = parsed.get_config_response!!
+ is RadioResponseResult.ChannelResponse -> {
+ val response = result.channel
+ // Stop once we get to the first disabled entry
+ if (response.role != Channel.Role.DISABLED) {
_radioConfigState.update { state ->
state.copy(
- radioConfig =
- state.radioConfig.copy(
- device = response.device ?: state.radioConfig.device,
- position = response.position ?: state.radioConfig.position,
- power = response.power ?: state.radioConfig.power,
- network = response.network ?: state.radioConfig.network,
- display = response.display ?: state.radioConfig.display,
- lora = response.lora ?: state.radioConfig.lora,
- bluetooth = response.bluetooth ?: state.radioConfig.bluetooth,
- security = response.security ?: state.radioConfig.security,
- ),
+ channelList =
+ state.channelList.toMutableList().apply {
+ val index = response.index
+ val settings = response.settings ?: ChannelSettings()
+ // Make sure list is large enough
+ while (size <= index) add(ChannelSettings())
+ set(index, settings)
+ },
)
}
incrementCompleted()
- }
-
- parsed.get_module_config_response != null -> {
- val response = parsed.get_module_config_response!!
- _radioConfigState.update { state ->
- state.copy(
- moduleConfig =
- state.moduleConfig.copy(
- mqtt = response.mqtt ?: state.moduleConfig.mqtt,
- serial = response.serial ?: state.moduleConfig.serial,
- external_notification =
- response.external_notification ?: state.moduleConfig.external_notification,
- store_forward = response.store_forward ?: state.moduleConfig.store_forward,
- range_test = response.range_test ?: state.moduleConfig.range_test,
- telemetry = response.telemetry ?: state.moduleConfig.telemetry,
- canned_message = response.canned_message ?: state.moduleConfig.canned_message,
- audio = response.audio ?: state.moduleConfig.audio,
- remote_hardware = response.remote_hardware ?: state.moduleConfig.remote_hardware,
- neighbor_info = response.neighbor_info ?: state.moduleConfig.neighbor_info,
- ambient_lighting = response.ambient_lighting ?: state.moduleConfig.ambient_lighting,
- detection_sensor = response.detection_sensor ?: state.moduleConfig.detection_sensor,
- paxcounter = response.paxcounter ?: state.moduleConfig.paxcounter,
- statusmessage = response.statusmessage ?: state.moduleConfig.statusmessage,
- traffic_management =
- response.traffic_management ?: state.moduleConfig.traffic_management,
- tak = response.tak ?: state.moduleConfig.tak,
- ),
- )
+ val index = response.index
+ if (index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) {
+ // Not done yet, request next channel
+ getChannel(destNum, index + 1)
}
- incrementCompleted()
+ } else {
+ // Received last channel, update total and start channel editor
+ setResponseStateTotal(response.index + 1)
}
-
- parsed.get_canned_message_module_messages_response != null -> {
- _radioConfigState.update {
- it.copy(cannedMessageMessages = parsed.get_canned_message_module_messages_response!!)
- }
- incrementCompleted()
- }
-
- parsed.get_ringtone_response != null -> {
- _radioConfigState.update { it.copy(ringtone = parsed.get_ringtone_response!!) }
- incrementCompleted()
- }
-
- parsed.get_device_connection_status_response != null -> {
- _radioConfigState.update {
- it.copy(deviceConnectionStatus = parsed.get_device_connection_status_response!!)
- }
- incrementCompleted()
- }
-
- else -> Logger.d { "No custom processing needed for $parsed" }
}
- if (AdminRoute.entries.any { it.name == route }) {
- sendAdminRequest(destNum)
+ is RadioResponseResult.Owner -> {
+ _radioConfigState.update { it.copy(userConfig = result.user) }
+ incrementCompleted()
}
- requestIds.update { it.apply { remove(data.request_id) } }
- if (requestIds.value.isEmpty()) {
- if (route.isNotEmpty() && !AdminRoute.entries.any { it.name == route }) {
- clearPacketResponse()
- } else if (route.isEmpty()) {
- setResponseStateSuccess()
+ is RadioResponseResult.ConfigResponse -> {
+ val response = result.config
+ _radioConfigState.update { state ->
+ state.copy(
+ radioConfig =
+ state.radioConfig.copy(
+ device = response.device ?: state.radioConfig.device,
+ position = response.position ?: state.radioConfig.position,
+ power = response.power ?: state.radioConfig.power,
+ network = response.network ?: state.radioConfig.network,
+ display = response.display ?: state.radioConfig.display,
+ lora = response.lora ?: state.radioConfig.lora,
+ bluetooth = response.bluetooth ?: state.radioConfig.bluetooth,
+ security = response.security ?: state.radioConfig.security,
+ ),
+ )
}
+ incrementCompleted()
+ }
+
+ is RadioResponseResult.ModuleConfigResponse -> {
+ val response = result.config
+ _radioConfigState.update { state ->
+ state.copy(
+ moduleConfig =
+ state.moduleConfig.copy(
+ mqtt = response.mqtt ?: state.moduleConfig.mqtt,
+ serial = response.serial ?: state.moduleConfig.serial,
+ external_notification =
+ response.external_notification ?: state.moduleConfig.external_notification,
+ store_forward = response.store_forward ?: state.moduleConfig.store_forward,
+ range_test = response.range_test ?: state.moduleConfig.range_test,
+ telemetry = response.telemetry ?: state.moduleConfig.telemetry,
+ canned_message = response.canned_message ?: state.moduleConfig.canned_message,
+ audio = response.audio ?: state.moduleConfig.audio,
+ remote_hardware = response.remote_hardware ?: state.moduleConfig.remote_hardware,
+ neighbor_info = response.neighbor_info ?: state.moduleConfig.neighbor_info,
+ ambient_lighting = response.ambient_lighting ?: state.moduleConfig.ambient_lighting,
+ detection_sensor = response.detection_sensor ?: state.moduleConfig.detection_sensor,
+ paxcounter = response.paxcounter ?: state.moduleConfig.paxcounter,
+ statusmessage = response.statusmessage ?: state.moduleConfig.statusmessage,
+ traffic_management =
+ response.traffic_management ?: state.moduleConfig.traffic_management,
+ tak = response.tak ?: state.moduleConfig.tak,
+ ),
+ )
+ }
+ incrementCompleted()
+ }
+
+ is RadioResponseResult.CannedMessages -> {
+ _radioConfigState.update { it.copy(cannedMessageMessages = result.messages) }
+ incrementCompleted()
+ }
+
+ is RadioResponseResult.Ringtone -> {
+ _radioConfigState.update { it.copy(ringtone = result.ringtone) }
+ incrementCompleted()
+ }
+
+ is RadioResponseResult.ConnectionStatus -> {
+ _radioConfigState.update { it.copy(deviceConnectionStatus = result.status) }
+ incrementCompleted()
+ }
+ }
+
+ if (AdminRoute.entries.any { it.name == route }) {
+ sendAdminRequest(destNum)
+ }
+
+ val requestId = packet.decoded?.request_id ?: return
+ requestIds.update { it.apply { remove(requestId) } }
+
+ if (requestIds.value.isEmpty()) {
+ if (route.isNotEmpty() && !AdminRoute.entries.any { it.name == route }) {
+ clearPacketResponse()
+ } else if (route.isEmpty()) {
+ setResponseStateSuccess()
}
}
}
diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/HomoglyphSettingTest.kt b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/HomoglyphSettingTest.kt
index 1c1863346..8ffb10fae 100644
--- a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/HomoglyphSettingTest.kt
+++ b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/HomoglyphSettingTest.kt
@@ -28,6 +28,7 @@ import org.junit.runner.RunWith
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.use_homoglyph_characters_encoding
+import org.meshtastic.feature.settings.component.HomoglyphSetting
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.Locale
diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt
new file mode 100644
index 000000000..9879d8903
--- /dev/null
+++ b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/SettingsViewModelTest.kt
@@ -0,0 +1,128 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.settings
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.common.BuildConfigProvider
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.data.repository.RadioConfigRepository
+import org.meshtastic.core.database.DatabaseManager
+import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase
+import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase
+import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase
+import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase
+import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase
+import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase
+import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase
+import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase
+import org.meshtastic.core.model.RadioController
+import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
+import org.meshtastic.core.prefs.ui.UiPrefs
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class SettingsViewModelTest {
+
+ private val testDispatcher = StandardTestDispatcher()
+
+ private val radioConfigRepository: RadioConfigRepository = mockk(relaxed = true)
+ private val radioController: RadioController = mockk(relaxed = true)
+ private val nodeRepository: NodeRepository = mockk(relaxed = true)
+ private val uiPrefs: UiPrefs = mockk(relaxed = true)
+ private val buildConfigProvider: BuildConfigProvider = mockk(relaxed = true)
+ private val databaseManager: DatabaseManager = mockk(relaxed = true)
+ private val meshLogPrefs: MeshLogPrefs = mockk(relaxed = true)
+
+ private val setThemeUseCase: SetThemeUseCase = mockk(relaxed = true)
+ private val setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase = mockk(relaxed = true)
+ private val setProvideLocationUseCase: SetProvideLocationUseCase = mockk(relaxed = true)
+ private val setDatabaseCacheLimitUseCase: SetDatabaseCacheLimitUseCase = mockk(relaxed = true)
+ private val setMeshLogSettingsUseCase: SetMeshLogSettingsUseCase = mockk(relaxed = true)
+ private val meshLocationUseCase: MeshLocationUseCase = mockk(relaxed = true)
+ private val exportDataUseCase: ExportDataUseCase = mockk(relaxed = true)
+ private val isOtaCapableUseCase: IsOtaCapableUseCase = mockk(relaxed = true)
+
+ private lateinit var viewModel: SettingsViewModel
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+
+ // Return real StateFlows to avoid ClassCastException
+ every { databaseManager.cacheLimit } returns MutableStateFlow(100)
+ every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
+ every { nodeRepository.ourNodeInfo } returns MutableStateFlow(null)
+ every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(org.meshtastic.proto.LocalConfig())
+ every { radioController.connectionState } returns
+ MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected)
+ every { isOtaCapableUseCase() } returns flowOf(false)
+
+ viewModel =
+ SettingsViewModel(
+ app = mockk(),
+ radioConfigRepository = radioConfigRepository,
+ radioController = radioController,
+ nodeRepository = nodeRepository,
+ uiPrefs = uiPrefs,
+ buildConfigProvider = buildConfigProvider,
+ databaseManager = databaseManager,
+ meshLogPrefs = meshLogPrefs,
+ setThemeUseCase = setThemeUseCase,
+ setAppIntroCompletedUseCase = setAppIntroCompletedUseCase,
+ setProvideLocationUseCase = setProvideLocationUseCase,
+ setDatabaseCacheLimitUseCase = setDatabaseCacheLimitUseCase,
+ setMeshLogSettingsUseCase = setMeshLogSettingsUseCase,
+ meshLocationUseCase = meshLocationUseCase,
+ exportDataUseCase = exportDataUseCase,
+ isOtaCapableUseCase = isOtaCapableUseCase,
+ )
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `setTheme calls useCase`() {
+ viewModel.setTheme(1)
+ verify { setThemeUseCase(1) }
+ }
+
+ @Test
+ fun `setDbCacheLimit calls useCase`() {
+ viewModel.setDbCacheLimit(50)
+ verify { setDatabaseCacheLimitUseCase(50) }
+ }
+
+ @Test
+ fun `startProvidingLocation calls useCase`() {
+ viewModel.startProvidingLocation()
+ verify { meshLocationUseCase.startProvidingLocation() }
+ }
+}
diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModelTest.kt b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModelTest.kt
new file mode 100644
index 000000000..b7a256bf4
--- /dev/null
+++ b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModelTest.kt
@@ -0,0 +1,115 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.feature.settings.debugging
+
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.data.repository.MeshLogRepository
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
+import org.meshtastic.core.ui.util.AlertManager
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class DebugViewModelTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+
+ private val meshLogRepository: MeshLogRepository = mockk(relaxed = true)
+ private val nodeRepository: NodeRepository = mockk(relaxed = true)
+ private val meshLogPrefs: MeshLogPrefs = mockk(relaxed = true)
+ private val alertManager: AlertManager = mockk(relaxed = true)
+
+ private lateinit var viewModel: DebugViewModel
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+
+ every { meshLogRepository.getAllLogs() } returns flowOf(emptyList())
+ every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
+ every { meshLogPrefs.retentionDays } returns 7
+ every { meshLogPrefs.loggingEnabled } returns true
+
+ viewModel =
+ DebugViewModel(
+ meshLogRepository = meshLogRepository,
+ nodeRepository = nodeRepository,
+ meshLogPrefs = meshLogPrefs,
+ alertManager = alertManager,
+ )
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `setRetentionDays updates prefs and deletes old logs`() = runTest {
+ viewModel.setRetentionDays(14)
+
+ verify { meshLogPrefs.retentionDays = 14 }
+ coVerify { meshLogRepository.deleteLogsOlderThan(14) }
+ assertEquals(14, viewModel.retentionDays.value)
+ }
+
+ @Test
+ fun `setLoggingEnabled false deletes all logs`() = runTest {
+ viewModel.setLoggingEnabled(false)
+
+ verify { meshLogPrefs.loggingEnabled = false }
+ coVerify { meshLogRepository.deleteAll() }
+ assertEquals(false, viewModel.loggingEnabled.value)
+ }
+
+ @Test
+ fun `search filters results correctly`() = runTest {
+ val logs =
+ listOf(
+ DebugViewModel.UiMeshLog("1", "TypeA", "Date1", "Message Apple"),
+ DebugViewModel.UiMeshLog("2", "TypeB", "Date2", "Message Banana"),
+ )
+
+ viewModel.searchManager.updateMatches("Apple", logs)
+
+ val state = viewModel.searchState.value
+ assertEquals(true, state.hasMatches)
+ assertEquals(1, state.allMatches.size)
+ assertEquals(0, state.allMatches[0].logIndex)
+ }
+
+ @Test
+ fun `requestDeleteAllLogs shows alert`() {
+ viewModel.requestDeleteAllLogs()
+ verify { alertManager.showAlert(titleRes = any(), messageRes = any(), onConfirm = any()) }
+ }
+}
diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModelTest.kt b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModelTest.kt
new file mode 100644
index 000000000..35fd61f2b
--- /dev/null
+++ b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/filter/FilterSettingsViewModelTest.kt
@@ -0,0 +1,67 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.feature.settings.filter
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.prefs.filter.FilterPrefs
+import org.meshtastic.core.service.filter.MessageFilterService
+
+class FilterSettingsViewModelTest {
+
+ private val filterPrefs: FilterPrefs = mockk(relaxed = true)
+ private val messageFilterService: MessageFilterService = mockk(relaxed = true)
+
+ private lateinit var viewModel: FilterSettingsViewModel
+
+ @Before
+ fun setUp() {
+ every { filterPrefs.filterEnabled } returns true
+ every { filterPrefs.filterWords } returns setOf("apple", "banana")
+
+ viewModel = FilterSettingsViewModel(filterPrefs = filterPrefs, messageFilterService = messageFilterService)
+ }
+
+ @Test
+ fun `setFilterEnabled updates prefs and state`() {
+ viewModel.setFilterEnabled(false)
+ verify { filterPrefs.filterEnabled = false }
+ assertEquals(false, viewModel.filterEnabled.value)
+ }
+
+ @Test
+ fun `addFilterWord updates prefs and rebuilds patterns`() {
+ viewModel.addFilterWord("cherry")
+
+ verify { filterPrefs.filterWords = any() }
+ verify { messageFilterService.rebuildPatterns() }
+ assertEquals(listOf("apple", "banana", "cherry"), viewModel.filterWords.value)
+ }
+
+ @Test
+ fun `removeFilterWord updates prefs and rebuilds patterns`() {
+ viewModel.removeFilterWord("apple")
+
+ verify { filterPrefs.filterWords = any() }
+ verify { messageFilterService.rebuildPatterns() }
+ assertEquals(listOf("banana"), viewModel.filterWords.value)
+ }
+}
diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModelTest.kt b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModelTest.kt
new file mode 100644
index 000000000..07beee89d
--- /dev/null
+++ b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/CleanNodeDatabaseViewModelTest.kt
@@ -0,0 +1,82 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.feature.settings.radio
+
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.domain.usecase.settings.CleanNodeDatabaseUseCase
+import org.meshtastic.core.ui.util.AlertManager
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class CleanNodeDatabaseViewModelTest {
+
+ private val testDispatcher = StandardTestDispatcher()
+ private lateinit var cleanNodeDatabaseUseCase: CleanNodeDatabaseUseCase
+ private lateinit var alertManager: AlertManager
+ private lateinit var viewModel: CleanNodeDatabaseViewModel
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ cleanNodeDatabaseUseCase = mockk(relaxed = true)
+ alertManager = mockk(relaxed = true)
+ viewModel = CleanNodeDatabaseViewModel(cleanNodeDatabaseUseCase, alertManager)
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `getNodesToDelete updates state`() = runTest {
+ val nodes = listOf(Node(num = 1), Node(num = 2))
+ coEvery { cleanNodeDatabaseUseCase.getNodesToClean(any(), any(), any()) } returns nodes
+
+ viewModel.getNodesToDelete()
+ advanceUntilIdle()
+
+ assertEquals(nodes, viewModel.nodesToDelete.value)
+ }
+
+ @Test
+ fun `cleanNodes calls useCase and clears state`() = runTest {
+ val nodes = listOf(Node(num = 1))
+ coEvery { cleanNodeDatabaseUseCase.getNodesToClean(any(), any(), any()) } returns nodes
+ viewModel.getNodesToDelete()
+ advanceUntilIdle()
+
+ viewModel.cleanNodes()
+ advanceUntilIdle()
+
+ coVerify { cleanNodeDatabaseUseCase.cleanNodes(listOf(1)) }
+ assertEquals(0, viewModel.nodesToDelete.value.size)
+ }
+}
diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt
new file mode 100644
index 000000000..cc45c7075
--- /dev/null
+++ b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt
@@ -0,0 +1,241 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.feature.settings.radio
+
+import androidx.lifecycle.SavedStateHandle
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.meshtastic.core.data.repository.LocationRepository
+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.model.Node
+import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase
+import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase
+import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase
+import org.meshtastic.core.domain.usecase.settings.ImportProfileUseCase
+import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase
+import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase
+import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase
+import org.meshtastic.core.domain.usecase.settings.RadioResponseResult
+import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase
+import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase
+import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
+import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
+import org.meshtastic.core.prefs.map.MapConsentPrefs
+import org.meshtastic.core.service.ServiceRepository
+import org.meshtastic.proto.ChannelSet
+import org.meshtastic.proto.ChannelSettings
+import org.meshtastic.proto.Config
+import org.meshtastic.proto.DeviceMetadata
+import org.meshtastic.proto.DeviceProfile
+import org.meshtastic.proto.LocalConfig
+import org.meshtastic.proto.LocalModuleConfig
+import org.meshtastic.proto.MeshPacket
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class RadioConfigViewModelTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+
+ private val radioConfigRepository: RadioConfigRepository = mockk(relaxed = true)
+ private val packetRepository: PacketRepository = mockk(relaxed = true)
+ private val serviceRepository: ServiceRepository = mockk(relaxed = true)
+ private val nodeRepository: NodeRepository = mockk(relaxed = true)
+ private val locationRepository: LocationRepository = mockk(relaxed = true)
+ private val mapConsentPrefs: MapConsentPrefs = mockk(relaxed = true)
+ private val analyticsPrefs: AnalyticsPrefs = mockk(relaxed = true)
+ private val homoglyphEncodingPrefs: HomoglyphPrefs = mockk(relaxed = true)
+ private val toggleAnalyticsUseCase: ToggleAnalyticsUseCase = mockk(relaxed = true)
+ private val toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase = mockk(relaxed = true)
+ private val importProfileUseCase: ImportProfileUseCase = mockk(relaxed = true)
+ private val exportProfileUseCase: ExportProfileUseCase = mockk(relaxed = true)
+ private val exportSecurityConfigUseCase: ExportSecurityConfigUseCase = mockk(relaxed = true)
+ private val installProfileUseCase: InstallProfileUseCase = mockk(relaxed = true)
+ private val radioConfigUseCase: RadioConfigUseCase = mockk(relaxed = true)
+ private val adminActionsUseCase: AdminActionsUseCase = mockk(relaxed = true)
+ private val processRadioResponseUseCase: ProcessRadioResponseUseCase = mockk(relaxed = true)
+
+ private lateinit var viewModel: RadioConfigViewModel
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
+ every { radioConfigRepository.deviceProfileFlow } returns MutableStateFlow(DeviceProfile())
+ every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(LocalConfig())
+ every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet())
+ every { radioConfigRepository.moduleConfigFlow } returns MutableStateFlow(LocalModuleConfig())
+ every { serviceRepository.meshPacketFlow } returns MutableSharedFlow()
+ every { serviceRepository.connectionState } returns
+ MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected)
+ every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
+
+ viewModel = createViewModel()
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ private fun createViewModel() = RadioConfigViewModel(
+ savedStateHandle = SavedStateHandle(),
+ app = mockk(),
+ radioConfigRepository = radioConfigRepository,
+ packetRepository = packetRepository,
+ serviceRepository = serviceRepository,
+ nodeRepository = nodeRepository,
+ locationRepository = locationRepository,
+ mapConsentPrefs = mapConsentPrefs,
+ analyticsPrefs = analyticsPrefs,
+ homoglyphEncodingPrefs = homoglyphEncodingPrefs,
+ toggleAnalyticsUseCase = toggleAnalyticsUseCase,
+ toggleHomoglyphEncodingUseCase = toggleHomoglyphEncodingUseCase,
+ importProfileUseCase = importProfileUseCase,
+ exportProfileUseCase = exportProfileUseCase,
+ exportSecurityConfigUseCase = exportSecurityConfigUseCase,
+ installProfileUseCase = installProfileUseCase,
+ radioConfigUseCase = radioConfigUseCase,
+ adminActionsUseCase = adminActionsUseCase,
+ processRadioResponseUseCase = processRadioResponseUseCase,
+ )
+
+ @Test
+ fun `setConfig updates state and calls useCase`() = runTest {
+ val node = Node(num = 123)
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
+ viewModel = createViewModel()
+
+ val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER))
+ coEvery { radioConfigUseCase.setConfig(123, any()) } returns 42
+
+ viewModel.setConfig(config)
+
+ val state = viewModel.radioConfigState.value
+ assertEquals(Config.DeviceConfig.Role.ROUTER, state.radioConfig.device?.role)
+ coVerify { radioConfigUseCase.setConfig(123, config) }
+ }
+
+ @Test
+ fun `processPacketResponse updates state on metadata result`() = runTest {
+ val node = Node(num = 123)
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
+
+ val packet = MeshPacket()
+ val metadata = DeviceMetadata(firmware_version = "3.0.0")
+ val packetFlow = MutableSharedFlow()
+
+ every { serviceRepository.meshPacketFlow } returns packetFlow
+ every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.Metadata(metadata)
+
+ viewModel = createViewModel()
+
+ packetFlow.emit(packet)
+
+ val state = viewModel.radioConfigState.value
+ assertEquals("3.0.0", state.metadata?.firmware_version)
+ }
+
+ @Test
+ fun `setOwner calls useCase`() = runTest {
+ val node = Node(num = 123)
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
+ viewModel = createViewModel()
+
+ val user = org.meshtastic.proto.User(long_name = "Test")
+ coEvery { radioConfigUseCase.setOwner(123, any()) } returns 42
+
+ viewModel.setOwner(user)
+
+ coVerify { radioConfigUseCase.setOwner(123, user) }
+ }
+
+ @Test
+ fun `updateChannels calls useCase for each changed channel`() = runTest {
+ val node = Node(num = 123)
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
+ viewModel = createViewModel()
+
+ val old = listOf(ChannelSettings(name = "Old"))
+ val new = listOf(ChannelSettings(name = "New"))
+
+ coEvery { radioConfigUseCase.setRemoteChannel(123, any()) } returns 42
+
+ viewModel.updateChannels(new, old)
+
+ coVerify { radioConfigUseCase.setRemoteChannel(123, any()) }
+ assertEquals(new, viewModel.radioConfigState.value.channelList)
+ }
+
+ @Test
+ fun `setResponseStateLoading for REBOOT calls useCase after packet response`() = runTest {
+ val node = Node(num = 123)
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
+
+ val packetFlow = MutableSharedFlow()
+ every { serviceRepository.meshPacketFlow } returns packetFlow
+ every { processRadioResponseUseCase(any(), any(), any()) } returns RadioResponseResult.Success
+
+ viewModel = createViewModel()
+
+ coEvery { adminActionsUseCase.reboot(123) } returns 42
+
+ viewModel.setResponseStateLoading(AdminRoute.REBOOT)
+
+ // Emit a packet to trigger processPacketResponse -> sendAdminRequest
+ packetFlow.emit(MeshPacket())
+
+ coVerify { adminActionsUseCase.reboot(123) }
+ }
+
+ @Test
+ fun `setResponseStateLoading for FACTORY_RESET calls useCase after packet response`() = runTest {
+ val node = Node(num = 123)
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
+
+ val packetFlow = MutableSharedFlow()
+ every { serviceRepository.meshPacketFlow } returns packetFlow
+ every { processRadioResponseUseCase(any(), any(), any()) } returns RadioResponseResult.Success
+
+ viewModel = createViewModel()
+
+ coEvery { adminActionsUseCase.factoryReset(123, any()) } returns 42
+
+ viewModel.setResponseStateLoading(AdminRoute.FACTORY_RESET)
+
+ // Emit a packet to trigger processPacketResponse -> sendAdminRequest
+ packetFlow.emit(MeshPacket())
+
+ coVerify { adminActionsUseCase.factoryReset(123, any()) }
+ }
+}
diff --git a/gradle.properties b/gradle.properties
index 2b135dd18..b0a71dbe3 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,59 +1,38 @@
+## For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
#
-# Copyright (c) 2025 Meshtastic LLC
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+# Default value: -Xmx1024m -XX:MaxPermSize=256m
+# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
#
-# 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 .
-#
-
-# Project-wide Gradle settings.
-org.gradle.jvmargs=-Xmx8g -XX:+UseParallelGC -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
-
-# Parallelism & Caching
-org.gradle.parallel=true
-org.gradle.caching=true
-org.gradle.configuration-cache=true
-org.gradle.isolated-projects=true
-org.gradle.vfs.watch=true
-org.gradle.configureondemand=false
-
-# Kotlin Optimization
-# Parallelize Kotlin tasks within a single project (great for KMP)
-kotlin.parallel.tasks.in.project=true
-# Give Kotlin daemon enough breathing room
-kotlin.daemon.jvm.options=-Xmx4g -XX:+UseParallelGC
-kotlin.code.style=official
-
-# Android (AGP) Optimization
-android.useAndroidX=true
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
+# org.gradle.parallel=true
+#Sat Feb 28 21:28:07 CST 2026
android.enableJetifier=false
-android.nonTransitiveRClass=true
-# More aggressive R8 optimizations
android.enableR8.fullMode=true
-# Parallel lint analysis
android.experimental.lint.analysisPerComponent=true
-
-# KSP 2 Configuration
-ksp.useKSP2=true
-ksp.run.in.process=true
-ksp.incremental=true
-ksp.incremental.classpath=true
-ksp.incremental.intermodule=true
-
-# UI & Analysis
+android.newDsl=false
+android.nonTransitiveRClass=true
+android.useAndroidX=true
dependency.analysis.print.build.health=true
enableComposeCompilerMetrics=false
enableComposeCompilerReports=false
-
-# Housekeeping
+kotlin.code.style=official
+kotlin.daemon.jvm.options=-Xmx4g -XX\:+UseParallelGC
+kotlin.parallel.tasks.in.project=true
+ksp.incremental=true
+ksp.incremental.classpath=true
+ksp.incremental.intermodule=true
+ksp.run.in.process=true
+ksp.useKSP2=true
+org.gradle.caching=true
+org.gradle.configuration-cache=true
+org.gradle.configureondemand=false
+org.gradle.isolated-projects=true
+org.gradle.jvmargs=-Xmx8g -XX:+UseParallelGC -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
+org.gradle.parallel=true
+org.gradle.vfs.watch=true
org.gradle.welcome=never
-android.newDsl=false
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index d51b3cce8..48fe82c7c 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -26,6 +26,7 @@ ktlint = "1.7.1"
kover = "0.9.7"
mockk = "1.14.9"
testRetry = "1.6.4"
+turbine = "1.1.0"
# Compose Multiplatform
compose-multiplatform = "1.11.0-alpha03"
@@ -108,6 +109,7 @@ androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "
androidx-savedstate-compose = { module = "androidx.savedstate:savedstate-compose", version.ref = "savedstate" }
androidx-savedstate-ktx = { module = "androidx.savedstate:savedstate-ktx", version.ref = "savedstate" }
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11.1" }
+androidx-work-testing = { module = "androidx.work:work-testing", version = "2.11.1" }
# AndroidX Compose
androidx-compose-bom = { module = "androidx.compose:compose-bom-alpha", version = "2026.02.01" }
@@ -178,6 +180,7 @@ androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core",
junit = { module = "junit:junit", version = "4.13.2" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
robolectric = { module = "org.robolectric:robolectric", version = "4.16.1" }
+turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
# Other
aboutlibraries-compose-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutlibraries" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 16c8309fb..0db4cf6c0 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -26,6 +26,7 @@ include(
":core:database",
":core:datastore",
":core:di",
+ ":core:domain",
":core:model",
":core:navigation",
":core:network",