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.