Refactor command handling, enhance tests, and improve discovery logic (#4878)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-03-22 00:42:27 -05:00
committed by GitHub
parent d136b162a4
commit c38bfc64de
76 changed files with 2220 additions and 1277 deletions

View File

@@ -29,7 +29,6 @@ import org.meshtastic.core.datastore.model.RecentAddress
import org.meshtastic.core.model.Node
import org.meshtastic.core.network.repository.DiscoveredService
import org.meshtastic.core.network.repository.NetworkRepository
import org.meshtastic.core.network.repository.NetworkRepository.Companion.toAddressString
import org.meshtastic.core.network.repository.UsbRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioInterfaceService
@@ -55,7 +54,6 @@ class AndroidGetDiscoveredDevicesUseCase(
private val radioInterfaceService: RadioInterfaceService,
private val usbManagerLazy: Lazy<UsbManager>,
) : GetDiscoveredDevicesUseCase {
private val suffixLength = 4
private val macSuffixLength = 8
@Suppress("LongMethod", "CyclomaticComplexMethod")
@@ -69,24 +67,8 @@ class AndroidGetDiscoveredDevicesUseCase(
tcpServices,
recentList,
->
val recentMap = recentList.associateBy({ it.address }) { it.name }
tcpServices
.map { service ->
val address = "t${service.toAddressString()}"
val txtRecords = service.txt
val shortNameBytes = txtRecords["shortname"]
val idBytes = txtRecords["id"]
val shortName =
shortNameBytes?.let { String(it, Charsets.UTF_8) } ?: getString(Res.string.meshtastic)
val deviceId = idBytes?.let { String(it, Charsets.UTF_8) }?.replace("!", "")
var displayName = recentMap[address] ?: shortName
if (deviceId != null && (displayName.split("_").none { it == deviceId })) {
displayName += "_$deviceId"
}
DeviceListEntry.Tcp(displayName, address)
}
.sortedBy { it.name }
val defaultName = getString(Res.string.meshtastic)
processTcpServices(tcpServices, recentList, defaultName)
}
val usbDevicesFlow =
@@ -131,6 +113,7 @@ class AndroidGetDiscoveredDevicesUseCase(
@Suppress("UNCHECKED_CAST", "MagicNumber")
val recentList = args[5] as List<RecentAddress>
// Android-specific: BLE node matching by MAC suffix and Meshtastic short name
val bleForUi =
bondedBle
.map { entry ->
@@ -153,61 +136,20 @@ class AndroidGetDiscoveredDevicesUseCase(
}
.sortedBy { it.name }
// Android-specific: USB node matching via shared helper
val usbForUi =
(
usbDevices +
if (showMock) listOf(DeviceListEntry.Mock(getString(Res.string.demo_mode))) else emptyList()
)
.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
db.values.find { node ->
val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT)
suffix != null &&
suffix.length >= suffixLength &&
node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
}
} else {
null
}
entry.copy(node = matchingNode)
entry.copy(node = findNodeByNameSuffix(entry.name, entry.fullAddress, db, databaseManager))
}
val discoveredTcpForUi =
processedTcp.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
val resolvedService = resolved.find { "t${it.toAddressString()}" == entry.fullAddress }
val deviceId = resolvedService?.txt?.get("id")?.let { String(it, Charsets.UTF_8) }
db.values.find { node ->
node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId")
}
} else {
null
}
entry.copy(node = matchingNode)
}
// Shared TCP logic via helpers
val discoveredTcpForUi = matchDiscoveredTcpNodes(processedTcp, db, resolved, databaseManager)
val discoveredTcpAddresses = processedTcp.map { it.fullAddress }.toSet()
val recentTcpForUi =
recentList
.filterNot { discoveredTcpAddresses.contains(it.address) }
.map { DeviceListEntry.Tcp(it.name, it.address) }
.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT)
db.values.find { node ->
suffix != null &&
suffix.length >= suffixLength &&
node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
}
} else {
null
}
entry.copy(node = matchingNode)
}
.sortedBy { it.name }
val recentTcpForUi = buildRecentTcpEntries(recentList, discoveredTcpAddresses, db, databaseManager)
DiscoveredDevices(
bleDevices = bleForUi,

View File

@@ -23,7 +23,6 @@ import org.koin.core.annotation.Single
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.network.repository.NetworkRepository
import org.meshtastic.core.network.repository.NetworkRepository.Companion.toAddressString
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.demo_mode
@@ -40,9 +39,7 @@ class CommonGetDiscoveredDevicesUseCase(
private val networkRepository: NetworkRepository,
private val usbScanner: UsbScanner? = null,
) : GetDiscoveredDevicesUseCase {
private val suffixLength = 4
@Suppress("LongMethod", "CyclomaticComplexMethod")
override fun invoke(showMock: Boolean): Flow<DiscoveredDevices> {
val nodeDb = nodeRepository.nodeDBbyNum
val usbFlow = usbScanner?.scanUsbDevices() ?: kotlinx.coroutines.flow.flowOf(emptyList())
@@ -52,25 +49,8 @@ class CommonGetDiscoveredDevicesUseCase(
tcpServices,
recentList,
->
val recentMap = recentList.associateBy({ it.address }) { it.name }
tcpServices
.map { service ->
val address = "t${service.toAddressString()}"
val txtRecords = service.txt
val shortNameBytes = txtRecords["shortname"]
val idBytes = txtRecords["id"]
val shortName =
shortNameBytes?.let { it.decodeToString() }
?: runCatching { getString(Res.string.meshtastic) }.getOrDefault("Meshtastic")
val deviceId = idBytes?.let { it.decodeToString() }?.replace("!", "")
var displayName = recentMap[address] ?: shortName
if (deviceId != null && (displayName.split("_").none { it == deviceId })) {
displayName += "_$deviceId"
}
DeviceListEntry.Tcp(displayName, address)
}
.sortedBy { it.name }
val defaultName = runCatching { getString(Res.string.meshtastic) }.getOrDefault("Meshtastic")
processTcpServices(tcpServices, recentList, defaultName)
}
return combine(
@@ -80,42 +60,9 @@ class CommonGetDiscoveredDevicesUseCase(
recentAddressesDataSource.recentAddresses,
usbFlow,
) { db, processedTcp, resolved, recentList, usbList ->
val discoveredTcpForUi =
processedTcp.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
val resolvedService = resolved.find { "t${it.toAddressString()}" == entry.fullAddress }
val deviceId = resolvedService?.txt?.get("id")?.let { it.decodeToString() }
db.values.find { node ->
node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId")
}
} else {
null
}
entry.copy(node = matchingNode)
}
val discoveredTcpForUi = matchDiscoveredTcpNodes(processedTcp, db, resolved, databaseManager)
val discoveredTcpAddresses = processedTcp.map { it.fullAddress }.toSet()
val recentTcpForUi =
recentList
.filterNot { discoveredTcpAddresses.contains(it.address) }
.map { DeviceListEntry.Tcp(it.name, it.address) }
.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
val suffix = entry.name.split("_").lastOrNull()?.lowercase()
db.values.find { node ->
suffix != null &&
suffix.length >= suffixLength &&
node.user.id.lowercase().endsWith(suffix)
}
} else {
null
}
entry.copy(node = matchingNode)
}
.sortedBy { it.name }
val recentTcpForUi = buildRecentTcpEntries(recentList, discoveredTcpAddresses, db, databaseManager)
DiscoveredDevices(
discoveredTcpDevices = discoveredTcpForUi,

View File

@@ -0,0 +1,112 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.connections.domain.usecase
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.datastore.model.RecentAddress
import org.meshtastic.core.model.Node
import org.meshtastic.core.network.repository.DiscoveredService
import org.meshtastic.core.network.repository.NetworkRepository.Companion.toAddressString
import org.meshtastic.feature.connections.model.DeviceListEntry
private const val SUFFIX_LENGTH = 4
/**
* Shared helpers for TCP device discovery logic used by both [CommonGetDiscoveredDevicesUseCase] and the
* Android-specific variant.
*/
/** Converts a list of [DiscoveredService] into [DeviceListEntry.Tcp] with display names derived from TXT records. */
internal fun processTcpServices(
tcpServices: List<DiscoveredService>,
recentAddresses: List<RecentAddress>,
defaultShortName: String = "Meshtastic",
): List<DeviceListEntry.Tcp> {
val recentMap = recentAddresses.associateBy({ it.address }) { it.name }
return tcpServices
.map { service ->
val address = "t${service.toAddressString()}"
val txtRecords = service.txt
val shortNameBytes = txtRecords["shortname"]
val idBytes = txtRecords["id"]
val shortName = shortNameBytes?.decodeToString() ?: defaultShortName
val deviceId = idBytes?.decodeToString()?.replace("!", "")
var displayName = recentMap[address] ?: shortName
if (deviceId != null && displayName.split("_").none { it == deviceId }) {
displayName += "_$deviceId"
}
DeviceListEntry.Tcp(displayName, address)
}
.sortedBy { it.name }
}
/** Matches each discovered TCP entry to a [Node] from the database using its mDNS device ID. */
internal fun matchDiscoveredTcpNodes(
entries: List<DeviceListEntry.Tcp>,
nodeDb: Map<Int, Node>,
resolvedServices: List<DiscoveredService>,
databaseManager: DatabaseManager,
): List<DeviceListEntry.Tcp> = entries.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
val resolvedService = resolvedServices.find { "t${it.toAddressString()}" == entry.fullAddress }
val deviceId = resolvedService?.txt?.get("id")?.decodeToString()
nodeDb.values.find { node ->
node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId")
}
} else {
null
}
entry.copy(node = matchingNode)
}
/**
* Builds the "recent TCP devices" list by filtering out currently discovered addresses and matching each entry to a
* [Node] by name suffix.
*/
internal fun buildRecentTcpEntries(
recentAddresses: List<RecentAddress>,
discoveredAddresses: Set<String>,
nodeDb: Map<Int, Node>,
databaseManager: DatabaseManager,
): List<DeviceListEntry.Tcp> = recentAddresses
.filterNot { discoveredAddresses.contains(it.address) }
.map { DeviceListEntry.Tcp(it.name, it.address) }
.map { entry ->
entry.copy(node = findNodeByNameSuffix(entry.name, entry.fullAddress, nodeDb, databaseManager))
}
.sortedBy { it.name }
/**
* Finds a [Node] matching the last `_`-delimited segment of [displayName], if a local database exists for the given
* [fullAddress]. Used by both TCP recent-device matching and Android USB device matching to avoid duplicated
* suffix-lookup logic.
*/
internal fun findNodeByNameSuffix(
displayName: String,
fullAddress: String,
nodeDb: Map<Int, Node>,
databaseManager: DatabaseManager,
): Node? {
val suffix = displayName.split("_").lastOrNull()?.lowercase()
return if (!databaseManager.hasDatabaseFor(fullAddress) || suffix == null || suffix.length < SUFFIX_LENGTH) {
null
} else {
nodeDb.values.find { it.user.id.lowercase().endsWith(suffix) }
}
}

