From dc9c325e1e8d2559140485df1f398fb729c0a665 Mon Sep 17 00:00:00 2001 From: Robert-0410 <62630290+Robert-0410@users.noreply.github.com> Date: Fri, 5 Sep 2025 06:14:32 -0700 Subject: [PATCH] Improvements to Channel management (#2935) Co-authored-by: DaneEvans Co-authored-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../mesh/ui/common/components/SecurityIcon.kt | 29 ++- .../radio/components/ChannelLegend.kt | 180 ++++++++++++++++++ .../components/ChannelSettingsItemList.kt | 119 ++++++++---- app/src/main/res/values/strings.xml | 7 + 4 files changed, 294 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/ui/settings/radio/components/ChannelLegend.kt diff --git a/app/src/main/java/com/geeksville/mesh/ui/common/components/SecurityIcon.kt b/app/src/main/java/com/geeksville/mesh/ui/common/components/SecurityIcon.kt index a95788346..e94588f12 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/common/components/SecurityIcon.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/common/components/SecurityIcon.kt @@ -15,6 +15,8 @@ * along with this program. If not, see . */ +@file:Suppress("TooManyFunctions") + package com.geeksville.mesh.ui.common.components import androidx.annotation.StringRes @@ -59,6 +61,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.geeksville.mesh.AppOnlyProtos +import com.geeksville.mesh.ChannelProtos.ChannelSettings +import com.geeksville.mesh.ConfigProtos.Config.LoRaConfig import com.geeksville.mesh.R import com.geeksville.mesh.model.Channel import com.geeksville.mesh.model.getChannel @@ -160,7 +164,7 @@ private fun SecurityIconDisplay( Icon( imageVector = badgeIcon, contentDescription = stringResource(R.string.security_icon_badge_warning_description), - tint = badgeIconColor ?: MaterialTheme.colorScheme.onError, // Default for contrast + tint = badgeIconColor ?: colorScheme.onError, // Default for contrast modifier = Modifier.size(16.dp), // Adjusted badge icon size ) } @@ -291,6 +295,29 @@ fun SecurityIcon( externalOnClick = externalOnClick, ) +/** + * Overload for [SecurityIcon] that enables recomposition when making changes to the [ChannelSettings]. + * + * @param baseContentDescription The base content description for the icon. + * @param externalOnClick Optional lambda for external actions, invoked when the icon is clicked. + */ +@Composable +fun SecurityIcon( + channelSettings: ChannelSettings, + loraConfig: LoRaConfig, + baseContentDescription: String = stringResource(id = R.string.security_icon_description), + externalOnClick: (() -> Unit)? = null, +) { + val channel = Channel(channelSettings, loraConfig) + SecurityIcon( + isLowEntropyKey = channel.isLowEntropyKey, + isPreciseLocation = channel.isPreciseLocation, + isMqttEnabled = channel.isMqttEnabled, + baseContentDescription = baseContentDescription, + externalOnClick = externalOnClick, + ) +} + /** * Overload for [SecurityIcon] that takes an [AppOnlyProtos.ChannelSet] and a channel index. If the channel at the given * index is not found, nothing is rendered. diff --git a/app/src/main/java/com/geeksville/mesh/ui/settings/radio/components/ChannelLegend.kt b/app/src/main/java/com/geeksville/mesh/ui/settings/radio/components/ChannelLegend.kt new file mode 100644 index 000000000..37255780a --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/settings/radio/components/ChannelLegend.kt @@ -0,0 +1,180 @@ +/* + * 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 . + */ + +package com.geeksville.mesh.ui.settings.radio.components + +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.CloudUpload +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.R +import com.geeksville.mesh.model.DeviceVersion + +/** + * At this firmware version periodic position sharing on a secondary channel was implemented. To enable this feature the + * user must disable position on the primary channel and enable on a secondary channel. The lowest indexed secondary + * channel with the position enabled will conduct the automatic position broadcasts. + */ +internal const val SECONDARY_CHANNEL_EPOCH = "2.6.10" + +internal enum class ChannelIcons( + val icon: ImageVector, + @StringRes val descriptionResId: Int, + @StringRes val additionalInfoResId: Int, +) { + LOCATION( + icon = Icons.Filled.LocationOn, + descriptionResId = R.string.location_sharing, + additionalInfoResId = R.string.periodic_position_broadcast, + ), + UPLINK( + icon = Icons.Filled.CloudUpload, + descriptionResId = R.string.uplink_enabled, + additionalInfoResId = R.string.uplink_feature_description, + ), + DOWNLINK( + icon = Icons.Filled.CloudDownload, + descriptionResId = R.string.downlink_enabled, + additionalInfoResId = R.string.downlink_feature_description, + ), +} + +@Composable +internal fun ChannelLegend(onClick: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().clickable { onClick.invoke() }, + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Row { + Icon(imageVector = Icons.Filled.Info, contentDescription = stringResource(R.string.info)) + Text( + text = stringResource(R.string.primary), + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 16.dp), + ) + } + Text( + text = stringResource(R.string.secondary), + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(start = 16.dp), + ) + } +} + +@Composable +internal fun ChannelLegendDialog(firmwareVersion: DeviceVersion, onDismiss: () -> Unit) { + AlertDialog( + modifier = Modifier.fillMaxSize(), + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.channel_features)) }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.verticalScroll(rememberScrollState()), + ) { + Text( + text = stringResource(R.string.primary), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = "- ${stringResource(R.string.primary_channel_feature)}", + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = stringResource(R.string.secondary), + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = "- ${stringResource(R.string.secondary_no_telemetry)}", + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = + if (firmwareVersion >= DeviceVersion(asString = SECONDARY_CHANNEL_EPOCH)) { + /* 2.6.10+ */ + "- ${stringResource(R.string.secondary_channel_position_feature)}" + } else { + "- ${stringResource(R.string.manual_position_request)}" + }, + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.bodyMedium, + ) + IconDefinitions() + } + }, + confirmButton = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.security_icon_help_dismiss)) } + } + }, + ) +} + +@Composable +private fun IconDefinitions() { + Text(text = stringResource(R.string.icon_meanings), style = MaterialTheme.typography.titleLarge) + ChannelIcons.entries.forEach { icon -> + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(imageVector = icon.icon, contentDescription = stringResource(icon.descriptionResId)) + Column(modifier = Modifier.padding(start = 16.dp)) { + Text(text = stringResource(icon.descriptionResId), style = MaterialTheme.typography.titleMedium) + Text(text = stringResource(icon.additionalInfoResId), style = MaterialTheme.typography.bodyMedium) + } + } + if (icon != ChannelIcons.entries.lastOrNull()) { + HorizontalDivider(modifier = Modifier.padding(top = 8.dp)) + } + } +} + +@Preview +@Composable +private fun PreviewChannelLegendDialog() { + ChannelLegendDialog(firmwareVersion = DeviceVersion(asString = SECONDARY_CHANNEL_EPOCH)) {} +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/settings/radio/components/ChannelSettingsItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/settings/radio/components/ChannelSettingsItemList.kt index 2115bc6b6..01b5e2769 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/settings/radio/components/ChannelSettingsItemList.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/settings/radio/components/ChannelSettingsItemList.kt @@ -32,7 +32,6 @@ import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize @@ -59,6 +58,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource @@ -73,6 +73,7 @@ import com.geeksville.mesh.ConfigProtos.Config.LoRaConfig import com.geeksville.mesh.R import com.geeksville.mesh.channelSettings import com.geeksville.mesh.model.Channel +import com.geeksville.mesh.model.DeviceVersion import com.geeksville.mesh.ui.common.components.PreferenceCategory import com.geeksville.mesh.ui.common.components.PreferenceFooter import com.geeksville.mesh.ui.common.components.SecurityIcon @@ -89,18 +90,20 @@ private fun ChannelItem( onClick: () -> Unit = {}, content: @Composable RowScope.() -> Unit, ) { + val fontColor = if (index == 0) MaterialTheme.colorScheme.primary else Color.Unspecified Card(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp).clickable(enabled = enabled) { onClick() }) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 4.dp, horizontal = 4.dp), ) { - AssistChip(onClick = onClick, label = { Text(text = "$index") }) + AssistChip(onClick = onClick, label = { Text(text = "$index", color = fontColor) }) Text( text = title, modifier = Modifier.weight(1f), overflow = TextOverflow.Ellipsis, maxLines = 1, style = MaterialTheme.typography.bodyLarge, + color = fontColor, ) content() } @@ -112,11 +115,34 @@ private fun ChannelCard( index: Int, title: String, enabled: Boolean, + channelSettings: ChannelSettings, + loraConfig: LoRaConfig, onEditClick: () -> Unit, onDeleteClick: () -> Unit, - channel: Channel, + sharesLocation: Boolean, ) = ChannelItem(index = index, title = title, enabled = enabled, onClick = onEditClick) { - SecurityIcon(channel) + if (sharesLocation) { + Icon( + imageVector = ChannelIcons.LOCATION.icon, + contentDescription = stringResource(ChannelIcons.LOCATION.descriptionResId), + modifier = Modifier.wrapContentSize().padding(horizontal = 5.dp), + ) + } + if (channelSettings.uplinkEnabled) { + Icon( + imageVector = ChannelIcons.UPLINK.icon, + contentDescription = stringResource(ChannelIcons.UPLINK.descriptionResId), + modifier = Modifier.wrapContentSize().padding(horizontal = 5.dp), + ) + } + if (channelSettings.downlinkEnabled) { + Icon( + imageVector = ChannelIcons.DOWNLINK.icon, + contentDescription = stringResource(ChannelIcons.DOWNLINK.descriptionResId), + modifier = Modifier.wrapContentSize().padding(horizontal = 5.dp), + ) + } + SecurityIcon(channelSettings, loraConfig) Spacer(modifier = Modifier.width(10.dp)) IconButton(onClick = { onDeleteClick() }) { Icon( @@ -135,7 +161,7 @@ fun ChannelSelection( isSelected: Boolean, onSelected: (Boolean) -> Unit, channel: Channel, -) = ChannelItem(index = index, title = title, enabled = enabled, onClick = {}) { +) = ChannelItem(index = index, title = title, enabled = enabled) { SecurityIcon(channel) Spacer(modifier = Modifier.width(10.dp)) Checkbox(enabled = enabled, checked = isSelected, onCheckedChange = onSelected) @@ -152,25 +178,28 @@ fun ChannelConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel()) { ChannelSettingsItemList( settingsList = state.channelList, loraConfig = state.radioConfig.lora, - enabled = state.connected, maxChannels = viewModel.maxChannels, + firmwareVersion = state.metadata?.firmwareVersion ?: "0.0.0", + enabled = state.connected, onPositiveClicked = { channelListInput -> viewModel.updateChannels(channelListInput, state.channelList) }, ) } @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable -fun ChannelSettingsItemList( +private fun ChannelSettingsItemList( settingsList: List, loraConfig: LoRaConfig, maxChannels: Int = 8, + firmwareVersion: String, enabled: Boolean, - onNegativeClicked: () -> Unit = {}, onPositiveClicked: (List) -> Unit, ) { val primarySettings = settingsList.getOrNull(0) ?: return val modemPresetName by remember(loraConfig) { mutableStateOf(Channel(loraConfig = loraConfig).name) } val primaryChannel by remember(loraConfig) { mutableStateOf(Channel(primarySettings, loraConfig)) } + val fwVersion by + remember(firmwareVersion) { mutableStateOf(DeviceVersion(firmwareVersion.substringBeforeLast("."))) } val focusManager = LocalFocusManager.current val settingsListInput = @@ -180,7 +209,7 @@ fun ChannelSettingsItemList( val listState = rememberLazyListState() val dragDropState = - rememberDragDropState(listState, headerCount = 1) { fromIndex, toIndex -> + rememberDragDropState(listState) { fromIndex, toIndex -> if (toIndex in settingsListInput.indices && fromIndex in settingsListInput.indices) { settingsListInput.apply { add(toIndex, removeAt(fromIndex)) } } @@ -191,6 +220,7 @@ fun ChannelSettingsItemList( settingsList.zip(settingsListInput).any { (item1, item2) -> item1 != item2 } var showEditChannelDialog: Int? by rememberSaveable { mutableStateOf(null) } + var showChannelLegendDialog by rememberSaveable { mutableStateOf(false) } if (showEditChannelDialog != null) { val index = showEditChannelDialog ?: return @@ -209,6 +239,10 @@ fun ChannelSettingsItemList( ) } + if (showChannelLegendDialog) { + ChannelLegendDialog(fwVersion) { showChannelLegendDialog = false } + } + Box(modifier = Modifier.fillMaxSize().clickable(onClick = {}, enabled = false)) { Column { ChannelsConfigHeader( @@ -230,11 +264,11 @@ fun ChannelSettingsItemList( fontSize = 11.sp, modifier = Modifier.padding(start = 16.dp), ) - Text( - text = stringResource(R.string.primary), - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 16.dp), - ) + + ChannelLegend { showChannelLegendDialog = true } + + val locationChannel = determineLocationSharingChannel(fwVersion, settingsListInput.toList()) + LazyColumn( modifier = Modifier.dragContainer(dragDropState = dragDropState, haptics = LocalHapticFeedback.current), state = listState, @@ -245,38 +279,16 @@ fun ChannelSettingsItemList( channel, isDragging, -> - val channelObj = Channel(channel, loraConfig) ChannelCard( index = index, title = channel.name.ifEmpty { modemPresetName }, enabled = enabled, + channelSettings = channel, + loraConfig = loraConfig, onEditClick = { showEditChannelDialog = index }, onDeleteClick = { settingsListInput.removeAt(index) }, - channel = channelObj, + sharesLocation = locationChannel == index, ) - if (index == 0 && !isDragging) { - Text( - text = stringResource(R.string.primary_channel_feature), - color = MaterialTheme.colorScheme.primary, - fontSize = 10.sp, - ) - Spacer(modifier = Modifier.height(16.dp)) - Text(text = stringResource(R.string.secondary), color = MaterialTheme.colorScheme.onBackground) - } - } - item { - Column { - Text( - text = stringResource(R.string.secondary_no_telemetry), - color = MaterialTheme.colorScheme.onBackground, - fontSize = 10.sp, - ) - Text( - text = stringResource(R.string.manual_position_request), - color = MaterialTheme.colorScheme.onBackground, - fontSize = 10.sp, - ) - } } item { PreferenceFooter( @@ -286,7 +298,6 @@ fun ChannelSettingsItemList( focusManager.clearFocus() settingsListInput.clear() settingsListInput.addAll(settingsList) - onNegativeClicked() }, positiveText = R.string.send, onPositiveClicked = { @@ -338,6 +349,33 @@ private fun ChannelsConfigHeader(frequency: Float, slot: Int) { } } +/** + * Determines what [Channel] if any is enabled to conduct automatic location sharing. + * + * @param firmwareVersion of the connected node. + * @param settingsList Current list of channels on the node. + * @return the index of the channel within `settingsList`. + */ +private fun determineLocationSharingChannel(firmwareVersion: DeviceVersion, settingsList: List): Int { + var output = -1 + if (firmwareVersion >= DeviceVersion(asString = SECONDARY_CHANNEL_EPOCH)) { + /* Essentially the first index with the setting enabled */ + for ((i, settings) in settingsList.withIndex()) { + if (settings.moduleSettings.positionPrecision > 0) { + output = i + break + } + } + } else { + /* Only the primary channel at index 0 can share locations automatically */ + val primary = settingsList[0] + if (primary.moduleSettings.positionPrecision > 0) { + output = 0 + } + } + return output +} + @Preview(showBackground = true) @Composable private fun ChannelSettingsPreview() { @@ -351,6 +389,7 @@ private fun ChannelSettingsPreview() { channelSettings { name = stringResource(R.string.channel_name) }, ), loraConfig = Channel.default.loraConfig, + firmwareVersion = "1.3.2", enabled = true, onPositiveClicked = {}, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bd65b2ca9..77b4473b9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -802,4 +802,11 @@ https://a.tile.openstreetmap.org/{z}/{x}/{y}.png track point Phone Settings + Channel Features + Location Sharing + Periodic position broadcast + Messages from the mesh will be sent to the public internet through any node\'s configured gateway. + Messages from a public internet gateway are forwarded to the local mesh. Due to the zero-hop policy, traffic from the default MQTT server will not propagate further than this device. + Icon Meanings + Disabling position on the primary channel allows periodic position broadcasts on the first secondary channel with the position enabled, otherwise manual position request required.