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

@@ -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() }
}
}