View File

@@ -47,7 +47,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.distinctUntilChanged
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
@@ -86,7 +85,6 @@ import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.getNavRouteFrom
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
import org.meshtastic.proto.Config
import kotlin.uuid.ExperimentalUuidApi
/** Composable screen for managing device connections (BLE, TCP, USB). It displays connection status. */
@@ -102,25 +100,12 @@ fun ConnectionsScreen(
onConfigNavigate: (Route) -> Unit,
) {
val radioConfigState by radioConfigViewModel.radioConfigState.collectAsStateWithLifecycle()
val config by connectionsViewModel.localConfig.collectAsStateWithLifecycle()
val scanStatusText by scanModel.errorText.collectAsStateWithLifecycle()
val connectionState by connectionsViewModel.connectionState.collectAsStateWithLifecycle()
// Prevent continuous recomposition from lastHeard and snr updates on the node
val ourNode by
remember(connectionsViewModel.ourNodeInfo) {
connectionsViewModel.ourNodeInfo.distinctUntilChanged { old, new ->
old?.num == new?.num &&
old?.user == new?.user &&
old?.batteryLevel == new?.batteryLevel &&
old?.voltage == new?.voltage &&
old?.metadata?.firmware_version == new?.metadata?.firmware_version
}
}
.collectAsStateWithLifecycle(initialValue = connectionsViewModel.ourNodeInfo.value)
val ourNode by connectionsViewModel.ourNodeForDisplay.collectAsStateWithLifecycle()
val regionUnset by connectionsViewModel.regionUnset.collectAsStateWithLifecycle()
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
val regionUnset = config.lora?.region == Config.LoRaConfig.RegionCode.UNSET
val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle()
val discoveredTcpDevices by scanModel.discoveredTcpDevicesForUi.collectAsStateWithLifecycle()
@@ -192,63 +177,31 @@ fun ConnectionsScreen(
Crossfade(targetState = uiState, label = "connection_state") { state ->
when (state) {
2 -> {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
ourNode?.let { node ->
TitledCard(title = stringResource(Res.string.connected_device)) {
CurrentlyConnectedInfo(
node = node,
bleDevice =
bleDevices.find { it.fullAddress == selectedDevice }
as DeviceListEntry.Ble?,
onNavigateToNodeDetails = onNavigateToNodeDetails,
onClickDisconnect = { scanModel.disconnect() },
)
}
}
2 ->
ConnectedDeviceContent(
ourNode = ourNode,
regionUnset = regionUnset,
selectedDevice = selectedDevice,
bleDevices = bleDevices,
onNavigateToNodeDetails = onNavigateToNodeDetails,
onClickDisconnect = { scanModel.disconnect() },
onSetRegion = {
isWaiting = true
radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA)
},
)
if (regionUnset && selectedDevice != "m") {
TitledCard(title = null) {
ListItem(
leadingIcon = Icons.Rounded.Language,
text = stringResource(Res.string.set_your_region),
) {
isWaiting = true
radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA)
}
}
}
}
}
1 ->
ConnectingDeviceContent(
selectedDevice = selectedDevice,
bleDevices = bleDevices,
discoveredTcpDevices = discoveredTcpDevices,
recentTcpDevices = recentTcpDevices,
usbDevices = usbDevices,
onClickDisconnect = { scanModel.disconnect() },
)
1 -> {
val selectedEntry =
bleDevices.find { it.fullAddress == selectedDevice }
?: discoveredTcpDevices.find { it.fullAddress == selectedDevice }
?: recentTcpDevices.find { it.fullAddress == selectedDevice }
?: usbDevices.find { it.fullAddress == selectedDevice }
val name = selectedEntry?.name ?: stringResource(Res.string.unknown_device)
val address = selectedEntry?.address ?: selectedDevice
TitledCard(title = stringResource(Res.string.connected_device)) {
ConnectingDeviceInfo(
deviceName = name,
deviceAddress = address,
onClickDisconnect = { scanModel.disconnect() },
)
}
}
else -> {
Card(modifier = Modifier.fillMaxWidth()) {
EmptyStateContent(
imageVector = MeshtasticIcons.NoDevice,
text = stringResource(Res.string.no_device_selected),
modifier = Modifier.height(160.dp),
)
}
}
else -> NoDeviceContent()
}
}
@@ -334,3 +287,74 @@ fun ConnectionsScreen(
}
}
}
/** Content shown when connected to a device with node info available. */
@Composable
private fun ConnectedDeviceContent(
ourNode: org.meshtastic.core.model.Node?,
regionUnset: Boolean,
selectedDevice: String,
bleDevices: List<DeviceListEntry>,
onNavigateToNodeDetails: (Int) -> Unit,
onClickDisconnect: () -> Unit,
onSetRegion: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
ourNode?.let { node ->
TitledCard(title = stringResource(Res.string.connected_device)) {
CurrentlyConnectedInfo(
node = node,
bleDevice = bleDevices.find { it.fullAddress == selectedDevice } as DeviceListEntry.Ble?,
onNavigateToNodeDetails = onNavigateToNodeDetails,
onClickDisconnect = onClickDisconnect,
)
}
}
if (regionUnset && selectedDevice != "m") {
TitledCard(title = null) {
ListItem(
leadingIcon = Icons.Rounded.Language,
text = stringResource(Res.string.set_your_region),
onClick = onSetRegion,
)
}
}
}
}
/** Content shown when connecting or a device is selected but node info is not yet available. */
@Composable
private fun ConnectingDeviceContent(
selectedDevice: String,
bleDevices: List<DeviceListEntry>,
discoveredTcpDevices: List<DeviceListEntry>,
recentTcpDevices: List<DeviceListEntry>,
usbDevices: List<DeviceListEntry>,
onClickDisconnect: () -> Unit,
) {
val selectedEntry =
bleDevices.find { it.fullAddress == selectedDevice }
?: discoveredTcpDevices.find { it.fullAddress == selectedDevice }
?: recentTcpDevices.find { it.fullAddress == selectedDevice }
?: usbDevices.find { it.fullAddress == selectedDevice }
val name = selectedEntry?.name ?: stringResource(Res.string.unknown_device)
val address = selectedEntry?.address ?: selectedDevice
TitledCard(title = stringResource(Res.string.connected_device)) {
ConnectingDeviceInfo(deviceName = name, deviceAddress = address, onClickDisconnect = onClickDisconnect)
}
}
/** Content shown when no device is selected. */
@Composable
private fun NoDeviceContent() {
Card(modifier = Modifier.fillMaxWidth()) {
EmptyStateContent(
imageVector = MeshtasticIcons.NoDevice,
text = stringResource(Res.string.no_device_selected),
modifier = Modifier.height(160.dp),
)
}
}

