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",