refactor: migrate from Hilt to Koin and expand KMP common modules (#4746)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-09 20:19:46 -05:00 committed by GitHub
parent a5390a80e7
commit 875cf1cff2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
440 changed files with 3738 additions and 3508 deletions

View file

@ -37,7 +37,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.model.Node
@ -58,7 +57,7 @@ import org.meshtastic.feature.settings.radio.component.ShutdownConfirmationDialo
import org.meshtastic.feature.settings.radio.component.WarningDialog
@Composable
fun AdministrationScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
fun AdministrationScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val destNode by viewModel.destNode.collectAsStateWithLifecycle()
val enabled = state.connected && !state.responseState.isWaiting()

View file

@ -26,7 +26,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
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.navigation.Route
@ -40,11 +39,7 @@ import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
@Composable
fun DeviceConfigurationScreen(
viewModel: RadioConfigViewModel = hiltViewModel(),
onBack: () -> Unit,
onNavigate: (Route) -> Unit,
) {
fun DeviceConfigurationScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit, onNavigate: (Route) -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val destNode by viewModel.destNode.collectAsStateWithLifecycle()

View file

@ -27,7 +27,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
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.navigation.Route
@ -42,8 +41,8 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel
@Composable
fun ModuleConfigurationScreen(
viewModel: RadioConfigViewModel = hiltViewModel(),
excludedModulesUnlocked: Boolean = false,
viewModel: RadioConfigViewModel,
excludedModulesUnlocked: Boolean,
onBack: () -> Unit,
onNavigate: (Route) -> Unit,
) {

View file

@ -74,7 +74,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.touchlab.kermit.Logger
import kotlinx.collections.immutable.toImmutableList
@ -125,7 +124,7 @@ private var redactedKeys: List<String> = listOf("session_passkey", "private_key"
@Suppress("LongMethod")
@Composable
fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel = hiltViewModel()) {
fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel) {
val listState = rememberLazyListState()
val logs by viewModel.meshLog.collectAsStateWithLifecycle()
val searchState by viewModel.searchState.collectAsStateWithLifecycle()
@ -194,7 +193,8 @@ fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel = hiltViewMo
targetValue = if (!listState.isScrollInProgress) 1.0f else 0f,
label = "alpha",
)
DebugSearchStateviewModelDefaults(
DebugSearchStateWithViewModel(
viewModel = viewModel,
modifier = Modifier.graphicsLayer(alpha = animatedAlpha),
searchState = searchState,
filterTexts = filterTexts,

View file

@ -50,7 +50,6 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.debug_default_search
@ -208,7 +207,8 @@ fun DebugSearchState(
}
@Composable
fun DebugSearchStateviewModelDefaults(
fun DebugSearchStateWithViewModel(
viewModel: DebugViewModel,
modifier: Modifier = Modifier,
searchState: SearchState,
filterTexts: List<String>,
@ -218,7 +218,6 @@ fun DebugSearchStateviewModelDefaults(
onFilterModeChange: (FilterMode) -> Unit,
onExportLogs: (() -> Unit)? = null,
) {
val viewModel: DebugViewModel = hiltViewModel()
DebugSearchState(
modifier = modifier,
searchState = searchState,

View file

@ -45,7 +45,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
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.resources.Res
@ -63,7 +62,7 @@ import org.meshtastic.core.resources.filter_words_summary
import org.meshtastic.core.ui.component.MainAppBar
@Composable
fun FilterSettingsScreen(viewModel: FilterSettingsViewModel = hiltViewModel(), onBack: () -> Unit) {
fun FilterSettingsScreen(viewModel: FilterSettingsViewModel, onBack: () -> Unit) {
val filterEnabled by viewModel.filterEnabled.collectAsStateWithLifecycle()
val filterWords by viewModel.filterWords.collectAsStateWithLifecycle()
var newWord by remember { mutableStateOf("") }

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.navigation
import org.meshtastic.core.navigation.Route

View file

@ -37,7 +37,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.model.Node
@ -55,7 +54,7 @@ import org.meshtastic.core.ui.component.NodeChip
* nodes to be deleted updates automatically as filter criteria change.
*/
@Composable
fun CleanNodeDatabaseScreen(viewModel: CleanNodeDatabaseViewModel = hiltViewModel()) {
fun CleanNodeDatabaseScreen(viewModel: CleanNodeDatabaseViewModel) {
val olderThanDays by viewModel.olderThanDays.collectAsStateWithLifecycle()
val onlyUnknownNodes by viewModel.onlyUnknownNodes.collectAsStateWithLifecycle()
val nodesToDelete by viewModel.nodesToDelete.collectAsStateWithLifecycle()

View file

@ -22,7 +22,6 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
@ -40,7 +39,7 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.ModuleConfig
@Composable
fun AmbientLightingConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
fun AmbientLightingConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val ambientLightingConfig = state.moduleConfig.ambient_lighting ?: ModuleConfig.AmbientLightingConfig()
val formState = rememberConfigState(initialValue = ambientLightingConfig)

View file

@ -22,7 +22,6 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
@ -43,7 +42,7 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.ModuleConfig
@Composable
fun AudioConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
fun AudioConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val audioConfig = state.moduleConfig.audio ?: ModuleConfig.AudioConfig()
val formState = rememberConfigState(initialValue = audioConfig)

View file

@ -22,7 +22,6 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
@ -39,7 +38,7 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.Config
@Composable
fun BluetoothConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
fun BluetoothConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val bluetoothConfig = state.radioConfig.bluetooth ?: Config.BluetoothConfig()
val formState = rememberConfigState(initialValue = bluetoothConfig)

View file

@ -28,7 +28,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
@ -54,7 +53,7 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.ModuleConfig
@Composable
fun CannedMessageConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
fun CannedMessageConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val cannedMessageConfig = state.moduleConfig.canned_message ?: ModuleConfig.CannedMessageConfig()
val messages = state.cannedMessageMessages

View file

@ -26,7 +26,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
@ -51,7 +50,7 @@ import org.meshtastic.feature.settings.util.toDisplayString
import org.meshtastic.proto.ModuleConfig
@Composable
fun DetectionSensorConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
fun DetectionSensorConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val detectionSensorConfig = state.moduleConfig.detection_sensor ?: ModuleConfig.DetectionSensorConfig()
val formState = rememberConfigState(initialValue = detectionSensorConfig)

View file

@ -58,7 +58,6 @@ import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import no.nordicsemi.android.common.core.registerReceiver
import org.jetbrains.compose.resources.StringResource
@ -155,7 +154,7 @@ private val Config.DeviceConfig.RebroadcastMode.description: StringResource
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun DeviceConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val deviceConfig = state.radioConfig.device ?: Config.DeviceConfig()
val formState = rememberConfigState(initialValue = deviceConfig)

View file

@ -21,7 +21,6 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
@ -58,7 +57,7 @@ import org.meshtastic.feature.settings.util.toDisplayString
import org.meshtastic.proto.Config
@Composable
fun DisplayConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
fun DisplayConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val displayConfig = state.radioConfig.display ?: Config.DisplayConfig()
val formState = rememberConfigState(initialValue = displayConfig)

View file

@ -41,7 +41,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import co.touchlab.kermit.Logger
import org.jetbrains.compose.resources.stringResource
@ -87,7 +86,7 @@ private const val MAX_RINGTONE_SIZE = 230
fun ExternalNotificationConfigScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: RadioConfigViewModel = hiltViewModel(),
viewModel: RadioConfigViewModel,
) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val extNotificationConfig = state.moduleConfig.external_notification ?: ModuleConfig.ExternalNotificationConfig()

View file

@ -27,7 +27,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
@ -52,7 +51,7 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.ModuleConfig
@Composable
fun MQTTConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
fun MQTTConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val destNode by viewModel.destNode.collectAsStateWithLifecycle()
val destNum = destNode?.num

View file

@ -22,7 +22,6 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
@ -39,7 +38,7 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.ModuleConfig
@Composable
fun NeighborInfoConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
fun NeighborInfoConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val neighborInfoConfig = state.moduleConfig.neighbor_info ?: ModuleConfig.NeighborInfoConfig()
val formState = rememberConfigState(initialValue = neighborInfoConfig)

View file

@ -37,7 +37,6 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.barcode.extractWifiCredentials
@ -91,7 +90,7 @@ private fun ScanErrorDialog(onDismiss: () -> Unit = {}) =
MeshtasticDialog(titleRes = Res.string.error, messageRes = Res.string.wifi_qr_code_error, onDismiss = onDismiss)
@Composable
fun NetworkConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val networkConfig = state.radioConfig.network ?: Config.NetworkConfig()
val formState = rememberConfigState(initialValue = networkConfig)

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.radio.component
import androidx.compose.foundation.layout.Row

View file

@ -23,7 +23,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalFocusManager
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
@ -43,7 +42,7 @@ import org.meshtastic.feature.settings.util.toDisplayString
import org.meshtastic.proto.ModuleConfig
@Composable
fun PaxcounterConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
fun PaxcounterConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val paxcounterConfig = state.moduleConfig.paxcounter ?: ModuleConfig.PaxcounterConfig()
val formState = rememberConfigState(initialValue = paxcounterConfig)

View file

@ -34,7 +34,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.core.location.LocationCompat
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import no.nordicsemi.android.common.permissions.ble.RequireLocation
@ -79,7 +78,7 @@ import org.meshtastic.proto.Config
@Composable
@Suppress("LongMethod", "CyclomaticComplexMethod")
fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
fun PositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val coroutineScope = rememberCoroutineScope()
var phoneLocation: Location? by remember { mutableStateOf(null) }
@ -257,7 +256,9 @@ fun PositionConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBa
enabled = state.connected && !isLocationRequiredAndDisabled,
onClick = {
@SuppressLint("MissingPermission")
coroutineScope.launch { phoneLocation = viewModel.getCurrentLocation() }
coroutineScope.launch {
phoneLocation = viewModel.getCurrentLocation() as? android.location.Location
}
},
) {
Text(text = stringResource(Res.string.position_config_set_fixed_from_phone))

View file

@ -23,7 +23,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalFocusManager
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
@ -48,7 +47,7 @@ import org.meshtastic.feature.settings.util.toDisplayString
import org.meshtastic.proto.Config
@Composable
fun PowerConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
fun PowerConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val powerConfig = state.radioConfig.power ?: Config.PowerConfig()
val formState = rememberConfigState(initialValue = powerConfig)

View file

@ -21,7 +21,6 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
@ -39,7 +38,7 @@ import org.meshtastic.feature.settings.util.toDisplayString
import org.meshtastic.proto.ModuleConfig
@Composable
fun RangeTestConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
fun RangeTestConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val rangeTestConfig = state.moduleConfig.range_test ?: ModuleConfig.RangeTestConfig()
val formState = rememberConfigState(initialValue = rangeTestConfig)

View file

@ -22,7 +22,6 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
@ -38,7 +37,7 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.ModuleConfig
@Composable
fun RemoteHardwareConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
fun RemoteHardwareConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val remoteHardwareConfig = state.moduleConfig.remote_hardware ?: ModuleConfig.RemoteHardwareConfig()
val formState = rememberConfigState(initialValue = remoteHardwareConfig)

View file

@ -35,7 +35,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import okio.ByteString
import okio.ByteString.Companion.toByteString
@ -77,7 +76,7 @@ import java.security.SecureRandom
@Composable
@Suppress("LongMethod")
fun SecurityConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
fun SecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val node by viewModel.destNode.collectAsStateWithLifecycle()
val securityConfig = state.radioConfig.security ?: Config.SecurityConfig()

View file

@ -22,7 +22,6 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
@ -42,7 +41,7 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.ModuleConfig
@Composable
fun SerialConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
fun SerialConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val serialConfig = state.moduleConfig.serial ?: ModuleConfig.SerialConfig()
val formState = rememberConfigState(initialValue = serialConfig)

View file

@ -29,7 +29,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
@ -42,7 +41,7 @@ import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
@Composable
fun StatusMessageConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
fun StatusMessageConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val destNode by viewModel.destNode.collectAsStateWithLifecycle()

View file

@ -22,7 +22,6 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
@ -41,7 +40,7 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.ModuleConfig
@Composable
fun StoreForwardConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
fun StoreForwardConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val storeForwardConfig = state.moduleConfig.store_forward ?: ModuleConfig.StoreForwardConfig()
val formState = rememberConfigState(initialValue = storeForwardConfig)

View file

@ -21,7 +21,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.Color
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.getColorFrom
@ -37,7 +36,7 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.ModuleConfig
@Composable
fun TAKConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
fun TAKConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val takConfig = state.moduleConfig.tak ?: ModuleConfig.TAKConfig()
val formState = rememberConfigState(initialValue = takConfig)

View file

@ -21,7 +21,6 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.Capabilities
@ -49,7 +48,7 @@ import org.meshtastic.feature.settings.util.toDisplayString
import org.meshtastic.proto.ModuleConfig
@Composable
fun TelemetryConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
fun TelemetryConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val telemetryConfig = state.moduleConfig.telemetry ?: ModuleConfig.TelemetryConfig()
val formState = rememberConfigState(initialValue = telemetryConfig)

View file

@ -23,7 +23,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
@ -51,7 +50,7 @@ import org.meshtastic.proto.ModuleConfig
@Suppress("LongMethod")
@Composable
fun TrafficManagementConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
fun TrafficManagementConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val tmConfig = state.moduleConfig.traffic_management ?: ModuleConfig.TrafficManagementConfig()
val formState = rememberConfigState(initialValue = tmConfig)

View file

@ -26,7 +26,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.Capabilities
@ -49,7 +48,7 @@ import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
@Composable
fun UserConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
fun UserConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val userConfig = state.userConfig
val formState = rememberConfigState(initialValue = userConfig)

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.util
import androidx.compose.runtime.Composable

View file

@ -19,9 +19,7 @@ package org.meshtastic.feature.settings.util
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalResources
import androidx.core.os.LocaleListCompat
import co.touchlab.kermit.Logger
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.fr_HT
@ -29,7 +27,6 @@ import org.meshtastic.core.resources.preferences_system_default
import org.meshtastic.core.resources.pt_BR
import org.meshtastic.core.resources.zh_CN
import org.meshtastic.core.resources.zh_TW
import org.xmlpull.v1.XmlPullParser
import java.util.Locale
object LanguageUtils {
@ -50,32 +47,54 @@ object LanguageUtils {
)
}
/** Using locales_config.xml, maps language tags to their localized language names (e.g.: "en" -> "English") */
@Suppress("CyclomaticComplexMethod")
/** Using a hardcoded list, maps language tags to their localized language names (e.g.: "en" -> "English") */
@Suppress("CyclomaticComplexMethod", "LongMethod")
@Composable
fun languageMap(): Map<String, String> {
val resources = LocalResources.current
val languageTags =
remember(resources) {
buildList {
add(SYSTEM_DEFAULT)
try {
resources.getXml(org.meshtastic.feature.settings.R.xml.locales_config).use { parser ->
while (parser.eventType != XmlPullParser.END_DOCUMENT) {
if (parser.eventType == XmlPullParser.START_TAG && parser.name == "locale") {
val languageTag =
parser.getAttributeValue("http://schemas.android.com/apk/res/android", "name")
languageTag?.let { add(it) }
}
parser.next()
}
}
} catch (e: Exception) {
Logger.e { "Error parsing locale_config.xml: ${e.message}" }
}
}
}
val languageTags = remember {
listOf(
SYSTEM_DEFAULT,
"en",
"ar",
"bg",
"ca",
"cs",
"de",
"el",
"es",
"et",
"fi",
"fr",
"ga",
"gl",
"hr",
"ht",
"hu",
"is",
"it",
"iw",
"ja",
"ko",
"lt",
"nl",
"nb",
"pl",
"pt",
"pt-BR",
"ro",
"ru",
"sk",
"sl",
"sq",
"sr",
"srp",
"sv",
"tr",
"uk",
"zh-CN",
"zh-TW",
)
}
return languageTags.associateWith { languageTag ->
when (languageTag) {

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.util
val gpioPins = (0..48).map { it to "Pin $it" }

View file

@ -16,12 +16,8 @@
*/
package org.meshtastic.feature.settings
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -30,10 +26,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okio.BufferedSink
import okio.buffer
import okio.sink
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.domain.usecase.settings.ExportDataUseCase
@ -53,16 +46,9 @@ import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.LocalConfig
import java.io.FileNotFoundException
import java.io.FileOutputStream
import javax.inject.Inject
@Suppress("LongParameterList", "TooManyFunctions")
@HiltViewModel
class SettingsViewModel
@Inject
constructor(
private val app: android.app.Application,
open class SettingsViewModel(
radioConfigRepository: RadioConfigRepository,
private val radioController: RadioController,
private val nodeRepository: NodeRepository,
@ -163,32 +149,15 @@ constructor(
/**
* Export all persisted packet data to a CSV file at the given URI.
*
* The CSV will include all packets, or only those matching the given port number if specified. Each row contains:
* date, time, sender node number, sender name, sender latitude, sender longitude, receiver latitude, receiver
* longitude, receiver elevation, received SNR, distance, hop limit, and payload.
*
* @param uri The destination URI for the CSV file.
* @param filterPortnum If provided, only packets with this port number will be exported.
*/
@Suppress("detekt:CyclomaticComplexMethod", "detekt:LongMethod")
fun saveDataCsv(uri: Uri, filterPortnum: Int? = null) {
viewModelScope.launch {
val myNodeNum = myNodeNum ?: return@launch
writeToUri(uri) { writer -> exportDataUseCase(writer, myNodeNum, filterPortnum) }
}
open fun saveDataCsv(uri: Any, filterPortnum: Int? = null) {
// To be implemented in platform-specific subclass
}
private suspend inline fun writeToUri(uri: Uri, crossinline block: suspend (BufferedSink) -> Unit) {
withContext(Dispatchers.IO) {
try {
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
FileOutputStream(parcelFileDescriptor.fileDescriptor).sink().buffer().use { writer ->
block.invoke(writer)
}
}
} catch (ex: FileNotFoundException) {
Logger.e { "Can't write file error: ${ex.message}" }
}
}
protected suspend fun performDataExport(writer: BufferedSink, filterPortnum: Int?) {
val myNodeNum = myNodeNum ?: return
exportDataUseCase(writer, myNodeNum, filterPortnum)
}
}

View file

@ -20,7 +20,6 @@ import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@ -33,9 +32,8 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.nowInstant
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.model.getTracerouteResponse
@ -62,9 +60,6 @@ import org.meshtastic.proto.StoreForwardPlusPlus
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import org.meshtastic.proto.Waypoint
import java.text.DateFormat
import java.util.Locale
import javax.inject.Inject
data class SearchMatch(val logIndex: Int, val start: Int, val end: Int, val field: String)
@ -75,6 +70,11 @@ data class SearchState(
val hasMatches: Boolean = false,
)
enum class FilterMode {
AND,
OR,
}
// --- Search and Filter Managers ---
class LogSearchManager {
data class SearchMatch(val logIndex: Int, val start: Int, val end: Int, val field: String)
@ -141,24 +141,24 @@ class LogSearchManager {
return filteredLogs
.flatMapIndexed { logIndex, log ->
searchText.split(" ").flatMap { term ->
val escapedTerm = Regex.escape(term)
val escapedTerm = term // Simple regex escape or just use contains
val regex = escapedTerm.toRegex(RegexOption.IGNORE_CASE)
val messageMatches =
regex.findAll(log.logMessage).map { match ->
SearchMatch(logIndex, match.range.first, match.range.last, "message")
regex.findAll(log.logMessage).map {
SearchMatch(logIndex, it.range.first, it.range.last, "message")
}
val typeMatches =
regex.findAll(log.messageType).map { match ->
SearchMatch(logIndex, match.range.first, match.range.last, "type")
regex.findAll(log.messageType).map {
SearchMatch(logIndex, it.range.first, it.range.last, "type")
}
val dateMatches =
regex.findAll(log.formattedReceivedDate).map { match ->
SearchMatch(logIndex, match.range.first, match.range.last, "date")
regex.findAll(log.formattedReceivedDate).map {
SearchMatch(logIndex, it.range.first, it.range.last, "date")
}
val decodedPayloadMatches =
log.decodedPayload?.let { decoded ->
regex.findAll(decoded).map { match ->
SearchMatch(logIndex, match.range.first, match.range.last, "decodedPayload")
log.decodedPayload?.let {
regex.findAll(it).map {
SearchMatch(logIndex, it.range.first, it.range.last, "decodedPayload")
}
} ?: emptySequence()
messageMatches + typeMatches + dateMatches + decodedPayloadMatches
@ -189,35 +189,30 @@ class LogFilterManager {
filterMode: FilterMode,
): List<DebugViewModel.UiMeshLog> {
if (filterTexts.isEmpty()) return logs
return logs.filter { log ->
return logs.filter { logItem ->
when (filterMode) {
FilterMode.OR ->
filterTexts.any { filterText ->
log.logMessage.contains(filterText, ignoreCase = true) ||
log.messageType.contains(filterText, ignoreCase = true) ||
log.formattedReceivedDate.contains(filterText, ignoreCase = true) ||
(log.decodedPayload?.contains(filterText, ignoreCase = true) == true)
filterTexts.any {
it.contains(logItem.logMessage, ignoreCase = true) ||
it.contains(logItem.messageType, ignoreCase = true) ||
it.contains(logItem.formattedReceivedDate, ignoreCase = true) ||
(logItem.decodedPayload?.contains(it, ignoreCase = true) == true)
}
FilterMode.AND ->
filterTexts.all { filterText ->
log.logMessage.contains(filterText, ignoreCase = true) ||
log.messageType.contains(filterText, ignoreCase = true) ||
log.formattedReceivedDate.contains(filterText, ignoreCase = true) ||
(log.decodedPayload?.contains(filterText, ignoreCase = true) == true)
filterTexts.all {
it.contains(logItem.logMessage, ignoreCase = true) ||
it.contains(logItem.messageType, ignoreCase = true) ||
it.contains(logItem.formattedReceivedDate, ignoreCase = true) ||
(logItem.decodedPayload?.contains(it, ignoreCase = true) == true)
}
}
}
}
}
private const val HEX_FORMAT = "%02x"
@Suppress("TooManyFunctions")
@HiltViewModel
class DebugViewModel
@Inject
constructor(
open class DebugViewModel(
private val meshLogRepository: MeshLogRepository,
private val nodeRepository: NodeRepository,
private val meshLogPrefs: MeshLogPrefs,
@ -304,13 +299,13 @@ constructor(
}
private fun toUiState(databaseLogs: List<MeshLog>) = databaseLogs
.map { log ->
.map {
UiMeshLog(
uuid = log.uuid,
messageType = log.message_type,
formattedReceivedDate = TIME_FORMAT.format(log.received_date.toInstant().toDate()),
logMessage = annotateMeshLogMessage(log),
decodedPayload = decodePayloadFromMeshLog(log),
uuid = it.uuid,
messageType = it.message_type,
formattedReceivedDate = DateFormatter.formatDateTime(it.received_date),
logMessage = annotateMeshLogMessage(it),
decodedPayload = decodePayloadFromMeshLog(it),
)
}
.toImmutableList()
@ -387,18 +382,21 @@ constructor(
private fun StringBuilder.annotateNodeId(nodeId: Int): Boolean {
val nodeIdStr = nodeId.toUInt().toString()
// Only match if whitespace before and after
val regex = Regex("""(?<=\s|^)${Regex.escape(nodeIdStr)}(?=\s|$)""")
val regex = Regex("""(?<=\s|^)${Regex.escape(nodeIdStr)}(?=\s|$)""", RegexOption.DOT_MATCHES_ALL)
regex.find(this)?.let { _ ->
regex.findAll(this).toList().asReversed().forEach { match ->
val idx = match.range.last + 1
insert(idx, " (${nodeId.asNodeId()})")
regex.findAll(this).toList().asReversed().forEach {
val idx = it.range.last + 1
insert(idx, " (${nodeId.toHex(8)})")
}
return true
}
return false
}
private fun Int.asNodeId(): String = "!%08x".format(Locale.getDefault(), this)
protected open fun Int.toHex(length: Int): String {
// Platform specific hex implementation
return "!$this"
}
fun requestDeleteAllLogs() {
alertManager.showAlert(
@ -419,20 +417,16 @@ constructor(
val decodedPayload: String? = null,
)
companion object {
private val TIME_FORMAT = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
}
val presetFilters: List<String>
get() = buildList {
// Our address if available
nodeRepository.myNodeInfo.value?.myNodeNum?.let { add("!%08x".format(it)) }
nodeRepository.myNodeInfo.value?.myNodeNum?.let { add(it.toHex(8)) }
// broadcast
add("!ffffffff")
// decoded
add("decoded")
// today (locale-dependent short date format)
add(DateFormat.getDateInstance(DateFormat.SHORT).format(nowInstant.toDate()))
add(DateFormatter.formatShortDate(nowInstant.toEpochMilliseconds()))
// Each app name
addAll(PortNum.entries.map { it.name })
}
@ -464,7 +458,7 @@ constructor(
when (portnumValue) {
PortNum.TEXT_MESSAGE_APP.value,
PortNum.ALERT_APP.value,
-> payload.toString(Charsets.UTF_8)
-> payload.decodeToString()
PortNum.POSITION_APP.value ->
Position.ADAPTER.decodeOrNull(payload)?.let { Position.ADAPTER.toReadableString(it) }
?: "Failed to decode Position"
@ -495,17 +489,19 @@ constructor(
} ?: "Failed to decode StoreForwardPlusPlus"
PortNum.NEIGHBORINFO_APP.value -> decodeNeighborInfo(payload)
PortNum.TRACEROUTE_APP.value -> decodeTraceroute(packet, payload)
else -> payload.joinToString(" ") { HEX_FORMAT.format(it) }
else -> payload.joinToString(" ") { it.toHex() }
}
} catch (e: Exception) {
"Failed to decode payload: ${e.message}"
}
}
protected open fun Byte.toHex(): String = this.toString()
private fun formatNodeWithShortName(nodeNum: Int): String {
val user = nodeRepository.nodeDBbyNum.value[nodeNum]?.user
val shortName = user?.short_name?.takeIf { it.isNotEmpty() } ?: ""
val nodeId = "!%08x".format(nodeNum)
val nodeId = nodeNum.toHex(8)
return if (shortName.isNotEmpty()) "$nodeId ($shortName)" else nodeId
}
@ -518,8 +514,8 @@ constructor(
appendLine(" node_broadcast_interval_secs: ${info.node_broadcast_interval_secs}")
if (info.neighbors.isNotEmpty()) {
appendLine(" neighbors:")
info.neighbors.forEach { n ->
appendLine(" - node_id: ${formatNodeWithShortName(n.node_id ?: 0)} snr: ${n.snr}")
info.neighbors.forEach {
appendLine(" - node_id: ${formatNodeWithShortName(it.node_id ?: 0)} snr: ${it.snr}")
}
}
}
@ -529,6 +525,6 @@ constructor(
val getUsername: (Int) -> String = { nodeNum -> formatNodeWithShortName(nodeNum) }
return packet.getTracerouteResponse(getUsername)
?: runCatching { RouteDiscovery.ADAPTER.decode(payload).toString() }.getOrNull()
?: payload.joinToString(" ") { HEX_FORMAT.format(it) }
?: payload.joinToString(" ") { it.toHex() }
}
}

View file

@ -0,0 +1,24 @@
/*
* 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.di
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
@Module
@ComponentScan("org.meshtastic.feature.settings")
class FeatureSettingsModule

View file

@ -17,21 +17,14 @@
package org.meshtastic.feature.settings.filter
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.meshtastic.core.repository.FilterPrefs
import org.meshtastic.core.repository.MessageFilter
import javax.inject.Inject
@HiltViewModel
class FilterSettingsViewModel
@Inject
constructor(
private val filterPrefs: FilterPrefs,
private val messageFilter: MessageFilter,
) : ViewModel() {
open class FilterSettingsViewModel(private val filterPrefs: FilterPrefs, private val messageFilter: MessageFilter) :
ViewModel() {
private val _filterEnabled = MutableStateFlow(filterPrefs.filterEnabled.value)
val filterEnabled: StateFlow<Boolean> = _filterEnabled.asStateFlow()

View file

@ -18,7 +18,6 @@ package org.meshtastic.feature.settings.radio
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@ -31,7 +30,6 @@ 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.ui.util.AlertManager
import javax.inject.Inject
private const val MIN_DAYS_THRESHOLD = 7f
@ -39,10 +37,7 @@ private const val MIN_DAYS_THRESHOLD = 7f
* ViewModel for [CleanNodeDatabaseScreen]. Manages the state and logic for cleaning the node database based on
* specified criteria. The "older than X days" filter is always active.
*/
@HiltViewModel
class CleanNodeDatabaseViewModel
@Inject
constructor(
open class CleanNodeDatabaseViewModel(
private val cleanNodeDatabaseUseCase: CleanNodeDatabaseUseCase,
private val alertManager: AlertManager,
) : ViewModel() {

View file

@ -16,34 +16,20 @@
*/
package org.meshtastic.feature.settings.radio
import android.Manifest
import android.app.Application
import android.content.pm.PackageManager
import android.location.Location
import android.net.Uri
import androidx.annotation.RequiresPermission
import androidx.core.content.ContextCompat
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import co.touchlab.kermit.Logger
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okio.buffer
import okio.sink
import okio.source
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase
import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase
@ -87,8 +73,6 @@ import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.User
import java.io.FileOutputStream
import javax.inject.Inject
/** Data class that represents the current RadioConfig state. */
data class RadioConfigState(
@ -110,12 +94,8 @@ data class RadioConfigState(
)
@Suppress("LongParameterList")
@HiltViewModel
class RadioConfigViewModel
@Inject
constructor(
open class RadioConfigViewModel(
savedStateHandle: SavedStateHandle,
private val app: Application,
private val radioConfigRepository: RadioConfigRepository,
private val packetRepository: PacketRepository,
private val serviceRepository: ServiceRepository,
@ -126,9 +106,9 @@ constructor(
private val homoglyphEncodingPrefs: HomoglyphPrefs,
private val toggleAnalyticsUseCase: ToggleAnalyticsUseCase,
private val toggleHomoglyphEncodingUseCase: ToggleHomoglyphEncodingUseCase,
private val importProfileUseCase: ImportProfileUseCase,
private val exportProfileUseCase: ExportProfileUseCase,
private val exportSecurityConfigUseCase: ExportSecurityConfigUseCase,
protected val importProfileUseCase: ImportProfileUseCase,
protected val exportProfileUseCase: ExportProfileUseCase,
protected val exportSecurityConfigUseCase: ExportSecurityConfigUseCase,
private val installProfileUseCase: InstallProfileUseCase,
private val radioConfigUseCase: RadioConfigUseCase,
private val adminActionsUseCase: AdminActionsUseCase,
@ -166,15 +146,7 @@ constructor(
val currentDeviceProfile
get() = _currentDeviceProfile.value
@RequiresPermission(Manifest.permission.ACCESS_FINE_LOCATION)
suspend fun getCurrentLocation(): Location? = if (
ContextCompat.checkSelfPermission(app, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
) {
locationRepository.getLocations().firstOrNull()
} else {
null
}
open suspend fun getCurrentLocation(): Any? = null
init {
nodeRepository.nodeDBbyNum
@ -254,13 +226,6 @@ constructor(
}
}
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 { channel ->
@ -279,13 +244,6 @@ constructor(
_radioConfigState.update { it.copy(channelList = new) }
}
private fun getChannel(destNum: Int, index: Int) {
viewModelScope.launch {
val packetId = radioConfigUseCase.getChannel(destNum, index)
registerRequestId(packetId)
}
}
fun setConfig(config: Config) {
val destNum = destNode.value?.num ?: return
viewModelScope.launch {
@ -309,13 +267,6 @@ constructor(
}
}
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
@ -349,76 +300,18 @@ constructor(
}
}
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) }
viewModelScope.launch { radioConfigUseCase.setRingtone(destNum, ringtone) }
}
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) }
viewModelScope.launch { radioConfigUseCase.setCannedMessages(destNum, messages) }
}
private fun getCannedMessages(destNum: Int) {
viewModelScope.launch {
val packetId = radioConfigUseCase.getCannedMessages(destNum)
registerRequestId(packetId)
}
}
private fun getDeviceConnectionStatus(destNum: Int) {
viewModelScope.launch {
val packetId = radioConfigUseCase.getDeviceConnectionStatus(destNum)
registerRequestId(packetId)
}
}
private fun requestShutdown(destNum: Int) {
viewModelScope.launch {
val packetId = adminActionsUseCase.shutdown(destNum)
registerRequestId(packetId)
}
}
private fun requestReboot(destNum: Int) {
viewModelScope.launch {
val packetId = adminActionsUseCase.reboot(destNum)
registerRequestId(packetId)
}
}
private fun requestFactoryReset(destNum: Int) {
viewModelScope.launch {
val isLocal = (destNum == myNodeNum)
val packetId = adminActionsUseCase.factoryReset(destNum, isLocal)
registerRequestId(packetId)
}
}
private fun requestNodedbReset(destNum: Int, preserveFavorites: Boolean) {
viewModelScope.launch {
val isLocal = (destNum == myNodeNum)
val packetId = adminActionsUseCase.nodedbReset(destNum, preserveFavorites, isLocal)
registerRequestId(packetId)
}
}
private fun sendAdminRequest(destNum: Int) {
val route = radioConfigState.value.route
_radioConfigState.update { it.copy(route = "") } // setter (response is PortNum.ROUTING_APP)
@ -426,18 +319,35 @@ constructor(
val preserveFavorites = radioConfigState.value.nodeDbResetPreserveFavorites
when (route) {
AdminRoute.REBOOT.name -> requestReboot(destNum)
AdminRoute.REBOOT.name ->
viewModelScope.launch {
val packetId = adminActionsUseCase.reboot(destNum)
registerRequestId(packetId)
}
AdminRoute.SHUTDOWN.name ->
with(radioConfigState.value) {
if (metadata?.canShutdown != true) {
sendError(Res.string.cant_shutdown)
} else {
requestShutdown(destNum)
viewModelScope.launch {
val packetId = adminActionsUseCase.shutdown(destNum)
registerRequestId(packetId)
}
}
}
AdminRoute.FACTORY_RESET.name -> requestFactoryReset(destNum)
AdminRoute.NODEDB_RESET.name -> requestNodedbReset(destNum, preserveFavorites)
AdminRoute.FACTORY_RESET.name ->
viewModelScope.launch {
val isLocal = (destNum == myNodeNum)
val packetId = adminActionsUseCase.factoryReset(destNum, isLocal)
registerRequestId(packetId)
}
AdminRoute.NODEDB_RESET.name ->
viewModelScope.launch {
val isLocal = (destNum == myNodeNum)
val packetId = adminActionsUseCase.nodedbReset(destNum, preserveFavorites, isLocal)
registerRequestId(packetId)
}
}
}
@ -451,50 +361,16 @@ constructor(
viewModelScope.launch { radioConfigUseCase.removeFixedPosition(destNum) }
}
fun importProfile(uri: Uri, onResult: (DeviceProfile) -> Unit) = viewModelScope.launch(Dispatchers.IO) {
try {
app.contentResolver.openInputStream(uri)?.source()?.buffer()?.use { inputStream ->
importProfileUseCase(inputStream).onSuccess(onResult).onFailure { throw it }
}
} catch (ex: Exception) {
Logger.e { "Import DeviceProfile error: ${ex.message}" }
sendError(ex.customMessage)
}
open fun importProfile(uri: Any, onResult: (DeviceProfile) -> Unit) {
// To be implemented in platform-specific subclass
}
fun exportProfile(uri: Uri, profile: DeviceProfile) = viewModelScope.launch {
withContext(Dispatchers.IO) {
try {
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
FileOutputStream(parcelFileDescriptor.fileDescriptor).sink().buffer().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)
}
}
open fun exportProfile(uri: Any, profile: DeviceProfile) {
// To be implemented in platform-specific subclass
}
fun exportSecurityConfig(uri: Uri, securityConfig: Config.SecurityConfig) = viewModelScope.launch {
withContext(Dispatchers.IO) {
try {
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
FileOutputStream(parcelFileDescriptor.fileDescriptor).sink().buffer().use { outputStream ->
exportSecurityConfigUseCase(outputStream, securityConfig)
.onSuccess { setResponseStateSuccess() }
.onFailure { throw it }
}
}
} catch (ex: Exception) {
val errorMessage = "Can't write security keys JSON error: ${ex.message}"
Logger.e { errorMessage }
sendError(ex.customMessage)
}
}
open fun exportSecurityConfig(uri: Any, securityConfig: Config.SecurityConfig) {
// To be implemented in platform-specific subclass
}
fun installProfile(protobuf: DeviceProfile) {
@ -513,38 +389,70 @@ constructor(
_radioConfigState.update { it.copy(route = route.name, responseState = ResponseState.Loading()) }
when (route) {
ConfigRoute.USER -> getOwner(destNum)
ConfigRoute.USER ->
viewModelScope.launch {
val packetId = radioConfigUseCase.getOwner(destNum)
registerRequestId(packetId)
}
ConfigRoute.CHANNELS -> {
getChannel(destNum, 0)
getConfig(destNum, AdminMessage.ConfigType.LORA_CONFIG.value)
viewModelScope.launch {
val packetId = radioConfigUseCase.getChannel(destNum, 0)
registerRequestId(packetId)
}
viewModelScope.launch {
val packetId = radioConfigUseCase.getConfig(destNum, AdminMessage.ConfigType.LORA_CONFIG.value)
registerRequestId(packetId)
}
// channel editor is synchronous, so we don't use requestIds as total
setResponseStateTotal(maxChannels + 1)
}
is AdminRoute -> {
getConfig(destNum, AdminMessage.ConfigType.SESSIONKEY_CONFIG.value)
viewModelScope.launch {
val packetId =
radioConfigUseCase.getConfig(destNum, AdminMessage.ConfigType.SESSIONKEY_CONFIG.value)
registerRequestId(packetId)
}
setResponseStateTotal(2)
}
is ConfigRoute -> {
if (route == ConfigRoute.LORA) {
getChannel(destNum, 0)
viewModelScope.launch {
val packetId = radioConfigUseCase.getChannel(destNum, 0)
registerRequestId(packetId)
}
}
if (route == ConfigRoute.NETWORK) {
getDeviceConnectionStatus(destNum)
viewModelScope.launch {
val packetId = radioConfigUseCase.getDeviceConnectionStatus(destNum)
registerRequestId(packetId)
}
}
viewModelScope.launch {
val packetId = radioConfigUseCase.getConfig(destNum, route.type)
registerRequestId(packetId)
}
getConfig(destNum, route.type)
}
is ModuleRoute -> {
if (route == ModuleRoute.CANNED_MESSAGE) {
getCannedMessages(destNum)
viewModelScope.launch {
val packetId = radioConfigUseCase.getCannedMessages(destNum)
registerRequestId(packetId)
}
}
if (route == ModuleRoute.EXT_NOTIFICATION) {
getRingtone(destNum)
viewModelScope.launch {
val packetId = radioConfigUseCase.getRingtone(destNum)
registerRequestId(packetId)
}
}
viewModelScope.launch {
val packetId = radioConfigUseCase.getModuleConfig(destNum, route.type)
registerRequestId(packetId)
}
getModuleConfig(destNum, route.type)
}
}
}
@ -565,7 +473,7 @@ constructor(
}
}
private fun setResponseStateSuccess() {
protected fun setResponseStateSuccess() {
_radioConfigState.update { state ->
if (state.responseState is ResponseState.Loading) {
state.copy(responseState = ResponseState.Success(true))
@ -575,14 +483,11 @@ constructor(
}
}
private val Exception.customMessage: String
get() = "${javaClass.simpleName}: $message"
protected fun sendError(error: String) = setResponseStateError(UiText.DynamicString(error))
private fun sendError(error: String) = setResponseStateError(UiText.DynamicString(error))
protected fun sendError(id: StringResource) = setResponseStateError(UiText.Resource(id))
private fun sendError(id: StringResource) = setResponseStateError(UiText.Resource(id))
private fun sendError(error: UiText) = setResponseStateError(error)
protected fun sendError(error: UiText) = setResponseStateError(error)
private fun setResponseStateError(error: UiText) {
_radioConfigState.update { it.copy(responseState = ResponseState.Error(error)) }
@ -658,7 +563,10 @@ constructor(
val index = response.index
if (index + 1 < maxChannels && route == ConfigRoute.CHANNELS.name) {
// Not done yet, request next channel
getChannel(destNum, index + 1)
viewModelScope.launch {
val packetId = radioConfigUseCase.getChannel(destNum, index + 1)
registerRequestId(packetId)
}
}
} else {
// Received last channel, update total and start channel editor

View file

@ -1,59 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2025 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/>.
-->
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en"/>
<locale android:name="ar"/>
<locale android:name="bg"/>
<locale android:name="ca"/>
<locale android:name="cs"/>
<locale android:name="de"/>
<locale android:name="el"/>
<locale android:name="es"/>
<locale android:name="et"/>
<locale android:name="fi"/>
<locale android:name="fr"/>
<locale android:name="ga"/>
<locale android:name="gl"/>
<locale android:name="hr"/>
<locale android:name="ht"/>
<locale android:name="hu"/>
<locale android:name="is"/>
<locale android:name="it"/>
<locale android:name="iw"/>
<locale android:name="ja"/>
<locale android:name="ko"/>
<locale android:name="lt"/>
<locale android:name="nl"/>
<locale android:name="nb"/>
<locale android:name="pl"/>
<locale android:name="pt"/>
<locale android:name="pt-BR"/>
<locale android:name="ro"/>
<locale android:name="ru"/>
<locale android:name="sk"/>
<locale android:name="sl"/>
<locale android:name="sq"/>
<locale android:name="sr"/>
<locale android:name="srp"/>
<locale android:name="sv"/>
<locale android:name="tr"/>
<locale android:name="uk"/>
<locale android:name="zh-CN"/>
<locale android:name="zh-TW"/>
</locale-config>