mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-02-06 13:53:02 -05:00
Feat/2334 channel indicators (#2356)
Signed-off-by: DaneEvans <dane@goneepic.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -176,8 +176,7 @@ data class Contact(
|
||||
val messageCount: Int,
|
||||
val isMuted: Boolean,
|
||||
val isUnmessageable: Boolean,
|
||||
val nodeColors: Pair<Int, Int>? = null,
|
||||
val isDefaultPSK: Boolean? = false
|
||||
val nodeColors: Pair<Int, Int>? = null
|
||||
)
|
||||
|
||||
@Suppress("LongParameterList", "LargeClass")
|
||||
@@ -520,15 +519,6 @@ class UIViewModel @Inject constructor(
|
||||
} else {
|
||||
user.longName
|
||||
}
|
||||
val isDefaultPSK = if (toBroadcast) {
|
||||
val _channel = channelSet.getChannel(data.channel)
|
||||
val isDefaultPSK = (_channel?.settings?.psk?.size() == 1 &&
|
||||
_channel.settings.psk.byteAt(0) == 1.toByte()) ||
|
||||
_channel?.settings?.psk?.toByteArray()?.isEmpty() == true
|
||||
isDefaultPSK
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
Contact(
|
||||
contactKey = contactKey,
|
||||
@@ -544,8 +534,7 @@ class UIViewModel @Inject constructor(
|
||||
node.colors
|
||||
} else {
|
||||
null
|
||||
},
|
||||
isDefaultPSK = isDefaultPSK
|
||||
}
|
||||
)
|
||||
}
|
||||
}.stateIn(
|
||||
|
||||
@@ -506,7 +506,7 @@ private fun MainMenuActions(
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = "Overflow menu",
|
||||
contentDescription = stringResource(R.string.overflow_menu),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ fun IndoorAirQuality(iaq: Int, displayMode: IaqDisplayMode = IaqDisplayMode.Pill
|
||||
)
|
||||
Icon(
|
||||
imageVector = if (iaq < 100) Icons.Default.ThumbUp else Icons.Filled.Warning,
|
||||
contentDescription = "AQI Icon",
|
||||
contentDescription = stringResource(R.string.air_quality_icon),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ fun ScannedQrCodeDialog(
|
||||
)
|
||||
}
|
||||
itemsIndexed(channelSet.settingsList) { index, channel ->
|
||||
val channelObj = Channel(channel, channelSet.loraConfig)
|
||||
ChannelSelection(
|
||||
index = index,
|
||||
title = channel.name.ifEmpty { modemPresetName },
|
||||
@@ -146,6 +147,7 @@ fun ScannedQrCodeDialog(
|
||||
channelSelections[index] = it
|
||||
}
|
||||
},
|
||||
channel = channelObj,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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 com.geeksville.mesh.ui.common.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.AppOnlyProtos
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.model.Channel
|
||||
import com.geeksville.mesh.model.getChannel
|
||||
|
||||
private const val PRECISE_POSITION_BITS = 32
|
||||
|
||||
/**
|
||||
* Returns the appropriate security icon composable based on the channel's security settings.
|
||||
*
|
||||
* @param isLowEntropyKey Whether the channel uses a low entropy key (0 or 1 byte PSK)
|
||||
* @param isPreciseLocation Whether the channel has precise location enabled (32 bits)
|
||||
* @param isMqttEnabled Whether MQTT is enabled (adds warning icon)
|
||||
* @param contentDescription The content description for the icon
|
||||
* @return A composable Icon element with appropriate imageVector and tint
|
||||
*/
|
||||
@Composable
|
||||
fun SecurityIcon(
|
||||
isLowEntropyKey: Boolean,
|
||||
isPreciseLocation: Boolean = false,
|
||||
isMqttEnabled: Boolean = false,
|
||||
contentDescription: String = stringResource(id = R.string.security_icon_description)
|
||||
) {
|
||||
val (icon, color, computedDescription) = when {
|
||||
!isLowEntropyKey -> {
|
||||
Triple(Icons.Default.Lock, Color.Green, stringResource(id = R.string.security_icon_secure))
|
||||
}
|
||||
isPreciseLocation && isMqttEnabled -> {
|
||||
Triple(Icons.Default.Warning, Color.Red, stringResource(id = R.string.security_icon_warning))
|
||||
}
|
||||
isPreciseLocation -> {
|
||||
Triple(ImageVector.vectorResource(R.drawable.ic_lock_open_right_24),
|
||||
Color.Red,
|
||||
stringResource(id = R.string.security_icon_insecure_precise))
|
||||
}
|
||||
else -> {
|
||||
Triple(ImageVector.vectorResource(R.drawable.ic_lock_open_right_24),
|
||||
Color.Yellow,
|
||||
stringResource(id = R.string.security_icon_insecure))
|
||||
}
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription + computedDescription,
|
||||
tint = color
|
||||
)
|
||||
}
|
||||
|
||||
fun Channel.isLowEntropyKey(): Boolean = settings.psk.size() <= 1
|
||||
fun Channel.isPreciseLocation(): Boolean = settings.getModuleSettings().positionPrecision == PRECISE_POSITION_BITS
|
||||
fun Channel.isMqttEnabled(): Boolean = settings.uplinkEnabled
|
||||
|
||||
@Composable
|
||||
fun SecurityIcon(
|
||||
channel: Channel,
|
||||
contentDescription: String = stringResource(id = R.string.security_icon_description)
|
||||
) = SecurityIcon(
|
||||
channel.isLowEntropyKey(),
|
||||
channel.isPreciseLocation(),
|
||||
channel.isMqttEnabled(),
|
||||
contentDescription
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SecurityIcon(
|
||||
channelSet: AppOnlyProtos.ChannelSet,
|
||||
channelIndex: Int,
|
||||
contentDescription: String = stringResource(id = R.string.security_icon_description)
|
||||
) {
|
||||
val channel = channelSet.getChannel(channelIndex) ?: return
|
||||
SecurityIcon(channel, contentDescription)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SecurityIcon(
|
||||
channelSet: AppOnlyProtos.ChannelSet,
|
||||
channelName: String,
|
||||
contentDescription: String = stringResource(id = R.string.security_icon_description)
|
||||
) {
|
||||
val channel = channelSet.settingsList.find {
|
||||
Channel(it, channelSet.loraConfig).name == channelName
|
||||
}?.let { Channel(it, channelSet.loraConfig) } ?: return
|
||||
SecurityIcon(channel, contentDescription)
|
||||
}
|
||||
|
||||
// Preview functions for development and testing
|
||||
@Preview(name = "Secure Channel - Green Lock")
|
||||
@Composable
|
||||
private fun PreviewSecureChannel() {
|
||||
SecurityIcon(
|
||||
isLowEntropyKey = false,
|
||||
isPreciseLocation = false,
|
||||
isMqttEnabled = false
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(name = "Insecure Channel with Precise Location - Red Unlock")
|
||||
@Composable
|
||||
private fun PreviewInsecureChannelWithPreciseLocation() {
|
||||
SecurityIcon(
|
||||
isLowEntropyKey = true,
|
||||
isPreciseLocation = true,
|
||||
isMqttEnabled = false
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(name = "Insecure Channel without Precise Location - Yellow Unlock")
|
||||
@Composable
|
||||
private fun PreviewInsecureChannelWithoutPreciseLocation() {
|
||||
SecurityIcon(
|
||||
isLowEntropyKey = true,
|
||||
isPreciseLocation = false,
|
||||
isMqttEnabled = false
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(name = "MQTT Enabled - Red Warning")
|
||||
@Composable
|
||||
private fun PreviewMqttEnabled() {
|
||||
SecurityIcon(
|
||||
isLowEntropyKey = false,
|
||||
isPreciseLocation = false,
|
||||
isMqttEnabled = true
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(name = "All Security Icons")
|
||||
@Composable
|
||||
private fun PreviewAllSecurityIcons() {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
"Security Icons Preview",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
SecurityIcon(
|
||||
isLowEntropyKey = false,
|
||||
isPreciseLocation = false,
|
||||
isMqttEnabled = false
|
||||
)
|
||||
Text("Secure")
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
SecurityIcon(
|
||||
isLowEntropyKey = true,
|
||||
isPreciseLocation = true,
|
||||
isMqttEnabled = false
|
||||
)
|
||||
Text("Insecure + Precise Location")
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
SecurityIcon(
|
||||
isLowEntropyKey = true,
|
||||
isPreciseLocation = false,
|
||||
isMqttEnabled = false
|
||||
)
|
||||
Text("Insecure")
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
SecurityIcon(
|
||||
isLowEntropyKey = false,
|
||||
isPreciseLocation = false,
|
||||
isMqttEnabled = true
|
||||
)
|
||||
Text("MQTT Enabled")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,16 +42,16 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.AppOnlyProtos
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.model.Contact
|
||||
import com.geeksville.mesh.ui.common.theme.AppTheme
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import com.geeksville.mesh.ui.common.components.SecurityIcon
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
@@ -61,6 +61,7 @@ fun ContactItem(
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit = {},
|
||||
onLongClick: () -> Unit = {},
|
||||
channels: AppOnlyProtos.ChannelSet? = null,
|
||||
) = with(contact) {
|
||||
Card(
|
||||
modifier = modifier
|
||||
@@ -111,28 +112,29 @@ fun ContactItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = longName,
|
||||
)
|
||||
Text(
|
||||
text = longName,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// Show unlock icon for broadcast with default PSK
|
||||
val isBroadcast = contact.contactKey.getOrNull(1) == '^' ||
|
||||
contact.contactKey.endsWith("^all") ||
|
||||
contact.contactKey.endsWith("^broadcast")
|
||||
|
||||
if (isBroadcast && isDefaultPSK == true) {
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_lock_open_right_24),
|
||||
contentDescription = "Unlocked"
|
||||
)
|
||||
if (isBroadcast && channels != null) {
|
||||
val channelIndex = contact.contactKey[0].digitToIntOrNull()
|
||||
channelIndex?.let { index ->
|
||||
SecurityIcon(channels, index)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text(
|
||||
text = lastMessageTime.orEmpty(),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
modifier = Modifier.width(80.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = lastMessageTime.orEmpty(),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -58,6 +58,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.AppOnlyProtos
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.model.Contact
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
@@ -136,12 +137,14 @@ fun ContactsScreen(
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
val channels by uiViewModel.channels.collectAsStateWithLifecycle()
|
||||
ContactListView(
|
||||
contacts = contacts,
|
||||
selectedList = selectedContactKeys,
|
||||
onClick = onContactClick,
|
||||
onLongClick = onContactLongClick,
|
||||
contentPadding = paddingValues
|
||||
contentPadding = paddingValues,
|
||||
channels = channels
|
||||
)
|
||||
}
|
||||
DeleteConfirmationDialog(
|
||||
@@ -302,7 +305,7 @@ fun SelectionToolbar(
|
||||
title = { Text(text = "$selectedCount") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onCloseSelection) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Close selection")
|
||||
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.close_selection))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
@@ -321,10 +324,10 @@ fun SelectionToolbar(
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDeleteSelected) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Delete selected")
|
||||
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.delete_selection))
|
||||
}
|
||||
IconButton(onClick = onSelectAll) {
|
||||
Icon(Icons.Default.SelectAll, contentDescription = "Select all")
|
||||
Icon(Icons.Default.SelectAll, contentDescription = stringResource(R.string.select_all))
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -336,7 +339,8 @@ fun ContactListView(
|
||||
selectedList: List<String>,
|
||||
onClick: (Contact) -> Unit,
|
||||
onLongClick: (Contact) -> Unit,
|
||||
contentPadding: PaddingValues
|
||||
contentPadding: PaddingValues,
|
||||
channels: AppOnlyProtos.ChannelSet? = null
|
||||
) {
|
||||
val haptics = LocalHapticFeedback.current
|
||||
LazyColumn(
|
||||
@@ -355,6 +359,7 @@ fun ContactListView(
|
||||
onLongClick(contact)
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
},
|
||||
channels = channels
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,7 +494,7 @@ private fun DebugMenuActionsPreview() {
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.FileDownload,
|
||||
contentDescription = "Export Logs"
|
||||
contentDescription = stringResource(id = R.string.debug_logs_export)
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
@@ -503,7 +503,7 @@ private fun DebugMenuActionsPreview() {
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "Clear All"
|
||||
contentDescription = stringResource(id = R.string.debug_clear)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -559,7 +559,7 @@ private fun DebugScreenEmptyPreview() {
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.FilterAltOff,
|
||||
contentDescription = "Filter"
|
||||
contentDescription = stringResource(id = R.string.debug_filters)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -709,7 +709,7 @@ fun DebugMenuActions(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.FileDownload,
|
||||
contentDescription = "Export Logs"
|
||||
contentDescription = stringResource(id = R.string.debug_logs_export)
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
@@ -718,7 +718,7 @@ fun DebugMenuActions(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "Clear All"
|
||||
contentDescription = stringResource(id = R.string.debug_clear)
|
||||
)
|
||||
}
|
||||
if (showDeleteLogsDialog) {
|
||||
|
||||
@@ -101,7 +101,7 @@ fun DebugCustomFilterInput(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = "Add filter"
|
||||
contentDescription = stringResource(id = R.string.debug_filter_add)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,7 @@ internal fun DebugPresetFilters(
|
||||
leadingIcon = { if (filter in filterTexts) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Done,
|
||||
contentDescription = "Done icon",
|
||||
contentDescription = stringResource(id = R.string.debug_filter_included),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -194,7 +194,7 @@ internal fun DebugFilterBar(
|
||||
} else {
|
||||
Icons.TwoTone.FilterAltOff
|
||||
},
|
||||
contentDescription = "Filter"
|
||||
contentDescription = stringResource(id = R.string.debug_filters)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -270,7 +270,7 @@ internal fun DebugActiveFilters(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Clear,
|
||||
contentDescription = "Clear all filters"
|
||||
contentDescription = stringResource(id = R.string.debug_filter_clear)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ internal fun DebugSearchNavigation(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyboardArrowUp,
|
||||
contentDescription = "Previous match",
|
||||
contentDescription = stringResource(R.string.debug_search_prev),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
@@ -94,7 +94,7 @@ internal fun DebugSearchNavigation(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = "Next match",
|
||||
contentDescription = stringResource(R.string.debug_search_next),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
@@ -143,7 +143,7 @@ internal fun DebugSearchBar(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Clear,
|
||||
contentDescription = "Clear search",
|
||||
contentDescription = stringResource(R.string.debug_search_clear),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,7 +77,6 @@ import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
@@ -87,6 +86,7 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.AppOnlyProtos
|
||||
import com.geeksville.mesh.DataPacket
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.database.entity.QuickChatAction
|
||||
@@ -99,7 +99,7 @@ import com.geeksville.mesh.ui.node.components.NodeKeyStatusIcon
|
||||
import com.geeksville.mesh.ui.node.components.NodeMenuAction
|
||||
import com.geeksville.mesh.ui.sharing.SharedContactDialog
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import com.geeksville.mesh.ui.common.components.SecurityIcon
|
||||
|
||||
private const val MESSAGE_CHARACTER_LIMIT = 200
|
||||
private const val SNIPPET_CHARACTER_LIMIT = 50
|
||||
@@ -123,22 +123,18 @@ internal fun MessageScreen(
|
||||
val channelIndex = contactKey[0].digitToIntOrNull()
|
||||
val nodeId = contactKey.substring(1)
|
||||
val channels by viewModel.channels.collectAsStateWithLifecycle()
|
||||
val channelName by remember(channelIndex) {
|
||||
val unknownChannelText = stringResource(id = R.string.unknown_channel)
|
||||
val channelName by remember(channelIndex, unknownChannelText) {
|
||||
derivedStateOf {
|
||||
channelIndex?.let {
|
||||
val channel = channels.getChannel(it)
|
||||
val name = channel?.name ?: "Unknown Channel"
|
||||
// Check if PSK is the default (base64 'AQ==', i.e., single byte 0x01)
|
||||
val isDefaultPSK = (channel?.settings?.psk?.size() == 1 &&
|
||||
channel.settings.psk.byteAt(0) == 1.toByte()) ||
|
||||
channel?.psk?.toByteArray()?.isEmpty() == true
|
||||
Pair(name, isDefaultPSK)
|
||||
} ?: Pair("Unknown Channel", false)
|
||||
channel?.name ?: unknownChannelText
|
||||
} ?: unknownChannelText
|
||||
}
|
||||
}
|
||||
val (channelTitle, isDefaultPsk) = channelName
|
||||
|
||||
val title = when (nodeId) {
|
||||
DataPacket.ID_BROADCAST -> channelTitle
|
||||
DataPacket.ID_BROADCAST -> channelName
|
||||
else -> viewModel.getUser(nodeId).longName
|
||||
}
|
||||
viewModel.setTitle(title)
|
||||
@@ -212,8 +208,7 @@ internal fun MessageScreen(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
MessageTopBar(title, channelIndex, mismatchKey, onNavigateBack, isDefaultPsk = isDefaultPsk
|
||||
)
|
||||
MessageTopBar(title, channelIndex, mismatchKey, onNavigateBack, channels, channelIndex)
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
@@ -455,20 +450,18 @@ private fun MessageTopBar(
|
||||
channelIndex: Int?,
|
||||
mismatchKey: Boolean = false,
|
||||
onNavigateBack: () -> Unit,
|
||||
isDefaultPsk: Boolean = false
|
||||
channels: AppOnlyProtos.ChannelSet,
|
||||
channelIndexParam: Int?,
|
||||
) = TopAppBar(
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = title)
|
||||
if (isDefaultPsk
|
||||
) {
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_lock_open_right_24),
|
||||
contentDescription = "Unlocked"
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
|
||||
channelIndexParam?.let { index ->
|
||||
SecurityIcon(channels, index)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
|
||||
@@ -255,7 +255,7 @@ private fun NavButton(@StringRes title: Int, enabled: Boolean, onClick: () -> Un
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Warning,
|
||||
contentDescription = "warning",
|
||||
contentDescription = stringResource(id = R.string.warning),
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
Text(
|
||||
@@ -263,7 +263,7 @@ private fun NavButton(@StringRes title: Int, enabled: Boolean, onClick: () -> Un
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Warning,
|
||||
contentDescription = "warning",
|
||||
contentDescription = stringResource(id = R.string.warning),
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,7 +62,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -80,7 +79,7 @@ import com.geeksville.mesh.ui.common.components.dragContainer
|
||||
import com.geeksville.mesh.ui.common.components.dragDropItemsIndexed
|
||||
import com.geeksville.mesh.ui.common.components.rememberDragDropState
|
||||
import com.geeksville.mesh.ui.radioconfig.RadioConfigViewModel
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import com.geeksville.mesh.ui.common.components.SecurityIcon
|
||||
|
||||
@Composable
|
||||
private fun ChannelItem(
|
||||
@@ -125,20 +124,15 @@ fun ChannelCard(
|
||||
enabled: Boolean,
|
||||
onEditClick: () -> Unit,
|
||||
onDeleteClick: () -> Unit,
|
||||
isDefaultPSK: Boolean = false,
|
||||
channel: Channel,
|
||||
) = ChannelItem(
|
||||
index = index,
|
||||
title = title,
|
||||
enabled = enabled,
|
||||
onClick = onEditClick,
|
||||
) {
|
||||
if (isDefaultPSK) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_lock_open_right_24),
|
||||
contentDescription = "Unlocked"
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
}
|
||||
SecurityIcon(channel)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
IconButton(onClick = { onDeleteClick() }) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Close,
|
||||
@@ -155,19 +149,15 @@ fun ChannelSelection(
|
||||
enabled: Boolean,
|
||||
isSelected: Boolean,
|
||||
onSelected: (Boolean) -> Unit,
|
||||
isDefaultPSK: Boolean = false,
|
||||
channel: Channel,
|
||||
) = ChannelItem(
|
||||
index = index,
|
||||
title = title,
|
||||
enabled = enabled,
|
||||
onClick = {},
|
||||
) {
|
||||
if (isDefaultPSK) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_lock_open_right_24),
|
||||
contentDescription = "Unlocked"
|
||||
)
|
||||
}
|
||||
SecurityIcon(channel)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Checkbox(
|
||||
enabled = enabled,
|
||||
checked = isSelected,
|
||||
@@ -292,15 +282,14 @@ fun ChannelSettingsItemList(
|
||||
items = settingsListInput,
|
||||
dragDropState = dragDropState,
|
||||
) { index, channel, isDragging ->
|
||||
val isDefaultPSK = (channel.psk.size() == 1 && channel.psk.byteAt(0) == 1.toByte()) ||
|
||||
channel.psk.toByteArray().isEmpty()
|
||||
val channelObj = Channel(channel, loraConfig)
|
||||
ChannelCard(
|
||||
index = index,
|
||||
title = channel.name.ifEmpty { primaryChannel.name },
|
||||
enabled = enabled,
|
||||
onEditClick = { showEditChannelDialog = index },
|
||||
onDeleteClick = { settingsListInput.removeAt(index) },
|
||||
isDefaultPSK = isDefaultPSK
|
||||
channel = channelObj
|
||||
)
|
||||
if (index == 0 && !isDragging) {
|
||||
Text(
|
||||
|
||||
@@ -448,8 +448,7 @@ private fun ChannelListView(
|
||||
AdaptiveTwoPane(
|
||||
first = {
|
||||
channelSet.settingsList.forEachIndexed { index, channel ->
|
||||
val isDefaultPSK = (channel.psk.size() == 1 && channel.psk.byteAt(0) == 1.toByte()) ||
|
||||
channel.psk.toByteArray().isEmpty()
|
||||
val channelObj = Channel(channel, channelSet.loraConfig)
|
||||
val displayTitle = channel.name.ifEmpty { modemPresetName }
|
||||
|
||||
ChannelSelection(
|
||||
@@ -462,7 +461,7 @@ private fun ChannelListView(
|
||||
channelSelections[index] = it
|
||||
}
|
||||
},
|
||||
isDefaultPSK = isDefaultPSK
|
||||
channel = channelObj
|
||||
)
|
||||
}
|
||||
OutlinedButton(
|
||||
|
||||
@@ -167,6 +167,12 @@
|
||||
<string name="debug_filters">Filters</string>
|
||||
<string name="debug_active_filters">Active filters</string>
|
||||
<string name="debug_default_search">Search in logs…</string>
|
||||
<string name="debug_search_next">Next match</string>
|
||||
<string name="debug_search_prev">Previous match</string>
|
||||
<string name="debug_search_clear">Clear search</string>
|
||||
<string name="debug_filter_add">Add filter</string>
|
||||
<string name="debug_filter_included">Filter included</string>
|
||||
<string name="debug_filter_clear">Clear all filters</string>
|
||||
<string name="debug_clear">Clear Logs</string>
|
||||
<string name="match_any">Match Any | All</string>
|
||||
<string name="match_all">Match All | Any</string>
|
||||
@@ -219,6 +225,8 @@
|
||||
<string name="delete_for_everyone">Delete for everyone</string>
|
||||
<string name="delete_for_me">Delete for me</string>
|
||||
<string name="select_all">Select all</string>
|
||||
<string name="close_selection">Close selection</string>
|
||||
<string name="delete_selection">Delete selected</string>
|
||||
<string name="modem_config_slow_long">Long Range / Slow</string>
|
||||
<string name="map_style_selection">Style Selection</string>
|
||||
<string name="map_download_region">Download Region</string>
|
||||
@@ -604,6 +612,7 @@
|
||||
<string name="environment_metrics_use_fahrenheit">Environment metrics use Fahrenheit</string>
|
||||
<string name="air_quality_metrics_module_enabled">Air quality metrics module enabled</string>
|
||||
<string name="air_quality_metrics_update_interval_seconds">Air quality metrics update interval (seconds)</string>
|
||||
<string name="air_quality_icon">Air quality icon</string>
|
||||
<string name="power_metrics_module_enabled">Power metrics module enabled</string>
|
||||
<string name="power_metrics_update_interval_seconds">Power metrics update interval (seconds)</string>
|
||||
<string name="power_metrics_on_screen_enabled">Power metrics on-screen enabled</string>
|
||||
@@ -711,4 +720,12 @@
|
||||
<string name="app_intro_next_button">Next</string>
|
||||
<string name="app_intro_done_button">Done</string>
|
||||
<string name="app_intro_skip_button">Skip</string>
|
||||
<string name="security_icon_description">Security Status</string>
|
||||
<string name="security_icon_secure"> - Secure</string>
|
||||
<string name="security_icon_warning"> - WARN, insecure MQTT</string>
|
||||
<string name="security_icon_insecure"> - WARN, low entropy key</string>
|
||||
<string name="security_icon_insecure_precise"> - WARNING, insecure location enabled</string>
|
||||
<string name="unknown_channel">Unknown Channel</string>
|
||||
<string name="warning">Warning</string>
|
||||
<string name="overflow_menu">Overflow menu</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user