View File

@@ -0,0 +1,232 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.connections.domain.usecase
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import io.kotest.matchers.shouldBe
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.datastore.model.RecentAddress
import org.meshtastic.core.network.repository.DiscoveredService
import org.meshtastic.core.testing.TestDataFactory
import org.meshtastic.feature.connections.model.DeviceListEntry
import kotlin.test.Test
import kotlin.test.assertNotNull
import kotlin.test.assertNull
/** Unit tests for the shared TCP discovery helper functions. */
class TcpDiscoveryHelpersTest {
@Test
fun `processTcpServices maps services to DeviceListEntry with shortname and id`() {
val services =
listOf(
DiscoveredService(
name = "Meshtastic_abcd",
hostAddress = "192.168.1.10",
port = 4403,
txt = mapOf("shortname" to "Mesh".encodeToByteArray(), "id" to "!abcd".encodeToByteArray()),
),
)
val result = processTcpServices(services, emptyList())
result.size shouldBe 1
result[0].name shouldBe "Mesh_abcd"
result[0].fullAddress shouldBe "t192.168.1.10"
}
@Test
fun `processTcpServices uses default shortname when missing`() {
val services =
listOf(DiscoveredService(name = "TestDevice", hostAddress = "10.0.0.1", port = 4403, txt = emptyMap()))
val result = processTcpServices(services, emptyList(), defaultShortName = "Meshtastic")
result.size shouldBe 1
result[0].name shouldBe "Meshtastic"
}
@Test
fun `processTcpServices uses recent name over shortname`() {
val services =
listOf(
DiscoveredService(
name = "Meshtastic_1234",
hostAddress = "192.168.1.50",
port = 4403,
txt = mapOf("shortname" to "Mesh".encodeToByteArray()),
),
)
val recentAddresses = listOf(RecentAddress("t192.168.1.50", "MyNode"))
val result = processTcpServices(services, recentAddresses)
result.size shouldBe 1
result[0].name shouldBe "MyNode"
}
@Test
fun `processTcpServices does not duplicate id in display name`() {
val services =
listOf(
DiscoveredService(
name = "Meshtastic_1234",
hostAddress = "192.168.1.50",
port = 4403,
txt = mapOf("shortname" to "Mesh".encodeToByteArray(), "id" to "!1234".encodeToByteArray()),
),
)
val recentAddresses = listOf(RecentAddress("t192.168.1.50", "Mesh_1234"))
val result = processTcpServices(services, recentAddresses)
result.size shouldBe 1
// Should NOT become "Mesh_1234_1234"
result[0].name shouldBe "Mesh_1234"
}
@Test
fun `processTcpServices results are sorted by name`() {
val services =
listOf(
DiscoveredService("Z", "10.0.0.2", 4403, mapOf("shortname" to "Zulu".encodeToByteArray())),
DiscoveredService("A", "10.0.0.1", 4403, mapOf("shortname" to "Alpha".encodeToByteArray())),
)
val result = processTcpServices(services, emptyList())
result[0].name shouldBe "Alpha"
result[1].name shouldBe "Zulu"
}
@Test
fun `matchDiscoveredTcpNodes matches node by device id`() {
val node = TestDataFactory.createTestNode(num = 1, userId = "!1234")
val nodeDb = mapOf(1 to node)
val entries = listOf(DeviceListEntry.Tcp("Mesh_1234", "t192.168.1.50"))
val resolved =
listOf(
DiscoveredService(
name = "Meshtastic",
hostAddress = "192.168.1.50",
port = 4403,
txt = mapOf("id" to "!1234".encodeToByteArray()),
),
)
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor("t192.168.1.50") } returns true }
val result = matchDiscoveredTcpNodes(entries, nodeDb, resolved, databaseManager)
result.size shouldBe 1
assertNotNull(result[0].node)
result[0].node?.user?.id shouldBe "!1234"
}
@Test
fun `matchDiscoveredTcpNodes returns null node when no database`() {
val node = TestDataFactory.createTestNode(num = 1, userId = "!1234")
val nodeDb = mapOf(1 to node)
val entries = listOf(DeviceListEntry.Tcp("Mesh_1234", "t192.168.1.50"))
val resolved =
listOf(
DiscoveredService(
name = "Meshtastic",
hostAddress = "192.168.1.50",
port = 4403,
txt = mapOf("id" to "!1234".encodeToByteArray()),
),
)
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor("t192.168.1.50") } returns false }
val result = matchDiscoveredTcpNodes(entries, nodeDb, resolved, databaseManager)
result.size shouldBe 1
assertNull(result[0].node)
}
@Test
fun `buildRecentTcpEntries filters out discovered addresses`() {
val recentAddresses = listOf(RecentAddress("t192.168.1.50", "NodeA"), RecentAddress("t192.168.1.51", "NodeB"))
val discoveredAddresses = setOf("t192.168.1.50")
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor(any()) } returns false }
val result = buildRecentTcpEntries(recentAddresses, discoveredAddresses, emptyMap(), databaseManager)
result.size shouldBe 1
result[0].name shouldBe "NodeB"
result[0].fullAddress shouldBe "t192.168.1.51"
}
@Test
fun `buildRecentTcpEntries matches node by suffix`() {
val node = TestDataFactory.createTestNode(num = 1, userId = "!test1234")
val recentAddresses = listOf(RecentAddress("tMeshtastic_1234", "Meshtastic_1234"))
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor("tMeshtastic_1234") } returns true }
val result = buildRecentTcpEntries(recentAddresses, emptySet(), mapOf(1 to node), databaseManager)
result.size shouldBe 1
assertNotNull(result[0].node)
result[0].node?.user?.id shouldBe "!test1234"
}
@Test
fun `buildRecentTcpEntries results are sorted by name`() {
val recentAddresses = listOf(RecentAddress("t10.0.0.2", "Zebra"), RecentAddress("t10.0.0.1", "Alpha"))
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor(any()) } returns false }
val result = buildRecentTcpEntries(recentAddresses, emptySet(), emptyMap(), databaseManager)
result[0].name shouldBe "Alpha"
result[1].name shouldBe "Zebra"
}
@Test
fun `findNodeByNameSuffix returns null when no database`() {
val node = TestDataFactory.createTestNode(num = 1, userId = "!abcd1234")
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor(any()) } returns false }
val result = findNodeByNameSuffix("Device_1234", "s/dev/ttyUSB0", mapOf(1 to node), databaseManager)
assertNull(result)
}
@Test
fun `findNodeByNameSuffix matches by last underscore segment`() {
val node = TestDataFactory.createTestNode(num = 1, userId = "!abcd1234")
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor("s/dev/ttyUSB0") } returns true }
val result = findNodeByNameSuffix("Device_1234", "s/dev/ttyUSB0", mapOf(1 to node), databaseManager)
assertNotNull(result)
result.user.id shouldBe "!abcd1234"
}
@Test
fun `findNodeByNameSuffix returns null when suffix is too short`() {
val node = TestDataFactory.createTestNode(num = 1, userId = "!abcd1234")
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor("s/dev/ttyUSB0") } returns true }
val result = findNodeByNameSuffix("Device_ab", "s/dev/ttyUSB0", mapOf(1 to node), databaseManager)
// "ab" is only 2 chars, below the minimum SUFFIX_LENGTH of 4
assertNull(result)
}
}

