feat: settings rework part 2, domain and usecase abstraction, tests (#4680)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-02 12:15:33 -06:00 committed by GitHub
parent 5f31df96d8
commit 8c6bd8ab7a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
121 changed files with 5245 additions and 1332 deletions

View file

@ -47,8 +47,8 @@ import org.meshtastic.core.resources.preserve_favorites
import org.meshtastic.core.resources.remotely_administrating
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.radio.AdminRoute
import org.meshtastic.feature.settings.radio.ExpressiveSection
import org.meshtastic.feature.settings.radio.RadioConfigState
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.ResponseState

View file

@ -35,8 +35,8 @@ import org.meshtastic.core.resources.device_configuration
import org.meshtastic.core.resources.remotely_administrating
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.radio.ExpressiveSection
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
@Composable

View file

@ -36,8 +36,8 @@ import org.meshtastic.core.resources.module_settings
import org.meshtastic.core.resources.remotely_administrating
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.navigation.ModuleRoute
import org.meshtastic.feature.settings.radio.ExpressiveSection
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
@Composable

View file

@ -16,101 +16,54 @@
*/
package org.meshtastic.feature.settings
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.provider.Settings.ACTION_APP_LOCALE_SETTINGS
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.filled.Abc
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.rounded.AppSettingsAlt
import androidx.compose.material.icons.rounded.FormatPaint
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.material.icons.rounded.Memory
import androidx.compose.material.icons.rounded.Output
import androidx.compose.material.icons.rounded.WavingHand
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.core.os.ConfigurationCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.gpsDisabled
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
import org.meshtastic.core.database.DatabaseConstants
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.acknowledgements
import org.meshtastic.core.resources.analytics_okay
import org.meshtastic.core.resources.app_settings
import org.meshtastic.core.resources.app_version
import org.meshtastic.core.resources.bottom_nav_settings
import org.meshtastic.core.resources.choose_theme
import org.meshtastic.core.resources.device_db_cache_limit
import org.meshtastic.core.resources.device_db_cache_limit_summary
import org.meshtastic.core.resources.dynamic
import org.meshtastic.core.resources.export_configuration
import org.meshtastic.core.resources.export_data_csv
import org.meshtastic.core.resources.import_configuration
import org.meshtastic.core.resources.intro_show
import org.meshtastic.core.resources.location_disabled
import org.meshtastic.core.resources.modules_already_unlocked
import org.meshtastic.core.resources.modules_unlocked
import org.meshtastic.core.resources.preferences_language
import org.meshtastic.core.resources.provide_location_to_mesh
import org.meshtastic.core.resources.remotely_administrating
import org.meshtastic.core.resources.save_rangetest
import org.meshtastic.core.resources.system_settings
import org.meshtastic.core.resources.theme
import org.meshtastic.core.resources.theme_dark
import org.meshtastic.core.resources.theme_light
import org.meshtastic.core.resources.theme_system
import org.meshtastic.core.resources.use_homoglyph_characters_encoding
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.SwitchListItem
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.settings.component.AppInfoSection
import org.meshtastic.feature.settings.component.AppearanceSection
import org.meshtastic.feature.settings.component.PersistenceSection
import org.meshtastic.feature.settings.component.PrivacySection
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.ModuleRoute
import org.meshtastic.feature.settings.radio.ExpressiveSection
import org.meshtastic.feature.settings.radio.RadioConfigItemList
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.EditDeviceProfileDialog
@ -119,7 +72,6 @@ import org.meshtastic.feature.settings.util.LanguageUtils.languageMap
import org.meshtastic.proto.DeviceProfile
import java.text.SimpleDateFormat
import java.util.Locale
import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalPermissionsApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@ -259,226 +211,37 @@ fun SettingsScreen(
onNavigate = onNavigate,
)
val context = LocalContext.current
PrivacySection(
analyticsAvailable = state.analyticsAvailable,
analyticsEnabled = viewModel.analyticsAllowedFlow.collectAsStateWithLifecycle(false).value,
onToggleAnalytics = { viewModel.toggleAnalyticsAllowed() },
provideLocation = settingsViewModel.provideLocation.collectAsStateWithLifecycle().value,
onToggleLocation = { settingsViewModel.setProvideLocation(it) },
homoglyphEnabled = viewModel.homoglyphEncodingEnabledFlow.collectAsStateWithLifecycle(false).value,
onToggleHomoglyph = { viewModel.toggleHomoglyphCharactersEncodingEnabled() },
startProvideLocation = { settingsViewModel.startProvidingLocation() },
stopProvideLocation = { settingsViewModel.stopProvidingLocation() },
)
ExpressiveSection(title = stringResource(Res.string.app_settings)) {
if (state.analyticsAvailable) {
val allowed by viewModel.analyticsAllowedFlow.collectAsStateWithLifecycle(false)
SwitchListItem(
text = stringResource(Res.string.analytics_okay),
checked = allowed,
leadingIcon = Icons.Default.BugReport,
onClick = { viewModel.toggleAnalyticsAllowed() },
)
}
AppearanceSection(
onShowLanguagePicker = { showLanguagePickerDialog = true },
onShowThemePicker = { showThemePickerDialog = true },
)
val locationPermissionsState =
rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
val isGpsDisabled = context.gpsDisabled()
val provideLocation by settingsViewModel.provideLocation.collectAsStateWithLifecycle()
PersistenceSection(
cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value,
onSetCacheLimit = { settingsViewModel.setDbCacheLimit(it) },
nodeShortName = ourNode?.user?.short_name ?: "",
onExportData = { settingsViewModel.saveDataCsv(it) },
)
LaunchedEffect(provideLocation, locationPermissionsState.allPermissionsGranted, isGpsDisabled) {
if (provideLocation) {
if (locationPermissionsState.allPermissionsGranted) {
if (!isGpsDisabled) {
settingsViewModel.meshService?.startProvideLocation()
} else {
context.showToast(Res.string.location_disabled)
}
} else {
// Request permissions if not granted and user wants to provide location
locationPermissionsState.launchMultiplePermissionRequest()
}
} else {
settingsViewModel.meshService?.stopProvideLocation()
}
}
SwitchListItem(
text = stringResource(Res.string.provide_location_to_mesh),
leadingIcon = Icons.Rounded.LocationOn,
enabled = !isGpsDisabled,
checked = provideLocation,
onClick = { settingsViewModel.setProvideLocation(!provideLocation) },
)
val homoglyphEncodingEnabled by
viewModel.homoglyphEncodingEnabledFlow.collectAsStateWithLifecycle(false)
HomoglyphSetting(
homoglyphEncodingEnabled = homoglyphEncodingEnabled,
onToggle = { viewModel.toggleHomoglyphCharactersEncodingEnabled() },
)
val settingsLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {}
// On Android 12 and below, system app settings for language are not available. Use the in-app language
// picker for these devices.
val useInAppLangPicker = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
ListItem(
text = stringResource(Res.string.preferences_language),
leadingIcon = Icons.Rounded.Language,
trailingIcon = if (useInAppLangPicker) null else Icons.AutoMirrored.Rounded.KeyboardArrowRight,
) {
if (useInAppLangPicker) {
showLanguagePickerDialog = true
} else {
val intent = Intent(ACTION_APP_LOCALE_SETTINGS, "package:${context.packageName}".toUri())
if (intent.resolveActivity(context.packageManager) != null) {
settingsLauncher.launch(intent)
} else {
// Fall back to the in-app picker
showLanguagePickerDialog = true
}
}
}
ListItem(
text = stringResource(Res.string.theme),
leadingIcon = Icons.Rounded.FormatPaint,
trailingIcon = null,
) {
showThemePickerDialog = true
}
// Node DB cache limit (App setting)
val cacheLimit = settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle().value
val cacheItems = remember {
(DatabaseConstants.MIN_CACHE_LIMIT..DatabaseConstants.MAX_CACHE_LIMIT).map {
it.toLong() to it.toString()
}
}
DropDownPreference(
title = stringResource(Res.string.device_db_cache_limit),
enabled = true,
items = cacheItems,
selectedItem = cacheLimit.toLong(),
onItemSelected = { selected -> settingsViewModel.setDbCacheLimit(selected.toInt()) },
summary = stringResource(Res.string.device_db_cache_limit_summary),
)
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(nowMillis.toInstant().toDate())
val nodeName = ourNode?.user?.short_name ?: ""
val exportRangeTestLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
it.data?.data?.let { uri -> settingsViewModel.saveDataCsv(uri) }
}
}
ListItem(
text = stringResource(Res.string.save_rangetest),
leadingIcon = Icons.Rounded.Output,
trailingIcon = null,
) {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/csv"
putExtra(Intent.EXTRA_TITLE, "Meshtastic_rangetest_${nodeName}_$timestamp.csv")
}
exportRangeTestLauncher.launch(intent)
}
val exportDataLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
it.data?.data?.let { uri -> settingsViewModel.saveDataCsv(uri) }
}
}
ListItem(
text = stringResource(Res.string.export_data_csv),
leadingIcon = Icons.Rounded.Output,
trailingIcon = null,
) {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/csv"
putExtra(Intent.EXTRA_TITLE, "Meshtastic_datalog_${nodeName}_$timestamp.csv")
}
exportDataLauncher.launch(intent)
}
ListItem(
text = stringResource(Res.string.intro_show),
leadingIcon = Icons.Rounded.WavingHand,
trailingIcon = null,
) {
settingsViewModel.showAppIntro()
}
ListItem(
text = stringResource(Res.string.system_settings),
leadingIcon = Icons.Rounded.AppSettingsAlt,
trailingIcon = null,
) {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.fromParts("package", context.packageName, null)
settingsLauncher.launch(intent)
}
ListItem(
text = stringResource(Res.string.acknowledgements),
leadingIcon = Icons.Rounded.Info,
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
) {
onNavigate(SettingsRoutes.About)
}
AppVersionButton(
excludedModulesUnlocked = excludedModulesUnlocked,
appVersionName = settingsViewModel.appVersionName,
) {
settingsViewModel.unlockExcludedModules()
}
}
}
}
}
private const val UNLOCK_CLICK_COUNT = 5 // Number of clicks required to unlock excluded modules.
private const val UNLOCKED_CLICK_COUNT = 3 // Number of clicks before we toast that modules are already unlocked.
private const val UNLOCK_TIMEOUT_SECONDS = 1 // Timeout in seconds to reset the click counter.
/** A button to display the app version. Clicking it 5 times will unlock the excluded modules. */
@Composable
private fun AppVersionButton(
excludedModulesUnlocked: Boolean,
appVersionName: String,
onUnlockExcludedModules: () -> Unit,
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
var clickCount by remember { mutableIntStateOf(0) }
LaunchedEffect(clickCount) {
if (clickCount in 1..<UNLOCK_CLICK_COUNT) {
delay(UNLOCK_TIMEOUT_SECONDS.seconds)
clickCount = 0
}
}
ListItem(
text = stringResource(Res.string.app_version),
leadingIcon = Icons.Rounded.Memory,
supportingText = appVersionName,
trailingIcon = null,
) {
clickCount = clickCount.inc().coerceIn(0, UNLOCK_CLICK_COUNT)
when {
clickCount == UNLOCKED_CLICK_COUNT && excludedModulesUnlocked -> {
clickCount = 0
scope.launch { context.showToast(Res.string.modules_already_unlocked) }
}
clickCount == UNLOCK_CLICK_COUNT -> {
clickCount = 0
onUnlockExcludedModules()
scope.launch { context.showToast(Res.string.modules_unlocked) }
}
AppInfoSection(
appVersionName = settingsViewModel.appVersionName,
excludedModulesUnlocked = excludedModulesUnlocked,
onUnlockExcludedModules = { settingsViewModel.unlockExcludedModules() },
onShowAppIntro = { settingsViewModel.showAppIntro() },
onNavigateToAbout = { onNavigate(SettingsRoutes.About) },
)
}
}
}
@ -525,18 +288,3 @@ private fun ThemePickerDialog(onClickTheme: (Int) -> Unit, onDismiss: () -> Unit
},
)
}
@VisibleForTesting
@Composable
fun HomoglyphSetting(homoglyphEncodingEnabled: Boolean, onToggle: () -> Unit) {
val currentLocale = ConfigurationCompat.getLocales(LocalConfiguration.current).get(0)
val supportedLanguages = listOf("ru", "uk", "be", "bg", "sr", "mk", "kk", "ky", "tg", "mn")
if (currentLocale?.language in supportedLanguages) {
SwitchListItem(
text = stringResource(Res.string.use_homoglyph_characters_encoding),
checked = homoglyphEncodingEnabled,
leadingIcon = Icons.Default.Abc,
onClick = onToggle,
)
}
}

