mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-27 10:11:48 -04:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 -> {}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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) },
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()) }
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user