From 1dd47bc09032fe0972af7eed6a7554e61be7a02b Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 18 May 2026 07:52:17 -0500 Subject: [PATCH] fix(settings): add input validation for BLE PIN, LoRa modem, and ambient lighting (#5477) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AmbientLightingConfigItemList.kt | 94 +++++++++++++------ .../component/BluetoothConfigItemList.kt | 49 ++++++++-- .../radio/component/LoRaConfigItemList.kt | 74 ++++++++++----- 3 files changed, 159 insertions(+), 58 deletions(-) diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/AmbientLightingConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/AmbientLightingConfigItemList.kt index 3099e9e8a..a751e5fc8 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/AmbientLightingConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/AmbientLightingConfigItemList.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.platform.LocalFocusManager import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource @@ -38,6 +39,9 @@ import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.proto.ModuleConfig +private const val MAX_LED_CURRENT = 31 +private const val MAX_RGB_VALUE = 255 + @Composable fun AmbientLightingConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() @@ -67,36 +71,72 @@ fun AmbientLightingConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> U containerColor = CardDefaults.cardColors().containerColor, ) HorizontalDivider() - EditTextPreference( - title = stringResource(Res.string.current), - value = formState.value.current, + LedColorFields( + config = formState.value, enabled = state.connected, - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy(current = it) }, - ) - EditTextPreference( - title = stringResource(Res.string.red), - value = formState.value.red, - enabled = state.connected, - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy(red = it) }, - ) - EditTextPreference( - title = stringResource(Res.string.green), - value = formState.value.green, - enabled = state.connected, - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy(green = it) }, - ) - - EditTextPreference( - title = stringResource(Res.string.blue), - value = formState.value.blue, - enabled = state.connected, - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy(blue = it) }, + focusManager = focusManager, + onConfigChange = { formState.value = it }, ) } } } } + +@Composable +private fun LedColorFields( + config: ModuleConfig.AmbientLightingConfig, + enabled: Boolean, + focusManager: FocusManager, + onConfigChange: (ModuleConfig.AmbientLightingConfig) -> Unit, +) { + androidx.compose.foundation.layout.Column { + EditTextPreference( + title = stringResource(Res.string.current), + value = config.current, + enabled = enabled, + isError = config.current !in 0..MAX_LED_CURRENT, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + if (it in 0..MAX_LED_CURRENT) { + onConfigChange(config.copy(current = it)) + } + }, + ) + EditTextPreference( + title = stringResource(Res.string.red), + value = config.red, + enabled = enabled, + isError = config.red !in 0..MAX_RGB_VALUE, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + if (it in 0..MAX_RGB_VALUE) { + onConfigChange(config.copy(red = it)) + } + }, + ) + EditTextPreference( + title = stringResource(Res.string.green), + value = config.green, + enabled = enabled, + isError = config.green !in 0..MAX_RGB_VALUE, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + if (it in 0..MAX_RGB_VALUE) { + onConfigChange(config.copy(green = it)) + } + }, + ) + EditTextPreference( + title = stringResource(Res.string.blue), + value = config.blue, + enabled = enabled, + isError = config.blue !in 0..MAX_RGB_VALUE, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + if (it in 0..MAX_RGB_VALUE) { + onConfigChange(config.copy(blue = it)) + } + }, + ) + } +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/BluetoothConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/BluetoothConfigItemList.kt index 08d27bbef..fd83155f6 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/BluetoothConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/BluetoothConfigItemList.kt @@ -17,11 +17,17 @@ package org.meshtastic.feature.settings.radio.component import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.resources.Res @@ -37,6 +43,8 @@ import org.meshtastic.core.ui.component.TitledCard import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.proto.Config +private const val PIN_LENGTH = 6 + @Composable fun BluetoothConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() @@ -79,18 +87,41 @@ fun BluetoothConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { onItemSelected = { formState.value = formState.value.copy(mode = it) }, ) HorizontalDivider() - EditTextPreference( - title = stringResource(Res.string.fixed_pin), - value = formState.value.fixed_pin, + FixedPinPreference( + pinValue = formState.value.fixed_pin, enabled = state.connected, - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { - if (it.toString().length == 6) { // ensure 6 digits - formState.value = formState.value.copy(fixed_pin = it) - } - }, + focusManager = focusManager, + onPinChange = { formState.value = formState.value.copy(fixed_pin = it) }, ) } } } } + +@Composable +private fun FixedPinPreference( + pinValue: Int, + enabled: Boolean, + focusManager: androidx.compose.ui.focus.FocusManager, + onPinChange: (Int) -> Unit, +) { + var pinState by remember(pinValue) { mutableStateOf(pinValue.toString().padStart(PIN_LENGTH, '0')) } + val pinIsError = pinState.length != PIN_LENGTH || !pinState.all { it.isDigit() } + EditTextPreference( + title = stringResource(Res.string.fixed_pin), + value = pinState, + enabled = enabled, + isError = pinIsError, + keyboardOptions = + KeyboardOptions.Default.copy(keyboardType = KeyboardType.NumberPassword, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { input -> + if (input.length <= PIN_LENGTH && input.all { it.isDigit() }) { + pinState = input + if (input.length == PIN_LENGTH) { + onPinChange(input.toInt()) + } + } + }, + ) +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt index f0dab11e1..ced63fff6 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt @@ -64,6 +64,9 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.feature.settings.util.hopLimits import org.meshtastic.proto.Config +private val SPREAD_FACTOR_RANGE = 7..12 +private val CODING_RATE_RANGE = 5..8 + @Composable fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { val state by viewModel.radioConfigState.collectAsStateWithLifecycle() @@ -115,28 +118,11 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { onItemSelected = { formState.value = formState.value.copy(modem_preset = it) }, ) } else { - EditTextPreference( - title = stringResource(Res.string.bandwidth), - value = formState.value.bandwidth, - enabled = state.connected && !formState.value.use_preset, - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy(bandwidth = it) }, - ) - HorizontalDivider() - EditTextPreference( - title = stringResource(Res.string.spread_factor), - value = formState.value.spread_factor, - enabled = state.connected && !formState.value.use_preset, - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy(spread_factor = it) }, - ) - HorizontalDivider() - EditTextPreference( - title = stringResource(Res.string.coding_rate), - value = formState.value.coding_rate, - enabled = state.connected && !formState.value.use_preset, - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - onValueChanged = { formState.value = formState.value.copy(coding_rate = it) }, + ManualModemSettings( + config = formState.value, + enabled = state.connected, + focusManager = focusManager, + onConfigChange = { formState.value = it }, ) } } @@ -250,3 +236,47 @@ fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) { } } } + +@Composable +private fun ManualModemSettings( + config: Config.LoRaConfig, + enabled: Boolean, + focusManager: androidx.compose.ui.focus.FocusManager, + onConfigChange: (Config.LoRaConfig) -> Unit, +) { + androidx.compose.foundation.layout.Column { + EditTextPreference( + title = stringResource(Res.string.bandwidth), + value = config.bandwidth, + enabled = enabled, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { onConfigChange(config.copy(bandwidth = it)) }, + ) + HorizontalDivider() + EditTextPreference( + title = stringResource(Res.string.spread_factor), + value = config.spread_factor, + enabled = enabled, + isError = config.spread_factor !in SPREAD_FACTOR_RANGE, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + if (it in SPREAD_FACTOR_RANGE) { + onConfigChange(config.copy(spread_factor = it)) + } + }, + ) + HorizontalDivider() + EditTextPreference( + title = stringResource(Res.string.coding_rate), + value = config.coding_rate, + enabled = enabled, + isError = config.coding_rate !in CODING_RATE_RANGE, + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { + if (it in CODING_RATE_RANGE) { + onConfigChange(config.copy(coding_rate = it)) + } + }, + ) + } +}