View file

@ -16,8 +16,6 @@
*/
package org.meshtastic.feature.settings
import android.app.Application
import android.icu.text.SimpleDateFormat
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -27,64 +25,57 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.data.repository.DeviceHardwareRepository
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.DatabaseConstants
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.positionToMeter
import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase
import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase
import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase
import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase
import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase
import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase
import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase
import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import org.meshtastic.core.prefs.radio.RadioPrefs
import org.meshtastic.core.prefs.radio.isBle
import org.meshtastic.core.prefs.radio.isSerial
import org.meshtastic.core.prefs.radio.isTcp
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.PortNum
import java.io.BufferedWriter
import java.io.FileNotFoundException
import java.io.FileWriter
import java.util.Locale
import javax.inject.Inject
import kotlin.math.roundToInt
import org.meshtastic.proto.Position as ProtoPosition
@Suppress("LongParameterList")
@Suppress("LongParameterList", "TooManyFunctions")
@HiltViewModel
class SettingsViewModel
@Inject
constructor(
private val app: Application,
private val app: android.app.Application,
radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
private val radioController: RadioController,
private val nodeRepository: NodeRepository,
private val meshLogRepository: MeshLogRepository,
private val uiPrefs: UiPrefs,
private val uiPreferencesDataSource: UiPreferencesDataSource,
private val buildConfigProvider: BuildConfigProvider,
private val databaseManager: DatabaseManager,
private val deviceHardwareRepository: DeviceHardwareRepository,
private val radioPrefs: RadioPrefs,
private val meshLogPrefs: MeshLogPrefs,
private val setThemeUseCase: SetThemeUseCase,
private val setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase,
private val setProvideLocationUseCase: SetProvideLocationUseCase,
private val setDatabaseCacheLimitUseCase: SetDatabaseCacheLimitUseCase,
private val setMeshLogSettingsUseCase: SetMeshLogSettingsUseCase,
private val meshLocationUseCase: MeshLocationUseCase,
private val exportDataUseCase: ExportDataUseCase,
private val isOtaCapableUseCase: IsOtaCapableUseCase,
) : ViewModel() {
val myNodeInfo: StateFlow<MyNodeEntity?> = nodeRepository.myNodeInfo
@ -94,14 +85,11 @@ constructor(
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo
val isConnected =
serviceRepository.connectionState.map { it.isConnected() }.stateInWhileSubscribed(initialValue = false)
radioController.connectionState.map { it.isConnected() }.stateInWhileSubscribed(initialValue = false)
val localConfig: StateFlow<LocalConfig> =
radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig())
val meshService: IMeshService?
get() = serviceRepository.meshService
val provideLocation: StateFlow<Boolean> =
myNodeInfo
.flatMapLatest { myNodeEntity ->
@ -114,41 +102,27 @@ constructor(
}
.stateInWhileSubscribed(initialValue = false)
fun startProvidingLocation() {
meshLocationUseCase.startProvidingLocation()
}
fun stopProvidingLocation() {
meshLocationUseCase.stopProvidingLocation()
}
private val _excludedModulesUnlocked = MutableStateFlow(false)
val excludedModulesUnlocked: StateFlow<Boolean> = _excludedModulesUnlocked.asStateFlow()
val appVersionName
get() = buildConfigProvider.versionName
val isOtaCapable: StateFlow<Boolean> =
combine(ourNodeInfo, serviceRepository.connectionState) { node, connectionState -> Pair(node, connectionState) }
.flatMapLatest { (node, connectionState) ->
if (node == null || !connectionState.isConnected()) {
flowOf(false)
} else if (radioPrefs.isBle() || radioPrefs.isSerial() || radioPrefs.isTcp()) {
val hwModel = node.user.hw_model.value
val hw = deviceHardwareRepository.getDeviceHardwareByModel(hwModel).getOrNull()
// Support both Nordic DFU (requiresDfu) and ESP32 Unified OTA (supportsUnifiedOta)
val capabilities = Capabilities(node.metadata?.firmware_version)
// ESP32 Unified OTA is only supported via BLE or WiFi (TCP), not USB Serial.
// TODO: Re-enable when supportsUnifiedOta is added to DeviceHardware
val isEsp32OtaSupported = false
// hw?.supportsUnifiedOta == true && capabilities.supportsEsp32Ota && !radioPrefs.isSerial()
flow { emit(hw?.requiresDfu == true || isEsp32OtaSupported) }
} else {
flowOf(false)
}
}
.stateInWhileSubscribed(initialValue = false)
val isOtaCapable: StateFlow<Boolean> = isOtaCapableUseCase().stateInWhileSubscribed(initialValue = false)
// Device DB cache limit (bounded by DatabaseConstants)
val dbCacheLimit: StateFlow<Int> = databaseManager.cacheLimit
fun setDbCacheLimit(limit: Int) {
val clamped = limit.coerceIn(DatabaseConstants.MIN_CACHE_LIMIT, DatabaseConstants.MAX_CACHE_LIMIT)
databaseManager.setCacheLimit(clamped)
setDatabaseCacheLimitUseCase(limit)
}
// MeshLog retention period (bounded by MeshLogPrefsImpl constants)
@ -159,32 +133,25 @@ constructor(
val meshLogLoggingEnabled: StateFlow<Boolean> = _meshLogLoggingEnabled.asStateFlow()
fun setMeshLogRetentionDays(days: Int) {
val clamped = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS)
meshLogPrefs.retentionDays = clamped
_meshLogRetentionDays.value = clamped
viewModelScope.launch { meshLogRepository.deleteLogsOlderThan(clamped) }
viewModelScope.launch { setMeshLogSettingsUseCase.setRetentionDays(days) }
_meshLogRetentionDays.value = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS)
}
fun setMeshLogLoggingEnabled(enabled: Boolean) {
meshLogPrefs.loggingEnabled = enabled
viewModelScope.launch { setMeshLogSettingsUseCase.setLoggingEnabled(enabled) }
_meshLogLoggingEnabled.value = enabled
if (!enabled) {
viewModelScope.launch { meshLogRepository.deleteAll() }
} else {
viewModelScope.launch { meshLogRepository.deleteLogsOlderThan(meshLogPrefs.retentionDays) }
}
}
fun setProvideLocation(value: Boolean) {
myNodeNum?.let { uiPrefs.setShouldProvideNodeLocation(it, value) }
myNodeNum?.let { setProvideLocationUseCase(it, value) }
}
fun setTheme(theme: Int) {
uiPreferencesDataSource.setTheme(theme)
setThemeUseCase(theme)
}
fun showAppIntro() {
uiPreferencesDataSource.setAppIntroCompleted(false)
setAppIntroCompletedUseCase(false)
}
fun unlockExcludedModules() {
@ -204,112 +171,8 @@ constructor(
@Suppress("detekt:CyclomaticComplexMethod", "detekt:LongMethod")
fun saveDataCsv(uri: Uri, filterPortnum: Int? = null) {
viewModelScope.launch(Dispatchers.Main) {
// Extract distances to this device from position messages and put (node,SNR,distance)
// in the file_uri
val myNodeNum = myNodeNum ?: return@launch
// Capture the current node value while we're still on main thread
val nodes = nodeRepository.nodeDBbyNum.value
// Converts a ProtoPosition (nullable) to a Position, but only if it's valid, otherwise returns null.
// The returned Position is guaranteed to be non-null and valid, or null if the input was null or invalid.
val positionToPos: (ProtoPosition?) -> Position? = { meshPosition ->
meshPosition?.let { Position(it) }?.takeIf { it.isValid() }
}
writeToUri(uri) { writer ->
val nodePositions = mutableMapOf<Int, ProtoPosition?>()
@Suppress("MaxLineLength")
writer.appendLine(
"\"date\",\"time\",\"from\",\"sender name\",\"sender lat\",\"sender long\",\"rx lat\",\"rx long\",\"rx elevation\",\"rx snr\",\"distance(m)\",\"hop limit\",\"payload\"",
)
// Packets are ordered by time, we keep most recent position of
// our device in localNodePosition.
val dateFormat = SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault())
meshLogRepository.getAllLogsInReceiveOrder(Int.MAX_VALUE).first().forEach { packet ->
// If we get a NodeInfo packet, use it to update our position data (if valid)
packet.nodeInfo?.let { nodeInfo ->
positionToPos.invoke(nodeInfo.position)?.let { nodePositions[nodeInfo.num] = nodeInfo.position }
}
packet.meshPacket?.let { proto ->
// If the packet contains position data then use it to update, if valid
packet.position?.let { position ->
positionToPos.invoke(position)?.let {
nodePositions[
proto.from.takeIf { it != 0 } ?: myNodeNum,
] = position
}
}
// packets must have rxSNR, and optionally match the filter given as a param.
if (
(filterPortnum == null || (proto.decoded?.portnum?.value ?: 0) == filterPortnum) &&
(proto.rx_snr ?: 0f) != 0.0f
) {
val rxDateTime = dateFormat.format(packet.received_date)
val rxFrom = proto.from.toUInt()
val senderName = nodes[proto.from]?.user?.long_name ?: ""
// sender lat & long
val senderPosition = nodePositions[proto.from]
val senderPos = positionToPos.invoke(senderPosition)
val senderLat = senderPos?.latitude ?: ""
val senderLong = senderPos?.longitude ?: ""
// rx lat, long, and elevation
val rxPosition = nodePositions[myNodeNum]
val rxPos = positionToPos.invoke(rxPosition)
val rxLat = rxPos?.latitude ?: ""
val rxLong = rxPos?.longitude ?: ""
val rxAlt = rxPos?.altitude ?: ""
val rxSnr = proto.rx_snr
// Calculate the distance if both positions are valid
val dist =
if (senderPos == null || rxPos == null) {
""
} else {
positionToMeter(
Position(rxPosition!!), // Use rxPosition but only if rxPos was
// valid
Position(senderPosition!!), // Use senderPosition but only if
// senderPos was valid
)
.roundToInt()
.toString()
}
val hopLimit = proto.hop_limit ?: 0
val decoded = proto.decoded
val encrypted = proto.encrypted
val payload =
when {
(decoded?.portnum?.value ?: 0) !in
setOf(PortNum.TEXT_MESSAGE_APP.value, PortNum.RANGE_TEST_APP.value) ->
"<${decoded?.portnum}>"
decoded != null -> decoded.payload.utf8().replace("\"", "\"\"")
encrypted != null -> "${encrypted.size} encrypted bytes"
else -> ""
}
// date,time,from,sender name,sender lat,sender long,rx lat,rx long,rx
// elevation,rx
// snr,distance,hop limit,payload
@Suppress("MaxLineLength")
writer.appendLine(
"$rxDateTime,\"$rxFrom\",\"$senderName\",\"$senderLat\",\"$senderLong\",\"$rxLat\",\"$rxLong\",\"$rxAlt\",\"$rxSnr\",\"$dist\",\"$hopLimit\",\"$payload\"",
)
}
}
}
}
writeToUri(uri) { writer -> exportDataUseCase(writer, myNodeNum, filterPortnum) }
}
}

View file

@ -0,0 +1,159 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.component
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.AppSettingsAlt
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Memory
import androidx.compose.material.icons.rounded.WavingHand
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.acknowledgements
import org.meshtastic.core.resources.app_version
import org.meshtastic.core.resources.info
import org.meshtastic.core.resources.intro_show
import org.meshtastic.core.resources.modules_already_unlocked
import org.meshtastic.core.resources.modules_unlocked
import org.meshtastic.core.resources.system_settings
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.util.showToast
import kotlin.time.Duration.Companion.seconds
/** Section displaying application information and related actions. */
@Composable
fun AppInfoSection(
appVersionName: String,
excludedModulesUnlocked: Boolean,
onUnlockExcludedModules: () -> Unit,
onShowAppIntro: () -> Unit,
onNavigateToAbout: () -> Unit,
) {
val context = LocalContext.current
val settingsLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {}
ExpressiveSection(title = stringResource(Res.string.info)) {
ListItem(
text = stringResource(Res.string.intro_show),
leadingIcon = Icons.Rounded.WavingHand,
trailingIcon = null,
) {
onShowAppIntro()
}
ListItem(
text = stringResource(Res.string.system_settings),
leadingIcon = Icons.Rounded.AppSettingsAlt,
trailingIcon = null,
) {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.fromParts("package", context.packageName, null)
settingsLauncher.launch(intent)
}
ListItem(
text = stringResource(Res.string.acknowledgements),
leadingIcon = Icons.Rounded.Info,
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
) {
onNavigateToAbout()
}
AppVersionButton(
excludedModulesUnlocked = excludedModulesUnlocked,
appVersionName = appVersionName,
onUnlockExcludedModules = onUnlockExcludedModules,
)
}
}
private const val UNLOCK_CLICK_COUNT = 5 // Number of clicks required to unlock excluded modules.
private const val UNLOCKED_CLICK_COUNT = 3 // Number of clicks before we toast that modules are already unlocked.
private const val UNLOCK_TIMEOUT_SECONDS = 1 // Timeout in seconds to reset the click counter.
@Composable
private fun AppVersionButton(
excludedModulesUnlocked: Boolean,
appVersionName: String,
onUnlockExcludedModules: () -> Unit,
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
var clickCount by remember { mutableIntStateOf(0) }
LaunchedEffect(clickCount) {
if (clickCount in 1..<UNLOCK_CLICK_COUNT) {
delay(UNLOCK_TIMEOUT_SECONDS.seconds)
clickCount = 0
}
}
ListItem(
text = stringResource(Res.string.app_version),
leadingIcon = Icons.Rounded.Memory,
supportingText = appVersionName,
trailingIcon = null,
) {
clickCount = clickCount.inc().coerceIn(0, UNLOCK_CLICK_COUNT)
when {
clickCount == UNLOCKED_CLICK_COUNT && excludedModulesUnlocked -> {
clickCount = 0
scope.launch { context.showToast(Res.string.modules_already_unlocked) }
}
clickCount == UNLOCK_CLICK_COUNT -> {
clickCount = 0
onUnlockExcludedModules()
scope.launch { context.showToast(Res.string.modules_unlocked) }
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun AppInfoSectionPreview() {
AppTheme {
AppInfoSection(
appVersionName = "2.5.0",
excludedModulesUnlocked = false,
onUnlockExcludedModules = {},
onShowAppIntro = {},
onNavigateToAbout = {},
)
}
}

View file

@ -0,0 +1,84 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.component
import android.content.Intent
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.FormatPaint
import androidx.compose.material.icons.rounded.Language
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.net.toUri
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.app_settings
import org.meshtastic.core.resources.preferences_language
import org.meshtastic.core.resources.theme
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.theme.AppTheme
/** Section for app appearance settings like language and theme. */
@Composable
fun AppearanceSection(onShowLanguagePicker: () -> Unit, onShowThemePicker: () -> Unit) {
val context = LocalContext.current
val settingsLauncher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.StartActivityForResult()) {}
// On Android 12 and below, system app settings for language are not available. Use the in-app language
// picker for these devices.
val useInAppLangPicker = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
ExpressiveSection(title = stringResource(Res.string.app_settings)) {
ListItem(
text = stringResource(Res.string.preferences_language),
leadingIcon = Icons.Rounded.Language,
trailingIcon = if (useInAppLangPicker) null else Icons.AutoMirrored.Rounded.KeyboardArrowRight,
) {
if (useInAppLangPicker) {
onShowLanguagePicker()
} else {
val intent = Intent(Settings.ACTION_APP_LOCALE_SETTINGS, "package:${context.packageName}".toUri())
if (intent.resolveActivity(context.packageManager) != null) {
settingsLauncher.launch(intent)
} else {
// Fall back to the in-app picker
onShowLanguagePicker()
}
}
}
ListItem(
text = stringResource(Res.string.theme),
leadingIcon = Icons.Rounded.FormatPaint,
trailingIcon = null,
) {
onShowThemePicker()
}
}
}
@Preview(showBackground = true)
@Composable
private fun AppearanceSectionPreview() {
AppTheme { AppearanceSection(onShowLanguagePicker = {}, onShowThemePicker = {}) }
}

View file

@ -0,0 +1,56 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
/** A styled section container for settings screens. */
@Composable
fun ExpressiveSection(
title: String,
modifier: Modifier = Modifier,
titleColor: Color = MaterialTheme.colorScheme.primary,
content: @Composable ColumnScope.() -> Unit,
) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = title,
modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth(),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = titleColor,
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
content = content,
)
}
}

View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.component
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Abc
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalConfiguration
import androidx.core.os.ConfigurationCompat
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.use_homoglyph_characters_encoding
import org.meshtastic.core.ui.component.SwitchListItem
@Composable
fun HomoglyphSetting(homoglyphEncodingEnabled: Boolean, onToggle: () -> Unit) {
val currentLocale = ConfigurationCompat.getLocales(LocalConfiguration.current).get(0)
val supportedLanguages = listOf("ru", "uk", "be", "bg", "sr", "mk", "kk", "ky", "tg", "mn")
if (currentLocale?.language in supportedLanguages) {
SwitchListItem(
text = stringResource(Res.string.use_homoglyph_characters_encoding),
checked = homoglyphEncodingEnabled,
leadingIcon = Icons.Default.Abc,
onClick = onToggle,
)
}
}

View file

@ -0,0 +1,116 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.component
import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity.RESULT_OK
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Output
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.tooling.preview.Preview
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
import org.meshtastic.core.database.DatabaseConstants
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.app_settings
import org.meshtastic.core.resources.device_db_cache_limit
import org.meshtastic.core.resources.device_db_cache_limit_summary
import org.meshtastic.core.resources.export_data_csv
import org.meshtastic.core.resources.save_rangetest
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.theme.AppTheme
import java.text.SimpleDateFormat
import java.util.Locale
/** Section for settings related to data persistence and exports. */
@Composable
fun PersistenceSection(
cacheLimit: Int,
onSetCacheLimit: (Int) -> Unit,
nodeShortName: String,
onExportData: (android.net.Uri) -> Unit,
) {
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(nowMillis.toInstant().toDate())
val exportRangeTestLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
it.data?.data?.let { uri -> onExportData(uri) }
}
}
val exportDataLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
it.data?.data?.let { uri -> onExportData(uri) }
}
}
ExpressiveSection(title = stringResource(Res.string.app_settings)) {
val cacheItems = remember {
(DatabaseConstants.MIN_CACHE_LIMIT..DatabaseConstants.MAX_CACHE_LIMIT).map { it.toLong() to it.toString() }
}
DropDownPreference(
title = stringResource(Res.string.device_db_cache_limit),
enabled = true,
items = cacheItems,
selectedItem = cacheLimit.toLong(),
onItemSelected = { selected -> onSetCacheLimit(selected.toInt()) },
summary = stringResource(Res.string.device_db_cache_limit_summary),
)
ListItem(
text = stringResource(Res.string.save_rangetest),
leadingIcon = Icons.Rounded.Output,
trailingIcon = null,
) {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/csv"
putExtra(Intent.EXTRA_TITLE, "Meshtastic_rangetest_${nodeShortName}_$timestamp.csv")
}
exportRangeTestLauncher.launch(intent)
}
ListItem(
text = stringResource(Res.string.export_data_csv),
leadingIcon = Icons.Rounded.Output,
trailingIcon = null,
) {
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/csv"
putExtra(Intent.EXTRA_TITLE, "Meshtastic_datalog_${nodeShortName}_$timestamp.csv")
}
exportDataLauncher.launch(intent)
}
}
}
@Preview(showBackground = true)
@Composable
private fun PersistenceSectionPreview() {
AppTheme { PersistenceSection(cacheLimit = 100, onSetCacheLimit = {}, nodeShortName = "TEST", onExportData = {}) }
}

