feat(wire): migrate from protobuf -> wire (#4401)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-03 18:01:12 -06:00 committed by GitHub
parent 9dbc8b7fbf
commit 25657e8f8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
239 changed files with 7149 additions and 6144 deletions

View file

@ -46,14 +46,14 @@ import co.touchlab.kermit.Logger
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.protobuf.ByteString
import com.google.protobuf.Descriptors
import com.google.zxing.BarcodeFormat
import com.google.zxing.MultiFormatWriter
import com.google.zxing.WriterException
import com.journeyapps.barcodescanner.BarcodeEncoder
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.strings.Res
@ -62,8 +62,8 @@ import org.meshtastic.core.strings.scan_qr_code
import org.meshtastic.core.strings.share_contact
import org.meshtastic.core.ui.R
import org.meshtastic.core.ui.share.SharedContactDialog
import org.meshtastic.proto.AdminProtos
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
import java.net.MalformedURLException
/**
@ -76,9 +76,9 @@ import java.net.MalformedURLException
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun AddContactFAB(
sharedContact: AdminProtos.SharedContact?,
sharedContact: SharedContact?,
modifier: Modifier = Modifier,
onSharedContactRequested: (AdminProtos.SharedContact?) -> Unit,
onSharedContactRequested: (SharedContact?) -> Unit,
) {
val barcodeLauncher =
rememberLauncherForActivityResult(ScanContract()) { result ->
@ -161,13 +161,13 @@ private fun SharedContact(contactUri: Uri) {
@Composable
fun SharedContactDialog(contact: Node?, onDismiss: () -> Unit) {
if (contact == null) return
val sharedContact = AdminProtos.SharedContact.newBuilder().setUser(contact.user).setNodeNum(contact.num).build()
val sharedContact = SharedContact(user = contact.user, node_num = contact.num)
val uri = sharedContact.getSharedContactUrl()
SimpleAlertDialog(
title = Res.string.share_contact,
text = {
Column {
Text(contact.user.longName)
Text(contact.user.long_name)
SharedContact(contactUri = uri)
}
},
@ -204,43 +204,41 @@ internal const val URL_PREFIX = "https://$MESHTASTIC_HOST$CONTACT_SHARE_PATH#"
private const val BASE64FLAGS = Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING
private const val CAMERA_ID = 0
/**
* Converts a URI to a [AdminProtos.SharedContact].
*
* @throws MalformedURLException if the URI is not a valid Meshtastic contact sharing URL.
*/
@Suppress("MagicNumber")
@Throws(MalformedURLException::class)
fun Uri.toSharedContact(): AdminProtos.SharedContact {
fun Uri.toSharedContact(): SharedContact {
if (fragment.isNullOrBlank() || !host.equals(MESHTASTIC_HOST, true) || !path.equals(CONTACT_SHARE_PATH, true)) {
throw MalformedURLException("Not a valid Meshtastic URL: ${toString().take(40)}")
}
val url = AdminProtos.SharedContact.parseFrom(Base64.decode(fragment!!, BASE64FLAGS))
return url.toBuilder().build()
return SharedContact.ADAPTER.decode(Base64.decode(fragment!!, BASE64FLAGS).toByteString())
}
/** Converts a [AdminProtos.SharedContact] to its corresponding URI representation. */
fun AdminProtos.SharedContact.getSharedContactUrl(): Uri {
val bytes = this.toByteArray() ?: ByteArray(0)
/** Converts a [SharedContact] to its corresponding URI representation. */
fun SharedContact.getSharedContactUrl(): Uri {
val bytes = SharedContact.ADAPTER.encode(this)
val enc = Base64.encodeToString(bytes, BASE64FLAGS)
return "$URL_PREFIX$enc".toUri()
}
/** Compares two [MeshProtos.User] objects and returns a string detailing the differences. */
fun compareUsers(oldUser: MeshProtos.User, newUser: MeshProtos.User): String {
/** Compares two [User] objects and returns a string detailing the differences. */
fun compareUsers(oldUser: User, newUser: User): String {
val changes = mutableListOf<String>()
// Iterate over all fields in the User message descriptor
for (fieldDescriptor: Descriptors.FieldDescriptor in MeshProtos.User.getDescriptor().fields) {
val fieldName = fieldDescriptor.name
val oldValue = if (oldUser.hasField(fieldDescriptor)) oldUser.getField(fieldDescriptor) else null
val newValue = if (newUser.hasField(fieldDescriptor)) newUser.getField(fieldDescriptor) else null
if (oldValue != newValue) {
val oldValueString = valueToString(oldValue, fieldDescriptor)
val newValueString = valueToString(newValue, fieldDescriptor)
changes.add("$fieldName: $oldValueString -> $newValueString")
}
if (oldUser.id != newUser.id) changes.add("id: ${oldUser.id} -> ${newUser.id}")
if (oldUser.long_name != newUser.long_name) changes.add("long_name: ${oldUser.long_name} -> ${newUser.long_name}")
if (oldUser.short_name != newUser.short_name) {
changes.add("short_name: ${oldUser.short_name} -> ${newUser.short_name}")
}
if (oldUser.macaddr != newUser.macaddr) {
changes.add("macaddr: ${oldUser.macaddr?.base64()} -> ${newUser.macaddr?.base64()}")
}
if (oldUser.hw_model != newUser.hw_model) changes.add("hw_model: ${oldUser.hw_model} -> ${newUser.hw_model}")
if (oldUser.is_licensed != newUser.is_licensed) {
changes.add("is_licensed: ${oldUser.is_licensed} -> ${newUser.is_licensed}")
}
if (oldUser.role != newUser.role) changes.add("role: ${oldUser.role} -> ${newUser.role}")
if (oldUser.public_key != newUser.public_key) {
changes.add("public_key: ${oldUser.public_key?.base64()} -> ${newUser.public_key?.base64()}")
}
return if (changes.isEmpty()) {
@ -250,52 +248,20 @@ fun compareUsers(oldUser: MeshProtos.User, newUser: MeshProtos.User): String {
}
}
/** Converts a [MeshProtos.User] object to a string representation of its fields and values. */
fun userFieldsToString(user: MeshProtos.User): String {
/** Converts a [User] object to a string representation of its fields and values. */
fun userFieldsToString(user: User): String {
val fieldLines = mutableListOf<String>()
for (fieldDescriptor: Descriptors.FieldDescriptor in MeshProtos.User.getDescriptor().fields) {
val fieldName = fieldDescriptor.name
if (user.hasField(fieldDescriptor)) {
val value = user.getField(fieldDescriptor)
val valueString = valueToString(value, fieldDescriptor) // Using the helper from previous example
fieldLines.add("$fieldName: $valueString")
} else if (fieldDescriptor.isRepeated || fieldDescriptor.hasDefaultValue() || fieldDescriptor.hasPresence()) {
val defaultValue = fieldDescriptor.defaultValue
val valueString =
if (fieldDescriptor.isRepeated) {
"[]" // Empty list
} else if (user.hasField(fieldDescriptor)) {
valueToString(user.getField(fieldDescriptor), fieldDescriptor)
} else {
valueToString(defaultValue, fieldDescriptor)
}
fieldLines.add("id: ${user.id}")
fieldLines.add("long_name: ${user.long_name}")
fieldLines.add("short_name: ${user.short_name}")
fieldLines.add("macaddr: ${user.macaddr?.base64()}")
fieldLines.add("hw_model: ${user.hw_model}")
fieldLines.add("is_licensed: ${user.is_licensed}")
fieldLines.add("role: ${user.role}")
fieldLines.add("public_key: ${user.public_key?.base64()}")
fieldLines.add("$fieldName: $valueString")
}
}
return if (fieldLines.isEmpty()) {
"User object has no fields set."
} else {
fieldLines.joinToString("\n")
}
return fieldLines.joinToString("\n")
}
private fun valueToString(value: Any?, fieldDescriptor: Descriptors.FieldDescriptor): String {
if (value == null) {
return "null"
}
return when (fieldDescriptor.type) {
Descriptors.FieldDescriptor.Type.BYTES -> {
// For ByteString, you might want to display it as hex or Base64
// For simplicity, here we'll just show its size.
if (value is ByteString) {
Base64.encodeToString(value.toByteArray(), Base64.DEFAULT).trim()
} else {
value.toString().trim()
}
}
// Add more custom formatting for other types if needed
else -> value.toString().trim()
}
}
private fun ByteString.base64(): String = Base64.encodeToString(this.toByteArray(), Base64.DEFAULT).trim()

View file

@ -34,7 +34,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.google.protobuf.ProtocolMessageEnum
@Composable
fun <T : Enum<T>> DropDownPreference(
@ -71,21 +70,7 @@ fun <T> DropDownPreference(
) {
var expanded by remember { mutableStateOf(false) }
val deprecatedItems: List<T> = remember {
if (selectedItem is ProtocolMessageEnum) {
val enum = (selectedItem as? Enum<*>)?.declaringJavaClass?.enumConstants
val descriptor = (selectedItem as ProtocolMessageEnum).descriptorForType
@Suppress("UNCHECKED_CAST")
(
enum?.filter { entries -> descriptor.values.any { it.name == entries.name && it.options.deprecated } }
?: emptyList()
)
as List<T>
} else {
emptyList()
}
}
val deprecatedItems: List<T> = emptyList() // Protobuf-Java specific deprecation check removed
Column(modifier = modifier.fillMaxWidth().padding(8.dp)) {
ExposedDropdownMenuBox(
expanded = expanded,

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 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
@ -14,7 +14,6 @@
* 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 org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.Column
@ -43,11 +42,11 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.google.protobuf.ByteString
import okio.ByteString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.Channel
import org.meshtastic.core.model.util.base64ToByteString
import org.meshtastic.core.model.util.encodeToString
import org.meshtastic.core.model.util.toByteString
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.error
import org.meshtastic.core.strings.reset
@ -88,7 +87,7 @@ fun EditBase64Preference(
value = valueState,
onValueChange = {
valueState = it
runCatching { it.toByteString() }.onSuccess(onValueChange)
runCatching { it.base64ToByteString() }.onSuccess(onValueChange)
},
modifier = Modifier.fillMaxWidth().onFocusChanged { focusState -> isFocused = focusState.isFocused },
enabled = enabled,
@ -147,7 +146,7 @@ private fun EditBase64PreferencePreview() {
value = Channel.getRandomKey(),
enabled = true,
keyboardActions = KeyboardActions {},
onValueChange = {},
onValueChange = { _ -> },
onGenerateKey = {},
modifier = Modifier.padding(16.dp),
)

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 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
@ -14,7 +14,6 @@
* 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 org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.Column
@ -39,7 +38,6 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.google.protobuf.ByteString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.add
@ -48,10 +46,8 @@ import org.meshtastic.core.strings.gpio_pin
import org.meshtastic.core.strings.ignore_incoming
import org.meshtastic.core.strings.name
import org.meshtastic.core.strings.type
import org.meshtastic.proto.ModuleConfigProtos.RemoteHardwarePin
import org.meshtastic.proto.ModuleConfigProtos.RemoteHardwarePinType
import org.meshtastic.proto.copy
import org.meshtastic.proto.remoteHardwarePin
import org.meshtastic.proto.RemoteHardwarePin
import org.meshtastic.proto.RemoteHardwarePinType
@Suppress("LongMethod")
@Composable
@ -110,7 +106,7 @@ inline fun <reified T> EditListPreference(
trailingIcon = trailingIcon,
)
}
is ByteString -> {
is okio.ByteString -> {
EditBase64Preference(
title = "${index + 1}/$maxCount",
value = value,
@ -126,12 +122,13 @@ inline fun <reified T> EditListPreference(
is RemoteHardwarePin -> {
EditTextPreference(
title = stringResource(Res.string.gpio_pin),
value = value.gpioPin,
value = value.gpio_pin,
enabled = enabled,
keyboardActions = keyboardActions,
onValueChanged = {
onValueChanged = { newValue ->
val it = newValue as Int
if (it in 0..255) {
listState[index] = value.copy { gpioPin = it } as T
listState[index] = value.copy(gpio_pin = it) as T
onValuesChanged(listState)
}
},
@ -145,8 +142,9 @@ inline fun <reified T> EditListPreference(
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = keyboardActions,
onValueChanged = {
listState[index] = value.copy { name = it } as T
onValueChanged = { newValue ->
val it = newValue as String
listState[index] = value.copy(name = it) as T
onValuesChanged(listState)
},
trailingIcon = trailingIcon,
@ -156,11 +154,11 @@ inline fun <reified T> EditListPreference(
enabled = enabled,
items =
RemoteHardwarePinType.entries
.filter { it != RemoteHardwarePinType.UNRECOGNIZED }
.filter { it != RemoteHardwarePinType.UNKNOWN }
.map { it to it.name },
selectedItem = value.type,
onItemSelected = {
listState[index] = value.copy { type = it } as T
listState[index] = value.copy(type = it) as T
onValuesChanged(listState)
},
)
@ -174,8 +172,8 @@ inline fun <reified T> EditListPreference(
val newElement =
when (T::class) {
Int::class -> 0 as T
ByteString::class -> ByteString.EMPTY as T
RemoteHardwarePin::class -> remoteHardwarePin {} as T
okio.ByteString::class -> okio.ByteString.EMPTY as T
RemoteHardwarePin::class -> RemoteHardwarePin() as T
else -> throw IllegalArgumentException("Unsupported type: ${T::class}")
}
listState.add(listState.size, newElement)
@ -204,11 +202,7 @@ private fun EditListPreferencePreview() {
title = "Available pins",
list =
listOf(
remoteHardwarePin {
gpioPin = 12
name = "Front door"
type = RemoteHardwarePinType.DIGITAL_READ
},
RemoteHardwarePin(gpio_pin = 12, name = "Front door", type = RemoteHardwarePinType.DIGITAL_READ),
),
maxCount = 4,
enabled = true,

View file

@ -29,7 +29,7 @@ import org.meshtastic.core.strings.altitude
import org.meshtastic.core.strings.elevation_suffix
import org.meshtastic.core.ui.icon.Elevation
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
@Composable
fun ElevationInfo(

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 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
@ -14,7 +14,6 @@
* 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 org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.Box
@ -38,9 +37,9 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.meshtastic.core.database.model.Node
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.PaxcountProtos
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.User
@Composable
fun NodeChip(modifier: Modifier = Modifier, node: Node, onClick: ((Node) -> Unit)? = null) {
@ -53,12 +52,12 @@ fun NodeChip(modifier: Modifier = Modifier, node: Node, onClick: ((Node) -> Unit
Modifier.width(IntrinsicSize.Min)
.defaultMinSize(minWidth = 72.dp, minHeight = 32.dp)
.padding(horizontal = 8.dp)
.semantics { contentDescription = node.user.shortName.ifEmpty { "Node" } },
.semantics { contentDescription = node.user.short_name.ifEmpty { "Node" } },
contentAlignment = Alignment.Center,
) {
Text(
modifier = Modifier.fillMaxWidth(),
text = node.user.shortName.ifEmpty { "???" },
text = node.user.short_name.ifEmpty { "???" },
fontSize = MaterialTheme.typography.labelLarge.fontSize,
textDecoration = TextDecoration.LineThrough.takeIf { node.isIgnored },
textAlign = TextAlign.Center,
@ -80,15 +79,14 @@ fun NodeChip(modifier: Modifier = Modifier, node: Node, onClick: ((Node) -> Unit
@Preview
@Composable
private fun NodeChipPreview() {
val user = MeshProtos.User.newBuilder().setShortName("\uD83E\uDEE0").setLongName("John Doe").build()
val user = User(short_name = "\uD83E\uDEE0", long_name = "John Doe")
val node =
Node(
num = 13444,
user = user,
isIgnored = false,
paxcounter = PaxcountProtos.Paxcount.newBuilder().setBle(10).setWifi(5).build(),
environmentMetrics =
TelemetryProtos.EnvironmentMetrics.newBuilder().setTemperature(25f).setRelativeHumidity(60f).build(),
paxcounter = Paxcount(ble = 10, wifi = 5),
environmentMetrics = EnvironmentMetrics(temperature = 25f, relative_humidity = 60f),
)
NodeChip(node = node)
}

View file

@ -51,7 +51,7 @@ 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 com.google.protobuf.ByteString
import okio.ByteString
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.Channel

View file

@ -80,9 +80,9 @@ import org.meshtastic.core.ui.icon.Warning
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
import org.meshtastic.proto.AppOnlyProtos
import org.meshtastic.proto.ChannelProtos.ChannelSettings
import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config.LoRaConfig
private const val PRECISE_POSITION_BITS = 32
@ -279,15 +279,15 @@ fun SecurityIcon(
/** Extension property to check if the channel uses a low entropy PSK (not securely encrypted). */
val Channel.isLowEntropyKey: Boolean
get() = settings.psk.size() <= 1
get() = settings.psk.size <= 1
/** Extension property to check if the channel has precise location enabled. */
val Channel.isPreciseLocation: Boolean
get() = settings.moduleSettings.positionPrecision == PRECISE_POSITION_BITS
get() = settings.module_settings?.position_precision == PRECISE_POSITION_BITS
/** Extension property to check if MQTT is enabled for the channel. */
val Channel.isMqttEnabled: Boolean
get() = settings.uplinkEnabled
get() = settings.uplink_enabled ?: false
/**
* Overload for [SecurityIcon] that takes a [Channel] object to determine its security state.
@ -343,7 +343,7 @@ fun SecurityIcon(
*/
@Composable
fun SecurityIcon(
channelSet: AppOnlyProtos.ChannelSet,
channelSet: ChannelSet,
channelIndex: Int,
baseContentDescription: String = stringResource(Res.string.security_icon_description),
externalOnClick: (() -> Unit)? = null,
@ -369,17 +369,19 @@ fun SecurityIcon(
*/
@Composable
fun SecurityIcon(
channelSet: AppOnlyProtos.ChannelSet,
channelSet: ChannelSet,
channelName: String,
baseContentDescription: String = stringResource(Res.string.security_icon_description),
externalOnClick: (() -> Unit)? = null,
) {
val channelByNameMap =
remember(channelSet) { channelSet.settingsList.associateBy { Channel(it, channelSet.loraConfig).name } }
remember(channelSet) {
channelSet.settings.associateBy { Channel(it, channelSet.lora_config ?: Channel.default.loraConfig).name }
}
channelByNameMap[channelName]?.let { channelSetting ->
SecurityIcon(
channel = Channel(channelSetting, channelSet.loraConfig),
channel = Channel(channelSetting, channelSet.lora_config ?: Channel.default.loraConfig),
baseContentDescription = baseContentDescription,
externalOnClick = externalOnClick,
)

View file

@ -62,13 +62,13 @@ fun SignalInfo(
IconInfo(
icon = MeshtasticIcons.ChannelUtilization,
contentDescription = stringResource(Res.string.channel_utilization),
text = "%.1f%%".format(node.deviceMetrics.channelUtilization),
text = "%.1f%%".format(node.deviceMetrics.channel_utilization),
contentColor = contentColor,
)
IconInfo(
icon = MeshtasticIcons.AirUtilization,
contentDescription = stringResource(Res.string.air_utilization),
text = "%.1f%%".format(node.deviceMetrics.airUtilTx),
text = "%.1f%%".format(node.deviceMetrics.air_util_tx),
contentColor = contentColor,
)
}

View file

@ -17,16 +17,16 @@
package org.meshtastic.core.ui.component.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.google.protobuf.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DeviceMetrics.Companion.currentTime
import org.meshtastic.proto.ConfigProtos
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.deviceMetrics
import org.meshtastic.proto.environmentMetrics
import org.meshtastic.proto.paxcount
import org.meshtastic.proto.position
import org.meshtastic.proto.user
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.Position
import org.meshtastic.proto.User
import kotlin.random.Random
class NodePreviewParameterProvider : PreviewParameterProvider<Node> {
@ -34,32 +34,26 @@ class NodePreviewParameterProvider : PreviewParameterProvider<Node> {
Node(
num = 1955,
user =
user {
id = "mickeyMouseId"
longName = "Mickey Mouse"
shortName = "MM"
hwModel = MeshProtos.HardwareModel.TBEAM
role = ConfigProtos.Config.DeviceConfig.Role.ROUTER
},
position =
position {
latitudeI = 338125110
longitudeI = -1179189760
altitude = 138
satsInView = 4
},
User(
id = "mickeyMouseId",
long_name = "Mickey Mouse",
short_name = "MM",
hw_model = HardwareModel.TBEAM,
role = Config.DeviceConfig.Role.ROUTER,
),
position = Position(latitude_i = 338125110, longitude_i = -1179189760, altitude = 138, sats_in_view = 4),
lastHeard = currentTime(),
channel = 0,
snr = 12.5F,
rssi = -42,
deviceMetrics =
deviceMetrics {
channelUtilization = 2.4F
airUtilTx = 3.5F
batteryLevel = 85
voltage = 3.7F
uptimeSeconds = 3600
},
DeviceMetrics(
channel_utilization = 2.4F,
air_util_tx = 3.5F,
battery_level = 85,
voltage = 3.7F,
uptime_seconds = 3600,
),
isFavorite = true,
hopsAway = 0,
)
@ -68,67 +62,55 @@ class NodePreviewParameterProvider : PreviewParameterProvider<Node> {
mickeyMouse.copy(
num = Random.nextInt(),
user =
user {
longName = "Minnie Mouse"
shortName = "MiMo"
id = "minnieMouseId"
hwModel = MeshProtos.HardwareModel.HELTEC_V3
},
User(
long_name = "Minnie Mouse",
short_name = "MiMo",
id = "minnieMouseId",
hw_model = HardwareModel.HELTEC_V3,
),
snr = 12.5F,
rssi = -42,
position = position {},
position = Position(),
hopsAway = 1,
)
private val donaldDuck =
Node(
num = Random.nextInt(),
position =
position {
latitudeI = 338052347
longitudeI = -1179208460
altitude = 121
satsInView = 66
},
position = Position(latitude_i = 338052347, longitude_i = -1179208460, altitude = 121, sats_in_view = 66),
lastHeard = currentTime() - 300,
channel = 0,
snr = 12.5F,
rssi = -42,
deviceMetrics =
deviceMetrics {
channelUtilization = 2.4F
airUtilTx = 3.5F
batteryLevel = 85
voltage = 3.7F
uptimeSeconds = 3600
},
DeviceMetrics(
channel_utilization = 2.4F,
air_util_tx = 3.5F,
battery_level = 85,
voltage = 3.7F,
uptime_seconds = 3600,
),
user =
user {
id = "donaldDuckId"
longName = "Donald Duck, the Grand Duck of the Ducks"
shortName = "DoDu"
hwModel = MeshProtos.HardwareModel.HELTEC_V3
publicKey = ByteString.copyFrom(ByteArray(32) { 1 })
},
User(
id = "donaldDuckId",
long_name = "Donald Duck, the Grand Duck of the Ducks",
short_name = "DoDu",
hw_model = HardwareModel.HELTEC_V3,
public_key = ByteArray(32) { 1 }.toByteString(),
),
environmentMetrics =
environmentMetrics {
temperature = 28.0F
relativeHumidity = 50.0F
barometricPressure = 1013.25F
gasResistance = 0.0F
voltage = 3.7F
current = 0.0F
iaq = 100
barometricPressure = 1013.25F
soilTemperature = 28.0F
soilMoisture = 50
},
paxcounter =
paxcount {
wifi = 30
ble = 39
uptime = 420
},
EnvironmentMetrics(
temperature = 28.0F,
relative_humidity = 50.0F,
barometric_pressure = 1013.25F,
gas_resistance = 0.0F,
voltage = 3.7F,
current = 0.0F,
iaq = 100,
soil_temperature = 28.0F,
soil_moisture = 50,
),
paxcounter = Paxcount(wifi = 30, ble = 39, uptime = 420),
isFavorite = true,
hopsAway = 2,
)
@ -136,14 +118,9 @@ class NodePreviewParameterProvider : PreviewParameterProvider<Node> {
private val unknown =
donaldDuck.copy(
user =
user {
id = "myId"
longName = "Meshtastic myId"
shortName = "myId"
hwModel = MeshProtos.HardwareModel.UNSET
},
environmentMetrics = environmentMetrics {},
paxcounter = paxcount {},
User(id = "myId", long_name = "Meshtastic myId", short_name = "myId", hw_model = HardwareModel.UNSET),
environmentMetrics = EnvironmentMetrics(),
paxcounter = Paxcount(),
)
private val almostNothing = Node(num = Random.nextInt())

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 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
@ -14,29 +14,27 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MatchingDeclarationName")
package org.meshtastic.core.ui.component.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import org.meshtastic.core.database.model.Node
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.PaxcountProtos
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.User
/** Simple [PreviewParameterProvider] that provides true and false values. */
class BooleanProvider : PreviewParameterProvider<Boolean> {
override val values: Sequence<Boolean> = sequenceOf(false, true)
}
private val user = MeshProtos.User.newBuilder().setShortName("\uD83E\uDEE0").setLongName("John Doe").build()
private val user = User(short_name = "\uD83E\uDEE0", long_name = "John Doe")
val previewNode =
Node(
num = 13444,
user = user,
isIgnored = false,
paxcounter = PaxcountProtos.Paxcount.newBuilder().setBle(10).setWifi(5).build(),
environmentMetrics =
TelemetryProtos.EnvironmentMetrics.newBuilder().setTemperature(25f).setRelativeHumidity(60f).build(),
paxcounter = Paxcount(ble = 10, wifi = 5),
environmentMetrics = EnvironmentMetrics(temperature = 25f, relative_humidity = 60f),
)

View file

@ -60,10 +60,7 @@ import org.meshtastic.core.strings.new_channel_rcvd
import org.meshtastic.core.strings.replace
import org.meshtastic.core.strings.replace_channels_and_settings_description
import org.meshtastic.core.ui.component.ChannelSelection
import org.meshtastic.proto.AppOnlyProtos.ChannelSet
import org.meshtastic.proto.ConfigProtos.Config.LoRaConfig.ModemPreset
import org.meshtastic.proto.channelSet
import org.meshtastic.proto.copy
import org.meshtastic.proto.ChannelSet
@Composable
fun ScannedQrCodeDialog(
@ -91,7 +88,7 @@ fun ScannedQrCodeDialog(
onDismiss: () -> Unit,
onConfirm: (ChannelSet) -> Unit,
) {
var shouldReplace by remember { mutableStateOf(incoming.hasLoraConfig()) }
var shouldReplace by remember { mutableStateOf(incoming.lora_config != null) }
val channelSet =
remember(shouldReplace) {
@ -99,67 +96,65 @@ fun ScannedQrCodeDialog(
// When replacing, apply the incoming LoRa configuration but preserve certain
// locally safe fields such as MQTT flags and TX power. This prevents QR codes
// from unintentionally overriding device-specific power limits (e.g. E22 caps).
incoming.copy {
loraConfig =
loraConfig.copy {
configOkToMqtt = channels.loraConfig.configOkToMqtt
txPower = channels.loraConfig.txPower
}
}
incoming.copy(
lora_config =
incoming.lora_config?.copy(
config_ok_to_mqtt = channels.lora_config?.config_ok_to_mqtt ?: false,
tx_power = channels.lora_config?.tx_power ?: 0,
),
)
} else {
channels.copy {
// To guarantee consistent ordering, using a LinkedHashSet which iterates through
// its entries according to the order an item was *first* inserted.
// https://kotlinlang.org/api/latest/jvm/stdlib/kotlin.collections/-linked-hash-set/
val result = LinkedHashSet(settings + incoming.settingsList)
settings.clear()
settings.addAll(result)
}
// To guarantee consistent ordering, using a LinkedHashSet which iterates through
// its entries according to the order an item was *first* inserted.
val result = (channels.settings + incoming.settings).distinct()
channels.copy(settings = result)
}
}
val modemPresetName = Channel(loraConfig = channelSet.loraConfig).name
val modemPresetName = Channel(loraConfig = channelSet.lora_config ?: Channel.default.loraConfig).name
/* Holds selections made by the user */
val channelSelections =
remember(channelSet) { mutableStateListOf(elements = Array(size = channelSet.settingsCount, init = { true })) }
remember(channelSet) { mutableStateListOf(elements = Array(size = channelSet.settings.size, init = { true })) }
val selectedChannelSet =
channelSet.copy {
// When adding (not replacing), include all previous channels + selected new channels.
// Since 'channelSet.settings' already contains the merged distinct list, we just filter it.
val result =
settings.filterIndexed { i, _ ->
val isExisting = !shouldReplace && i < channels.settingsCount
if (shouldReplace) {
channelSet.copy(
settings = channelSet.settings.filterIndexed { i, _ -> channelSelections.getOrNull(i) == true },
)
} else {
channelSet.copy(
settings =
channelSet.settings.filterIndexed { i, _ ->
val isExisting = i < channels.settings.size
isExisting || channelSelections.getOrNull(i) == true
}
settings.clear()
settings.addAll(result)
},
)
}
// Compute LoRa configuration changes when in replace mode
val loraChanges =
remember(shouldReplace, channels, incoming) {
if (shouldReplace && incoming.hasLoraConfig()) {
val current = channels.loraConfig
val new = incoming.loraConfig
if (shouldReplace && incoming.lora_config != null) {
val current = channels.lora_config
val new = incoming.lora_config
val changes = mutableListOf<String>()
if (current.hopLimit != new.hopLimit) {
changes.add("Hop Limit: ${current.hopLimit} -> ${new.hopLimit}")
if (current?.hop_limit != new?.hop_limit) {
changes.add("Hop Limit: ${current?.hop_limit} -> ${new?.hop_limit}")
}
if (current.getRegion() != new.getRegion()) {
val currentRegionDesc = current.getRegion()?.name ?: "Unknown"
val newRegionDesc = new.getRegion()?.name ?: "Unknown"
if (current?.region != new?.region) {
val currentRegionDesc = current?.region?.name ?: "Unknown"
val newRegionDesc = new?.region?.name ?: "Unknown"
changes.add("Region: $currentRegionDesc -> $newRegionDesc")
}
if (current.modemPreset != new.modemPreset) {
val currentPresetDesc = ModemPreset.forNumber(current.modemPreset.number)?.name ?: "Unknown"
val newPresetDesc = ModemPreset.forNumber(new.modemPreset.number)?.name ?: "Unknown"
if (current?.modem_preset != new?.modem_preset) {
val currentPresetDesc = current?.modem_preset?.name ?: "Unknown"
val newPresetDesc = new?.modem_preset?.name ?: "Unknown"
changes.add("Modem Preset: $currentPresetDesc -> $newPresetDesc")
}
if (current.usePreset != new.usePreset) {
changes.add("Use Preset: ${current.usePreset} -> ${new.usePreset}")
if (current?.use_preset != new?.use_preset) {
changes.add("Use Preset: ${current?.use_preset} -> ${new?.use_preset}")
}
changes
@ -204,16 +199,16 @@ fun ScannedQrCodeDialog(
)
}
itemsIndexed(channelSet.settingsList) { index, channel ->
val isExisting = !shouldReplace && index < channels.settingsCount
val channelObj = Channel(channel, channelSet.loraConfig)
itemsIndexed(channelSet.settings) { index, channel ->
val isExisting = !shouldReplace && index < channels.settings.size
val channelObj = Channel(channel, channelSet.lora_config ?: Channel.default.loraConfig)
ChannelSelection(
index = index,
title = channel.name.ifEmpty { modemPresetName },
enabled = !isExisting,
isSelected = if (isExisting) true else channelSelections[index],
onSelected = {
if (it || selectedChannelSet.settingsCount > 1) {
if (it || selectedChannelSet.settings.size > 1) {
channelSelections[index] = it
}
},
@ -256,7 +251,7 @@ fun ScannedQrCodeDialog(
OutlinedButton(
onClick = { shouldReplace = true },
modifier = Modifier.height(48.dp).weight(1f),
enabled = incoming.hasLoraConfig(),
enabled = incoming.lora_config != null,
colors = if (shouldReplace) selectedColors else unselectedColors,
) {
Text(text = stringResource(Res.string.replace))
@ -285,7 +280,7 @@ fun ScannedQrCodeDialog(
onDismiss()
onConfirm(selectedChannelSet)
},
enabled = selectedChannelSet.settingsCount in 1..8,
enabled = selectedChannelSet.settings.size in 1..8,
) {
Text(
text = stringResource(Res.string.accept),
@ -306,16 +301,8 @@ fun ScannedQrCodeDialog(
@Composable
private fun ScannedQrCodeDialogPreview() {
ScannedQrCodeDialog(
channels =
channelSet {
settings.add(Channel.default.settings)
loraConfig = Channel.default.loraConfig
},
incoming =
channelSet {
settings.add(Channel.default.settings)
loraConfig = Channel.default.loraConfig
},
channels = ChannelSet(settings = listOf(Channel.default.settings), lora_config = Channel.default.loraConfig),
incoming = ChannelSet(settings = listOf(Channel.default.settings), lora_config = Channel.default.loraConfig),
onDismiss = {},
onConfirm = {},
)

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 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
@ -14,7 +14,6 @@
* 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 org.meshtastic.core.ui.qr
import android.os.RemoteException
@ -27,12 +26,10 @@ import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.util.getChannelList
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.AppOnlyProtos
import org.meshtastic.proto.ChannelProtos
import org.meshtastic.proto.ConfigProtos.Config
import org.meshtastic.proto.LocalOnlyProtos.LocalConfig
import org.meshtastic.proto.channelSet
import org.meshtastic.proto.config
import org.meshtastic.proto.Channel
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig
import javax.inject.Inject
@HiltViewModel
@ -43,23 +40,24 @@ constructor(
private val serviceRepository: ServiceRepository,
) : ViewModel() {
val channels = radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = channelSet {})
val channels = radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = ChannelSet())
private val localConfig =
radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig.getDefaultInstance())
private val localConfig = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig())
/** Set the radio config (also updates our saved copy in preferences). */
fun setChannels(channelSet: AppOnlyProtos.ChannelSet) = viewModelScope.launch {
getChannelList(channelSet.settingsList, channels.value.settingsList).forEach(::setChannel)
radioConfigRepository.replaceAllSettings(channelSet.settingsList)
fun setChannels(channelSet: ChannelSet) = viewModelScope.launch {
getChannelList(channelSet.settings, channels.value.settings).forEach(::setChannel)
radioConfigRepository.replaceAllSettings(channelSet.settings)
val newConfig = config { lora = channelSet.loraConfig }
if (localConfig.value.lora != newConfig.lora) setConfig(newConfig)
val loraConfig = channelSet.lora_config
if (loraConfig != null && localConfig.value.lora != loraConfig) {
setConfig(Config(lora = loraConfig))
}
}
private fun setChannel(channel: ChannelProtos.Channel) {
private fun setChannel(channel: Channel) {
try {
serviceRepository.meshService?.setChannel(channel.toByteArray())
serviceRepository.meshService?.setChannel(Channel.ADAPTER.encode(channel))
} catch (ex: RemoteException) {
Logger.e(ex) { "Set channel error" }
}
@ -68,7 +66,7 @@ constructor(
// Set the radio config (also updates our saved copy in preferences)
private fun setConfig(config: Config) {
try {
serviceRepository.meshService?.setConfig(config.toByteArray())
serviceRepository.meshService?.setConfig(Config.ADAPTER.encode(config))
} catch (ex: RemoteException) {
Logger.e(ex) { "Set config error" }
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 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
@ -14,7 +14,6 @@
* 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 org.meshtastic.core.ui.share
import androidx.compose.foundation.layout.Column
@ -35,18 +34,19 @@ import org.meshtastic.core.strings.public_key_changed
import org.meshtastic.core.ui.component.SimpleAlertDialog
import org.meshtastic.core.ui.component.compareUsers
import org.meshtastic.core.ui.component.userFieldsToString
import org.meshtastic.proto.AdminProtos
import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
/** A dialog for importing a shared contact that was scanned from a QR code. */
@Composable
fun SharedContactDialog(
sharedContact: AdminProtos.SharedContact,
sharedContact: SharedContact,
onDismiss: () -> Unit,
viewModel: SharedContactViewModel = hiltViewModel(),
) {
val unfilteredNodes by viewModel.unfilteredNodes.collectAsStateWithLifecycle()
val nodeNum = sharedContact.nodeNum
val nodeNum = sharedContact.node_num
val node = unfilteredNodes.find { it.num == nodeNum }
SimpleAlertDialog(
@ -55,16 +55,18 @@ fun SharedContactDialog(
Column {
if (node != null) {
Text(text = stringResource(Res.string.import_known_shared_contact_text))
if (node.user.publicKey.size() > 0 && node.user.publicKey != sharedContact.user?.publicKey) {
if (
(node.user.public_key?.size ?: 0) > 0 && node.user.public_key != sharedContact.user?.public_key
) {
Text(
text = stringResource(Res.string.public_key_changed),
color = MaterialTheme.colorScheme.error,
)
}
HorizontalDivider()
Text(text = compareUsers(node.user, sharedContact.user))
Text(text = compareUsers(node.user, sharedContact.user ?: User()))
} else {
Text(text = userFieldsToString(sharedContact.user))
Text(text = userFieldsToString(sharedContact.user ?: User()))
}
}
},

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 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
@ -14,7 +14,6 @@
* 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 org.meshtastic.core.ui.share
import androidx.lifecycle.ViewModel
@ -27,7 +26,7 @@ import org.meshtastic.core.database.model.Node
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.AdminProtos
import org.meshtastic.proto.SharedContact
import javax.inject.Inject
@HiltViewModel
@ -41,6 +40,6 @@ constructor(
val unfilteredNodes: StateFlow<List<Node>> =
nodeRepository.getNodes().stateInWhileSubscribed(initialValue = emptyList())
fun addSharedContact(sharedContact: AdminProtos.SharedContact) =
fun addSharedContact(sharedContact: SharedContact) =
viewModelScope.launch { serviceRepository.onServiceAction(ServiceAction.ImportContact(sharedContact)) }
}

View file

@ -22,39 +22,39 @@ import androidx.compose.ui.platform.LocalContext
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.unknown_age
import org.meshtastic.proto.ChannelProtos
import org.meshtastic.proto.ChannelProtos.ChannelSettings
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.MeshProtos.MeshPacket
import org.meshtastic.proto.MeshProtos.Position
import org.meshtastic.proto.channel
import org.meshtastic.proto.channelSettings
import org.meshtastic.proto.Channel
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Position
import kotlin.time.Duration.Companion.days
private const val SECONDS_TO_MILLIS = 1000L
@Composable
fun MeshProtos.Position.formatPositionTime(): String {
fun Position.formatPositionTime(): String {
val currentTime = System.currentTimeMillis()
val sixMonthsAgo = currentTime - 180.days.inWholeMilliseconds
val isOlderThanSixMonths = time * SECONDS_TO_MILLIS < sixMonthsAgo
val isOlderThanSixMonths = (time ?: 0) * SECONDS_TO_MILLIS < sixMonthsAgo
val timeText =
if (isOlderThanSixMonths) {
stringResource(Res.string.unknown_age)
} else {
DateUtils.formatDateTime(
LocalContext.current,
time * SECONDS_TO_MILLIS,
(time ?: 0) * SECONDS_TO_MILLIS,
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_ALL,
)
}
return timeText
}
fun MeshPacket.toPosition(): Position? = if (!decoded.wantResponse) {
runCatching { Position.parseFrom(decoded.payload) }.getOrNull()
} else {
null
fun MeshPacket.toPosition(): Position? {
val decoded = decoded ?: return null
return if (decoded.want_response != true) {
decoded.payload.let { runCatching { Position.ADAPTER.decode(it) }.getOrNull() }
} else {
null
}
}
/**
@ -65,20 +65,20 @@ fun MeshPacket.toPosition(): Position? = if (!decoded.wantResponse) {
* @param old The current [ChannelSettings] list (required when disabling unused channels).
* @return A [Channel] list containing only the modified channels.
*/
fun getChannelList(new: List<ChannelSettings>, old: List<ChannelSettings>): List<ChannelProtos.Channel> = buildList {
fun getChannelList(new: List<ChannelSettings>, old: List<ChannelSettings>): List<Channel> = buildList {
for (i in 0..maxOf(old.lastIndex, new.lastIndex)) {
if (old.getOrNull(i) != new.getOrNull(i)) {
add(
channel {
Channel(
role =
when (i) {
0 -> ChannelProtos.Channel.Role.PRIMARY
in 1..new.lastIndex -> ChannelProtos.Channel.Role.SECONDARY
else -> ChannelProtos.Channel.Role.DISABLED
}
index = i
settings = new.getOrNull(i) ?: channelSettings {}
},
when (i) {
0 -> Channel.Role.PRIMARY
in 1..new.lastIndex -> Channel.Role.SECONDARY
else -> Channel.Role.DISABLED
},
index = i,
settings = new.getOrNull(i) ?: ChannelSettings(),
),
)
}
}