diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt index 79e070200..cdd83ede5 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt @@ -96,6 +96,7 @@ import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.Channel import org.meshtastic.core.model.util.getChannelUrl +import org.meshtastic.core.model.util.hasDuplicateKeys import org.meshtastic.core.model.util.qrCode import org.meshtastic.core.model.util.toChannelSet import org.meshtastic.core.navigation.Route @@ -107,6 +108,7 @@ import org.meshtastic.core.strings.are_you_sure_change_default import org.meshtastic.core.strings.cancel import org.meshtastic.core.strings.cant_change_no_radio import org.meshtastic.core.strings.channel_invalid +import org.meshtastic.core.strings.channel_key_already_in_use import org.meshtastic.core.strings.copy import org.meshtastic.core.strings.edit import org.meshtastic.core.strings.modem_preset @@ -231,6 +233,12 @@ fun ChannelScreen( // Send new channel settings to the device fun installSettings(newChannelSet: ChannelSet) { + // Check for duplicate keys before installing + if (newChannelSet.hasDuplicateKeys()) { + scope.launch { context.showToast(Res.string.channel_key_already_in_use) } + return + } + // Try to change the radio, if it fails, tell the user why and throw away their edits try { viewModel.setChannels(newChannelSet) diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/ChannelSet.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/util/ChannelSet.kt index 757fefc3e..90db14a74 100644 --- a/core/model/src/main/kotlin/org/meshtastic/core/model/util/ChannelSet.kt +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/util/ChannelSet.kt @@ -89,3 +89,21 @@ fun ChannelSet.qrCode(shouldAdd: Boolean): Bitmap? = try { Timber.e("URL was too complex to render as barcode") null } + +/** + * Check if the ChannelSet contains any duplicate PSKs. + * + * @return true if there are duplicate PSKs, false otherwise + */ +fun ChannelSet.hasDuplicateKeys(): Boolean { + val pskList = mutableListOf() + for (setting in settingsList) { + val channel = Channel(setting, loraConfig) + val pskBytes = channel.psk.toByteArray() + if (pskList.any { it contentEquals pskBytes }) { + return true + } + pskList.add(pskBytes) + } + return false +} diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index a79af3186..4db966f8a 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -218,6 +218,7 @@ Service notifications About This Channel URL is invalid and can not be used + A channel with this key is already in use This contact is invalid and can not be added Debug Panel Decoded Payload: diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt index ae715e339..02d2b471b 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/qr/ScannedQrCodeDialog.kt @@ -40,9 +40,11 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewScreenSizes import androidx.compose.ui.unit.dp @@ -50,15 +52,19 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.Channel +import org.meshtastic.core.model.util.hasDuplicateKeys import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.accept import org.meshtastic.core.strings.add import org.meshtastic.core.strings.cancel +import org.meshtastic.core.strings.channel_key_already_in_use import org.meshtastic.core.strings.new_channel_rcvd import org.meshtastic.core.strings.replace import org.meshtastic.core.ui.component.ChannelSelection +import org.meshtastic.core.ui.util.showToast import org.meshtastic.proto.AppOnlyProtos.ChannelSet import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig.ModemPreset import org.meshtastic.proto.channelSet @@ -290,10 +296,16 @@ fun ScannedQrCodeDialog( ) } + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() TextButton( onClick = { - onDismiss() - onConfirm(selectedChannelSet) + if (selectedChannelSet.hasDuplicateKeys()) { + coroutineScope.launch { context.showToast(Res.string.channel_key_already_in_use) } + } else { + onDismiss() + onConfirm(selectedChannelSet) + } }, enabled = selectedChannelSet.settingsCount in 1..8, ) { diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt index 1e20496fc..addad471c 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelConfigScreen.kt @@ -37,27 +37,33 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.Channel import org.meshtastic.core.model.DeviceVersion +import org.meshtastic.core.model.util.hasDuplicateKeys import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.add import org.meshtastic.core.strings.cancel +import org.meshtastic.core.strings.channel_key_already_in_use import org.meshtastic.core.strings.channel_name import org.meshtastic.core.strings.channels import org.meshtastic.core.strings.press_and_drag @@ -67,6 +73,7 @@ import org.meshtastic.core.ui.component.PreferenceFooter import org.meshtastic.core.ui.component.dragContainer import org.meshtastic.core.ui.component.dragDropItemsIndexed import org.meshtastic.core.ui.component.rememberDragDropState +import org.meshtastic.core.ui.util.showToast import org.meshtastic.feature.settings.radio.RadioConfigViewModel import org.meshtastic.feature.settings.radio.channel.component.ChannelCard import org.meshtastic.feature.settings.radio.channel.component.ChannelConfigHeader @@ -75,6 +82,7 @@ import org.meshtastic.feature.settings.radio.channel.component.ChannelLegendDial import org.meshtastic.feature.settings.radio.channel.component.EditChannelDialog import org.meshtastic.feature.settings.radio.channel.component.SECONDARY_CHANNEL_EPOCH import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog +import org.meshtastic.proto.AppOnlyProtos import org.meshtastic.proto.ChannelProtos.ChannelSettings import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig import org.meshtastic.proto.channelSettings @@ -135,21 +143,69 @@ private fun ChannelConfigScreen( settingsList.size != settingsListInput.size || settingsList.zip(settingsListInput).any { (item1, item2) -> item1 != item2 } + // Check if the current channel list has duplicate PSKs - recompute when list changes + var hasDuplicateKeys by remember { mutableStateOf(false) } + LaunchedEffect(settingsListInput.size, settingsListInput.toList(), loraConfig) { + val channelSet = + AppOnlyProtos.ChannelSet.newBuilder() + .apply { + addAllSettings(settingsListInput.toList()) + setLoraConfig(loraConfig) + } + .build() + hasDuplicateKeys = channelSet.hasDuplicateKeys() + } + var showEditChannelDialog: Int? by rememberSaveable { mutableStateOf(null) } var showChannelLegendDialog by rememberSaveable { mutableStateOf(false) } + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + if (showEditChannelDialog != null) { val index = showEditChannelDialog ?: return EditChannelDialog( channelSettings = with(settingsListInput) { if (size > index) get(index) else channelSettings {} }, modemPresetName = modemPresetName, - onAddClick = { - if (settingsListInput.size > index) { - settingsListInput[index] = it + onAddClick = { newChannelSettings -> + val isEditing = index < settingsListInput.size + + // Build a temporary list to check for duplicates + val tempList = settingsListInput.toMutableList() + if (isEditing) { + tempList[index] = newChannelSettings } else { - settingsListInput.add(it) + tempList.add(newChannelSettings) + } + + // Check for duplicates in the temporary list + val tempChannelSet = + AppOnlyProtos.ChannelSet.newBuilder() + .apply { + addAllSettings(tempList) + setLoraConfig(loraConfig) + } + .build() + + val hasDuplicate = tempChannelSet.hasDuplicateKeys() + + if (hasDuplicate) { + coroutineScope.launch { context.showToast(Res.string.channel_key_already_in_use) } + // If this was a new channel added by FAB (at the end), remove it + // FAB adds channel then opens dialog, so index will be lastIndex + if (index == settingsListInput.size - 1 && index >= 0) { + settingsListInput.removeAt(index) + showEditChannelDialog = null + } + // If editing existing channel, don't update - keep original and dialog open + } else { + if (isEditing) { + settingsListInput[index] = newChannelSettings + } else { + settingsListInput.add(newChannelSettings) + } + showEditChannelDialog = null } - showEditChannelDialog = null }, onDismissRequest = { showEditChannelDialog = null }, ) @@ -238,7 +294,7 @@ private fun ChannelConfigScreen( item { Spacer(modifier = Modifier.weight(1f)) } item { PreferenceFooter( - enabled = enabled && isEditing, + enabled = enabled && isEditing && !hasDuplicateKeys, negativeText = stringResource(Res.string.cancel), onNegativeClicked = { focusManager.clearFocus() @@ -248,7 +304,11 @@ private fun ChannelConfigScreen( positiveText = stringResource(Res.string.send), onPositiveClicked = { focusManager.clearFocus() - onPositiveClicked(settingsListInput) + if (hasDuplicateKeys) { + coroutineScope.launch { context.showToast(Res.string.channel_key_already_in_use) } + } else { + onPositiveClicked(settingsListInput) + } }, ) }