View file

@ -0,0 +1,113 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.component
import android.Manifest
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.rounded.LocationOn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.gpsDisabled
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.analytics_okay
import org.meshtastic.core.resources.app_settings
import org.meshtastic.core.resources.location_disabled
import org.meshtastic.core.resources.provide_location_to_mesh
import org.meshtastic.core.ui.component.SwitchListItem
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.util.showToast
/** Section managing privacy settings like analytics and location sharing. */
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun PrivacySection(
analyticsAvailable: Boolean,
analyticsEnabled: Boolean,
onToggleAnalytics: () -> Unit,
provideLocation: Boolean,
onToggleLocation: (Boolean) -> Unit,
homoglyphEnabled: Boolean,
onToggleHomoglyph: () -> Unit,
startProvideLocation: () -> Unit,
stopProvideLocation: () -> Unit,
) {
val context = LocalContext.current
val locationPermissionsState =
rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION))
val isGpsDisabled = context.gpsDisabled()
LaunchedEffect(provideLocation, locationPermissionsState.allPermissionsGranted, isGpsDisabled) {
if (provideLocation) {
if (locationPermissionsState.allPermissionsGranted) {
if (!isGpsDisabled) {
startProvideLocation()
} else {
context.showToast(Res.string.location_disabled)
}
} else {
locationPermissionsState.launchMultiplePermissionRequest()
}
} else {
stopProvideLocation()
}
}
ExpressiveSection(title = stringResource(Res.string.app_settings)) {
if (analyticsAvailable) {
SwitchListItem(
text = stringResource(Res.string.analytics_okay),
checked = analyticsEnabled,
leadingIcon = Icons.Default.BugReport,
onClick = onToggleAnalytics,
)
}
SwitchListItem(
text = stringResource(Res.string.provide_location_to_mesh),
leadingIcon = Icons.Rounded.LocationOn,
enabled = !isGpsDisabled,
checked = provideLocation,
onClick = { onToggleLocation(!provideLocation) },
)
HomoglyphSetting(homoglyphEncodingEnabled = homoglyphEnabled, onToggle = onToggleHomoglyph)
}
}
@Preview(showBackground = true)
@Composable
private fun PrivacySectionPreview() {
AppTheme {
PrivacySection(
analyticsAvailable = true,
analyticsEnabled = true,
onToggleAnalytics = {},
provideLocation = true,
onToggleLocation = {},
homoglyphEnabled = false,
onToggleHomoglyph = {},
startProvideLocation = {},
stopProvideLocation = {},
)
}
}

View file

