diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt index efdc699b1..d1b7970c5 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt @@ -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, Int>( + save = { states -> states.map { it.toSaveableFlags() } }, + restore = { flags -> flags.map { it.toChannelPskEditState() }.toMutableStateList() }, + ) + +internal fun MutableList.move(fromIndex: Int, toIndex: Int) { + add(toIndex, removeAt(fromIndex)) +} + +internal fun MutableList.replaceWith(values: List) { + clear() + addAll(values) +} + +internal fun MutableList.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. * diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/component/EditChannelDialog.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/component/EditChannelDialog.kt index cb2176c03..ecd3901fb 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/component/EditChannelDialog.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/component/EditChannelDialog.kt @@ -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), + ) +} diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreenTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreenTest.kt new file mode 100644 index 000000000..c8194c692 --- /dev/null +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreenTest.kt @@ -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 . + */ +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) + } +} diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/channel/component/EditChannelDialogTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/channel/component/EditChannelDialogTest.kt new file mode 100644 index 000000000..3e16911d5 --- /dev/null +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/channel/component/EditChannelDialogTest.kt @@ -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 . + */ +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) + } +}