mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-08 20:35:46 -04:00
fix(settings): Generate fresh PSK for named manual channels (#6076)
This commit is contained in:
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user