@ -40,7 +40,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.clean_node_database_description
import org.meshtastic.core.resources.clean_node_database_title
@ -150,7 +150,7 @@ private fun UnknownNodesFilter(onlyUnknownNodes: Boolean, onCheckedChanged: (Boo
* @param nodesToDelete The list of nodes to be deleted.
*/
@Composable
private fun NodesDeletionPreview(nodesToDelete: List<NodeEntity>) {
private fun NodesDeletionPreview(nodesToDelete: List<Node>) {
Text(
stringResource(Res.string.nodes_queued_for_deletion, nodesToDelete.size),
modifier = Modifier.padding(bottom = 16.dp),
@ -160,8 +160,6 @@ private fun NodesDeletionPreview(nodesToDelete: List<NodeEntity>) {
horizontalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.Center,
) {
nodesToDelete.forEach { node ->
NodeChip(node = node.toModel(), modifier = Modifier.padding(end = 8.dp, bottom = 8.dp))
}
nodesToDelete.forEach { node -> NodeChip(node = node, modifier = Modifier.padding(end = 8.dp, bottom = 8.dp)) }
}
}

View file

@ -24,16 +24,14 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.domain.usecase.settings.CleanNodeDatabaseUseCase
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.are_you_sure
import org.meshtastic.core.resources.clean_node_database_confirmation
import org.meshtastic.core.resources.clean_now
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.util.AlertManager
import javax.inject.Inject
import kotlin.time.Duration.Companion.days
private const val MIN_DAYS_THRESHOLD = 7f
@ -45,8 +43,7 @@ private const val MIN_DAYS_THRESHOLD = 7f
class CleanNodeDatabaseViewModel
@Inject
constructor(
private val nodeRepository: NodeRepository,
private val serviceRepository: ServiceRepository,
private val cleanNodeDatabaseUseCase: CleanNodeDatabaseUseCase,
private val alertManager: AlertManager,
) : ViewModel() {
private val _olderThanDays = MutableStateFlow(30f)
@ -55,7 +52,7 @@ constructor(
private val _onlyUnknownNodes = MutableStateFlow(false)
val onlyUnknownNodes = _onlyUnknownNodes.asStateFlow()
private val _nodesToDelete = MutableStateFlow<List<NodeEntity>>(emptyList())
private val _nodesToDelete = MutableStateFlow<List<Node>>(emptyList())
val nodesToDelete = _nodesToDelete.asStateFlow()
fun onOlderThanDaysChanged(value: Float) {
@ -69,40 +66,15 @@ constructor(
}
}
/**
* Updates the list of nodes to be deleted based on the current filter criteria. The logic is as follows:
* - The "older than X days" filter (controlled by the slider) is always active.
* - If "only unknown nodes" is also enabled, nodes that are BOTH unknown AND older than X days are selected.
* - If "only unknown nodes" is not enabled, all nodes older than X days are selected.
* - Nodes with an associated public key (PKI) heard from within the last 7 days are always excluded from deletion.
* - Nodes marked as ignored or favorite are always excluded from deletion.
*/
/** Updates the list of nodes to be deleted based on the current filter criteria. */
fun getNodesToDelete() {
viewModelScope.launch {
val onlyUnknownEnabled = _onlyUnknownNodes.value
val currentTimeSeconds = nowSeconds
val sevenDaysAgoSeconds = currentTimeSeconds - 7.days.inWholeSeconds
val olderThanTimestamp = currentTimeSeconds - _olderThanDays.value.toInt().days.inWholeSeconds
val initialNodesToConsider =
if (onlyUnknownEnabled) {
// Both "older than X days" and "only unknown nodes" filters apply
val olderNodes = nodeRepository.getNodesOlderThan(olderThanTimestamp.toInt())
val unknownNodes = nodeRepository.getUnknownNodes()
olderNodes.filter { itNode -> unknownNodes.any { unknownNode -> itNode.num == unknownNode.num } }
} else {
// Only "older than X days" filter applies
nodeRepository.getNodesOlderThan(olderThanTimestamp.toInt())
}
_nodesToDelete.value =
initialNodesToConsider.filterNot { node ->
// Exclude nodes with PKI heard in the last 7 days
(node.hasPKC && node.lastHeard >= sevenDaysAgoSeconds) ||
// Exclude ignored or favorite nodes
node.isIgnored ||
node.isFavorite
}
cleanNodeDatabaseUseCase.getNodesToClean(
olderThanDays = _olderThanDays.value,
onlyUnknownNodes = _onlyUnknownNodes.value,
currentTimeSeconds = nowSeconds,
)
}
}
@ -126,16 +98,7 @@ constructor(
fun cleanNodes() {
viewModelScope.launch {
val nodeNums = _nodesToDelete.value.map { it.num }
if (nodeNums.isNotEmpty()) {
nodeRepository.deleteNodes(nodeNums)
val service = serviceRepository.meshService
if (service != null) {
for (nodeNum in nodeNums) {
service.removeByNodenum(service.packetId, nodeNum)
}
}
}
cleanNodeDatabaseUseCase.cleanNodes(nodeNums)
// Clear the list after deletion or if it was empty
_nodesToDelete.value = emptyList()
}

View file

@ -18,8 +18,6 @@ package org.meshtastic.feature.settings.radio
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
@ -35,16 +33,11 @@ import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Storage
import androidx.compose.material.icons.rounded.SystemUpdate
import androidx.compose.material.icons.rounded.Upload
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
@ -72,10 +65,9 @@ import org.meshtastic.core.resources.shutdown
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.navigation.ConfigRoute
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun RadioConfigItemList(
state: RadioConfigState,
@ -89,130 +81,135 @@ fun RadioConfigItemList(
val enabled = state.connected && !state.responseState.isWaiting() && !isManaged
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
ExpressiveSection(title = stringResource(Res.string.radio_configuration)) {
if (isManaged) {
ManagedMessage()
}
ConfigRoute.radioConfigRoutes.forEach {
ListItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) { onRouteClick(it) }
}
}
ExpressiveSection(title = stringResource(Res.string.device_configuration)) {
if (isManaged) {
ManagedMessage()
}
ListItem(
text = stringResource(Res.string.device_configuration),
leadingIcon = Icons.Rounded.AppSettingsAlt,
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
enabled = enabled,
) {
onNavigate(SettingsRoutes.DeviceConfiguration)
}
}
ExpressiveSection(title = stringResource(Res.string.module_settings)) {
if (isManaged) {
ManagedMessage()
}
ListItem(
text = stringResource(Res.string.module_settings),
leadingIcon = Icons.Rounded.Settings,
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
enabled = enabled,
) {
onNavigate(SettingsRoutes.ModuleConfiguration)
}
}
RadioConfigSection(isManaged, enabled, onRouteClick)
DeviceConfigSection(isManaged, enabled, onNavigate)
ModuleSettingsSection(isManaged, enabled, onNavigate)
if (state.isLocal) {
ExpressiveSection(title = stringResource(Res.string.backup_restore)) {
if (isManaged) {
ManagedMessage()
}
ListItem(
text = stringResource(Res.string.import_configuration),
leadingIcon = Icons.Rounded.Download,
enabled = enabled,
onClick = onImport,
)
ListItem(
text = stringResource(Res.string.export_configuration),
leadingIcon = Icons.Rounded.Upload,
enabled = enabled,
onClick = onExport,
)
}
BackupRestoreSection(isManaged, enabled, onImport, onExport)
}
ExpressiveSection(title = stringResource(Res.string.administration)) {
ListItem(
text = stringResource(Res.string.administration),
leadingIcon = Icons.Rounded.AdminPanelSettings,
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
leadingIconTint = MaterialTheme.colorScheme.error,
textColor = MaterialTheme.colorScheme.error,
trailingIconTint = MaterialTheme.colorScheme.error,
enabled = enabled,
) {
onNavigate(SettingsRoutes.Administration)
}
}
AdministrationSection(enabled, onNavigate)
if (state.isLocal) {
ExpressiveSection(title = stringResource(Res.string.advanced_title)) {
if (isManaged) {
ManagedMessage()
}
if (isOtaCapable) {
ListItem(
text = stringResource(Res.string.firmware_update_title),
leadingIcon = Icons.Rounded.SystemUpdate,
enabled = enabled,
onClick = { onNavigate(FirmwareRoutes.FirmwareUpdate) },
)
}
ListItem(
text = stringResource(Res.string.clean_node_database_title),
leadingIcon = Icons.Rounded.CleaningServices,
enabled = enabled,
onClick = { onNavigate(SettingsRoutes.CleanNodeDb) },
)
ListItem(
text = stringResource(Res.string.debug_panel),
leadingIcon = Icons.Rounded.BugReport,
enabled = enabled,
onClick = { onNavigate(SettingsRoutes.DebugPanel) },
)
}
AdvancedSection(isManaged, isOtaCapable, enabled, onNavigate)
}
}
}
@Composable
fun ExpressiveSection(
title: String,
modifier: Modifier = Modifier,
titleColor: Color = MaterialTheme.colorScheme.primary,
content: @Composable ColumnScope.() -> Unit,
) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = title,
modifier = Modifier.padding(horizontal = 16.dp).fillMaxWidth(),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = titleColor,
private fun RadioConfigSection(isManaged: Boolean, enabled: Boolean, onRouteClick: (Enum<*>) -> Unit) {
ExpressiveSection(title = stringResource(Res.string.radio_configuration)) {
if (isManaged) {
ManagedMessage()
}
ConfigRoute.radioConfigRoutes.forEach {
ListItem(text = stringResource(it.title), leadingIcon = it.icon, enabled = enabled) { onRouteClick(it) }
}
}
}
@Composable
private fun DeviceConfigSection(isManaged: Boolean, enabled: Boolean, onNavigate: (Route) -> Unit) {
ExpressiveSection(title = stringResource(Res.string.device_configuration)) {
if (isManaged) {
ManagedMessage()
}
ListItem(
text = stringResource(Res.string.device_configuration),
leadingIcon = Icons.Rounded.AppSettingsAlt,
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
enabled = enabled,
) {
onNavigate(SettingsRoutes.DeviceConfiguration)
}
}
}
@Composable
private fun ModuleSettingsSection(isManaged: Boolean, enabled: Boolean, onNavigate: (Route) -> Unit) {
ExpressiveSection(title = stringResource(Res.string.module_settings)) {
if (isManaged) {
ManagedMessage()
}
ListItem(
text = stringResource(Res.string.module_settings),
leadingIcon = Icons.Rounded.Settings,
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
enabled = enabled,
) {
onNavigate(SettingsRoutes.ModuleConfiguration)
}
}
}
@Composable
private fun BackupRestoreSection(isManaged: Boolean, enabled: Boolean, onImport: () -> Unit, onExport: () -> Unit) {
ExpressiveSection(title = stringResource(Res.string.backup_restore)) {
if (isManaged) {
ManagedMessage()
}
ListItem(
text = stringResource(Res.string.import_configuration),
leadingIcon = Icons.Rounded.Download,
enabled = enabled,
onClick = onImport,
)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
content = content,
ListItem(
text = stringResource(Res.string.export_configuration),
leadingIcon = Icons.Rounded.Upload,
enabled = enabled,
onClick = onExport,
)
}
}
@Composable
private fun AdministrationSection(enabled: Boolean, onNavigate: (Route) -> Unit) {
ExpressiveSection(title = stringResource(Res.string.administration)) {
ListItem(
text = stringResource(Res.string.administration),
leadingIcon = Icons.Rounded.AdminPanelSettings,
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
leadingIconTint = MaterialTheme.colorScheme.error,
textColor = MaterialTheme.colorScheme.error,
trailingIconTint = MaterialTheme.colorScheme.error,
enabled = enabled,
) {
onNavigate(SettingsRoutes.Administration)
}
}
}
@Composable
private fun AdvancedSection(isManaged: Boolean, isOtaCapable: Boolean, enabled: Boolean, onNavigate: (Route) -> Unit) {
ExpressiveSection(title = stringResource(Res.string.advanced_title)) {
if (isManaged) {
ManagedMessage()
}
if (isOtaCapable) {
ListItem(
text = stringResource(Res.string.firmware_update_title),
leadingIcon = Icons.Rounded.SystemUpdate,
enabled = enabled,
onClick = { onNavigate(FirmwareRoutes.FirmwareUpdate) },
)
}
ListItem(
text = stringResource(Res.string.clean_node_database_title),
leadingIcon = Icons.Rounded.CleaningServices,
enabled = enabled,
onClick = { onNavigate(SettingsRoutes.CleanNodeDb) },
)
ListItem(
text = stringResource(Res.string.debug_panel),
leadingIcon = Icons.Rounded.BugReport,
enabled = enabled,
onClick = { onNavigate(SettingsRoutes.DebugPanel) },
)
}
}

View file

@ -21,8 +21,6 @@ import android.app.Application
import android.content.pm.PackageManager
import android.location.Location
import android.net.Uri
import android.os.RemoteException
import android.util.Base64
import androidx.annotation.RequiresPermission
import androidx.core.content.ContextCompat
import androidx.lifecycle.SavedStateHandle
@ -44,15 +42,23 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.StringResource
import org.json.JSONObject
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.LocationRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.getStringResFrom
import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase
import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase
import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase
import org.meshtastic.core.domain.usecase.settings.ImportProfileUseCase
import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase
import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase
import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase
import org.meshtastic.core.domain.usecase.settings.RadioResponseResult
import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase
import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.Position
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
@ -61,8 +67,6 @@ import org.meshtastic.core.prefs.map.MapConsentPrefs
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.cant_shutdown
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.service.IMeshService
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.util.getChannelList
import org.meshtastic.feature.settings.navigation.ConfigRoute
@ -79,8 +83,6 @@ import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Routing
import org.meshtastic.proto.User
import java.io.FileOutputStream
import javax.inject.Inject
@ -119,20 +121,26 @@ constructor(
private val mapConsentPrefs: MapConsentPrefs,
private val analyticsPrefs: AnalyticsPrefs,
private val homoglyphEncodingPrefs: HomoglyphPrefs,
private val toggleAnalyticsUseCase: ToggleAnalyticsUseCase,
private val toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase,
private val importProfileUseCase: ImportProfileUseCase,
private val exportProfileUseCase: ExportProfileUseCase,
private val exportSecurityConfigUseCase: ExportSecurityConfigUseCase,
private val installProfileUseCase: InstallProfileUseCase,
private val radioConfigUseCase: RadioConfigUseCase,
private val adminActionsUseCase: AdminActionsUseCase,
private val processRadioResponseUseCase: ProcessRadioResponseUseCase,
) : ViewModel() {
private val meshService: IMeshService?
get() = serviceRepository.meshService
var analyticsAllowedFlow = analyticsPrefs.getAnalyticsAllowedChangesFlow()
fun toggleAnalyticsAllowed() {
analyticsPrefs.analyticsAllowed = !analyticsPrefs.analyticsAllowed
toggleAnalyticsUseCase()
}
val homoglyphEncodingEnabledFlow = homoglyphEncodingPrefs.getHomoglyphEncodingEnabledChangesFlow()
fun toggleHomoglyphCharactersEncodingEnabled() {
homoglyphEncodingPrefs.homoglyphEncodingEnabled = !homoglyphEncodingPrefs.homoglyphEncodingEnabled
toggleHomoglyphEncodingUseCase()
}
private val destNum =
@ -234,52 +242,30 @@ constructor(
Logger.d { "RadioConfigViewModel cleared" }
}
private fun request(destNum: Int, requestAction: suspend (IMeshService, Int, Int) -> Unit, errorMessage: String) =
viewModelScope.launch {
meshService?.let { service ->
val packetId = service.getPacketId()
try {
requestAction(service, packetId, destNum)
requestIds.update { it.apply { add(packetId) } }
_radioConfigState.update { state ->
if (state.responseState is ResponseState.Loading) {
val total = maxOf(requestIds.value.size, state.responseState.total)
state.copy(responseState = state.responseState.copy(total = total))
} else {
state.copy(
route = "", // setter (response is PortNum.ROUTING_APP)
responseState = ResponseState.Loading(),
)
}
}
} catch (ex: RemoteException) {
Logger.e { "$errorMessage: ${ex.message}" }
}
}
}
fun setOwner(user: User) {
setRemoteOwner(destNode.value?.num ?: return, user)
val destNum = destNode.value?.num ?: return
viewModelScope.launch {
_radioConfigState.update { it.copy(userConfig = user) }
val packetId = radioConfigUseCase.setOwner(destNum, user)
registerRequestId(packetId)
}
}
private fun setRemoteOwner(destNum: Int, user: User) = request(
destNum,
{ service, packetId, _ ->
_radioConfigState.update { it.copy(userConfig = user) }
service.setRemoteOwner(packetId, destNum, user.encode())
},
"Request setOwner error",
)
private fun getOwner(destNum: Int) = request(
destNum,
{ service, packetId, dest -> service.getRemoteOwner(packetId, dest) },
"Request getOwner error",
)
private fun getOwner(destNum: Int) {
viewModelScope.launch {
val packetId = radioConfigUseCase.getOwner(destNum)
registerRequestId(packetId)
}
}
fun updateChannels(new: List<ChannelSettings>, old: List<ChannelSettings>) {
val destNum = destNode.value?.num ?: return
getChannelList(new, old).forEach { setRemoteChannel(destNum, it) }
getChannelList(new, old).forEach { channel ->
viewModelScope.launch {
val packetId = radioConfigUseCase.setRemoteChannel(destNum, channel)
registerRequestId(packetId)
}
}
if (destNum == myNodeNum) {
viewModelScope.launch {
@ -290,25 +276,16 @@ constructor(
_radioConfigState.update { it.copy(channelList = new) }
}
private fun setRemoteChannel(destNum: Int, channel: Channel) = request(
destNum,
{ service, packetId, dest -> service.setRemoteChannel(packetId, dest, channel.encode()) },
"Request setRemoteChannel error",
)
private fun getChannel(destNum: Int, index: Int) = request(
destNum,
{ service, packetId, dest -> service.getRemoteChannel(packetId, dest, index) },
"Request getChannel error",
)
fun setConfig(config: Config) {
setRemoteConfig(destNode.value?.num ?: return, config)
private fun getChannel(destNum: Int, index: Int) {
viewModelScope.launch {
val packetId = radioConfigUseCase.getChannel(destNum, index)
registerRequestId(packetId)
}
}
private fun setRemoteConfig(destNum: Int, config: Config) = request(
destNum,
{ service, packetId, dest ->
fun setConfig(config: Config) {
val destNum = destNode.value?.num ?: return
viewModelScope.launch {
_radioConfigState.update { state ->
state.copy(
radioConfig =
@ -324,24 +301,22 @@ constructor(
),
)
}
service.setRemoteConfig(packetId, dest, config.encode())
},
"Request setConfig error",
)
private fun getConfig(destNum: Int, configType: Int) = request(
destNum,
{ service, packetId, dest -> service.getRemoteConfig(packetId, dest, configType) },
"Request getConfig error",
)
fun setModuleConfig(config: ModuleConfig) {
setRemoteModuleConfig(destNode.value?.num ?: return, config)
val packetId = radioConfigUseCase.setConfig(destNum, config)
registerRequestId(packetId)
}
}
private fun setRemoteModuleConfig(destNum: Int, config: ModuleConfig) = request(
destNum,
{ service, packetId, dest ->
private fun getConfig(destNum: Int, configType: Int) {
viewModelScope.launch {
val packetId = radioConfigUseCase.getConfig(destNum, configType)
registerRequestId(packetId)
}
}
@Suppress("CyclomaticComplexMethod")
fun setModuleConfig(config: ModuleConfig) {
val destNum = destNode.value?.num ?: return
viewModelScope.launch {
_radioConfigState.update { state ->
state.copy(
moduleConfig =
@ -366,97 +341,78 @@ constructor(
),
)
}
service.setModuleConfig(packetId, dest, config.encode())
},
"Request setModuleConfig error",
)
val packetId = radioConfigUseCase.setModuleConfig(destNum, config)
registerRequestId(packetId)
}
}
private fun getModuleConfig(destNum: Int, configType: Int) = request(
destNum,
{ service, packetId, dest -> service.getModuleConfig(packetId, dest, configType) },
"Request getModuleConfig error",
)
private fun getModuleConfig(destNum: Int, configType: Int) {
viewModelScope.launch {
val packetId = radioConfigUseCase.getModuleConfig(destNum, configType)
registerRequestId(packetId)
}
}
fun setRingtone(ringtone: String) {
val destNum = destNode.value?.num ?: return
_radioConfigState.update { it.copy(ringtone = ringtone) }
try {
meshService?.setRingtone(destNum, ringtone)
} catch (ex: RemoteException) {
Logger.e { "Set ringtone error: ${ex.message}" }
}
viewModelScope.launch { radioConfigUseCase.setRingtone(destNum, ringtone) }
}
private fun getRingtone(destNum: Int) = request(
destNum,
{ service, packetId, dest -> service.getRingtone(packetId, dest) },
"Request getRingtone error",
)
private fun getRingtone(destNum: Int) {
viewModelScope.launch {
val packetId = radioConfigUseCase.getRingtone(destNum)
registerRequestId(packetId)
}
}
fun setCannedMessages(messages: String) {
val destNum = destNode.value?.num ?: return
_radioConfigState.update { it.copy(cannedMessageMessages = messages) }
try {
meshService?.setCannedMessages(destNum, messages)
} catch (ex: RemoteException) {
Logger.e { "Set canned messages error: ${ex.message}" }
viewModelScope.launch { radioConfigUseCase.setCannedMessages(destNum, messages) }
}
private fun getCannedMessages(destNum: Int) {
viewModelScope.launch {
val packetId = radioConfigUseCase.getCannedMessages(destNum)
registerRequestId(packetId)
}
}
private fun getCannedMessages(destNum: Int) = request(
destNum,
{ service, packetId, dest -> service.getCannedMessages(packetId, dest) },
"Request getCannedMessages error",
)
private fun getDeviceConnectionStatus(destNum: Int) {
viewModelScope.launch {
val packetId = radioConfigUseCase.getDeviceConnectionStatus(destNum)
registerRequestId(packetId)
}
}
private fun getDeviceConnectionStatus(destNum: Int) = request(
destNum,
{ service, packetId, dest -> service.getDeviceConnectionStatus(packetId, dest) },
"Request getDeviceConnectionStatus error",
)
private fun requestShutdown(destNum: Int) {
viewModelScope.launch {
val packetId = adminActionsUseCase.shutdown(destNum)
registerRequestId(packetId)
}
}
private fun requestShutdown(destNum: Int) = request(
destNum,
{ service, packetId, dest -> service.requestShutdown(packetId, dest) },
"Request shutdown error",
)
private fun requestReboot(destNum: Int) =
request(destNum, { service, packetId, dest -> service.requestReboot(packetId, dest) }, "Request reboot error")
private fun requestReboot(destNum: Int) {
viewModelScope.launch {
val packetId = adminActionsUseCase.reboot(destNum)
registerRequestId(packetId)
}
}
private fun requestFactoryReset(destNum: Int) {
request(
destNum,
{ service, packetId, dest -> service.requestFactoryReset(packetId, dest) },
"Request factory reset error",
)
if (destNum == myNodeNum) {
viewModelScope.launch {
// Clear the service's in-memory node cache first so screens refresh immediately.
val existingNodeNums = nodeRepository.getNodeDBbyNum().firstOrNull()?.keys?.toList().orEmpty()
meshService?.let { service ->
existingNodeNums.forEach { service.removeByNodenum(service.getPacketId(), it) }
}
nodeRepository.clearNodeDB()
}
viewModelScope.launch {
val isLocal = (destNum == myNodeNum)
val packetId = adminActionsUseCase.factoryReset(destNum, isLocal)
registerRequestId(packetId)
}
}
private fun requestNodedbReset(destNum: Int, preserveFavorites: Boolean) {
request(
destNum,
{ service, packetId, dest -> service.requestNodedbReset(packetId, dest, preserveFavorites) },
"Request NodeDB reset error",
)
if (destNum == myNodeNum) {
viewModelScope.launch {
// Clear the service's in-memory node cache as well so UI updates immediately.
val existingNodeNums = nodeRepository.getNodeDBbyNum().firstOrNull()?.keys?.toList().orEmpty()
meshService?.let { service ->
existingNodeNums.forEach { service.removeByNodenum(service.getPacketId(), it) }
}
nodeRepository.clearNodeDB(preserveFavorites)
}
viewModelScope.launch {
val isLocal = (destNum == myNodeNum)
val packetId = adminActionsUseCase.nodedbReset(destNum, preserveFavorites, isLocal)
registerRequestId(packetId)
}
}
@ -484,21 +440,18 @@ constructor(
fun setFixedPosition(position: Position) {
val destNum = destNode.value?.num ?: return
try {
meshService?.setFixedPosition(destNum, position)
} catch (ex: RemoteException) {
Logger.e { "Set fixed position error: ${ex.message}" }
}
viewModelScope.launch { radioConfigUseCase.setFixedPosition(destNum, position) }
}
fun removeFixedPosition() = setFixedPosition(Position(0.0, 0.0, 0))
fun removeFixedPosition() {
val destNum = destNode.value?.num ?: return
viewModelScope.launch { radioConfigUseCase.removeFixedPosition(destNum) }
}
fun importProfile(uri: Uri, onResult: (DeviceProfile) -> Unit) = viewModelScope.launch(Dispatchers.IO) {
try {
app.contentResolver.openInputStream(uri).use { inputStream ->
val bytes = inputStream?.readBytes() ?: ByteArray(0)
val protobuf = DeviceProfile.ADAPTER.decode(bytes)
onResult(protobuf)
app.contentResolver.openInputStream(uri)?.use { inputStream ->
importProfileUseCase(inputStream).onSuccess(onResult).onFailure { throw it }
}
} catch (ex: Exception) {
Logger.e { "Import DeviceProfile error: ${ex.message}" }
@ -506,104 +459,44 @@ constructor(
}
}
fun exportProfile(uri: Uri, profile: DeviceProfile) = viewModelScope.launch { writeToUri(uri, profile) }
private suspend fun writeToUri(uri: Uri, message: com.squareup.wire.Message<*, *>) = withContext(Dispatchers.IO) {
try {
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
FileOutputStream(parcelFileDescriptor.fileDescriptor).use { outputStream ->
outputStream.write(message.encode())
fun exportProfile(uri: Uri, profile: DeviceProfile) = viewModelScope.launch {
withContext(Dispatchers.IO) {
try {
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
FileOutputStream(parcelFileDescriptor.fileDescriptor).use { outputStream ->
exportProfileUseCase(outputStream, profile)
.onSuccess { setResponseStateSuccess() }
.onFailure { throw it }
}
}
} catch (ex: Exception) {
Logger.e { "Can't write file error: ${ex.message}" }
sendError(ex.customMessage)
}
setResponseStateSuccess()
} catch (ex: Exception) {
Logger.e { "Can't write file error: ${ex.message}" }
sendError(ex.customMessage)
}
}
fun exportSecurityConfig(uri: Uri, securityConfig: Config.SecurityConfig) =
viewModelScope.launch { writeSecurityKeysJsonToUri(uri, securityConfig) }
private val indentSpaces = 4
private suspend fun writeSecurityKeysJsonToUri(uri: Uri, securityConfig: Config.SecurityConfig) =
fun exportSecurityConfig(uri: Uri, securityConfig: Config.SecurityConfig) = viewModelScope.launch {
withContext(Dispatchers.IO) {
try {
val publicKeyBytes = securityConfig.public_key.toByteArray()
val privateKeyBytes = securityConfig.private_key.toByteArray()
// Convert byte arrays to Base64 strings for human readability in JSON
val publicKeyBase64 = Base64.encodeToString(publicKeyBytes, Base64.NO_WRAP)
val privateKeyBase64 = Base64.encodeToString(privateKeyBytes, Base64.NO_WRAP)
// Create a JSON object
val jsonObject =
JSONObject().apply {
put("timestamp", nowMillis)
put("public_key", publicKeyBase64)
put("private_key", privateKeyBase64)
}
// Convert JSON object to a string
val jsonString = jsonObject.toString(indentSpaces)
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
FileOutputStream(parcelFileDescriptor.fileDescriptor).use { outputStream ->
outputStream.write(jsonString.toByteArray(Charsets.UTF_8))
exportSecurityConfigUseCase(outputStream, securityConfig)
.onSuccess { setResponseStateSuccess() }
.onFailure { throw it }
}
}
setResponseStateSuccess()
} catch (ex: Exception) {
val errorMessage = "Can't write security keys JSON error: ${ex.message}"
Logger.e { errorMessage }
sendError(ex.customMessage)
}
}
}
fun installProfile(protobuf: DeviceProfile) {
val destNum = destNode.value?.num ?: return
with(protobuf) {
meshService?.beginEditSettings(destNum)
if (long_name != null || short_name != null) {
destNode.value?.user?.let {
val user = it.copy(long_name = long_name ?: it.long_name, short_name = short_name ?: it.short_name)
setOwner(user)
}
}
config?.let { lc ->
lc.device?.let { setConfig(Config(device = it)) }
lc.position?.let { setConfig(Config(position = it)) }
lc.power?.let { setConfig(Config(power = it)) }
lc.network?.let { setConfig(Config(network = it)) }
lc.display?.let { setConfig(Config(display = it)) }
lc.lora?.let { setConfig(Config(lora = it)) }
lc.bluetooth?.let { setConfig(Config(bluetooth = it)) }
lc.security?.let { setConfig(Config(security = it)) }
}
if (fixed_position != null) {
setFixedPosition(Position(fixed_position!!))
}
module_config?.let { lmc ->
lmc.mqtt?.let { setModuleConfig(ModuleConfig(mqtt = it)) }
lmc.serial?.let { setModuleConfig(ModuleConfig(serial = it)) }
lmc.external_notification?.let { setModuleConfig(ModuleConfig(external_notification = it)) }
lmc.store_forward?.let { setModuleConfig(ModuleConfig(store_forward = it)) }
lmc.range_test?.let { setModuleConfig(ModuleConfig(range_test = it)) }
lmc.telemetry?.let { setModuleConfig(ModuleConfig(telemetry = it)) }
lmc.canned_message?.let { setModuleConfig(ModuleConfig(canned_message = it)) }
lmc.audio?.let { setModuleConfig(ModuleConfig(audio = it)) }
lmc.remote_hardware?.let { setModuleConfig(ModuleConfig(remote_hardware = it)) }
lmc.neighbor_info?.let { setModuleConfig(ModuleConfig(neighbor_info = it)) }
lmc.ambient_lighting?.let { setModuleConfig(ModuleConfig(ambient_lighting = it)) }
lmc.detection_sensor?.let { setModuleConfig(ModuleConfig(detection_sensor = it)) }
lmc.paxcounter?.let { setModuleConfig(ModuleConfig(paxcounter = it)) }
lmc.statusmessage?.let { setModuleConfig(ModuleConfig(statusmessage = it)) }
lmc.traffic_management?.let { setModuleConfig(ModuleConfig(traffic_management = it)) }
lmc.tak?.let { setModuleConfig(ModuleConfig(tak = it)) }
}
meshService?.commitEditSettings(destNum)
}
viewModelScope.launch { installProfileUseCase(destNum, protobuf, destNode.value?.user) }
}
fun clearPacketResponse() {
@ -686,6 +579,8 @@ constructor(
private fun sendError(id: StringResource) = setResponseStateError(UiText.Resource(id))
private fun sendError(error: UiText) = setResponseStateError(error)
private fun setResponseStateError(error: UiText) {
_radioConfigState.update { it.copy(responseState = ResponseState.Error(error)) }
}
@ -701,171 +596,156 @@ constructor(
}
}
private fun processPacketResponse(packet: MeshPacket) {
val data = packet.decoded ?: return
if (data.request_id !in requestIds.value) return
val route = radioConfigState.value.route
val destNum = destNode.value?.num ?: return
val debugMsg = "requestId: ${data.request_id.toUInt()} to: ${destNum.toUInt()} received %s"
if (data.portnum == PortNum.ROUTING_APP) {
val parsed = Routing.ADAPTER.decode(data.payload)
Logger.d { debugMsg.format(parsed.error_reason?.name) }
if (parsed.error_reason != Routing.Error.NONE) {
sendError(getStringResFrom(parsed.error_reason?.value ?: 0))
} else if (packet.from == destNum && route.isEmpty()) {
requestIds.update { it.apply { remove(data.request_id) } }
if (requestIds.value.isEmpty()) {
setResponseStateSuccess()
} else {
incrementCompleted()
}
private fun registerRequestId(packetId: Int) {
requestIds.update { it.apply { add(packetId) } }
_radioConfigState.update { state ->
if (state.responseState is ResponseState.Loading) {
val total = maxOf(requestIds.value.size, state.responseState.total)
state.copy(responseState = state.responseState.copy(total = total))
} else {
state.copy(
route = "", // setter (response is PortNum.ROUTING_APP)
responseState = ResponseState.Loading(),
)
}
}
if (data.portnum == PortNum.ADMIN_APP) {
val parsed = AdminMessage.ADAPTER.decode(data.payload)
// Explicitly log the non-null field name for clarity
val variant =
when {
parsed.get_device_metadata_response != null -> "get_device_metadata_response"
parsed.get_channel_response != null -> "get_channel_response"
parsed.get_owner_response != null -> "get_owner_response"
parsed.get_config_response != null -> "get_config_response"
parsed.get_module_config_response != null -> "get_module_config_response"
parsed.get_canned_message_module_messages_response != null ->
"get_canned_message_module_messages_response"
parsed.get_ringtone_response != null -> "get_ringtone_response"
parsed.get_device_connection_status_response != null -> "get_device_connection_status_response"
else -> "unknown"
}
Logger.d { debugMsg.format(variant) }
if (destNum != packet.from) {
sendError("Unexpected sender: ${packet.from.toUInt()} instead of ${destNum.toUInt()}.")
return
}
when {
parsed.get_device_metadata_response != null -> {
_radioConfigState.update { it.copy(metadata = parsed.get_device_metadata_response) }
incrementCompleted()
}
}
parsed.get_channel_response != null -> {
val response = parsed.get_channel_response!!
// Stop once we get to the first disabled entry
if (response.role != Channel.Role.DISABLED) {
_radioConfigState.update { state ->
state.copy(
channelList =
state.channelList.toMutableList().apply {
val index = response.index
val settings = response.settings ?: ChannelSettings()
// Make sure list is large enough
while (size <= index) add(ChannelSettings())
set(index, settings)
},
)
}
incrementCompleted()
val index = response.index
if (index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) {
// Not done yet, request next channel
getChannel(destNum, index + 1)
}
private fun processPacketResponse(packet: MeshPacket) {
val destNum = destNode.value?.num ?: return
val result = processRadioResponseUseCase(packet, destNum, requestIds.value) ?: return
val route = radioConfigState.value.route
when (result) {
is RadioResponseResult.Error -> sendError(result.message)
is RadioResponseResult.Success -> {
if (route.isEmpty()) {
val data = packet.decoded!!
requestIds.update { it.apply { remove(data.request_id) } }
if (requestIds.value.isEmpty()) {
setResponseStateSuccess()
} else {
// Received last channel, update total and start channel editor
setResponseStateTotal(response.index + 1)
incrementCompleted()
}
}
}
parsed.get_owner_response != null -> {
_radioConfigState.update { it.copy(userConfig = parsed.get_owner_response!!) }
incrementCompleted()
}
is RadioResponseResult.Metadata -> {
_radioConfigState.update { it.copy(metadata = result.metadata) }
incrementCompleted()
}
parsed.get_config_response != null -> {
val response = parsed.get_config_response!!
is RadioResponseResult.ChannelResponse -> {
val response = result.channel
// Stop once we get to the first disabled entry
if (response.role != Channel.Role.DISABLED) {
_radioConfigState.update { state ->
state.copy(
radioConfig =
state.radioConfig.copy(
device = response.device ?: state.radioConfig.device,
position = response.position ?: state.radioConfig.position,
power = response.power ?: state.radioConfig.power,
network = response.network ?: state.radioConfig.network,
display = response.display ?: state.radioConfig.display,
lora = response.lora ?: state.radioConfig.lora,
bluetooth = response.bluetooth ?: state.radioConfig.bluetooth,
security = response.security ?: state.radioConfig.security,
),
channelList =
state.channelList.toMutableList().apply {
val index = response.index
val settings = response.settings ?: ChannelSettings()
// Make sure list is large enough
while (size <= index) add(ChannelSettings())
set(index, settings)
},
)
}
incrementCompleted()
}
parsed.get_module_config_response != null -> {
val response = parsed.get_module_config_response!!
_radioConfigState.update { state ->
state.copy(
moduleConfig =
state.moduleConfig.copy(
mqtt = response.mqtt ?: state.moduleConfig.mqtt,
serial = response.serial ?: state.moduleConfig.serial,
external_notification =
response.external_notification ?: state.moduleConfig.external_notification,
store_forward = response.store_forward ?: state.moduleConfig.store_forward,
range_test = response.range_test ?: state.moduleConfig.range_test,
telemetry = response.telemetry ?: state.moduleConfig.telemetry,
canned_message = response.canned_message ?: state.moduleConfig.canned_message,
audio = response.audio ?: state.moduleConfig.audio,
remote_hardware = response.remote_hardware ?: state.moduleConfig.remote_hardware,
neighbor_info = response.neighbor_info ?: state.moduleConfig.neighbor_info,
ambient_lighting = response.ambient_lighting ?: state.moduleConfig.ambient_lighting,
detection_sensor = response.detection_sensor ?: state.moduleConfig.detection_sensor,
paxcounter = response.paxcounter ?: state.moduleConfig.paxcounter,
statusmessage = response.statusmessage ?: state.moduleConfig.statusmessage,
traffic_management =
response.traffic_management ?: state.moduleConfig.traffic_management,
tak = response.tak ?: state.moduleConfig.tak,
),
)
val index = response.index
if (index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) {
// Not done yet, request next channel
getChannel(destNum, index + 1)
}
incrementCompleted()
} else {
// Received last channel, update total and start channel editor
setResponseStateTotal(response.index + 1)
}
parsed.get_canned_message_module_messages_response != null -> {
_radioConfigState.update {
it.copy(cannedMessageMessages = parsed.get_canned_message_module_messages_response!!)
}
incrementCompleted()
}
parsed.get_ringtone_response != null -> {
_radioConfigState.update { it.copy(ringtone = parsed.get_ringtone_response!!) }
incrementCompleted()
}
parsed.get_device_connection_status_response != null -> {
_radioConfigState.update {
it.copy(deviceConnectionStatus = parsed.get_device_connection_status_response!!)
}
incrementCompleted()
}
else -> Logger.d { "No custom processing needed for $parsed" }
}
if (AdminRoute.entries.any { it.name == route }) {
sendAdminRequest(destNum)
is RadioResponseResult.Owner -> {
_radioConfigState.update { it.copy(userConfig = result.user) }
incrementCompleted()
}
requestIds.update { it.apply { remove(data.request_id) } }
if (requestIds.value.isEmpty()) {
if (route.isNotEmpty() && !AdminRoute.entries.any { it.name == route }) {
clearPacketResponse()
} else if (route.isEmpty()) {
setResponseStateSuccess()
is RadioResponseResult.ConfigResponse -> {
val response = result.config
_radioConfigState.update { state ->
state.copy(
radioConfig =
state.radioConfig.copy(
device = response.device ?: state.radioConfig.device,
position = response.position ?: state.radioConfig.position,
power = response.power ?: state.radioConfig.power,
network = response.network ?: state.radioConfig.network,
display = response.display ?: state.radioConfig.display,
lora = response.lora ?: state.radioConfig.lora,
bluetooth = response.bluetooth ?: state.radioConfig.bluetooth,
security = response.security ?: state.radioConfig.security,
),
)
}
incrementCompleted()
}
is RadioResponseResult.ModuleConfigResponse -> {
val response = result.config
_radioConfigState.update { state ->
state.copy(
moduleConfig =
state.moduleConfig.copy(
mqtt = response.mqtt ?: state.moduleConfig.mqtt,
serial = response.serial ?: state.moduleConfig.serial,
external_notification =
response.external_notification ?: state.moduleConfig.external_notification,
store_forward = response.store_forward ?: state.moduleConfig.store_forward,
range_test = response.range_test ?: state.moduleConfig.range_test,
telemetry = response.telemetry ?: state.moduleConfig.telemetry,
canned_message = response.canned_message ?: state.moduleConfig.canned_message,
audio = response.audio ?: state.moduleConfig.audio,
remote_hardware = response.remote_hardware ?: state.moduleConfig.remote_hardware,
neighbor_info = response.neighbor_info ?: state.moduleConfig.neighbor_info,
ambient_lighting = response.ambient_lighting ?: state.moduleConfig.ambient_lighting,
detection_sensor = response.detection_sensor ?: state.moduleConfig.detection_sensor,
paxcounter = response.paxcounter ?: state.moduleConfig.paxcounter,
statusmessage = response.statusmessage ?: state.moduleConfig.statusmessage,
traffic_management =
response.traffic_management ?: state.moduleConfig.traffic_management,
tak = response.tak ?: state.moduleConfig.tak,
),
)
}
incrementCompleted()
}
is RadioResponseResult.CannedMessages -> {
_radioConfigState.update { it.copy(cannedMessageMessages = result.messages) }
incrementCompleted()
}
is RadioResponseResult.Ringtone -> {
_radioConfigState.update { it.copy(ringtone = result.ringtone) }
incrementCompleted()
}
is RadioResponseResult.ConnectionStatus -> {
_radioConfigState.update { it.copy(deviceConnectionStatus = result.status) }
incrementCompleted()
}
}
if (AdminRoute.entries.any { it.name == route }) {
sendAdminRequest(destNum)
}
val requestId = packet.decoded?.request_id ?: return
requestIds.update { it.apply { remove(requestId) } }
if (requestIds.value.isEmpty()) {
if (route.isNotEmpty() && !AdminRoute.entries.any { it.name == route }) {
clearPacketResponse()
} else if (route.isEmpty()) {
setResponseStateSuccess()
}
}
}

View file

@ -28,6 +28,7 @@ import org.junit.runner.RunWith
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.use_homoglyph_characters_encoding
import org.meshtastic.feature.settings.component.HomoglyphSetting
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.Locale

View file

@ -0,0 +1,128 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase
import org.meshtastic.core.domain.usecase.settings.IsOtaCapableUseCase
import org.meshtastic.core.domain.usecase.settings.MeshLocationUseCase
import org.meshtastic.core.domain.usecase.settings.SetAppIntroCompletedUseCase
import org.meshtastic.core.domain.usecase.settings.SetDatabaseCacheLimitUseCase
import org.meshtastic.core.domain.usecase.settings.SetMeshLogSettingsUseCase
import org.meshtastic.core.domain.usecase.settings.SetProvideLocationUseCase
import org.meshtastic.core.domain.usecase.settings.SetThemeUseCase
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import org.meshtastic.core.prefs.ui.UiPrefs
@OptIn(ExperimentalCoroutinesApi::class)
class SettingsViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val radioConfigRepository: RadioConfigRepository = mockk(relaxed = true)
private val radioController: RadioController = mockk(relaxed = true)
private val nodeRepository: NodeRepository = mockk(relaxed = true)
private val uiPrefs: UiPrefs = mockk(relaxed = true)
private val buildConfigProvider: BuildConfigProvider = mockk(relaxed = true)
private val databaseManager: DatabaseManager = mockk(relaxed = true)
private val meshLogPrefs: MeshLogPrefs = mockk(relaxed = true)
private val setThemeUseCase: SetThemeUseCase = mockk(relaxed = true)
private val setAppIntroCompletedUseCase: SetAppIntroCompletedUseCase = mockk(relaxed = true)
private val setProvideLocationUseCase: SetProvideLocationUseCase = mockk(relaxed = true)
private val setDatabaseCacheLimitUseCase: SetDatabaseCacheLimitUseCase = mockk(relaxed = true)
private val setMeshLogSettingsUseCase: SetMeshLogSettingsUseCase = mockk(relaxed = true)
private val meshLocationUseCase: MeshLocationUseCase = mockk(relaxed = true)
private val exportDataUseCase: ExportDataUseCase = mockk(relaxed = true)
private val isOtaCapableUseCase: IsOtaCapableUseCase = mockk(relaxed = true)
private lateinit var viewModel: SettingsViewModel
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
// Return real StateFlows to avoid ClassCastException
every { databaseManager.cacheLimit } returns MutableStateFlow(100)
every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(null)
every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(org.meshtastic.proto.LocalConfig())
every { radioController.connectionState } returns
MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected)
every { isOtaCapableUseCase() } returns flowOf(false)
viewModel =
SettingsViewModel(
app = mockk(),
radioConfigRepository = radioConfigRepository,
radioController = radioController,
nodeRepository = nodeRepository,
uiPrefs = uiPrefs,
buildConfigProvider = buildConfigProvider,
databaseManager = databaseManager,
meshLogPrefs = meshLogPrefs,
setThemeUseCase = setThemeUseCase,
setAppIntroCompletedUseCase = setAppIntroCompletedUseCase,
setProvideLocationUseCase = setProvideLocationUseCase,
setDatabaseCacheLimitUseCase = setDatabaseCacheLimitUseCase,
setMeshLogSettingsUseCase = setMeshLogSettingsUseCase,
meshLocationUseCase = meshLocationUseCase,
exportDataUseCase = exportDataUseCase,
isOtaCapableUseCase = isOtaCapableUseCase,
)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `setTheme calls useCase`() {
viewModel.setTheme(1)
verify { setThemeUseCase(1) }
}
@Test
fun `setDbCacheLimit calls useCase`() {
viewModel.setDbCacheLimit(50)
verify { setDatabaseCacheLimitUseCase(50) }
}
@Test
fun `startProvidingLocation calls useCase`() {
viewModel.startProvidingLocation()
verify { meshLocationUseCase.startProvidingLocation() }
}
}

View file

@ -0,0 +1,115 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.debugging
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import org.meshtastic.core.ui.util.AlertManager
@OptIn(ExperimentalCoroutinesApi::class)
class DebugViewModelTest {
private val testDispatcher = UnconfinedTestDispatcher()
private val meshLogRepository: MeshLogRepository = mockk(relaxed = true)
private val nodeRepository: NodeRepository = mockk(relaxed = true)
private val meshLogPrefs: MeshLogPrefs = mockk(relaxed = true)
private val alertManager: AlertManager = mockk(relaxed = true)
private lateinit var viewModel: DebugViewModel
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
every { meshLogRepository.getAllLogs() } returns flowOf(emptyList())
every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
every { meshLogPrefs.retentionDays } returns 7
every { meshLogPrefs.loggingEnabled } returns true
viewModel =
DebugViewModel(
meshLogRepository = meshLogRepository,
nodeRepository = nodeRepository,
meshLogPrefs = meshLogPrefs,
alertManager = alertManager,
)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `setRetentionDays updates prefs and deletes old logs`() = runTest {
viewModel.setRetentionDays(14)
verify { meshLogPrefs.retentionDays = 14 }
coVerify { meshLogRepository.deleteLogsOlderThan(14) }
assertEquals(14, viewModel.retentionDays.value)
}
@Test
fun `setLoggingEnabled false deletes all logs`() = runTest {
viewModel.setLoggingEnabled(false)
verify { meshLogPrefs.loggingEnabled = false }
coVerify { meshLogRepository.deleteAll() }
assertEquals(false, viewModel.loggingEnabled.value)
}
@Test
fun `search filters results correctly`() = runTest {
val logs =
listOf(
DebugViewModel.UiMeshLog("1", "TypeA", "Date1", "Message Apple"),
DebugViewModel.UiMeshLog("2", "TypeB", "Date2", "Message Banana"),
)
viewModel.searchManager.updateMatches("Apple", logs)
val state = viewModel.searchState.value
assertEquals(true, state.hasMatches)
assertEquals(1, state.allMatches.size)
assertEquals(0, state.allMatches[0].logIndex)
}
@Test
fun `requestDeleteAllLogs shows alert`() {
viewModel.requestDeleteAllLogs()
verify { alertManager.showAlert(titleRes = any(), messageRes = any(), onConfirm = any()) }
}
}

View file

@ -0,0 +1,67 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.filter
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.prefs.filter.FilterPrefs
import org.meshtastic.core.service.filter.MessageFilterService
class FilterSettingsViewModelTest {
private val filterPrefs: FilterPrefs = mockk(relaxed = true)
private val messageFilterService: MessageFilterService = mockk(relaxed = true)
private lateinit var viewModel: FilterSettingsViewModel
@Before
fun setUp() {
every { filterPrefs.filterEnabled } returns true
every { filterPrefs.filterWords } returns setOf("apple", "banana")
viewModel = FilterSettingsViewModel(filterPrefs = filterPrefs, messageFilterService = messageFilterService)
}
@Test
fun `setFilterEnabled updates prefs and state`() {
viewModel.setFilterEnabled(false)
verify { filterPrefs.filterEnabled = false }
assertEquals(false, viewModel.filterEnabled.value)
}
@Test
fun `addFilterWord updates prefs and rebuilds patterns`() {
viewModel.addFilterWord("cherry")
verify { filterPrefs.filterWords = any() }
verify { messageFilterService.rebuildPatterns() }
assertEquals(listOf("apple", "banana", "cherry"), viewModel.filterWords.value)
}
@Test
fun `removeFilterWord updates prefs and rebuilds patterns`() {
viewModel.removeFilterWord("apple")
verify { filterPrefs.filterWords = any() }
verify { messageFilterService.rebuildPatterns() }
assertEquals(listOf("banana"), viewModel.filterWords.value)
}
}

View file

@ -0,0 +1,82 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.domain.usecase.settings.CleanNodeDatabaseUseCase
import org.meshtastic.core.ui.util.AlertManager
@OptIn(ExperimentalCoroutinesApi::class)
class CleanNodeDatabaseViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var cleanNodeDatabaseUseCase: CleanNodeDatabaseUseCase
private lateinit var alertManager: AlertManager
private lateinit var viewModel: CleanNodeDatabaseViewModel
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
cleanNodeDatabaseUseCase = mockk(relaxed = true)
alertManager = mockk(relaxed = true)
viewModel = CleanNodeDatabaseViewModel(cleanNodeDatabaseUseCase, alertManager)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `getNodesToDelete updates state`() = runTest {
val nodes = listOf(Node(num = 1), Node(num = 2))
coEvery { cleanNodeDatabaseUseCase.getNodesToClean(any(), any(), any()) } returns nodes
viewModel.getNodesToDelete()
advanceUntilIdle()
assertEquals(nodes, viewModel.nodesToDelete.value)
}
@Test
fun `cleanNodes calls useCase and clears state`() = runTest {
val nodes = listOf(Node(num = 1))
coEvery { cleanNodeDatabaseUseCase.getNodesToClean(any(), any(), any()) } returns nodes
viewModel.getNodesToDelete()
advanceUntilIdle()
viewModel.cleanNodes()
advanceUntilIdle()
coVerify { cleanNodeDatabaseUseCase.cleanNodes(listOf(1)) }
assertEquals(0, viewModel.nodesToDelete.value.size)
}
}

View file

@ -0,0 +1,241 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio
import androidx.lifecycle.SavedStateHandle
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.data.repository.LocationRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase
import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase
import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase
import org.meshtastic.core.domain.usecase.settings.ImportProfileUseCase
import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase
import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase
import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase
import org.meshtastic.core.domain.usecase.settings.RadioResponseResult
import org.meshtastic.core.domain.usecase.settings.ToggleAnalyticsUseCase
import org.meshtastic.core.domain.usecase.settings.ToggleHomoglyphEncodingUseCase
import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
import org.meshtastic.core.prefs.map.MapConsentPrefs
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.MeshPacket
@OptIn(ExperimentalCoroutinesApi::class)
class RadioConfigViewModelTest {
private val testDispatcher = UnconfinedTestDispatcher()
private val radioConfigRepository: RadioConfigRepository = mockk(relaxed = true)
private val packetRepository: PacketRepository = mockk(relaxed = true)
private val serviceRepository: ServiceRepository = mockk(relaxed = true)
private val nodeRepository: NodeRepository = mockk(relaxed = true)
private val locationRepository: LocationRepository = mockk(relaxed = true)
private val mapConsentPrefs: MapConsentPrefs = mockk(relaxed = true)
private val analyticsPrefs: AnalyticsPrefs = mockk(relaxed = true)
private val homoglyphEncodingPrefs: HomoglyphPrefs = mockk(relaxed = true)
private val toggleAnalyticsUseCase: ToggleAnalyticsUseCase = mockk(relaxed = true)
private val toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase = mockk(relaxed = true)
private val importProfileUseCase: ImportProfileUseCase = mockk(relaxed = true)
private val exportProfileUseCase: ExportProfileUseCase = mockk(relaxed = true)
private val exportSecurityConfigUseCase: ExportSecurityConfigUseCase = mockk(relaxed = true)
private val installProfileUseCase: InstallProfileUseCase = mockk(relaxed = true)
private val radioConfigUseCase: RadioConfigUseCase = mockk(relaxed = true)
private val adminActionsUseCase: AdminActionsUseCase = mockk(relaxed = true)
private val processRadioResponseUseCase: ProcessRadioResponseUseCase = mockk(relaxed = true)
private lateinit var viewModel: RadioConfigViewModel
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
every { radioConfigRepository.deviceProfileFlow } returns MutableStateFlow(DeviceProfile())
every { radioConfigRepository.localConfigFlow } returns MutableStateFlow(LocalConfig())
every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet())
every { radioConfigRepository.moduleConfigFlow } returns MutableStateFlow(LocalModuleConfig())
every { serviceRepository.meshPacketFlow } returns MutableSharedFlow()
every { serviceRepository.connectionState } returns
MutableStateFlow(org.meshtastic.core.model.ConnectionState.Connected)
every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
viewModel = createViewModel()
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
private fun createViewModel() = RadioConfigViewModel(
savedStateHandle = SavedStateHandle(),
app = mockk(),
radioConfigRepository = radioConfigRepository,
packetRepository = packetRepository,
serviceRepository = serviceRepository,
nodeRepository = nodeRepository,
locationRepository = locationRepository,
mapConsentPrefs = mapConsentPrefs,
analyticsPrefs = analyticsPrefs,
homoglyphEncodingPrefs = homoglyphEncodingPrefs,
toggleAnalyticsUseCase = toggleAnalyticsUseCase,
toggleHomoglyphEncodingUseCase = toggleHomoglyphEncodingUseCase,
importProfileUseCase = importProfileUseCase,
exportProfileUseCase = exportProfileUseCase,
exportSecurityConfigUseCase = exportSecurityConfigUseCase,
installProfileUseCase = installProfileUseCase,
radioConfigUseCase = radioConfigUseCase,
adminActionsUseCase = adminActionsUseCase,
processRadioResponseUseCase = processRadioResponseUseCase,
)
@Test
fun `setConfig updates state and calls useCase`() = runTest {
val node = Node(num = 123)
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
viewModel = createViewModel()
val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER))
coEvery { radioConfigUseCase.setConfig(123, any()) } returns 42
viewModel.setConfig(config)
val state = viewModel.radioConfigState.value
assertEquals(Config.DeviceConfig.Role.ROUTER, state.radioConfig.device?.role)
coVerify { radioConfigUseCase.setConfig(123, config) }
}
@Test
fun `processPacketResponse updates state on metadata result`() = runTest {
val node = Node(num = 123)
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
val packet = MeshPacket()
val metadata = DeviceMetadata(firmware_version = "3.0.0")
val packetFlow = MutableSharedFlow<MeshPacket>()
every { serviceRepository.meshPacketFlow } returns packetFlow
every { processRadioResponseUseCase(any(), 123, any()) } returns RadioResponseResult.Metadata(metadata)
viewModel = createViewModel()
packetFlow.emit(packet)
val state = viewModel.radioConfigState.value
assertEquals("3.0.0", state.metadata?.firmware_version)
}
@Test
fun `setOwner calls useCase`() = runTest {
val node = Node(num = 123)
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
viewModel = createViewModel()
val user = org.meshtastic.proto.User(long_name = "Test")
coEvery { radioConfigUseCase.setOwner(123, any()) } returns 42
viewModel.setOwner(user)
coVerify { radioConfigUseCase.setOwner(123, user) }
}
@Test
fun `updateChannels calls useCase for each changed channel`() = runTest {
val node = Node(num = 123)
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
viewModel = createViewModel()
val old = listOf(ChannelSettings(name = "Old"))
val new = listOf(ChannelSettings(name = "New"))
coEvery { radioConfigUseCase.setRemoteChannel(123, any()) } returns 42
viewModel.updateChannels(new, old)
coVerify { radioConfigUseCase.setRemoteChannel(123, any()) }
assertEquals(new, viewModel.radioConfigState.value.channelList)
}
@Test
fun `setResponseStateLoading for REBOOT calls useCase after packet response`() = runTest {
val node = Node(num = 123)
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
val packetFlow = MutableSharedFlow<MeshPacket>()
every { serviceRepository.meshPacketFlow } returns packetFlow
every { processRadioResponseUseCase(any(), any(), any()) } returns RadioResponseResult.Success
viewModel = createViewModel()
coEvery { adminActionsUseCase.reboot(123) } returns 42
viewModel.setResponseStateLoading(AdminRoute.REBOOT)
// Emit a packet to trigger processPacketResponse -> sendAdminRequest
packetFlow.emit(MeshPacket())
coVerify { adminActionsUseCase.reboot(123) }
}
@Test
fun `setResponseStateLoading for FACTORY_RESET calls useCase after packet response`() = runTest {
val node = Node(num = 123)
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(123 to node))
val packetFlow = MutableSharedFlow<MeshPacket>()
every { serviceRepository.meshPacketFlow } returns packetFlow
every { processRadioResponseUseCase(any(), any(), any()) } returns RadioResponseResult.Success
viewModel = createViewModel()
coEvery { adminActionsUseCase.factoryReset(123, any()) } returns 42
viewModel.setResponseStateLoading(AdminRoute.FACTORY_RESET)
// Emit a packet to trigger processPacketResponse -> sendAdminRequest
packetFlow.emit(MeshPacket())
coVerify { adminActionsUseCase.factoryReset(123, any()) }
}
}