fix(release): fixes to prep for release (#4546)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-12 14:23:19 -06:00 committed by GitHub
parent c5f2b1bbea
commit 80d9a2e0aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 2324 additions and 312 deletions

View file

@ -162,7 +162,7 @@ constructor(
val spinner: StateFlow<Boolean> = bluetoothRepository.isScanning
init {
serviceRepository.statusMessage.onEach { errorText.value = it }.launchIn(viewModelScope)
serviceRepository.connectionProgress.onEach { errorText.value = it }.launchIn(viewModelScope)
Logger.d { "BTScanModel created" }
}

View file

@ -55,7 +55,7 @@ constructor(
metadata != null -> router.configFlowManager.handleLocalMetadata(metadata)
nodeInfo != null -> {
router.configFlowManager.handleNodeInfo(nodeInfo)
serviceRepository.setStatusMessage("Nodes (${router.configFlowManager.newNodeCount})")
serviceRepository.setConnectionProgress("Nodes (${router.configFlowManager.newNodeCount})")
}
configCompleteId != null -> router.configFlowManager.handleConfigComplete(configCompleteId)
mqttProxyMessage != null -> mqttManager.handleMqttProxyMessage(mqttProxyMessage)

View file

@ -208,6 +208,7 @@ constructor(
fun handleSetRemoteOwner(id: Int, destNum: Int, payload: ByteArray) {
val u = User.ADAPTER.decode(payload)
commandSender.sendAdmin(destNum, id) { AdminMessage(set_owner = u) }
nodeManager.handleReceivedUser(destNum, u)
}
fun handleGetRemoteOwner(id: Int, destNum: Int) {
@ -237,6 +238,7 @@ constructor(
fun handleSetModuleConfig(id: Int, destNum: Int, payload: ByteArray) {
val c = ModuleConfig.ADAPTER.decode(payload)
commandSender.sendAdmin(destNum, id) { AdminMessage(set_module_config = c) }
c.statusmessage?.node_status?.let { status -> nodeManager.updateNodeStatus(destNum, status) }
}
fun handleGetModuleConfig(id: Int, destNum: Int, config: Int) {

View file

@ -59,12 +59,16 @@ constructor(
fun handleDeviceConfig(config: Config) {
scope.handledLaunch { radioConfigRepository.setLocalConfig(config) }
serviceRepository.setStatusMessage("Device config received")
serviceRepository.setConnectionProgress("Device config received")
}
fun handleModuleConfig(config: ModuleConfig) {
scope.handledLaunch { radioConfigRepository.setLocalModuleConfig(config) }
serviceRepository.setStatusMessage("Module config received")
serviceRepository.setConnectionProgress("Module config received")
config.statusmessage?.node_status?.let { status ->
nodeManager.myNodeNum?.let { num -> nodeManager.updateNodeStatus(num, status) }
}
}
fun handleChannel(ch: Channel) {
@ -75,9 +79,9 @@ constructor(
val mi = nodeManager.getMyNodeInfo()
val index = ch.index ?: 0
if (mi != null) {
serviceRepository.setStatusMessage("Channels (${index + 1} / ${mi.maxChannels})")
serviceRepository.setConnectionProgress("Channels (${index + 1} / ${mi.maxChannels})")
} else {
serviceRepository.setStatusMessage("Channels (${index + 1})")
serviceRepository.setConnectionProgress("Channels (${index + 1})")
}
}
}

View file

@ -335,6 +335,14 @@ constructor(
u.session_passkey.let { commandSender.setSessionPasskey(it) }
val fromNum = packet.from
u.get_module_config_response?.let { config ->
if (fromNum == myNodeNum) {
configHandler.handleModuleConfig(config)
} else {
config.statusmessage?.node_status?.let { nodeManager.updateNodeStatus(fromNum, it) }
}
}
if (fromNum == myNodeNum) {
u.get_config_response?.let { configHandler.handleDeviceConfig(it) }
u.get_channel_response?.let { configHandler.handleChannel(it) }

View file

@ -49,6 +49,7 @@ class MeshDataMapper @Inject constructor(private val nodeManager: MeshNodeManage
relayNode = packet.relay_node,
viaMqtt = packet.via_mqtt == true,
emoji = decoded.emoji,
transportMechanism = packet.transport_mechanism.value,
)
}
}

View file

@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.util.isLora
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.LogRecord
@ -205,11 +206,20 @@ constructor(
}
nodeManager.updateNodeInfo(from, withBroadcast = false, channel = packet.channel) {
it.lastHeard = packet.rx_time
it.snr = packet.rx_snr
it.rssi = packet.rx_rssi
it.viaMqtt = packet.via_mqtt == true
it.lastTransport = packet.transport_mechanism.value
val isDirect = packet.hop_start == packet.hop_limit
if (isDirect && packet.isLora() && !it.viaMqtt) {
it.snr = packet.rx_snr
it.rssi = packet.rx_rssi
}
it.hopsAway =
if (decoded.portnum == PortNum.RANGE_TEST_APP) {
0
} else if (it.viaMqtt) {
-1
} else if (packet.hop_start == 0 && (decoded.bitfield ?: 0) == 0) {
-1
} else if (packet.hop_limit > packet.hop_start) {

View file

@ -214,7 +214,11 @@ constructor(
}
fun handleReceivedNodeStatus(fromNum: Int, s: StatusMessage) {
updateNodeInfo(fromNum) { it.nodeStatus = s.status }
updateNodeStatus(fromNum, s.status)
}
fun updateNodeStatus(nodeNum: Int, status: String) {
updateNodeInfo(nodeNum) { it.nodeStatus = status }
}
fun installNodeInfo(info: ProtoNodeInfo, withBroadcast: Boolean = true) {

View file

@ -72,7 +72,7 @@ class FromRadioPacketHandlerTest {
handler.handleFromRadio(proto)
verify { router.configFlowManager.handleNodeInfo(nodeInfo) }
verify { serviceRepository.setStatusMessage(any()) }
verify { serviceRepository.setConnectionProgress(any()) }
}
@Test

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -93,8 +93,10 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
AutoMigration(from = 32, to = 33),
AutoMigration(from = 33, to = 34, spec = AutoMigration33to34::class),
AutoMigration(from = 34, to = 35, spec = AutoMigration34to35::class),
AutoMigration(from = 35, to = 36),
AutoMigration(from = 36, to = 37),
],
version = 35,
version = 37,
exportSchema = true,
)
@TypeConverters(Converters::class)

View file

@ -33,6 +33,7 @@ import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
@ -65,6 +66,7 @@ data class NodeWithRelations(
notes = notes,
manuallyVerified = manuallyVerified,
nodeStatus = nodeStatus,
lastTransport = lastTransport,
)
}
@ -89,6 +91,7 @@ data class NodeWithRelations(
notes = notes,
manuallyVerified = manuallyVerified,
nodeStatus = nodeStatus,
lastTransport = lastTransport,
)
}
}
@ -140,6 +143,8 @@ data class NodeEntity(
@ColumnInfo(name = "manually_verified", defaultValue = "0")
var manuallyVerified: Boolean = false, // ONLY set true when scanned/imported manually
@ColumnInfo(name = "node_status") var nodeStatus: String? = null,
/** The transport mechanism this node was last heard over (see [MeshPacket.TransportMechanism]). */
@ColumnInfo(name = "last_transport", defaultValue = "0") var lastTransport: Int = 0,
) {
val deviceMetrics: org.meshtastic.proto.DeviceMetrics?
get() = deviceTelemetry.device_metrics
@ -199,6 +204,7 @@ data class NodeEntity(
publicKey = publicKey ?: user.public_key,
notes = notes,
nodeStatus = nodeStatus,
lastTransport = lastTransport,
)
fun toNodeInfo() = NodeInfo(
@ -243,5 +249,6 @@ data class NodeEntity(
environmentTelemetry.time,
),
hopsAway = hopsAway,
nodeStatus = nodeStatus,
)
}

View file

@ -58,6 +58,7 @@ data class PacketEntity(
relayNode = data.relayNode,
relays = data.relays,
filtered = filtered,
transportMechanism = data.transportMechanism,
)
}
}

View file

@ -46,6 +46,7 @@ import org.meshtastic.core.strings.routing_error_rate_limit_exceeded
import org.meshtastic.core.strings.routing_error_timeout
import org.meshtastic.core.strings.routing_error_too_large
import org.meshtastic.core.strings.unrecognized
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Routing
@Suppress("CyclomaticComplexMethod")
@ -92,6 +93,8 @@ data class Message(
val relayNode: Int? = null,
val relays: Int = 0,
val filtered: Boolean = false,
/** The transport mechanism this packet arrived over (see [MeshPacket.TransportMechanism]). */
val transportMechanism: Int = 0,
) {
fun getStatusStringRes(): Pair<StringResource, StringResource> {
val title = if (routingError > 0) Res.string.error else Res.string.message_delivery_status

View file

@ -29,9 +29,11 @@ import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Paxcount
import org.meshtastic.proto.Position
import org.meshtastic.proto.PowerMetrics
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
@Suppress("MagicNumber")
@ -57,6 +59,8 @@ data class Node(
val notes: String = "",
val manuallyVerified: Boolean = false,
val nodeStatus: String? = null,
/** The transport mechanism this node was last heard over (see [MeshPacket.TransportMechanism]). */
val lastTransport: Int = 0,
) {
val capabilities: Capabilities by lazy { Capabilities(metadata?.firmware_version) }
@ -175,6 +179,32 @@ data class Node(
fun getTelemetryStrings(isFahrenheit: Boolean = false): List<String> =
environmentMetrics.getDisplayStrings(isFahrenheit)
fun toEntity() = NodeEntity(
num = num,
user = user,
position = position,
latitude = latitude,
longitude = longitude,
snr = snr,
rssi = rssi,
lastHeard = lastHeard,
deviceTelemetry = Telemetry(device_metrics = deviceMetrics),
channel = channel,
viaMqtt = viaMqtt,
hopsAway = hopsAway,
isFavorite = isFavorite,
isIgnored = isIgnored,
isMuted = isMuted,
environmentTelemetry = Telemetry(environment_metrics = environmentMetrics),
powerTelemetry = Telemetry(power_metrics = powerMetrics),
paxcounter = paxcounter,
publicKey = publicKey ?: user.public_key,
notes = notes,
manuallyVerified = manuallyVerified,
nodeStatus = nodeStatus,
lastTransport = lastTransport,
)
}
fun Config.DeviceConfig.Role?.isUnmessageableRole(): Boolean = this in

View file

@ -27,6 +27,7 @@ import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.util.ByteStringParceler
import org.meshtastic.core.model.util.ByteStringSerializer
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Waypoint
@ -70,6 +71,8 @@ data class DataPacket(
@Serializable(with = ByteStringSerializer::class)
@TypeParceler<ByteString?, ByteStringParceler>
var sfppHash: ByteString? = null,
/** The transport mechanism this packet arrived over (see [MeshPacket.TransportMechanism]). */
var transportMechanism: Int = 0,
) : Parcelable {
fun readFromParcel(parcel: Parcel) {
@ -108,6 +111,7 @@ data class DataPacket(
viaMqtt = parcel.readInt() != 0
emoji = parcel.readInt()
sfppHash = ByteStringParceler.create(parcel)
transportMechanism = parcel.readInt()
}
/** If there was an error with this message, this string describes what was wrong. */

View file

@ -200,6 +200,7 @@ data class NodeInfo(
var channel: Int = 0,
var environmentMetrics: EnvironmentMetrics? = null,
var hopsAway: Int = 0,
var nodeStatus: String? = null,
) : Parcelable {
@Suppress("MagicNumber")

View file

@ -68,3 +68,9 @@ fun Int.mpsToMph(): Float {
val mph = this * MPS_TO_KMPH * KM_TO_MILES
return mph
}
/** Returns true if this packet arrived via a LoRa transport mechanism. */
fun MeshPacket.isLora(): Boolean = transport_mechanism == MeshPacket.TransportMechanism.TRANSPORT_LORA ||
transport_mechanism == MeshPacket.TransportMechanism.TRANSPORT_LORA_ALT1 ||
transport_mechanism == MeshPacket.TransportMechanism.TRANSPORT_LORA_ALT2 ||
transport_mechanism == MeshPacket.TransportMechanism.TRANSPORT_LORA_ALT3

View file

@ -87,13 +87,13 @@ class ServiceRepository @Inject constructor() {
_errorMessage.value = null
}
private val _statusMessage = MutableStateFlow<String?>(null)
val statusMessage: StateFlow<String?>
get() = _statusMessage
private val _connectionProgress = MutableStateFlow<String?>(null)
val connectionProgress: StateFlow<String?>
get() = _connectionProgress
fun setStatusMessage(text: String) {
fun setConnectionProgress(text: String) {
if (connectionState.value != ConnectionState.Connected) {
_statusMessage.value = text
_connectionProgress.value = text
}
}

View file

@ -48,6 +48,9 @@
<string name="node_sort_last_heard">Last heard</string>
<string name="node_sort_via_mqtt">via MQTT</string>
<string name="via_mqtt">via MQTT</string>
<string name="via_udp">via UDP</string>
<string name="via_api">via API</string>
<string name="internal">Internal</string>
<string name="node_sort_via_favorite">via Favorite</string>
<string name="node_filter_show_ignored">Only show ignored Nodes</string>
<string name="unrecognized">Unrecognized</string>

View file

@ -0,0 +1,50 @@
/*
* 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
* 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 org.meshtastic.core.ui.component
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.internal
import org.meshtastic.core.strings.via_api
import org.meshtastic.core.strings.via_mqtt
import org.meshtastic.core.strings.via_udp
import org.meshtastic.core.ui.icon.Api
import org.meshtastic.core.ui.icon.Cloud
import org.meshtastic.core.ui.icon.Device
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Udp
import org.meshtastic.proto.MeshPacket
@Composable
fun TransportIcon(transport: Int, viaMqtt: Boolean, modifier: Modifier = Modifier) {
val (icon, description) =
when {
viaMqtt || transport == MeshPacket.TransportMechanism.TRANSPORT_MQTT.value ->
MeshtasticIcons.Cloud to stringResource(Res.string.via_mqtt)
transport == MeshPacket.TransportMechanism.TRANSPORT_MULTICAST_UDP.value ->
MeshtasticIcons.Udp to stringResource(Res.string.via_udp)
transport == MeshPacket.TransportMechanism.TRANSPORT_API.value ->
MeshtasticIcons.Api to stringResource(Res.string.via_api)
transport == MeshPacket.TransportMechanism.TRANSPORT_INTERNAL.value ->
MeshtasticIcons.Device to stringResource(Res.string.internal)
else -> return
}
Icon(icon, contentDescription = description, modifier = modifier)
}

View file

@ -28,10 +28,13 @@ import androidx.compose.material.icons.rounded.Cloud
import androidx.compose.material.icons.rounded.CloudOff
import androidx.compose.material.icons.rounded.Dangerous
import androidx.compose.material.icons.rounded.History
import androidx.compose.material.icons.rounded.Lan
import androidx.compose.material.icons.rounded.NoCell
import androidx.compose.material.icons.rounded.SettingsEthernet
import androidx.compose.material.icons.rounded.SpeakerNotesOff
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarBorder
import androidx.compose.material.icons.rounded.Terminal
import androidx.compose.material.icons.twotone.Cloud
import androidx.compose.material.icons.twotone.CloudDone
import androidx.compose.material.icons.twotone.CloudOff
@ -84,3 +87,10 @@ val MeshtasticIcons.CheckCircle: ImageVector
val MeshtasticIcons.Acknowledged: ImageVector
get() = Icons.TwoTone.HowToReg
val MeshtasticIcons.Udp: ImageVector
get() = Icons.Rounded.Lan
val MeshtasticIcons.Api: ImageVector
get() = Icons.Rounded.Terminal
val MeshtasticIcons.Ethernet: ImageVector
get() = Icons.Rounded.SettingsEthernet

View file

@ -70,15 +70,14 @@ import org.meshtastic.core.strings.filter_message_label
import org.meshtastic.core.strings.message_delivery_status
import org.meshtastic.core.strings.reply
import org.meshtastic.core.strings.sample_message
import org.meshtastic.core.strings.via_mqtt
import org.meshtastic.core.ui.component.AutoLinkText
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.component.Rssi
import org.meshtastic.core.ui.component.Snr
import org.meshtastic.core.ui.component.TransportIcon
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.emoji.EmojiPicker
import org.meshtastic.core.ui.icon.Acknowledged
import org.meshtastic.core.ui.icon.Cloud
import org.meshtastic.core.ui.icon.CloudDone
import org.meshtastic.core.ui.icon.CloudOffTwoTone
import org.meshtastic.core.ui.icon.CloudSync
@ -239,13 +238,11 @@ internal fun MessageItem(
maxLines = 1,
style = MaterialTheme.typography.labelMedium,
)
if (message.viaMqtt) {
Icon(
MeshtasticIcons.Cloud,
contentDescription = stringResource(Res.string.via_mqtt),
modifier = Modifier.size(16.dp),
)
}
TransportIcon(
transport = message.transportMechanism,
viaMqtt = message.viaMqtt,
modifier = Modifier.size(16.dp),
)
}
}
Surface(
@ -292,7 +289,7 @@ internal fun MessageItem(
Row(modifier = Modifier, verticalAlignment = Alignment.CenterVertically) {
if (!message.fromLocal) {
if (message.hopsAway == 0) {
if (message.hopsAway == 0 && !message.viaMqtt) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Snr(message.snr)
Rssi(message.rssi)
@ -309,7 +306,12 @@ internal fun MessageItem(
tint = cardColors.contentColor.copy(alpha = 0.7f),
)
Text(
text = message.hopsAway.toString(),
text =
if (message.hopsAway >= 0) {
message.hopsAway.toString()
} else {
"?"
},
style = MaterialTheme.typography.labelSmall,
)
}

View file

@ -73,6 +73,7 @@ fun DeviceDetailsSection(state: MetricsState, modifier: Modifier = Modifier) {
Spacer(modifier = Modifier.height(16.dp))
SectionDivider()
val deviceText =
state.reportedTarget?.let { target -> "${deviceHardware.displayName} ($target)" }
?: deviceHardware.displayName

View file

@ -14,6 +14,8 @@
* 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("TooManyFunctions")
package org.meshtastic.feature.node.component
import android.content.ClipData
@ -30,6 +32,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Notes
import androidx.compose.material.icons.rounded.Numbers
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -48,6 +51,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
@ -69,10 +73,12 @@ import org.meshtastic.core.strings.role
import org.meshtastic.core.strings.rssi
import org.meshtastic.core.strings.short_name
import org.meshtastic.core.strings.snr
import org.meshtastic.core.strings.status_message
import org.meshtastic.core.strings.supported
import org.meshtastic.core.strings.uptime
import org.meshtastic.core.strings.user_id
import org.meshtastic.core.strings.via_mqtt
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.icon.ArrowCircleUp
import org.meshtastic.core.ui.icon.ChannelUtilization
import org.meshtastic.core.ui.icon.Cloud
@ -84,6 +90,7 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Person
import org.meshtastic.core.ui.icon.Role
import org.meshtastic.core.ui.icon.Verified
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.util.formatAgo
@Composable
@ -135,13 +142,17 @@ private fun MismatchKeyWarning(modifier: Modifier = Modifier) {
private fun MainNodeDetails(node: Node) {
Column {
NameAndRoleRow(node)
node.nodeStatus?.let { status ->
SectionDivider()
StatusMessageRow(status)
}
SectionDivider()
NodeIdentificationRow(node)
SectionDivider()
HearsAndHopsRow(node)
SectionDivider()
UserAndUptimeRow(node)
if (node.hopsAway == 0) {
if (node.hopsAway == 0 && !node.viaMqtt) {
SectionDivider()
SignalRow(node)
}
@ -175,6 +186,16 @@ private fun NameAndRoleRow(node: Node) {
}
}
@Composable
private fun StatusMessageRow(status: String) {
InfoItem(
label = stringResource(Res.string.status_message),
value = status,
icon = Icons.AutoMirrored.Rounded.Notes,
modifier = Modifier.fillMaxWidth(),
)
}
@Composable
private fun NodeIdentificationRow(node: Node) {
Row(modifier = Modifier.fillMaxWidth()) {
@ -352,3 +373,12 @@ private fun PublicKeyItem(publicKeyBytes: ByteArray) {
)
}
}
@PreviewLightDark
@Composable
private fun NodeDetailsSectionPreview() {
AppTheme {
val node = NodePreviewParameterProvider().values.last().copy(nodeStatus = "Going to the farm.. to grow wheat.")
NodeDetailsSection(node = node)
}
}

View file

@ -30,9 +30,12 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Notes
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
@ -84,6 +87,7 @@ import org.meshtastic.core.ui.component.Snr
import org.meshtastic.core.ui.component.SoilMoistureInfo
import org.meshtastic.core.ui.component.SoilTemperatureInfo
import org.meshtastic.core.ui.component.TemperatureInfo
import org.meshtastic.core.ui.component.TransportIcon
import org.meshtastic.core.ui.component.determineSignalQuality
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.icon.AirUtilization
@ -171,6 +175,28 @@ fun NodeItem(
contentColor = contentColor,
)
thatNode.nodeStatus?.let { status ->
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.Notes,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = contentColor.copy(alpha = 0.7f),
)
Text(
text = status,
style = MaterialTheme.typography.bodyMedium,
color = contentColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
NodeBatteryPositionRow(
thatNode = thatNode,
distance = distance,
@ -252,7 +278,7 @@ private fun NodeSignalRow(thatNode: Node, isThisNode: Boolean, contentColor: Col
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
if (thatNode.hopsAway > 0) {
HopsInfo(hops = thatNode.hopsAway, contentColor = contentColor)
} else {
} else if (thatNode.hopsAway == 0 && !thatNode.viaMqtt) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
@ -395,13 +421,21 @@ private fun NodeItemHeader(
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = longName,
style = MaterialTheme.typography.titleMediumEmphasized.copy(fontStyle = style),
textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = longName,
style = MaterialTheme.typography.titleMediumEmphasized.copy(fontStyle = style),
textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
TransportIcon(
transport = thatNode.lastTransport,
viaMqtt = thatNode.viaMqtt,
modifier = Modifier.size(16.dp),
)
}
LastHeardInfo(lastHeard = thatNode.lastHeard, showLabel = false, contentColor = contentColor)
}
@ -439,6 +473,17 @@ fun NodeInfoSimplePreview() {
}
}
@Composable
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
fun NodeInfoStatusPreview() {
AppTheme {
val thisNode = NodePreviewParameterProvider().values.first()
val thatNode =
NodePreviewParameterProvider().values.last().copy(nodeStatus = "Going to the farm.. to grow wheat.")
NodeItem(thisNode = thisNode, thatNode = thatNode, 0, true, connectionState = ConnectionState.Connected)
}
}
@Composable
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
fun NodeInfoSignalPreview() {

View file

@ -1,274 +0,0 @@
/*
* 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
* 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 org.meshtastic.feature.node.detail
import android.Manifest
import android.content.Intent
import android.provider.Settings
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.ui.component.SharedContactDialog
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.feature.node.compass.CompassUiState
import org.meshtastic.feature.node.compass.CompassViewModel
import org.meshtastic.feature.node.component.AdministrationSection
import org.meshtastic.feature.node.component.CompassSheetContent
import org.meshtastic.feature.node.component.DeviceActions
import org.meshtastic.feature.node.component.DeviceDetailsSection
import org.meshtastic.feature.node.component.FirmwareReleaseSheetContent
import org.meshtastic.feature.node.component.NodeDetailsSection
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.component.NotesSection
import org.meshtastic.feature.node.component.PositionSection
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
@Composable
fun NodeDetailContent(
node: Node,
ourNode: Node?,
metricsState: MetricsState,
lastTracerouteTime: Long?,
lastRequestNeighborsTime: Long?,
availableLogs: Set<LogsType>,
onAction: (NodeDetailAction) -> Unit,
onSaveNotes: (nodeNum: Int, notes: String) -> Unit,
modifier: Modifier = Modifier,
) {
var showShareDialog by remember { mutableStateOf(false) }
if (showShareDialog) {
SharedContactDialog(node) { showShareDialog = false }
}
NodeDetailList(
node = node,
lastTracerouteTime = lastTracerouteTime,
lastRequestNeighborsTime = lastRequestNeighborsTime,
ourNode = ourNode,
metricsState = metricsState,
onAction = { action ->
if (action is NodeDetailAction.ShareContact) {
showShareDialog = true
} else {
onAction(action)
}
},
modifier = modifier,
availableLogs = availableLogs,
onSaveNotes = onSaveNotes,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Suppress("LongMethod")
fun NodeDetailList(
node: Node,
lastTracerouteTime: Long?,
lastRequestNeighborsTime: Long?,
ourNode: Node?,
metricsState: MetricsState,
onAction: (NodeDetailAction) -> Unit,
availableLogs: Set<LogsType>,
onSaveNotes: (Int, String) -> Unit,
modifier: Modifier = Modifier,
) {
var showFirmwareSheet by remember { mutableStateOf(false) }
var selectedFirmware by remember { mutableStateOf<FirmwareRelease?>(null) }
var showCompassSheet by remember { mutableStateOf(false) }
val inspectionMode = LocalInspectionMode.current
val compassViewModel = if (inspectionMode) null else hiltViewModel<CompassViewModel>()
val compassUiState by
compassViewModel?.uiState?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(CompassUiState()) }
var compassTargetNode by remember { mutableStateOf<Node?>(null) }
val permissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { _ -> }
val locationSettingsLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { _ -> }
FirmwareSheetHost(
showFirmwareSheet = showFirmwareSheet,
onDismiss = { showFirmwareSheet = false },
firmwareRelease = selectedFirmware,
)
CompassSheetHost(
showCompassSheet = showCompassSheet,
compassViewModel = compassViewModel,
compassUiState = compassUiState,
onDismiss = { showCompassSheet = false },
permissionLauncher = permissionLauncher,
locationSettingsLauncher = locationSettingsLauncher,
onRequestPosition = {
compassTargetNode?.let { target ->
onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestPosition(target)))
}
},
)
Column(
modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp).focusable(),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
NodeDetailsSection(node)
DeviceActions(
isLocal = metricsState.isLocal,
lastTracerouteTime = lastTracerouteTime,
lastRequestNeighborsTime = lastRequestNeighborsTime,
node = node,
availableLogs = availableLogs,
onAction = onAction,
metricsState = metricsState,
)
PositionSection(
node = node,
ourNode = ourNode,
metricsState = metricsState,
availableLogs = availableLogs,
onAction = { action ->
when (action) {
is NodeDetailAction.OpenCompass -> {
compassViewModel?.start(action.node, action.displayUnits)
compassTargetNode = action.node
showCompassSheet = compassViewModel != null
}
else -> onAction(action)
}
},
)
if (metricsState.deviceHardware != null) {
DeviceDetailsSection(metricsState)
}
NotesSection(node = node, onSaveNotes = onSaveNotes)
if (!metricsState.isManaged) {
AdministrationSection(
node = node,
metricsState = metricsState,
onAction = onAction,
onFirmwareSelect = { firmware ->
selectedFirmware = firmware
showFirmwareSheet = true
},
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FirmwareSheetHost(showFirmwareSheet: Boolean, onDismiss: () -> Unit, firmwareRelease: FirmwareRelease?) {
if (showFirmwareSheet) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
firmwareRelease?.let { FirmwareReleaseSheetContent(firmwareRelease = it) }
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Suppress("LongParameterList")
private fun CompassSheetHost(
showCompassSheet: Boolean,
compassViewModel: CompassViewModel?,
compassUiState: CompassUiState,
onDismiss: () -> Unit,
permissionLauncher: ManagedActivityResultLauncher<Array<String>, Map<String, Boolean>>,
locationSettingsLauncher: ManagedActivityResultLauncher<Intent, ActivityResult>,
onRequestPosition: () -> Unit,
) {
if (showCompassSheet && compassViewModel != null) {
DisposableEffect(Unit) { onDispose { compassViewModel.stop() } }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
ModalBottomSheet(
onDismissRequest = {
compassViewModel.stop()
onDismiss()
},
sheetState = sheetState,
) {
CompassSheetContent(
uiState = compassUiState,
onRequestLocationPermission = {
permissionLauncher.launch(
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION),
)
},
onOpenLocationSettings = {
locationSettingsLauncher.launch(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS))
},
onRequestPosition = onRequestPosition,
modifier = Modifier.padding(bottom = 24.dp),
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun NodeDetailsPreview(@PreviewParameter(NodePreviewParameterProvider::class) node: Node) {
AppTheme {
NodeDetailList(
node = node,
ourNode = node,
lastTracerouteTime = null,
lastRequestNeighborsTime = null,
metricsState = MetricsState.Companion.Empty,
availableLogs = emptySet(),
onAction = {},
onSaveNotes = { _, _ -> },
)
}
}

View file

@ -52,6 +52,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -61,9 +63,12 @@ import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.details
import org.meshtastic.core.strings.loading
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.SharedContactDialog
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.feature.node.compass.CompassUiState
import org.meshtastic.feature.node.compass.CompassViewModel
import org.meshtastic.feature.node.component.AdministrationSection
@ -75,6 +80,7 @@ import org.meshtastic.feature.node.component.NodeDetailsSection
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.component.NotesSection
import org.meshtastic.feature.node.component.PositionSection
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
private sealed interface NodeDetailOverlay {
@ -143,7 +149,8 @@ private fun NodeDetailScaffold(
modifier = modifier,
topBar = {
MainAppBar(
title = node?.user?.long_name ?: "",
title = getString(Res.string.details),
subtitle = node?.user?.long_name ?: "",
ourNode = uiState.ourNode,
showNodeChip = false,
canNavigateUp = true,
@ -343,3 +350,26 @@ private fun handleNodeAction(
else -> {}
}
}
@Preview(showBackground = true)
@Composable
private fun NodeDetailListPreview(@PreviewParameter(NodePreviewParameterProvider::class) node: Node) {
AppTheme {
val uiState =
NodeDetailUiState(
node = node,
ourNode = node,
metricsState = MetricsState(node = node, isLocal = true, isManaged = false),
availableLogs = emptySet(),
)
NodeDetailList(
node = node,
ourNode = node,
uiState = uiState,
listState = rememberLazyListState(),
onAction = {},
onFirmwareSelect = {},
onSaveNotes = { _, _ -> },
)
}
}

View file

@ -46,6 +46,7 @@ import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.util.isLora
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
@ -216,7 +217,13 @@ constructor(
deviceMetrics = data.telemetry.filter { it.device_metrics != null },
powerMetrics = data.telemetry.filter { it.power_metrics != null },
hostMetrics = data.telemetry.filter { it.host_metrics != null },
signalMetrics = data.packets.filter { (it.rx_time ?: 0) > 0 },
signalMetrics =
data.packets.filter { pkt ->
(pkt.rx_time ?: 0) > 0 &&
pkt.hop_start == pkt.hop_limit &&
pkt.via_mqtt != true &&
pkt.isLora()
},
positionLogs = data.positionPackets.mapNotNull { it.toPosition() },
paxMetrics = data.paxLogs,
tracerouteRequests = data.tracerouteRequests,