View File

@@ -45,16 +45,7 @@ kotlin {
implementation(projects.core.di)
}
androidMain.dependencies {
implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.navigation.common)
implementation(libs.androidx.savedstate.compose)
implementation(libs.androidx.savedstate.ktx)
implementation(libs.material)
}
androidMain.dependencies { implementation(libs.material) }
androidUnitTest.dependencies {
implementation(libs.junit)

View File

@@ -16,25 +16,11 @@
*/
package org.meshtastic.feature.map.navigation
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import org.meshtastic.core.ui.component.PlaceholderScreen
@Composable
actual fun MapMainScreen(onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
// Desktop placeholder for now
org.meshtastic.feature.map.navigation.PlaceholderScreen(name = "Map")
}
@Composable
internal fun PlaceholderScreen(name: String) {
androidx.compose.foundation.layout.Box(
modifier = androidx.compose.ui.Modifier.fillMaxSize(),
contentAlignment = androidx.compose.ui.Alignment.Center,
) {
androidx.compose.material3.Text(
text = name,
style = androidx.compose.material3.MaterialTheme.typography.headlineMedium,
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant,
)
}
PlaceholderScreen(name = "Map")
}

View File

@@ -229,34 +229,6 @@ private fun NodeDetailBottomSheet(onDismiss: () -> Unit, content: @Composable ()
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { content() }
}
private fun handleNodeAction(
action: NodeDetailAction,
uiState: NodeDetailUiState,
navigateToMessages: (String) -> Unit,
onNavigateUp: () -> Unit,
onNavigate: (Route) -> Unit,
viewModel: NodeDetailViewModel,
) {
when (action) {
is NodeDetailAction.Navigate -> onNavigate(action.route)
is NodeDetailAction.TriggerServiceAction -> viewModel.onServiceAction(action.action)
is NodeDetailAction.HandleNodeMenuAction -> {
when (val menuAction = action.action) {
is NodeMenuAction.DirectMessage -> {
val route = viewModel.getDirectMessageRoute(menuAction.node, uiState.ourNode)
navigateToMessages(route)
}
is NodeMenuAction.Remove -> {
viewModel.handleNodeMenuAction(menuAction)
onNavigateUp()
}
else -> viewModel.handleNodeMenuAction(menuAction)
}
}
else -> {}
}
}
@Preview(showBackground = true)
@Composable
private fun NodeDetailListPreview(@PreviewParameter(NodePreviewParameterProvider::class) node: Node) {

View File

@@ -0,0 +1,55 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.detail
import org.meshtastic.core.navigation.Route
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.model.NodeDetailAction
/**
* Shared handler for [NodeDetailAction]s that are common across all platforms.
*
* Platform-specific actions (e.g. [NodeDetailAction.ShareContact], [NodeDetailAction.OpenCompass]) are ignored by this
* handler and should be handled by the platform-specific caller.
*/
internal fun handleNodeAction(
action: NodeDetailAction,
uiState: NodeDetailUiState,
navigateToMessages: (String) -> Unit,
onNavigateUp: () -> Unit,
onNavigate: (Route) -> Unit,
viewModel: NodeDetailViewModel,
) {
when (action) {
is NodeDetailAction.Navigate -> onNavigate(action.route)
is NodeDetailAction.TriggerServiceAction -> viewModel.onServiceAction(action.action)
is NodeDetailAction.HandleNodeMenuAction -> {
when (val menuAction = action.action) {
is NodeMenuAction.DirectMessage -> {
val route = viewModel.getDirectMessageRoute(menuAction.node, uiState.ourNode)
navigateToMessages(route)
}
is NodeMenuAction.Remove -> {
viewModel.handleNodeMenuAction(menuAction)
onNavigateUp()
}
else -> viewModel.handleNodeMenuAction(menuAction)
}
}
else -> {}
}
}

View File

@@ -23,14 +23,13 @@ import dev.mokkery.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.proto.User
import kotlin.test.Test
@OptIn(ExperimentalCoroutinesApi::class)
class NodeManagementActionsTest {
@@ -51,7 +50,7 @@ class NodeManagementActionsTest {
)
@Test
fun `requestRemoveNode shows confirmation alert`() {
fun requestRemoveNode_shows_confirmation_alert() {
val node = Node(num = 123, user = User(long_name = "Test Node"))
actions.requestRemoveNode(testScope, node)
@@ -70,11 +69,4 @@ class NodeManagementActionsTest {
)
}
}
@Test
fun `requestFavoriteNode shows confirmation alert`() = runTest(testDispatcher) {
// This test might fail due to getString() not being mocked easily
// but let's see if we can at least get requestRemoveNode passing.
// Actually, if getString() fails, the coroutine will fail.
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2026 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -16,17 +16,17 @@
*/
package org.meshtastic.feature.node.metrics
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.Telemetry
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class EnvironmentMetricsStateTest {
@Test
fun `environmentMetricsForGraphing correctly calculates times`() {
fun environmentMetricsForGraphing_correctly_calculates_times() {
val now = nowSeconds.toInt()
val metrics =
listOf(
@@ -42,7 +42,7 @@ class EnvironmentMetricsStateTest {
}
@Test
fun `environmentMetricsForGraphing handles valid zero temperatures`() {
fun environmentMetricsForGraphing_handles_valid_zero_temperatures() {
val now = nowSeconds.toInt()
val metrics = listOf(Telemetry(time = now, environment_metrics = EnvironmentMetrics(temperature = 0.0f)))
val state = EnvironmentMetricsState(metrics)

View File

@@ -23,8 +23,6 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.core.navigation.Route
import org.meshtastic.feature.node.compass.CompassViewModel
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.model.NodeDetailAction
@Composable
actual fun NodeDetailScreen(
@@ -45,24 +43,14 @@ actual fun NodeDetailScreen(
uiState = uiState,
modifier = modifier,
onAction = { action ->
when (action) {
is NodeDetailAction.Navigate -> onNavigate(action.route)
is NodeDetailAction.TriggerServiceAction -> viewModel.onServiceAction(action.action)
is NodeDetailAction.HandleNodeMenuAction -> {
when (val menuAction = action.action) {
is NodeMenuAction.DirectMessage -> {
val route = viewModel.getDirectMessageRoute(menuAction.node, uiState.ourNode)
navigateToMessages(route)
}
is NodeMenuAction.Remove -> {
viewModel.handleNodeMenuAction(menuAction)
onNavigateUp()
}
else -> viewModel.handleNodeMenuAction(menuAction)
}
}
else -> {}
}
handleNodeAction(
action = action,
uiState = uiState,
navigateToMessages = navigateToMessages,
onNavigateUp = onNavigateUp,
onNavigate = onNavigate,
viewModel = viewModel,
)
},
onFirmwareSelect = { /* No-op on desktop for now */ },
onSaveNotes = { num, notes -> viewModel.setNodeNotes(num, notes) },

View File

@@ -16,21 +16,10 @@
*/
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import org.meshtastic.core.ui.component.PlaceholderScreen
@Composable
actual fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = "Position Log",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
PlaceholderScreen(name = "Position Log")
}

View File

@@ -16,27 +16,11 @@
*/
package org.meshtastic.feature.node.navigation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import org.meshtastic.core.ui.component.PlaceholderScreen
@Composable
actual fun TracerouteMapScreen(destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) {
// Desktop placeholder for now
PlaceholderScreen(name = "Traceroute Map")
}
@Composable
internal fun PlaceholderScreen(name: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = name,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View File

@@ -17,6 +17,7 @@
package org.meshtastic.feature.settings.radio
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
@@ -58,7 +59,7 @@ class CleanNodeDatabaseViewModelTest {
}
@Test
fun `getNodesToDelete updates state`() = runTest {
fun getNodesToDelete_updates_state() = runTest {
val nodes = listOf(Node(num = 1), Node(num = 2))
everySuspend { cleanNodeDatabaseUseCase.getNodesToClean(any(), any(), any()) } returns nodes
@@ -69,7 +70,7 @@ class CleanNodeDatabaseViewModelTest {
}
@Test
fun `cleanNodes calls useCase and clears state`() = runTest {
fun cleanNodes_calls_useCase_and_clears_state() = runTest {
val nodes = listOf(Node(num = 1))
everySuspend { cleanNodeDatabaseUseCase.getNodesToClean(any(), any(), any()) } returns nodes
viewModel.getNodesToDelete()

View File

@@ -20,7 +20,6 @@ import android.app.Activity
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
@@ -36,7 +35,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.toDate
@@ -46,23 +44,18 @@ import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.bottom_nav_settings
import org.meshtastic.core.resources.choose_theme
import org.meshtastic.core.resources.dynamic
import org.meshtastic.core.resources.export_configuration
import org.meshtastic.core.resources.import_configuration
import org.meshtastic.core.resources.preferences_language
import org.meshtastic.core.resources.remotely_administrating
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.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
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.component.ThemePickerDialog
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.ModuleRoute
import org.meshtastic.feature.settings.radio.RadioConfigItemList
@@ -269,28 +262,3 @@ private fun LanguagePickerDialog(onDismiss: () -> Unit) {
},
)
}
private enum class ThemeOption(val label: StringResource, val mode: Int) {
DYNAMIC(label = Res.string.dynamic, mode = MODE_DYNAMIC),
LIGHT(label = Res.string.theme_light, mode = AppCompatDelegate.MODE_NIGHT_NO),
DARK(label = Res.string.theme_dark, mode = AppCompatDelegate.MODE_NIGHT_YES),
SYSTEM(label = Res.string.theme_system, mode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM),
}
@Composable
private fun ThemePickerDialog(onClickTheme: (Int) -> Unit, onDismiss: () -> Unit) {
MeshtasticDialog(
title = stringResource(Res.string.choose_theme),
onDismiss = onDismiss,
text = {
Column {
ThemeOption.entries.forEach { option ->
ListItem(text = stringResource(option.label), trailingIcon = null) {
onClickTheme(option.mode)
onDismiss()
}
}
}
},
)
}

View File

@@ -57,32 +57,7 @@ private suspend fun exportAllLogsToUri(context: Context, targetUri: Uri, logs: L
}
context.contentResolver.openOutputStream(targetUri)?.use { os ->
OutputStreamWriter(os, StandardCharsets.UTF_8).use { writer ->
logs.forEach { log ->
writer.write("${log.formattedReceivedDate} [${log.messageType}]\n")
writer.write(log.logMessage)
log.decodedPayload?.let { decodedPayload ->
if (decodedPayload.isNotBlank()) {
writer.write("\n\nDecoded Payload:\n{\n")
// Redact Decoded keys.
decodedPayload.lineSequence().forEach { line ->
var outputLine = line
val redacted = redactedKeys.firstOrNull { line.contains(it) }
if (redacted != null) {
val idx = line.indexOf(':')
if (idx != -1) {
outputLine = line.take(idx + 1)
outputLine += "<redacted>"
}
}
writer.write(outputLine)
writer.write("\n")
}
writer.write("}\n\n")
}
}
}
}
OutputStreamWriter(os, StandardCharsets.UTF_8).use { writer -> formatLogsTo(writer, logs) }
}
Logger.i { "MeshLog exported successfully to $targetUri" }
withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_success, logs.size) }
@@ -91,5 +66,3 @@ private suspend fun exportAllLogsToUri(context: Context, targetUri: Uri, logs: L
withContext(Dispatchers.Main) { context.showToast(Res.string.debug_export_failed, e.message ?: "") }
}
}
private val redactedKeys = listOf("session_passkey", "private_key", "admin_key")

View File

@@ -0,0 +1,60 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MatchingDeclarationName")
package org.meshtastic.feature.settings.component
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.choose_theme
import org.meshtastic.core.resources.dynamic
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.ui.component.ListItem
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
/** Theme modes that match AppCompatDelegate constants for cross-platform use. */
enum class ThemeOption(val label: StringResource, val mode: Int) {
DYNAMIC(label = Res.string.dynamic, mode = MODE_DYNAMIC),
LIGHT(label = Res.string.theme_light, mode = 1), // AppCompatDelegate.MODE_NIGHT_NO
DARK(label = Res.string.theme_dark, mode = 2), // AppCompatDelegate.MODE_NIGHT_YES
SYSTEM(label = Res.string.theme_system, mode = -1), // AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
/** Shared dialog for picking a theme option. Used by both Android and Desktop settings screens. */
@Composable
fun ThemePickerDialog(onClickTheme: (Int) -> Unit, onDismiss: () -> Unit) {
MeshtasticDialog(
title = stringResource(Res.string.choose_theme),
onDismiss = onDismiss,
text = {
Column {
ThemeOption.entries.forEach { option ->
ListItem(text = stringResource(option.label), trailingIcon = null) {
onClickTheme(option.mode)
onDismiss()
}
}
}
},
)
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2026 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View File

@@ -0,0 +1,49 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.debugging
internal val redactedKeys = listOf("session_passkey", "private_key", "admin_key")
/**
* Formats a list of [DebugViewModel.UiMeshLog] entries into the given [Appendable], redacting sensitive keys in decoded
* payloads.
*/
internal fun formatLogsTo(out: Appendable, logs: List<DebugViewModel.UiMeshLog>) {
logs.forEach { log ->
out.append("${log.formattedReceivedDate} [${log.messageType}]\n")
out.append(log.logMessage)
val decodedPayload = log.decodedPayload
if (!decodedPayload.isNullOrBlank()) {
appendRedactedPayload(out, decodedPayload)
}
}
}
private fun appendRedactedPayload(out: Appendable, payload: String) {
out.append("\n\nDecoded Payload:\n{\n")
payload.lineSequence().forEach { line ->
out.append(redactLine(line))
out.append("\n")
}
out.append("}\n\n")
}
private fun redactLine(line: String): String {
if (redactedKeys.none { line.contains(it) }) return line
val idx = line.indexOf(':')
return if (idx != -1) line.take(idx + 1) + "<redacted>" else line
}

View File

@@ -17,15 +17,17 @@
package org.meshtastic.feature.settings.filter
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import kotlinx.coroutines.flow.MutableStateFlow
import org.meshtastic.core.repository.FilterPrefs
import org.meshtastic.core.repository.MessageFilter
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
class FilterSettingsViewModelTest {
@@ -34,23 +36,23 @@ class FilterSettingsViewModelTest {
private lateinit var viewModel: FilterSettingsViewModel
@Before
@BeforeTest
fun setUp() {
every { filterPrefs.filterEnabled.value } returns true
every { filterPrefs.filterWords.value } returns setOf("apple", "banana")
every { filterPrefs.filterEnabled } returns MutableStateFlow(true)
every { filterPrefs.filterWords } returns MutableStateFlow(setOf("apple", "banana"))
viewModel = FilterSettingsViewModel(filterPrefs = filterPrefs, messageFilter = messageFilter)
}
@Test
fun `setFilterEnabled updates prefs and state`() {
fun setFilterEnabled_updates_prefs_and_state() {
viewModel.setFilterEnabled(false)
verify { filterPrefs.setFilterEnabled(false) }
assertEquals(false, viewModel.filterEnabled.value)
}
@Test
fun `addFilterWord updates prefs and rebuilds patterns`() {
fun addFilterWord_updates_prefs_and_rebuilds_patterns() {
viewModel.addFilterWord("cherry")
verify { filterPrefs.setFilterWords(any()) }
@@ -59,7 +61,7 @@ class FilterSettingsViewModelTest {
}
@Test
fun `removeFilterWord updates prefs and rebuilds patterns`() {
fun removeFilterWord_updates_prefs_and_rebuilds_patterns() {
viewModel.removeFilterWord("apple")
verify { filterPrefs.setFilterWords(any()) }

View File

@@ -42,7 +42,6 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.DatabaseConstants
import org.meshtastic.core.navigation.Route
@@ -52,28 +51,23 @@ import org.meshtastic.core.resources.acknowledgements
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.info
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.remotely_administrating
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.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.theme.MODE_DYNAMIC
import org.meshtastic.core.ui.util.rememberShowToastResource
import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.component.HomoglyphSetting
import org.meshtastic.feature.settings.component.NotificationSection
import org.meshtastic.feature.settings.component.ThemePickerDialog
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.ModuleRoute
import org.meshtastic.feature.settings.radio.RadioConfigItemList
@@ -291,31 +285,6 @@ private fun DesktopAppVersionButton(
}
}
private enum class ThemeOption(val label: StringResource, val mode: Int) {
DYNAMIC(label = Res.string.dynamic, mode = MODE_DYNAMIC),
LIGHT(label = Res.string.theme_light, mode = 1), // MODE_NIGHT_NO
DARK(label = Res.string.theme_dark, mode = 2), // MODE_NIGHT_YES
SYSTEM(label = Res.string.theme_system, mode = -1), // MODE_NIGHT_FOLLOW_SYSTEM
}
@Composable
private fun ThemePickerDialog(onClickTheme: (Int) -> Unit, onDismiss: () -> Unit) {
MeshtasticDialog(
title = stringResource(Res.string.choose_theme),
onDismiss = onDismiss,
text = {
Column {
ThemeOption.entries.forEach { option ->
ListItem(text = stringResource(option.label), trailingIcon = null) {
onClickTheme(option.mode)
onDismiss()
}
}
}
},
)
}
/**
* Supported languages — tag must match the CMP `values-<qualifier>` directory names. Empty tag means system default.
* Display names are written in the native language for clarity.

View File

@@ -54,32 +54,7 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List<DebugViewModel.U
val exportFile = File(directory, selectedFile)
try {
FileOutputStream(exportFile).use { fos ->
OutputStreamWriter(fos, StandardCharsets.UTF_8).use { writer ->
logs.forEach { log ->
writer.write("${log.formattedReceivedDate} [${log.messageType}]\n")
writer.write(log.logMessage)
log.decodedPayload?.let { decodedPayload ->
if (decodedPayload.isNotBlank()) {
writer.write("\n\nDecoded Payload:\n{\n")
// Redact Decoded keys.
decodedPayload.lineSequence().forEach { line ->
var outputLine = line
val redacted = redactedKeys.firstOrNull { line.contains(it) }
if (redacted != null) {
val idx = line.indexOf(':')
if (idx != -1) {
outputLine = line.take(idx + 1)
outputLine += "<redacted>"
}
}
writer.write(outputLine)
writer.write("\n")
}
writer.write("}\n\n")
}
}
}
}
OutputStreamWriter(fos, StandardCharsets.UTF_8).use { writer -> formatLogsTo(writer, logs) }
}
Logger.i { "MeshLog exported successfully to ${exportFile.absolutePath}" }
} catch (e: java.io.IOException) {
@@ -92,5 +67,3 @@ actual fun rememberLogExporter(logsProvider: suspend () -> List<DebugViewModel.U
}
}
}
private val redactedKeys = listOf("session_passkey", "private_key", "admin_key")

View File

@@ -1,140 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings
import dev.mokkery.MockMode
import dev.mokkery.every
import dev.mokkery.mock
import dev.mokkery.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.common.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.repository.MeshLogPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.UiPrefs
import org.robolectric.annotation.Config
@OptIn(ExperimentalCoroutinesApi::class)
@Config(sdk = [34])
class LegacySettingsViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
private val radioController: RadioController = mock(MockMode.autofill)
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
private val uiPrefs: UiPrefs = mock(MockMode.autofill)
private val buildConfigProvider: BuildConfigProvider = mock(MockMode.autofill)
private val databaseManager: DatabaseManager = mock(MockMode.autofill)
private val meshLogPrefs: MeshLogPrefs = mock(MockMode.autofill)
private lateinit var setThemeUseCase: SetThemeUseCase
private lateinit var setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase
private lateinit var setProvideLocationUseCase: SetProvideLocationUseCase
private lateinit var setDatabaseCacheLimitUseCase: SetDatabaseCacheLimitUseCase
private lateinit var setMeshLogSettingsUseCase: SetMeshLogSettingsUseCase
private lateinit var meshLocationUseCase: MeshLocationUseCase
private lateinit var exportDataUseCase: ExportDataUseCase
private lateinit var isOtaCapableUseCase: IsOtaCapableUseCase
private lateinit var viewModel: SettingsViewModel
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
setThemeUseCase = mock(MockMode.autofill)
setAppIntroCompletedUseCase = mock(MockMode.autofill)
setProvideLocationUseCase = mock(MockMode.autofill)
setDatabaseCacheLimitUseCase = mock(MockMode.autofill)
setMeshLogSettingsUseCase = mock(MockMode.autofill)
meshLocationUseCase = mock(MockMode.autofill)
exportDataUseCase = mock(MockMode.autofill)
isOtaCapableUseCase = mock(MockMode.autofill)
// 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 = mock(),
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() }
}
}