Skip to content

Commit cd98090

Browse files
authored
fix(settings): Generate fresh PSK for named manual channels (#6076)
1 parent 9b5e3bb commit cd98090

4 files changed

Lines changed: 283 additions & 20 deletions

File tree

feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import androidx.compose.runtime.remember
4040
import androidx.compose.runtime.saveable.listSaver
4141
import androidx.compose.runtime.saveable.rememberSaveable
4242
import androidx.compose.runtime.setValue
43+
import androidx.compose.runtime.snapshots.SnapshotStateList
4344
import androidx.compose.runtime.toMutableStateList
4445
import androidx.compose.ui.Alignment
4546
import androidx.compose.ui.Modifier
@@ -70,6 +71,7 @@ import org.meshtastic.feature.settings.radio.channel.component.ChannelCard
7071
import org.meshtastic.feature.settings.radio.channel.component.ChannelConfigHeader
7172
import org.meshtastic.feature.settings.radio.channel.component.ChannelLegend
7273
import org.meshtastic.feature.settings.radio.channel.component.ChannelLegendDialog
74+
import org.meshtastic.feature.settings.radio.channel.component.ChannelPskEditState
7375
import org.meshtastic.feature.settings.radio.channel.component.EditChannelDialog
7476
import org.meshtastic.feature.settings.radio.component.LoadingOverlay
7577
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
@@ -122,12 +124,17 @@ private fun ChannelConfigScreen(
122124
rememberSaveable(saver = listSaver(save = { it.toList() }, restore = { it.toMutableStateList() })) {
123125
settingsList.toMutableStateList()
124126
}
127+
val pskEditStatesInput =
128+
rememberSaveable(saver = channelPskEditStatesSaver) {
129+
List(settingsListInput.size) { ChannelPskEditState() }.toMutableStateList()
130+
}
125131

126132
val listState = rememberLazyListState()
127133
val dragDropState =
128134
rememberDragDropState(listState) { fromIndex, toIndex ->
129135
if (toIndex in settingsListInput.indices && fromIndex in settingsListInput.indices) {
130-
settingsListInput.apply { add(toIndex, removeAt(fromIndex)) }
136+
settingsListInput.move(fromIndex, toIndex)
137+
pskEditStatesInput.move(fromIndex, toIndex)
131138
}
132139
}
133140

@@ -143,11 +150,14 @@ private fun ChannelConfigScreen(
143150
EditChannelDialog(
144151
channelSettings = settingsListInput.getOrNull(index) ?: ChannelSettings(),
145152
modemPresetName = modemPresetName,
146-
onAddClick = {
153+
initialPskEditState = pskEditStatesInput.getOrElse(index) { ChannelPskEditState() },
154+
onAddClick = { settings, pskEditState ->
147155
if (settingsListInput.size > index) {
148-
settingsListInput[index] = it
156+
settingsListInput[index] = settings
157+
pskEditStatesInput[index] = pskEditState
149158
} else {
150-
settingsListInput.add(it)
159+
settingsListInput.add(settings)
160+
pskEditStatesInput.add(pskEditState)
151161
}
152162
showEditChannelDialog = null
153163
},
@@ -176,7 +186,8 @@ private fun ChannelConfigScreen(
176186
FloatingActionButton(
177187
onClick = {
178188
if (maxChannels > settingsListInput.size) {
179-
settingsListInput.add(ChannelSettings(psk = Channel.default.settings.psk))
189+
settingsListInput.add(Channel.default.settings)
190+
pskEditStatesInput.add(ChannelPskEditState(canGeneratePskForName = true))
180191
showEditChannelDialog = settingsListInput.lastIndex
181192
}
182193
},
@@ -231,7 +242,10 @@ private fun ChannelConfigScreen(
231242
channelSettings = channel,
232243
loraConfig = loraConfig,
233244
onEditClick = { showEditChannelDialog = index },
234-
onDeleteClick = { settingsListInput.removeAt(index) },
245+
onDeleteClick = {
246+
settingsListInput.removeAt(index)
247+
pskEditStatesInput.removeAt(index)
248+
},
235249
sharesLocation = locationChannel == index,
236250
)
237251
}
@@ -242,13 +256,16 @@ private fun ChannelConfigScreen(
242256
negativeText = stringResource(Res.string.cancel),
243257
onNegativeClicked = {
244258
focusManager.clearFocus()
245-
settingsListInput.clear()
246-
settingsListInput.addAll(settingsList)
259+
settingsListInput.replaceWith(settingsList)
260+
pskEditStatesInput.replaceAll(settingsList.size, ChannelPskEditState())
247261
},
248262
positiveText = stringResource(Res.string.send),
249263
onPositiveClicked = {
250264
focusManager.clearFocus()
251-
onPositiveClicked(settingsListInput)
265+
val committedSettings = settingsListInput.toList()
266+
onPositiveClicked(committedSettings)
267+
settingsListInput.replaceWith(committedSettings)
268+
pskEditStatesInput.replaceAll(committedSettings.size, ChannelPskEditState())
252269
},
253270
)
254271
}
@@ -273,6 +290,44 @@ private fun ChannelConfigScreen(
273290
}
274291
}
275292

293+
private val channelPskEditStatesSaver =
294+
listSaver<SnapshotStateList<ChannelPskEditState>, Int>(
295+
save = { states -> states.map { it.toSaveableFlags() } },
296+
restore = { flags -> flags.map { it.toChannelPskEditState() }.toMutableStateList() },
297+
)
298+
299+
internal fun <T> MutableList<T>.move(fromIndex: Int, toIndex: Int) {
300+
add(toIndex, removeAt(fromIndex))
301+
}
302+
303+
internal fun <T> MutableList<T>.replaceWith(values: List<T>) {
304+
clear()
305+
addAll(values)
306+
}
307+
308+
internal fun MutableList<ChannelPskEditState>.replaceAll(size: Int, value: ChannelPskEditState) {
309+
clear()
310+
repeat(size) { add(value) }
311+
}
312+
313+
internal fun ChannelPskEditState.toSaveableFlags(): Int {
314+
var flags = 0
315+
if (canGeneratePskForName) flags = flags or CAN_GENERATE_PSK_FOR_NAME_FLAG
316+
if (generatedPskForName) flags = flags or GENERATED_PSK_FOR_NAME_FLAG
317+
if (pskExplicitlyEdited) flags = flags or PSK_EXPLICITLY_EDITED_FLAG
318+
return flags
319+
}
320+
321+
internal fun Int.toChannelPskEditState(): ChannelPskEditState = ChannelPskEditState(
322+
canGeneratePskForName = this and CAN_GENERATE_PSK_FOR_NAME_FLAG != 0,
323+
generatedPskForName = this and GENERATED_PSK_FOR_NAME_FLAG != 0,
324+
pskExplicitlyEdited = this and PSK_EXPLICITLY_EDITED_FLAG != 0,
325+
)
326+
327+
private const val CAN_GENERATE_PSK_FOR_NAME_FLAG = 1
328+
private const val GENERATED_PSK_FOR_NAME_FLAG = 1 shl 1
329+
private const val PSK_EXPLICITLY_EDITED_FLAG = 1 shl 2
330+
276331
/**
277332
* Determines what [Channel] if any is enabled to conduct automatic location sharing.
278333
*

feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/component/EditChannelDialog.kt

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -53,21 +53,23 @@ import org.meshtastic.proto.ModuleSettings
5353
@Composable
5454
fun EditChannelDialog(
5555
channelSettings: ChannelSettings,
56-
onAddClick: (ChannelSettings) -> Unit,
56+
onAddClick: (ChannelSettings, ChannelPskEditState) -> Unit,
5757
onDismissRequest: () -> Unit,
5858
modifier: Modifier = Modifier,
5959
modemPresetName: String = stringResource(Res.string.default_),
60+
initialPskEditState: ChannelPskEditState = ChannelPskEditState(),
6061
) {
6162
val focusManager = LocalFocusManager.current
6263
var isFocused by remember { mutableStateOf(false) }
6364

6465
var channelInput by remember(channelSettings) { mutableStateOf(channelSettings) }
66+
var pskEditState by remember(channelSettings, initialPskEditState) { mutableStateOf(initialPskEditState) }
6567

6668
MeshtasticDialog(
6769
onDismiss = onDismissRequest,
6870
dismissText = stringResource(Res.string.cancel),
6971
confirmText = stringResource(Res.string.save),
70-
onConfirm = { onAddClick(channelInput) },
72+
onConfirm = { onAddClick(channelInput, pskEditState) },
7173
modifier = modifier,
7274
title = "", // Title is handled internally by specific items if needed, or we could add one
7375
text = {
@@ -87,14 +89,9 @@ fun EditChannelDialog(
8789
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
8890
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
8991
onValueChanged = {
90-
val defaultPsk = Channel.default.settings.psk
91-
val newPsk =
92-
if (channelInput.psk == defaultPsk) {
93-
Channel.getRandomKey()
94-
} else {
95-
channelInput.psk
96-
}
97-
channelInput = channelInput.copy(name = it.trim(), psk = newPsk)
92+
val update = channelInput.applyChannelNameEdit(it, pskEditState)
93+
channelInput = update.settings
94+
pskEditState = update.pskEditState
9895
},
9996
onFocusChanged = { isFocused = it.isFocused },
10097
)
@@ -107,10 +104,14 @@ fun EditChannelDialog(
107104
onValueChange = {
108105
val fullPsk = Channel(ChannelSettings(psk = it)).psk
109106
if (fullPsk.size in setOf(0, 16, 32)) {
107+
pskEditState = pskEditState.copy(generatedPskForName = false, pskExplicitlyEdited = true)
110108
channelInput = channelInput.copy(psk = it)
111109
}
112110
},
113-
onGenerateKey = { channelInput = channelInput.copy(psk = Channel.getRandomKey()) },
111+
onGenerateKey = {
112+
pskEditState = pskEditState.copy(generatedPskForName = false, pskExplicitlyEdited = true)
113+
channelInput = channelInput.copy(psk = Channel.getRandomKey())
114+
},
114115
)
115116

116117
SwitchPreference(
@@ -142,3 +143,41 @@ fun EditChannelDialog(
142143
},
143144
)
144145
}
146+
147+
data class ChannelPskEditState(
148+
val canGeneratePskForName: Boolean = false,
149+
val generatedPskForName: Boolean = false,
150+
val pskExplicitlyEdited: Boolean = false,
151+
)
152+
153+
internal data class ChannelNameUpdate(val settings: ChannelSettings, val pskEditState: ChannelPskEditState)
154+
155+
internal fun ChannelSettings.applyChannelNameEdit(
156+
nameInput: String,
157+
pskEditState: ChannelPskEditState,
158+
): ChannelNameUpdate {
159+
val name = nameInput.trim()
160+
val defaultPsk = Channel.default.settings.psk
161+
val clearGeneratedPsk = name.isBlank() && pskEditState.generatedPskForName && !pskEditState.pskExplicitlyEdited
162+
val generateNamedPsk =
163+
name.isNotBlank() &&
164+
psk == defaultPsk &&
165+
pskEditState.canGeneratePskForName &&
166+
!pskEditState.pskExplicitlyEdited
167+
val nextPsk =
168+
when {
169+
clearGeneratedPsk -> defaultPsk
170+
generateNamedPsk -> Channel.getRandomKey()
171+
else -> psk
172+
}
173+
val nextGeneratedPskForName =
174+
when {
175+
clearGeneratedPsk -> false
176+
generateNamedPsk -> true
177+
else -> pskEditState.generatedPskForName
178+
}
179+
return ChannelNameUpdate(
180+
settings = copy(name = name, psk = nextPsk),
181+
pskEditState = pskEditState.copy(generatedPskForName = nextGeneratedPskForName),
182+
)
183+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package org.meshtastic.feature.settings.radio.channel
18+
19+
import org.meshtastic.feature.settings.radio.channel.component.ChannelPskEditState
20+
import kotlin.test.Test
21+
import kotlin.test.assertEquals
22+
import kotlin.test.assertTrue
23+
24+
class ChannelConfigScreenTest {
25+
@Test
26+
fun `psk edit state round-trips through saveable flags for all boolean combinations`() {
27+
for (canGenerate in listOf(false, true)) {
28+
for (generated in listOf(false, true)) {
29+
for (edited in listOf(false, true)) {
30+
val original =
31+
ChannelPskEditState(
32+
canGeneratePskForName = canGenerate,
33+
generatedPskForName = generated,
34+
pskExplicitlyEdited = edited,
35+
)
36+
val restored = original.toSaveableFlags().toChannelPskEditState()
37+
assertEquals(original, restored)
38+
}
39+
}
40+
}
41+
}
42+
43+
@Test
44+
fun `move forward shifts element to higher index`() {
45+
val list = mutableListOf("A", "B", "C", "D")
46+
list.move(fromIndex = 1, toIndex = 3)
47+
assertEquals(listOf("A", "C", "D", "B"), list)
48+
}
49+
50+
@Test
51+
fun `move backward shifts element to lower index`() {
52+
val list = mutableListOf("A", "B", "C", "D")
53+
list.move(fromIndex = 3, toIndex = 1)
54+
assertEquals(listOf("A", "D", "B", "C"), list)
55+
}
56+
57+
@Test
58+
fun `replaceAll clears and refills with given size and value`() {
59+
val list =
60+
mutableListOf(
61+
ChannelPskEditState(canGeneratePskForName = true),
62+
ChannelPskEditState(generatedPskForName = true),
63+
)
64+
val fillValue = ChannelPskEditState(pskExplicitlyEdited = true)
65+
list.replaceAll(size = 3, value = fillValue)
66+
assertEquals(3, list.size)
67+
assertTrue(list.all { it == fillValue })
68+
}
69+
70+
@Test
71+
fun `replaceWith clears and refills with committed values`() {
72+
val list = mutableListOf("draft", "stale")
73+
74+
list.replaceWith(listOf("committed"))
75+
76+
assertEquals(listOf("committed"), list)
77+
}
78+
79+
@Test
80+
fun `replaceAll clears committed channel psk generation eligibility`() {
81+
val list = mutableListOf(ChannelPskEditState(canGeneratePskForName = true, generatedPskForName = true))
82+
83+
list.replaceAll(size = 1, value = ChannelPskEditState())
84+
85+
assertEquals(listOf(ChannelPskEditState()), list)
86+
}
87+
}

0 commit comments

Comments
 (0)