diff --git a/core/strings/src/main/res/values/strings.xml b/core/strings/src/main/res/values/strings.xml index 8ab0db0d2..356181296 100644 --- a/core/strings/src/main/res/values/strings.xml +++ b/core/strings/src/main/res/values/strings.xml @@ -354,6 +354,7 @@ Utilization for the current channel, including well formed TX, RX and malformed RX (aka noise). Percent of airtime for transmission used within the last hour. IAQ + Encryption Key Meanings Shared Key Only channel messages can be sent/received. Direct Messages require the Public Key Infrastructure feature in 2.5+ firmware. Public Key Encryption diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/NodeKeyStatusIcon.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/NodeKeyStatusIcon.kt index 33cf89e6f..2b82961ba 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/NodeKeyStatusIcon.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/NodeKeyStatusIcon.kt @@ -20,40 +20,45 @@ package org.meshtastic.core.ui.component import android.util.Base64 import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyOff import androidx.compose.material.icons.filled.Lock -import androidx.compose.material3.ButtonDefaults +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog import com.google.protobuf.ByteString import org.meshtastic.core.model.Channel import org.meshtastic.core.strings.R @@ -62,56 +67,19 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusGreen import org.meshtastic.core.ui.theme.StatusColors.StatusRed import org.meshtastic.core.ui.theme.StatusColors.StatusYellow -@Composable -private fun KeyStatusDialog(@StringRes title: Int, @StringRes text: Int, key: ByteString?, onDismiss: () -> Unit = {}) = - Dialog(onDismissRequest = onDismiss) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - color = MaterialTheme.colorScheme.background, - ) { - LazyColumn( - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - item { - Text(text = stringResource(id = title), textAlign = TextAlign.Center) - Spacer(Modifier.height(16.dp)) - Text(text = stringResource(id = text), textAlign = TextAlign.Center) - Spacer(Modifier.height(16.dp)) - if (key != null && title == R.string.encryption_pkc) { - val keyString = Base64.encodeToString(key.toByteArray(), Base64.NO_WRAP) - Text( - text = stringResource(id = R.string.config_security_public_key) + ":", - textAlign = TextAlign.Center, - ) - Spacer(Modifier.height(8.dp)) - SelectionContainer { Text(text = keyString, textAlign = TextAlign.Center) } - Spacer(Modifier.height(8.dp)) - CopyIconButton(valueToCopy = keyString, modifier = Modifier.padding(start = 8.dp)) - Spacer(Modifier.height(16.dp)) - } - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - TextButton( - onClick = onDismiss, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.onSurface, - ), - ) { - Text(text = stringResource(id = R.string.close)) - } - } - } - } - } - } - +/** + * function to display information about the current node's encryption key. + * + * @property hasPKC boolean if the node has public key encryption + * @property mismatchKey boolean if the public key does not match the recorded key. + * @property publicKey boolean if the node has a shared public key. + */ @Composable fun NodeKeyStatusIcon( + modifier: Modifier = Modifier, hasPKC: Boolean, mismatchKey: Boolean, publicKey: ByteString? = null, - modifier: Modifier = Modifier, ) { var showEncryptionDialog by remember { mutableStateOf(false) } if (showEncryptionDialog) { @@ -150,6 +118,141 @@ fun NodeKeyStatusIcon( } } +/** + * Represents the various visual states of the node key as an enum. Each enum constant encapsulates the icon, color, + * descriptive text, and optional badge details. + * + * @property icon The primary vector graphic for the icon. + * @property color The tint color for the primary icon. + * @property descriptionResId The string resource ID for the accessibility description of the icon's state. + * @property helpTextResId The string resource ID for the detailed help text associated with this state. + * @property title The string resource ID for the title associated with this state. + */ +@Immutable +enum class NodeKeySecurityState( + @Stable val icon: ImageVector, + @Stable val color: @Composable () -> Color, + @StringRes val descriptionResId: Int, + @StringRes val helpTextResId: Int, + @Stable val title: Int, +) { + // State for public key mismatch + PKM( + icon = Icons.Default.KeyOff, + color = { colorScheme.StatusRed }, + descriptionResId = R.string.encryption_error, + helpTextResId = R.string.encryption_error_text, + title = R.string.encryption_error, + ), + + // State for public key encryption + PKC( + icon = Icons.Default.Lock, + color = { colorScheme.StatusGreen }, + title = R.string.encryption_pkc, + helpTextResId = R.string.encryption_pkc_text, + descriptionResId = R.string.encryption_pkc, + ), + + // State for shared key encryption + PSK( + icon = Icons.Default.LockOpen, + color = { colorScheme.StatusYellow }, + title = R.string.encryption_psk, + helpTextResId = R.string.encryption_psk_text, + descriptionResId = R.string.encryption_psk, + ), +} + +@Composable +private fun KeyStatusDialog(@StringRes title: Int, @StringRes text: Int, key: ByteString?, onDismiss: () -> Unit = {}) { + var showAll by rememberSaveable { mutableStateOf(false) } + AlertDialog( + modifier = Modifier, + onDismissRequest = onDismiss, + title = { + if (showAll) { + Text(stringResource(R.string.show_all_key_title)) + } else { + Text(stringResource(id = title)) + } + }, + text = { + if (showAll) { + AllKeyStates() + } else { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = stringResource(id = text), textAlign = TextAlign.Center) + Spacer(Modifier.height(16.dp)) + if (key != null && title == R.string.encryption_pkc) { + val keyString = Base64.encodeToString(key.toByteArray(), Base64.NO_WRAP) + Text( + text = stringResource(id = R.string.config_security_public_key) + ":", + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + SelectionContainer { Text(text = keyString, textAlign = TextAlign.Center) } + Spacer(Modifier.height(8.dp)) + CopyIconButton(valueToCopy = keyString, modifier = Modifier.padding(start = 8.dp)) + Spacer(Modifier.height(16.dp)) + } + } + } + }, + confirmButton = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = { showAll = !showAll }) { + Text( + if (showAll) { + stringResource(R.string.security_icon_help_show_less) + } else { + stringResource(R.string.security_icon_help_show_all) + }, + ) + } + TextButton(onClick = onDismiss) { Text(stringResource(R.string.security_icon_help_dismiss)) } + } + }, + ) +} + +/** + * Displays a list of all possible node key states with their icons and descriptions within the help dialog. Iterates + * over `NodeKeySecurityState.entries` which is provided by the enum class. + */ +@Composable +private fun AllKeyStates() { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.verticalScroll(rememberScrollState()), + ) { + NodeKeySecurityState.entries.forEach { state -> + // Uses enum entries + Row(verticalAlignment = Alignment.CenterVertically) { + when (state) { + NodeKeySecurityState.PKM -> NodeKeyStatusIcon(hasPKC = false, mismatchKey = true) + + NodeKeySecurityState.PKC -> NodeKeyStatusIcon(hasPKC = true, mismatchKey = false) + + else -> NodeKeyStatusIcon(hasPKC = false, mismatchKey = false) + } + + Column(modifier = Modifier.padding(start = 16.dp)) { + Text(text = stringResource(state.descriptionResId), style = MaterialTheme.typography.titleMedium) + Text(text = stringResource(state.helpTextResId), style = MaterialTheme.typography.bodyMedium) + } + } + if (state != NodeKeySecurityState.entries.lastOrNull()) { + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) + } + } + } +} + @PreviewLightDark @Composable private fun KeyStatusDialogErrorPreview() { @@ -173,3 +276,9 @@ private fun KeyStatusDialogPkcPreview() { private fun KeyStatusDialogPskPreview() { AppTheme { KeyStatusDialog(title = R.string.encryption_psk, text = R.string.encryption_psk_text, key = null) } } + +@Preview +@Composable +private fun AllKeyStatusDialogPreview() { + AppTheme { AllKeyStates() } +}