fix(settings): Generate fresh PSK for named manual channels (#6076)

This commit is contained in:
Jeremiah K
2026-07-06 12:08:25 -05:00
committed by GitHub
parent 9b5e3bb5b4
commit cd98090efa
4 changed files with 283 additions and 20 deletions

View File

@@ -40,6 +40,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -70,6 +71,7 @@ import org.meshtastic.feature.settings.radio.channel.component.ChannelCard
import org.meshtastic.feature.settings.radio.channel.component.ChannelConfigHeader
import org.meshtastic.feature.settings.radio.channel.component.ChannelLegend
import org.meshtastic.feature.settings.radio.channel.component.ChannelLegendDialog
import org.meshtastic.feature.settings.radio.channel.component.ChannelPskEditState
import org.meshtastic.feature.settings.radio.channel.component.EditChannelDialog
import org.meshtastic.feature.settings.radio.component.LoadingOverlay
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
@@ -122,12 +124,17 @@ private fun ChannelConfigScreen(
rememberSaveable(saver = listSaver(save = { it.toList() }, restore = { it.toMutableStateList() })) {
settingsList.toMutableStateList()
}
val pskEditStatesInput =
rememberSaveable(saver = channelPskEditStatesSaver) {
List(settingsListInput.size) { ChannelPskEditState() }.toMutableStateList()
}
val listState = rememberLazyListState()
val dragDropState =
rememberDragDropState(listState) { fromIndex, toIndex ->
if (toIndex in settingsListInput.indices && fromIndex in settingsListInput.indices) {
settingsListInput.apply { add(toIndex, removeAt(fromIndex)) }
settingsListInput.move(fromIndex, toIndex)
pskEditStatesInput.move(fromIndex, toIndex)
}
}
@@ -143,11 +150,14 @@ private fun ChannelConfigScreen(
EditChannelDialog(
channelSettings = settingsListInput.getOrNull(index) ?: ChannelSettings(),
modemPresetName = modemPresetName,
onAddClick = {
initialPskEditState = pskEditStatesInput.getOrElse(index) { ChannelPskEditState() },
onAddClick = { settings, pskEditState ->
if (settingsListInput.size > index) {
settingsListInput[index] = it
settingsListInput[index] = settings
pskEditStatesInput[index] = pskEditState
} else {
settingsListInput.add(it)
settingsListInput.add(settings)
pskEditStatesInput.add(pskEditState)
}
showEditChannelDialog = null
},
@@ -176,7 +186,8 @@ private fun ChannelConfigScreen(
FloatingActionButton(
onClick = {
if (maxChannels > settingsListInput.size) {
settingsListInput.add(ChannelSettings(psk = Channel.default.settings.psk))
settingsListInput.add(Channel.default.settings)
pskEditStatesInput.add(ChannelPskEditState(canGeneratePskForName = true))
showEditChannelDialog = settingsListInput.lastIndex
}
},
@@ -231,7 +242,10 @@ private fun ChannelConfigScreen(
channelSettings = channel,
loraConfig = loraConfig,
onEditClick = { showEditChannelDialog = index },
onDeleteClick = { settingsListInput.removeAt(index) },
onDeleteClick = {
settingsListInput.removeAt(index)
pskEditStatesInput.removeAt(index)
},
sharesLocation = locationChannel == index,
)
}
@@ -242,13 +256,16 @@ private fun ChannelConfigScreen(
negativeText = stringResource(Res.string.cancel),
onNegativeClicked = {
focusManager.clearFocus()
settingsListInput.clear()
settingsListInput.addAll(settingsList)
settingsListInput.replaceWith(settingsList)
pskEditStatesInput.replaceAll(settingsList.size, ChannelPskEditState())
},
positiveText = stringResource(Res.string.send),
onPositiveClicked = {
focusManager.clearFocus()
onPositiveClicked(settingsListInput)
val committedSettings = settingsListInput.toList()
onPositiveClicked(committedSettings)
settingsListInput.replaceWith(committedSettings)
pskEditStatesInput.replaceAll(committedSettings.size, ChannelPskEditState())
},
)
}
@@ -273,6 +290,44 @@ private fun ChannelConfigScreen(
}
}
private val channelPskEditStatesSaver =
listSaver<SnapshotStateList<ChannelPskEditState>, Int>(
save = { states -> states.map { it.toSaveableFlags() } },
restore = { flags -> flags.map { it.toChannelPskEditState() }.toMutableStateList() },
)
internal fun <T> MutableList<T>.move(fromIndex: Int, toIndex: Int) {
add(toIndex, removeAt(fromIndex))
}
internal fun <T> MutableList<T>.replaceWith(values: List<T>) {
clear()
addAll(values)
}
internal fun MutableList<ChannelPskEditState>.replaceAll(size: Int, value: ChannelPskEditState) {
clear()
repeat(size) { add(value) }
}
internal fun ChannelPskEditState.toSaveableFlags(): Int {
var flags = 0
if (canGeneratePskForName) flags = flags or CAN_GENERATE_PSK_FOR_NAME_FLAG
if (generatedPskForName) flags = flags or GENERATED_PSK_FOR_NAME_FLAG
if (pskExplicitlyEdited) flags = flags or PSK_EXPLICITLY_EDITED_FLAG
return flags
}
internal fun Int.toChannelPskEditState(): ChannelPskEditState = ChannelPskEditState(
canGeneratePskForName = this and CAN_GENERATE_PSK_FOR_NAME_FLAG != 0,
generatedPskForName = this and GENERATED_PSK_FOR_NAME_FLAG != 0,
pskExplicitlyEdited = this and PSK_EXPLICITLY_EDITED_FLAG != 0,
)
private const val CAN_GENERATE_PSK_FOR_NAME_FLAG = 1
private const val GENERATED_PSK_FOR_NAME_FLAG = 1 shl 1
private const val PSK_EXPLICITLY_EDITED_FLAG = 1 shl 2
/**
* Determines what [Channel] if any is enabled to conduct automatic location sharing.
*

View File

@@ -53,21 +53,23 @@ import org.meshtastic.proto.ModuleSettings
@Composable
fun EditChannelDialog(
channelSettings: ChannelSettings,
onAddClick: (ChannelSettings) -> Unit,
onAddClick: (ChannelSettings, ChannelPskEditState) -> Unit,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
modemPresetName: String = stringResource(Res.string.default_),
initialPskEditState: ChannelPskEditState = ChannelPskEditState(),
) {
val focusManager = LocalFocusManager.current
var isFocused by remember { mutableStateOf(false) }
var channelInput by remember(channelSettings) { mutableStateOf(channelSettings) }
var pskEditState by remember(channelSettings, initialPskEditState) { mutableStateOf(initialPskEditState) }
MeshtasticDialog(
onDismiss = onDismissRequest,
dismissText = stringResource(Res.string.cancel),
confirmText = stringResource(Res.string.save),
onConfirm = { onAddClick(channelInput) },
onConfirm = { onAddClick(channelInput, pskEditState) },
modifier = modifier,
title = "", // Title is handled internally by specific items if needed, or we could add one
text = {
@@ -87,14 +89,9 @@ fun EditChannelDialog(
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
val defaultPsk = Channel.default.settings.psk
val newPsk =
if (channelInput.psk == defaultPsk) {
Channel.getRandomKey()
} else {
channelInput.psk
}
channelInput = channelInput.copy(name = it.trim(), psk = newPsk)
val update = channelInput.applyChannelNameEdit(it, pskEditState)
channelInput = update.settings
pskEditState = update.pskEditState
},
onFocusChanged = { isFocused = it.isFocused },
)
@@ -107,10 +104,14 @@ fun EditChannelDialog(
onValueChange = {
val fullPsk = Channel(ChannelSettings(psk = it)).psk
if (fullPsk.size in setOf(0, 16, 32)) {
pskEditState = pskEditState.copy(generatedPskForName = false, pskExplicitlyEdited = true)
channelInput = channelInput.copy(psk = it)
}
},
onGenerateKey = { channelInput = channelInput.copy(psk = Channel.getRandomKey()) },
onGenerateKey = {
pskEditState = pskEditState.copy(generatedPskForName = false, pskExplicitlyEdited = true)
channelInput = channelInput.copy(psk = Channel.getRandomKey())
},
)
SwitchPreference(
@@ -142,3 +143,41 @@ fun EditChannelDialog(
},
)
}
data class ChannelPskEditState(
val canGeneratePskForName: Boolean = false,
val generatedPskForName: Boolean = false,
val pskExplicitlyEdited: Boolean = false,
)
internal data class ChannelNameUpdate(val settings: ChannelSettings, val pskEditState: ChannelPskEditState)
internal fun ChannelSettings.applyChannelNameEdit(
nameInput: String,
pskEditState: ChannelPskEditState,
): ChannelNameUpdate {
val name = nameInput.trim()
val defaultPsk = Channel.default.settings.psk
val clearGeneratedPsk = name.isBlank() && pskEditState.generatedPskForName && !pskEditState.pskExplicitlyEdited
val generateNamedPsk =
name.isNotBlank() &&
psk == defaultPsk &&
pskEditState.canGeneratePskForName &&
!pskEditState.pskExplicitlyEdited
val nextPsk =
when {
clearGeneratedPsk -> defaultPsk
generateNamedPsk -> Channel.getRandomKey()
else -> psk
}
val nextGeneratedPskForName =
when {
clearGeneratedPsk -> false
generateNamedPsk -> true
else -> pskEditState.generatedPskForName
}
return ChannelNameUpdate(
settings = copy(name = name, psk = nextPsk),
pskEditState = pskEditState.copy(generatedPskForName = nextGeneratedPskForName),
)
}

View File

@@ -0,0 +1,87 @@
/*
* 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.channel
import org.meshtastic.feature.settings.radio.channel.component.ChannelPskEditState
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ChannelConfigScreenTest {
@Test
fun `psk edit state round-trips through saveable flags for all boolean combinations`() {
for (canGenerate in listOf(false, true)) {
for (generated in listOf(false, true)) {
for (edited in listOf(false, true)) {
val original =
ChannelPskEditState(
canGeneratePskForName = canGenerate,
generatedPskForName = generated,
pskExplicitlyEdited = edited,
)
val restored = original.toSaveableFlags().toChannelPskEditState()
assertEquals(original, restored)
}
}
}
}
@Test
fun `move forward shifts element to higher index`() {
val list = mutableListOf("A", "B", "C", "D")
list.move(fromIndex = 1, toIndex = 3)
assertEquals(listOf("A", "C", "D", "B"), list)
}
@Test
fun `move backward shifts element to lower index`() {
val list = mutableListOf("A", "B", "C", "D")
list.move(fromIndex = 3, toIndex = 1)
assertEquals(listOf("A", "D", "B", "C"), list)
}
@Test
fun `replaceAll clears and refills with given size and value`() {
val list =
mutableListOf(
ChannelPskEditState(canGeneratePskForName = true),
ChannelPskEditState(generatedPskForName = true),
)
val fillValue = ChannelPskEditState(pskExplicitlyEdited = true)
list.replaceAll(size = 3, value = fillValue)
assertEquals(3, list.size)
assertTrue(list.all { it == fillValue })
}
@Test
fun `replaceWith clears and refills with committed values`() {
val list = mutableListOf("draft", "stale")
list.replaceWith(listOf("committed"))
assertEquals(listOf("committed"), list)
}
@Test
fun `replaceAll clears committed channel psk generation eligibility`() {
val list = mutableListOf(ChannelPskEditState(canGeneratePskForName = true, generatedPskForName = true))
list.replaceAll(size = 1, value = ChannelPskEditState())
assertEquals(listOf(ChannelPskEditState()), list)
}
}

View File

@@ -0,0 +1,82 @@
/*
* 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.channel.component
import org.meshtastic.core.model.Channel
import org.meshtastic.proto.ChannelSettings
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertTrue
class EditChannelDialogTest {
@Test
fun `new named manual channel generates fresh psk`() {
val update =
Channel.default.settings.applyChannelNameEdit(
nameInput = "custom",
pskEditState = ChannelPskEditState(canGeneratePskForName = true),
)
assertEquals("custom", update.settings.name)
assertNotEquals(Channel.default.settings.psk, update.settings.psk)
assertTrue(update.pskEditState.generatedPskForName)
assertFalse(update.pskEditState.pskExplicitlyEdited)
}
@Test
fun `explicitly entered default marker is preserved when naming channel`() {
val update =
Channel.default.settings.applyChannelNameEdit(
nameInput = "custom",
pskEditState = ChannelPskEditState(canGeneratePskForName = true, pskExplicitlyEdited = true),
)
assertEquals("custom", update.settings.name)
assertEquals(Channel.default.settings.psk, update.settings.psk)
assertFalse(update.pskEditState.generatedPskForName)
}
@Test
fun `existing default psk channel is not rotated when renamed`() {
val update =
Channel.default.settings.applyChannelNameEdit(
nameInput = "custom",
pskEditState = ChannelPskEditState(canGeneratePskForName = false),
)
assertEquals("custom", update.settings.name)
assertEquals(Channel.default.settings.psk, update.settings.psk)
assertFalse(update.pskEditState.generatedPskForName)
}
@Test
fun `generated name psk is restored to default when name is cleared after reopen`() {
val generatedSettings = ChannelSettings(name = "custom", psk = Channel.getRandomKey())
val update =
generatedSettings.applyChannelNameEdit(
nameInput = "",
pskEditState = ChannelPskEditState(canGeneratePskForName = true, generatedPskForName = true),
)
assertEquals("", update.settings.name)
assertEquals(Channel.default.settings.psk, update.settings.psk)
assertFalse(update.pskEditState.generatedPskForName)
}
}