diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt
index 661bbd832..f634450c4 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshActionHandler.kt
@@ -75,6 +75,7 @@ constructor(
when (action) {
is ServiceAction.Favorite -> handleFavorite(action, myNodeNum)
is ServiceAction.Ignore -> handleIgnore(action, myNodeNum)
+ is ServiceAction.Mute -> handleMute(action, myNodeNum)
is ServiceAction.Reaction -> handleReaction(action, myNodeNum)
is ServiceAction.ImportContact -> handleImportContact(action, myNodeNum)
is ServiceAction.SendContact -> {
@@ -103,6 +104,12 @@ constructor(
nodeManager.updateNodeInfo(node.num) { it.isIgnored = !node.isIgnored }
}
+ private fun handleMute(action: ServiceAction.Mute, myNodeNum: Int) {
+ val node = action.node
+ commandSender.sendAdmin(myNodeNum) { toggleMutedNode = node.num }
+ nodeManager.updateNodeInfo(node.num) { it.isMuted = !node.isMuted }
+ }
+
private fun handleReaction(action: ServiceAction.Reaction, myNodeNum: Int) {
val channel = action.contactKey[0].digitToInt()
val destId = action.contactKey.substring(1)
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt
index 1580ba388..a22e7de3d 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt
@@ -614,17 +614,17 @@ constructor(
}
insert(packetToSave)
- val isMuted = getContactSettings(contactKey).isMuted
- if (!isMuted) {
- if (packetToSave.port_num == Portnums.PortNum.ALERT_APP_VALUE) {
- serviceNotifications.showAlertNotification(
- contactKey,
- getSenderName(dataPacket),
- dataPacket.alert ?: getString(Res.string.critical_alert),
- )
- } else if (updateNotification) {
- scope.handledLaunch { updateNotification(contactKey, dataPacket) }
- }
+ val conversationMuted = getContactSettings(contactKey).isMuted
+ val nodeMuted = nodeManager.nodeDBbyID[dataPacket.from]?.isMuted == true
+ val isSilent = conversationMuted || nodeMuted
+ if (packetToSave.port_num == Portnums.PortNum.ALERT_APP_VALUE && !isSilent) {
+ serviceNotifications.showAlertNotification(
+ contactKey,
+ getSenderName(dataPacket),
+ dataPacket.alert ?: getString(Res.string.critical_alert),
+ )
+ } else if (updateNotification) {
+ scope.handledLaunch { updateNotification(contactKey, dataPacket, isSilent) }
}
}
}
@@ -638,7 +638,7 @@ constructor(
return nodeManager.nodeDBbyID[packet.from]?.user?.longName ?: getString(Res.string.unknown_username)
}
- private suspend fun updateNotification(contactKey: String, dataPacket: DataPacket) {
+ private suspend fun updateNotification(contactKey: String, dataPacket: DataPacket, isSilent: Boolean) {
when (dataPacket.dataType) {
Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> {
val message = dataPacket.text!!
@@ -654,6 +654,7 @@ constructor(
message,
dataPacket.to == DataPacket.ID_BROADCAST,
channelName,
+ isSilent,
)
}
@@ -664,6 +665,7 @@ constructor(
getSenderName(dataPacket),
message,
dataPacket.waypoint!!.id,
+ isSilent,
)
}
@@ -712,26 +714,27 @@ constructor(
// Find the original packet to get the contactKey
packetRepository.get().getPacketByPacketId(packet.decoded.replyId)?.let { original ->
val contactKey = original.packet.contact_key
- val isMuted = packetRepository.get().getContactSettings(contactKey).isMuted
- if (!isMuted) {
- val channelName =
- if (original.packet.data.to == DataPacket.ID_BROADCAST) {
- radioConfigRepository.channelSetFlow
- .first()
- .settingsList
- .getOrNull(original.packet.data.channel)
- ?.name
- } else {
- null
- }
- serviceNotifications.updateReactionNotification(
- contactKey,
- getSenderName(dataMapper.toDataPacket(packet)!!),
- emoji,
- original.packet.data.to == DataPacket.ID_BROADCAST,
- channelName,
- )
- }
+ val conversationMuted = packetRepository.get().getContactSettings(contactKey).isMuted
+ val nodeMuted = nodeManager.nodeDBbyID[fromId]?.isMuted == true
+ val isSilent = conversationMuted || nodeMuted
+ val channelName =
+ if (original.packet.data.to == DataPacket.ID_BROADCAST) {
+ radioConfigRepository.channelSetFlow
+ .first()
+ .settingsList
+ .getOrNull(original.packet.data.channel)
+ ?.name
+ } else {
+ null
+ }
+ serviceNotifications.updateReactionNotification(
+ contactKey,
+ getSenderName(dataMapper.toDataPacket(packet)!!),
+ emoji,
+ original.packet.data.to == DataPacket.ID_BROADCAST,
+ channelName,
+ isSilent,
+ )
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt
index eea557131..615f1eae3 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt
@@ -239,6 +239,7 @@ constructor(
entity.hopsAway = if (info.hasHopsAway()) info.hopsAway else -1
entity.isFavorite = info.isFavorite
entity.isIgnored = info.isIgnored
+ entity.isMuted = info.isMuted
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt
index dda2c7664..3fd81570d 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt
@@ -313,8 +313,9 @@ constructor(
message: String,
isBroadcast: Boolean,
channelName: String?,
+ isSilent: Boolean,
) {
- showConversationNotification(contactKey, isBroadcast, channelName)
+ showConversationNotification(contactKey, isBroadcast, channelName, isSilent = isSilent)
}
override suspend fun updateReactionNotification(
@@ -323,8 +324,9 @@ constructor(
emoji: String,
isBroadcast: Boolean,
channelName: String?,
+ isSilent: Boolean,
) {
- showConversationNotification(contactKey, isBroadcast, channelName, isSilent = isBroadcast)
+ showConversationNotification(contactKey, isBroadcast, channelName, isSilent = isSilent)
}
override suspend fun updateWaypointNotification(
@@ -332,8 +334,9 @@ constructor(
name: String,
message: String,
waypointId: Int,
+ isSilent: Boolean,
) {
- val notification = createWaypointNotification(name, message, waypointId)
+ val notification = createWaypointNotification(name, message, waypointId, isSilent)
notificationManager.notify(contactKey.hashCode(), notification)
}
@@ -571,19 +574,30 @@ constructor(
return builder.build()
}
- private fun createWaypointNotification(name: String, message: String, waypointId: Int): Notification {
+ private fun createWaypointNotification(
+ name: String,
+ message: String,
+ waypointId: Int,
+ isSilent: Boolean,
+ ): Notification {
val person = Person.Builder().setName(name).build()
val style = NotificationCompat.MessagingStyle(person).addMessage(message, System.currentTimeMillis(), person)
- return commonBuilder(NotificationType.Waypoint, createOpenWaypointIntent(waypointId))
- .setCategory(Notification.CATEGORY_MESSAGE)
- .setAutoCancel(true)
- .setStyle(style)
- .setGroup(GROUP_KEY_MESSAGES)
- .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
- .setWhen(System.currentTimeMillis())
- .setShowWhen(true)
- .build()
+ val builder =
+ commonBuilder(NotificationType.Waypoint, createOpenWaypointIntent(waypointId))
+ .setCategory(Notification.CATEGORY_MESSAGE)
+ .setAutoCancel(true)
+ .setStyle(style)
+ .setGroup(GROUP_KEY_MESSAGES)
+ .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
+ .setWhen(System.currentTimeMillis())
+ .setShowWhen(true)
+
+ if (isSilent) {
+ builder.setSilent(true)
+ }
+
+ return builder.build()
}
private fun createAlertNotification(contactKey: String, name: String, alert: String): Notification {
diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts
index 00c914148..0c5815cef 100644
--- a/core/database/build.gradle.kts
+++ b/core/database/build.gradle.kts
@@ -16,23 +16,6 @@
*/
import com.android.build.api.dsl.LibraryExtension
-/*
- * 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 .
- */
-
plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.android.room)
@@ -60,6 +43,9 @@ dependencies {
ksp(libs.androidx.room.compiler)
+ testImplementation(libs.junit)
+ testImplementation(libs.kotlinx.coroutines.test)
+
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.room.testing)
diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/31.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/31.json
new file mode 100644
index 000000000..20c513501
--- /dev/null
+++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/31.json
@@ -0,0 +1,992 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 31,
+ "identityHash": "21cc0da9018ae840a3d58cb667049cdd",
+ "entities": [
+ {
+ "tableName": "my_node",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, PRIMARY KEY(`myNodeNum`))",
+ "fields": [
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "model",
+ "columnName": "model",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "firmwareVersion",
+ "columnName": "firmwareVersion",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "couldUpdate",
+ "columnName": "couldUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "shouldUpdate",
+ "columnName": "shouldUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currentPacketId",
+ "columnName": "currentPacketId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "messageTimeoutMsec",
+ "columnName": "messageTimeoutMsec",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "minAppVersion",
+ "columnName": "minAppVersion",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "maxChannels",
+ "columnName": "maxChannels",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasWifi",
+ "columnName": "hasWifi",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceId",
+ "columnName": "deviceId",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "myNodeNum"
+ ]
+ }
+ },
+ {
+ "tableName": "nodes",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `manually_verified` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))",
+ "fields": [
+ {
+ "fieldPath": "num",
+ "columnName": "num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "user",
+ "columnName": "user",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longName",
+ "columnName": "long_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "shortName",
+ "columnName": "short_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastHeard",
+ "columnName": "last_heard",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceTelemetry",
+ "columnName": "device_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channel",
+ "columnName": "channel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viaMqtt",
+ "columnName": "via_mqtt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hops_away",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isFavorite",
+ "columnName": "is_favorite",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isIgnored",
+ "columnName": "is_ignored",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isMuted",
+ "columnName": "is_muted",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "environmentTelemetry",
+ "columnName": "environment_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "powerTelemetry",
+ "columnName": "power_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "paxcounter",
+ "columnName": "paxcounter",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "public_key",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "notes",
+ "columnName": "notes",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "manuallyVerified",
+ "columnName": "manually_verified",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_nodes_last_heard",
+ "unique": false,
+ "columnNames": [
+ "last_heard"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)"
+ },
+ {
+ "name": "index_nodes_short_name",
+ "unique": false,
+ "columnNames": [
+ "short_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)"
+ },
+ {
+ "name": "index_nodes_long_name",
+ "unique": false,
+ "columnNames": [
+ "long_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)"
+ },
+ {
+ "name": "index_nodes_hops_away",
+ "unique": false,
+ "columnNames": [
+ "hops_away"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)"
+ },
+ {
+ "name": "index_nodes_is_favorite",
+ "unique": false,
+ "columnNames": [
+ "is_favorite"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)"
+ },
+ {
+ "name": "index_nodes_last_heard_is_favorite",
+ "unique": false,
+ "columnNames": [
+ "last_heard",
+ "is_favorite"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)"
+ }
+ ]
+ },
+ {
+ "tableName": "packet",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB)",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "port_num",
+ "columnName": "port_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "contact_key",
+ "columnName": "contact_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "received_time",
+ "columnName": "received_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "read",
+ "columnName": "read",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "1"
+ },
+ {
+ "fieldPath": "data",
+ "columnName": "data",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "packetId",
+ "columnName": "packet_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "routingError",
+ "columnName": "routing_error",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hopsAway",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "sfpp_hash",
+ "columnName": "sfpp_hash",
+ "affinity": "BLOB"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_packet_myNodeNum",
+ "unique": false,
+ "columnNames": [
+ "myNodeNum"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)"
+ },
+ {
+ "name": "index_packet_port_num",
+ "unique": false,
+ "columnNames": [
+ "port_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)"
+ },
+ {
+ "name": "index_packet_contact_key",
+ "unique": false,
+ "columnNames": [
+ "contact_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)"
+ },
+ {
+ "name": "index_packet_contact_key_port_num_received_time",
+ "unique": false,
+ "columnNames": [
+ "contact_key",
+ "port_num",
+ "received_time"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)"
+ },
+ {
+ "name": "index_packet_packet_id",
+ "unique": false,
+ "columnNames": [
+ "packet_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "contact_settings",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, PRIMARY KEY(`contact_key`))",
+ "fields": [
+ {
+ "fieldPath": "contact_key",
+ "columnName": "contact_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "muteUntil",
+ "columnName": "muteUntil",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastReadMessageUuid",
+ "columnName": "last_read_message_uuid",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastReadMessageTimestamp",
+ "columnName": "last_read_message_timestamp",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "contact_key"
+ ]
+ }
+ },
+ {
+ "tableName": "log",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message_type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "received_date",
+ "columnName": "received_date",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "raw_message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fromNum",
+ "columnName": "from_num",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "portNum",
+ "columnName": "port_num",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "fromRadio",
+ "columnName": "from_radio",
+ "affinity": "BLOB",
+ "notNull": true,
+ "defaultValue": "x''"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_log_from_num",
+ "unique": false,
+ "columnNames": [
+ "from_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)"
+ },
+ {
+ "name": "index_log_port_num",
+ "unique": false,
+ "columnNames": [
+ "port_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)"
+ }
+ ]
+ },
+ {
+ "tableName": "quick_chat",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mode",
+ "columnName": "mode",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uuid"
+ ]
+ }
+ },
+ {
+ "tableName": "reactions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `retry_count` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))",
+ "fields": [
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "replyId",
+ "columnName": "reply_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "emoji",
+ "columnName": "emoji",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hopsAway",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "packetId",
+ "columnName": "packet_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "routingError",
+ "columnName": "routing_error",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "retryCount",
+ "columnName": "retry_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "relays",
+ "columnName": "relays",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "relayNode",
+ "columnName": "relay_node",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "to",
+ "columnName": "to",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "channel",
+ "columnName": "channel",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "sfpp_hash",
+ "columnName": "sfpp_hash",
+ "affinity": "BLOB"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "myNodeNum",
+ "reply_id",
+ "user_id",
+ "emoji"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_reactions_reply_id",
+ "unique": false,
+ "columnNames": [
+ "reply_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)"
+ },
+ {
+ "name": "index_reactions_packet_id",
+ "unique": false,
+ "columnNames": [
+ "packet_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "metadata",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))",
+ "fields": [
+ {
+ "fieldPath": "num",
+ "columnName": "num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "proto",
+ "columnName": "proto",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_metadata_num",
+ "unique": false,
+ "columnNames": [
+ "num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)"
+ }
+ ]
+ },
+ {
+ "tableName": "device_hardware",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`hwModel`))",
+ "fields": [
+ {
+ "fieldPath": "activelySupported",
+ "columnName": "actively_supported",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "architecture",
+ "columnName": "architecture",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayName",
+ "columnName": "display_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasInkHud",
+ "columnName": "has_ink_hud",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hasMui",
+ "columnName": "has_mui",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hwModel",
+ "columnName": "hwModel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hwModelSlug",
+ "columnName": "hw_model_slug",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "images",
+ "columnName": "images",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "last_updated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "partitionScheme",
+ "columnName": "partition_scheme",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "platformioTarget",
+ "columnName": "platformio_target",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requiresDfu",
+ "columnName": "requires_dfu",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "supportLevel",
+ "columnName": "support_level",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "tags",
+ "columnName": "tags",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "hwModel"
+ ]
+ }
+ },
+ {
+ "tableName": "firmware_release",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pageUrl",
+ "columnName": "page_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseNotes",
+ "columnName": "release_notes",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "zipUrl",
+ "columnName": "zip_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "last_updated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseType",
+ "columnName": "release_type",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "traceroute_node_position",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "logUuid",
+ "columnName": "log_uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requestId",
+ "columnName": "request_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nodeNum",
+ "columnName": "node_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "BLOB",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "log_uuid",
+ "node_num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_traceroute_node_position_log_uuid",
+ "unique": false,
+ "columnNames": [
+ "log_uuid"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)"
+ },
+ {
+ "name": "index_traceroute_node_position_request_id",
+ "unique": false,
+ "columnNames": [
+ "request_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "log",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "log_uuid"
+ ],
+ "referencedColumns": [
+ "uuid"
+ ]
+ }
+ ]
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '21cc0da9018ae840a3d58cb667049cdd')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
index e248f0114..67d60e30e 100644
--- a/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
+++ b/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
@@ -88,8 +88,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
AutoMigration(from = 27, to = 28),
AutoMigration(from = 28, to = 29),
AutoMigration(from = 29, to = 30, spec = AutoMigration29to30::class),
+ AutoMigration(from = 30, to = 31),
],
- version = 30,
+ version = 31,
exportSchema = true,
)
@TypeConverters(Converters::class)
diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt
index ff7ad1efd..53df68dd9 100644
--- a/core/database/src/main/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt
+++ b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt
@@ -106,6 +106,9 @@ interface NodeInfoDao {
paxcounter = incomingNode.paxcounter,
channel = incomingNode.channel,
viaMqtt = incomingNode.viaMqtt,
+ isFavorite = incomingNode.isFavorite,
+ isIgnored = incomingNode.isIgnored,
+ isMuted = incomingNode.isMuted,
notes = resolvedNotes,
)
}
diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt
index 6435e65fc..8f36af52e 100644
--- a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt
+++ b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt
@@ -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 .
*/
-
package org.meshtastic.core.database.entity
import androidx.room.ColumnInfo
@@ -57,6 +56,7 @@ data class NodeWithRelations(
hopsAway = hopsAway,
isFavorite = isFavorite,
isIgnored = isIgnored,
+ isMuted = isMuted,
environmentMetrics = environmentTelemetry.environmentMetrics,
powerMetrics = powerTelemetry.powerMetrics,
paxcounter = paxcounter,
@@ -79,6 +79,7 @@ data class NodeWithRelations(
hopsAway = hopsAway,
isFavorite = isFavorite,
isIgnored = isIgnored,
+ isMuted = isMuted,
environmentTelemetry = environmentTelemetry,
powerTelemetry = powerTelemetry,
paxcounter = paxcounter,
@@ -127,6 +128,7 @@ data class NodeEntity(
@ColumnInfo(name = "hops_away") var hopsAway: Int = -1,
@ColumnInfo(name = "is_favorite") var isFavorite: Boolean = false,
@ColumnInfo(name = "is_ignored", defaultValue = "0") var isIgnored: Boolean = false,
+ @ColumnInfo(name = "is_muted", defaultValue = "0") var isMuted: Boolean = false,
@ColumnInfo(name = "environment_metrics", typeAffinity = ColumnInfo.BLOB)
var environmentTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.newBuilder().build(),
@ColumnInfo(name = "power_metrics", typeAffinity = ColumnInfo.BLOB)
@@ -186,6 +188,7 @@ data class NodeEntity(
hopsAway = hopsAway,
isFavorite = isFavorite,
isIgnored = isIgnored,
+ isMuted = isMuted,
environmentMetrics = environmentTelemetry.environmentMetrics,
powerMetrics = powerTelemetry.powerMetrics,
paxcounter = paxcounter,
diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/model/Node.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/model/Node.kt
index 76914750f..5f3a32b05 100644
--- a/core/database/src/main/kotlin/org/meshtastic/core/database/model/Node.kt
+++ b/core/database/src/main/kotlin/org/meshtastic/core/database/model/Node.kt
@@ -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,13 +14,13 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.core.database.model
import android.graphics.Color
import com.google.protobuf.ByteString
import com.google.protobuf.kotlin.isNotEmpty
import org.meshtastic.core.database.entity.NodeEntity
+import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.util.GPSFormat
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.model.util.latLongToMeter
@@ -48,6 +48,7 @@ data class Node(
val hopsAway: Int = -1,
val isFavorite: Boolean = false,
val isIgnored: Boolean = false,
+ val isMuted: Boolean = false,
val environmentMetrics: EnvironmentMetrics = EnvironmentMetrics.getDefaultInstance(),
val powerMetrics: PowerMetrics = PowerMetrics.getDefaultInstance(),
val paxcounter: PaxcountProtos.Paxcount = PaxcountProtos.Paxcount.getDefaultInstance(),
@@ -55,6 +56,8 @@ data class Node(
val notes: String = "",
val manuallyVerified: Boolean = false,
) {
+ val capabilities: Capabilities by lazy { Capabilities(metadata?.firmwareVersion) }
+
val colors: Pair
get() { // returns foreground and background @ColorInt for each 'num'
val r = (num and 0xFF0000) shr 16
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/Capabilities.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/Capabilities.kt
new file mode 100644
index 000000000..5b867aea7
--- /dev/null
+++ b/core/model/src/main/kotlin/org/meshtastic/core/model/Capabilities.kt
@@ -0,0 +1,55 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.core.model
+
+/**
+ * Defines the capabilities and feature support based on the device firmware version.
+ *
+ * This class provides a centralized way to check if specific features are supported by the connected node's firmware.
+ * Add new features here to ensure consistency across the app.
+ */
+data class Capabilities(val firmwareVersion: String?) {
+ private val version = firmwareVersion?.let { DeviceVersion(it) }
+
+ /**
+ * Ability to mute notifications from specific nodes via admin messages.
+ *
+ * Note: This is currently not available in firmware but defined here for future support.
+ */
+ val canMuteNode: Boolean
+ get() = version != null && version >= DeviceVersion("2.8.0")
+
+ /** Ability to request neighbor information from other nodes. Supported since firmware v2.7.15. */
+ val canRequestNeighborInfo: Boolean
+ get() = version != null && version >= DeviceVersion("2.7.15")
+
+ /** Ability to send verified shared contacts. Supported since firmware v2.7.12. */
+ val canSendVerifiedContacts: Boolean
+ get() = version != null && version >= DeviceVersion("2.7.12")
+
+ /** Ability to toggle device telemetry globally via module config. Supported since firmware v2.7.12. */
+ val canToggleTelemetryEnabled: Boolean
+ get() = version != null && version >= DeviceVersion("2.7.12")
+
+ /** Ability to toggle the 'is_unmessageable' flag in user config. Supported since firmware v2.6.9. */
+ val canToggleUnmessageable: Boolean
+ get() = version != null && version >= DeviceVersion("2.6.9")
+
+ /** Support for sharing contact information via QR codes. Supported since firmware v2.6.8. */
+ val supportsQrCodeSharing: Boolean
+ get() = version != null && version >= DeviceVersion("2.6.8")
+}
diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/util/Extensions.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/util/Extensions.kt
index 05ff8018a..638f9ef2b 100644
--- a/core/model/src/main/kotlin/org/meshtastic/core/model/util/Extensions.kt
+++ b/core/model/src/main/kotlin/org/meshtastic/core/model/util/Extensions.kt
@@ -16,7 +16,6 @@
*/
package org.meshtastic.core.model.util
-import android.widget.EditText
import org.meshtastic.core.model.BuildConfig
import org.meshtastic.proto.ConfigProtos
import org.meshtastic.proto.MeshProtos
@@ -66,18 +65,6 @@ fun Any.toPIIString() = if (!BuildConfig.DEBUG) {
fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) }
-@Suppress("MagicNumber")
-fun formatAgo(lastSeenUnix: Int, currentTimeMillis: Long = System.currentTimeMillis()): String {
- val timeInMillis = lastSeenUnix * 1000L
- return android.text.format.DateUtils.getRelativeTimeSpanString(
- timeInMillis,
- currentTimeMillis,
- android.text.format.DateUtils.MINUTE_IN_MILLIS,
- android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE,
- )
- .toString()
-}
-
private const val MPS_TO_KMPH = 3.6f
private const val KM_TO_MILES = 0.621371f
@@ -92,13 +79,3 @@ fun Int.mpsToMph(): Float {
val mph = this * MPS_TO_KMPH * KM_TO_MILES
return mph
}
-
-// Allows usage like email.onEditorAction(EditorInfo.IME_ACTION_NEXT, { confirm() })
-fun EditText.onEditorAction(actionId: Int, func: () -> Unit) {
- setOnEditorActionListener { _, receivedActionId, _ ->
- if (actionId == receivedActionId) {
- func()
- }
- true
- }
-}
diff --git a/core/model/src/test/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt b/core/model/src/test/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt
new file mode 100644
index 000000000..acfa00eb2
--- /dev/null
+++ b/core/model/src/test/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt
@@ -0,0 +1,86 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.core.model
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class CapabilitiesTest {
+
+ @Test
+ fun `canMuteNode requires v2 8 0`() {
+ assertFalse(Capabilities("2.7.15").canMuteNode)
+ assertFalse(Capabilities("2.7.99").canMuteNode)
+ assertTrue(Capabilities("2.8.0").canMuteNode)
+ assertTrue(Capabilities("2.8.1").canMuteNode)
+ }
+
+ @Test
+ fun `canRequestNeighborInfo requires v2 7 15`() {
+ assertFalse(Capabilities("2.7.14").canRequestNeighborInfo)
+ assertTrue(Capabilities("2.7.15").canRequestNeighborInfo)
+ assertTrue(Capabilities("2.8.0").canRequestNeighborInfo)
+ }
+
+ @Test
+ fun `canSendVerifiedContacts requires v2 7 12`() {
+ assertFalse(Capabilities("2.7.11").canSendVerifiedContacts)
+ assertTrue(Capabilities("2.7.12").canSendVerifiedContacts)
+ assertTrue(Capabilities("2.7.15").canSendVerifiedContacts)
+ }
+
+ @Test
+ fun `canToggleTelemetryEnabled requires v2 7 12`() {
+ assertFalse(Capabilities("2.7.11").canToggleTelemetryEnabled)
+ assertTrue(Capabilities("2.7.12").canToggleTelemetryEnabled)
+ }
+
+ @Test
+ fun `canToggleUnmessageable requires v2 6 9`() {
+ assertFalse(Capabilities("2.6.8").canToggleUnmessageable)
+ assertTrue(Capabilities("2.6.9").canToggleUnmessageable)
+ }
+
+ @Test
+ fun `supportsQrCodeSharing requires v2 6 8`() {
+ assertFalse(Capabilities("2.6.7").supportsQrCodeSharing)
+ assertTrue(Capabilities("2.6.8").supportsQrCodeSharing)
+ }
+
+ @Test
+ fun `null firmware returns all false`() {
+ val caps = Capabilities(null)
+ assertFalse(caps.canMuteNode)
+ assertFalse(caps.canRequestNeighborInfo)
+ assertFalse(caps.canSendVerifiedContacts)
+ assertFalse(caps.canToggleTelemetryEnabled)
+ assertFalse(caps.canToggleUnmessageable)
+ assertFalse(caps.supportsQrCodeSharing)
+ }
+
+ @Test
+ fun `invalid firmware returns all false`() {
+ val caps = Capabilities("invalid")
+ assertFalse(caps.canMuteNode)
+ assertFalse(caps.canRequestNeighborInfo)
+ assertFalse(caps.canSendVerifiedContacts)
+ assertFalse(caps.canToggleTelemetryEnabled)
+ assertFalse(caps.canToggleUnmessageable)
+ assertFalse(caps.supportsQrCodeSharing)
+ }
+}
diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/MeshServiceNotifications.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/MeshServiceNotifications.kt
index 297711d9e..7fd9f950a 100644
--- a/core/service/src/main/kotlin/org/meshtastic/core/service/MeshServiceNotifications.kt
+++ b/core/service/src/main/kotlin/org/meshtastic/core/service/MeshServiceNotifications.kt
@@ -37,9 +37,16 @@ interface MeshServiceNotifications {
message: String,
isBroadcast: Boolean,
channelName: String?,
+ isSilent: Boolean = false,
)
- suspend fun updateWaypointNotification(contactKey: String, name: String, message: String, waypointId: Int)
+ suspend fun updateWaypointNotification(
+ contactKey: String,
+ name: String,
+ message: String,
+ waypointId: Int,
+ isSilent: Boolean = false,
+ )
suspend fun updateReactionNotification(
contactKey: String,
@@ -47,6 +54,7 @@ interface MeshServiceNotifications {
emoji: String,
isBroadcast: Boolean,
channelName: String?,
+ isSilent: Boolean = false,
)
fun showAlertNotification(contactKey: String, name: String, alert: String)
diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceAction.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceAction.kt
index c9779f36d..a1fc3d27c 100644
--- a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceAction.kt
+++ b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceAction.kt
@@ -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 .
*/
-
package org.meshtastic.core.service
import org.meshtastic.core.database.model.Node
@@ -27,6 +26,8 @@ sealed class ServiceAction {
data class Ignore(val node: Node) : ServiceAction()
+ data class Mute(val node: Node) : ServiceAction()
+
data class Reaction(val emoji: String, val replyId: Int, val contactKey: String) : ServiceAction()
data class ImportContact(val contact: AdminProtos.SharedContact) : ServiceAction()
diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml
index 28d543fca..109f1a87a 100644
--- a/core/strings/src/commonMain/composeResources/values/strings.xml
+++ b/core/strings/src/commonMain/composeResources/values/strings.xml
@@ -372,9 +372,11 @@
Currently:
Always muted
Not muted
- Muted for %1d days, %.1f hours
- Muted for %.1f hours
+ Muted for %1$d days, %2$.1f hours
+ Muted for %1$.1f hours
Mute status
+ Mute notifications for '%1$s'?
+ Unmute notifications for '%1$s'?
Replace
Scan WiFi QR code
Invalid WiFi Credential QR code format
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt
index e8b8cd202..9bc6449d0 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt
@@ -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 .
*/
-
package org.meshtastic.core.ui.component
import android.Manifest
@@ -57,7 +56,6 @@ import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
-import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.qr_code
import org.meshtastic.core.strings.scan_qr_code
@@ -197,7 +195,6 @@ val Uri.qrCode: Bitmap?
null
}
-private const val REQUIRED_MIN_FIRMWARE = "2.6.8"
private const val BARCODE_PIXEL_SIZE = 960
private const val MESHTASTIC_HOST = "meshtastic.org"
private const val CONTACT_SHARE_PATH = "/v/"
@@ -207,9 +204,6 @@ 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
-/** Checks if the device firmware version supports QR code sharing. */
-fun DeviceVersion.supportsQrCodeSharing(): Boolean = this >= DeviceVersion(REQUIRED_MIN_FIRMWARE)
-
/**
* Converts a URI to a [AdminProtos.SharedContact].
*
diff --git a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
index 1382cc560..ed92476fa 100644
--- a/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
+++ b/feature/messaging/src/main/kotlin/org/meshtastic/feature/messaging/MessageViewModel.kt
@@ -40,8 +40,8 @@ import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.DataPacket
-import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.core.service.ServiceAction
@@ -52,8 +52,6 @@ import org.meshtastic.proto.channelSet
import org.meshtastic.proto.sharedContact
import javax.inject.Inject
-private const val VERIFIED_CONTACT_FIRMWARE_CUTOFF = "2.7.12"
-
@Suppress("LongParameterList", "TooManyFunctions")
@HiltViewModel
class MessageViewModel
@@ -155,19 +153,14 @@ constructor(
val fwVersion = ourNodeInfo.value?.metadata?.firmwareVersion
val destNode = nodeRepository.getNode(dest)
val isClientBase = ourNodeInfo.value?.user?.role == Role.CLIENT_BASE
- fwVersion?.let { fw ->
- val ver = DeviceVersion(asString = fw)
- val verifiedSharedContactsVersion =
- DeviceVersion(
- asString = VERIFIED_CONTACT_FIRMWARE_CUTOFF,
- ) // Version cutover to verified shared contacts
- if (ver >= verifiedSharedContactsVersion) {
- sendSharedContact(destNode)
- } else {
- if (!destNode.isFavorite && !isClientBase) {
- favoriteNode(destNode)
- }
+ val capabilities = Capabilities(fwVersion)
+
+ if (capabilities.canSendVerifiedContacts) {
+ sendSharedContact(destNode)
+ } else {
+ if (!destNode.isFavorite && !isClientBase) {
+ favoriteNode(destNode)
}
}
}
diff --git a/feature/node/component/DeviceActions.kt b/feature/node/component/DeviceActions.kt
new file mode 100644
index 000000000..d509c118e
--- /dev/null
+++ b/feature/node/component/DeviceActions.kt
@@ -0,0 +1,261 @@
+/*
+ * 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 org.meshtastic.feature.node.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Message
+import androidx.compose.material.icons.automirrored.filled.VolumeOff
+import androidx.compose.material.icons.automirrored.filled.VolumeUp
+import androidx.compose.material.icons.automirrored.outlined.VolumeMute
+import androidx.compose.material.icons.filled.Star
+import androidx.compose.material.icons.filled.StarBorder
+import androidx.compose.material.icons.rounded.Delete
+import androidx.compose.material.icons.rounded.QrCode2
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconToggleButton
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.strings.Res
+import org.meshtastic.core.strings.actions
+import org.meshtastic.core.strings.direct_message
+import org.meshtastic.core.strings.favorite
+import org.meshtastic.core.strings.ignore
+import org.meshtastic.core.strings.mute_always
+import org.meshtastic.core.strings.remove
+import org.meshtastic.core.strings.share_contact
+import org.meshtastic.core.strings.unmute
+import org.meshtastic.core.ui.component.ListItem
+import org.meshtastic.core.ui.component.SwitchListItem
+import org.meshtastic.feature.node.model.NodeDetailAction
+import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
+
+@Composable
+fun DeviceActions(
+ node: Node,
+ lastTracerouteTime: Long?,
+ lastRequestNeighborsTime: Long?,
+ onAction: (NodeDetailAction) -> Unit,
+ modifier: Modifier = Modifier,
+ isLocal: Boolean = false,
+) {
+ var displayFavoriteDialog by remember { mutableStateOf(false) }
+ var displayIgnoreDialog by remember { mutableStateOf(false) }
+ var displayMuteDialog by remember { mutableStateOf(false) }
+ var displayRemoveDialog by remember { mutableStateOf(false) }
+
+ NodeActionDialogs(
+ node = node,
+ displayFavoriteDialog = displayFavoriteDialog,
+ displayIgnoreDialog = displayIgnoreDialog,
+ displayMuteDialog = displayMuteDialog,
+ displayRemoveDialog = displayRemoveDialog,
+ onDismissMenuRequest = {
+ displayFavoriteDialog = false
+ displayIgnoreDialog = false
+ displayMuteDialog = false
+ displayRemoveDialog = false
+ },
+ onConfirmFavorite = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Favorite(it))) },
+ onConfirmIgnore = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Ignore(it))) },
+ onConfirmMute = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Mute(it))) },
+ onConfirmRemove = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(it))) },
+ )
+
+ ElevatedCard(
+ modifier = modifier.fillMaxWidth(),
+ colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
+ shape = MaterialTheme.shapes.extraLarge,
+ ) {
+ DeviceActionsContent(
+ node = node,
+ isLocal = isLocal,
+ lastTracerouteTime = lastTracerouteTime,
+ lastRequestNeighborsTime = lastRequestNeighborsTime,
+ onAction = onAction,
+ onFavoriteClick = { displayFavoriteDialog = true },
+ onIgnoreClick = { displayIgnoreDialog = true },
+ onMuteClick = { displayMuteDialog = true },
+ onRemoveClick = { displayRemoveDialog = true },
+ )
+ }
+}
+
+@Composable
+private fun DeviceActionsContent(
+ node: Node,
+ isLocal: Boolean,
+ lastTracerouteTime: Long?,
+ lastRequestNeighborsTime: Long?,
+ onAction: (NodeDetailAction) -> Unit,
+ onFavoriteClick: () -> Unit,
+ onIgnoreClick: () -> Unit,
+ onMuteClick: () -> Unit,
+ onRemoveClick: () -> Unit,
+) {
+ Column(modifier = Modifier.padding(vertical = 12.dp)) {
+ Text(
+ text = stringResource(Res.string.actions),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
+ )
+
+ PrimaryActionsRow(node, isLocal, onAction, onFavoriteClick)
+
+ if (!isLocal) {
+ HorizontalDivider(
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
+ )
+
+ RemoteDeviceActions(
+ node = node,
+ lastTracerouteTime = lastTracerouteTime,
+ lastRequestNeighborsTime = lastRequestNeighborsTime,
+ onAction = onAction,
+ )
+ }
+
+ HorizontalDivider(
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
+ )
+
+ ManagementActions(node, onIgnoreClick, onMuteClick, onRemoveClick)
+ }
+}
+
+@Composable
+private fun PrimaryActionsRow(
+ node: Node,
+ isLocal: Boolean,
+ onAction: (NodeDetailAction) -> Unit,
+ onFavoriteClick: () -> Unit,
+) {
+ Row(
+ modifier = Modifier.padding(horizontal = 20.dp).fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (!node.isEffectivelyUnmessageable && !isLocal) {
+ Button(
+ onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.DirectMessage(node))) },
+ modifier = Modifier.weight(1f),
+ shape = MaterialTheme.shapes.large,
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ ),
+ ) {
+ Icon(Icons.AutoMirrored.Filled.Message, contentDescription = null)
+ Spacer(Modifier.width(8.dp))
+ Text(stringResource(Res.string.direct_message))
+ }
+ }
+
+ OutlinedButton(
+ onClick = { onAction(NodeDetailAction.ShareContact) },
+ modifier = if (node.isEffectivelyUnmessageable || isLocal) Modifier.weight(1f) else Modifier,
+ shape = MaterialTheme.shapes.large,
+ ) {
+ Icon(Icons.Rounded.QrCode2, contentDescription = null)
+ if (node.isEffectivelyUnmessageable || isLocal) {
+ Spacer(Modifier.width(8.dp))
+ Text(stringResource(Res.string.share_contact))
+ }
+ }
+
+ IconToggleButton(checked = node.isFavorite, onCheckedChange = { onFavoriteClick() }) {
+ Icon(
+ imageVector = if (node.isFavorite) Icons.Default.Star else Icons.Default.StarBorder,
+ contentDescription = stringResource(Res.string.favorite),
+ tint = if (node.isFavorite) Color.Yellow else LocalContentColor.current,
+ )
+ }
+ }
+}
+
+@Composable
+private fun ManagementActions(
+ node: Node,
+ onIgnoreClick: () -> Unit,
+ onMuteClick: () -> Unit,
+ onRemoveClick: () -> Unit,
+) {
+ Column {
+ SwitchListItem(
+ text = stringResource(Res.string.ignore),
+ leadingIcon =
+ if (node.isIgnored) {
+ Icons.AutoMirrored.Outlined.VolumeMute
+ } else {
+ Icons.AutoMirrored.Default.VolumeUp
+ },
+ checked = node.isIgnored,
+ onClick = onIgnoreClick,
+ )
+
+ SwitchListItem(
+ text = stringResource(if (node.isMuted) Res.string.unmute else Res.string.mute_always),
+ leadingIcon = if (node.isMuted) {
+ Icons.AutoMirrored.Filled.VolumeOff
+ } else {
+ Icons.AutoMirrored.Default.VolumeUp
+ },
+ checked = node.isMuted,
+ onClick = onMuteClick,
+ )
+
+ ListItem(
+ text = stringResource(Res.string.remove),
+ leadingIcon = Icons.Rounded.Delete,
+ trailingIcon = null,
+ textColor = MaterialTheme.colorScheme.error,
+ leadingIconTint = MaterialTheme.colorScheme.error,
+ onClick = onRemoveClick,
+ )
+ }
+}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt
index 5cdf1062f..05d4eadcc 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt
@@ -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 .
*/
-
package org.meshtastic.feature.node.component
import androidx.compose.foundation.layout.Arrangement
@@ -26,6 +25,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Message
+import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.automirrored.outlined.VolumeMute
import androidx.compose.material.icons.filled.Star
@@ -60,6 +60,7 @@ import org.meshtastic.core.strings.actions
import org.meshtastic.core.strings.direct_message
import org.meshtastic.core.strings.favorite
import org.meshtastic.core.strings.ignore
+import org.meshtastic.core.strings.mute_notifications
import org.meshtastic.core.strings.remove
import org.meshtastic.core.strings.share_contact
import org.meshtastic.core.ui.component.ListItem
@@ -67,6 +68,13 @@ import org.meshtastic.core.ui.component.SwitchListItem
import org.meshtastic.feature.node.model.NodeDetailAction
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
+private enum class DialogType {
+ FAVORITE,
+ IGNORE,
+ MUTE,
+ REMOVE,
+}
+
@Composable
fun DeviceActions(
node: Node,
@@ -76,22 +84,18 @@ fun DeviceActions(
modifier: Modifier = Modifier,
isLocal: Boolean = false,
) {
- var displayFavoriteDialog by remember { mutableStateOf(false) }
- var displayIgnoreDialog by remember { mutableStateOf(false) }
- var displayRemoveDialog by remember { mutableStateOf(false) }
+ var displayedDialog by remember { mutableStateOf(null) }
NodeActionDialogs(
node = node,
- displayFavoriteDialog = displayFavoriteDialog,
- displayIgnoreDialog = displayIgnoreDialog,
- displayRemoveDialog = displayRemoveDialog,
- onDismissMenuRequest = {
- displayFavoriteDialog = false
- displayIgnoreDialog = false
- displayRemoveDialog = false
- },
+ displayFavoriteDialog = displayedDialog == DialogType.FAVORITE,
+ displayIgnoreDialog = displayedDialog == DialogType.IGNORE,
+ displayMuteDialog = displayedDialog == DialogType.MUTE,
+ displayRemoveDialog = displayedDialog == DialogType.REMOVE,
+ onDismissMenuRequest = { displayedDialog = null },
onConfirmFavorite = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Favorite(it))) },
onConfirmIgnore = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Ignore(it))) },
+ onConfirmMute = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Mute(it))) },
onConfirmRemove = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(it))) },
)
@@ -101,21 +105,17 @@ fun DeviceActions(
shape = MaterialTheme.shapes.extraLarge,
) {
Column(modifier = Modifier.padding(vertical = 12.dp)) {
- Text(
- text = stringResource(Res.string.actions),
- style = MaterialTheme.typography.titleMedium,
- color = MaterialTheme.colorScheme.primary,
- fontWeight = FontWeight.Bold,
- modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
+ ActionsHeader()
+
+ PrimaryActionsRow(
+ node = node,
+ isLocal = isLocal,
+ onAction = onAction,
+ onFavoriteClick = { displayedDialog = DialogType.FAVORITE },
)
- PrimaryActionsRow(node, isLocal, onAction, onFavoriteClick = { displayFavoriteDialog = true })
-
if (!isLocal) {
- HorizontalDivider(
- modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
- color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
- )
+ ActionsDivider()
RemoteDeviceActions(
node = node,
@@ -125,20 +125,37 @@ fun DeviceActions(
)
}
- HorizontalDivider(
- modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
- color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
- )
+ ActionsDivider()
ManagementActions(
node = node,
- onIgnoreClick = { displayIgnoreDialog = true },
- onRemoveClick = { displayRemoveDialog = true },
+ onIgnoreClick = { displayedDialog = DialogType.IGNORE },
+ onMuteClick = { displayedDialog = DialogType.MUTE },
+ onRemoveClick = { displayedDialog = DialogType.REMOVE },
)
}
}
}
+@Composable
+private fun ActionsHeader() {
+ Text(
+ text = stringResource(Res.string.actions),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
+ )
+}
+
+@Composable
+private fun ActionsDivider() {
+ HorizontalDivider(
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
+ )
+}
+
@Composable
private fun PrimaryActionsRow(
node: Node,
@@ -191,7 +208,12 @@ private fun PrimaryActionsRow(
}
@Composable
-private fun ManagementActions(node: Node, onIgnoreClick: () -> Unit, onRemoveClick: () -> Unit) {
+private fun ManagementActions(
+ node: Node,
+ onIgnoreClick: () -> Unit,
+ onMuteClick: () -> Unit,
+ onRemoveClick: () -> Unit,
+) {
Column {
SwitchListItem(
text = stringResource(Res.string.ignore),
@@ -205,6 +227,20 @@ private fun ManagementActions(node: Node, onIgnoreClick: () -> Unit, onRemoveCli
onClick = onIgnoreClick,
)
+ if (node.capabilities.canMuteNode) {
+ SwitchListItem(
+ text = stringResource(Res.string.mute_notifications),
+ leadingIcon =
+ if (node.isMuted) {
+ Icons.AutoMirrored.Filled.VolumeOff
+ } else {
+ Icons.AutoMirrored.Default.VolumeUp
+ },
+ checked = node.isMuted,
+ onClick = onMuteClick,
+ )
+ }
+
ListItem(
text = stringResource(Res.string.remove),
leadingIcon = Icons.Rounded.Delete,
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
index a6f4cbaea..01a1cdcc2 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt
@@ -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 .
*/
-
package org.meshtastic.feature.node.component
import android.content.res.Configuration
@@ -80,6 +79,7 @@ fun NodeItem(
isActive: Boolean = false,
) {
val isFavorite = remember(thatNode) { thatNode.isFavorite }
+ val isMuted = remember(thatNode) { thatNode.isMuted }
val isIgnored = thatNode.isIgnored
val longName = thatNode.user.longName.ifEmpty { stringResource(Res.string.unknown_username) }
val isThisNode = remember(thatNode) { thisNode?.num == thatNode.num }
@@ -145,6 +145,7 @@ fun NodeItem(
NodeStatusIcons(
isThisNode = isThisNode,
isFavorite = isFavorite,
+ isMuted = isMuted,
isUnmessageable = unmessageable,
connectionState = connectionState,
)
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenu.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenu.kt
index 2d3532de1..4be04a1f4 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenu.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenu.kt
@@ -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 .
*/
-
package org.meshtastic.feature.node.component
import androidx.compose.runtime.Composable
@@ -28,8 +27,12 @@ import org.meshtastic.core.strings.favorite_remove
import org.meshtastic.core.strings.ignore
import org.meshtastic.core.strings.ignore_add
import org.meshtastic.core.strings.ignore_remove
+import org.meshtastic.core.strings.mute_add
+import org.meshtastic.core.strings.mute_notifications
+import org.meshtastic.core.strings.mute_remove
import org.meshtastic.core.strings.remove
import org.meshtastic.core.strings.remove_node_text
+import org.meshtastic.core.strings.unmute
import org.meshtastic.core.ui.component.SimpleAlertDialog
@Composable
@@ -37,10 +40,12 @@ fun NodeActionDialogs(
node: Node,
displayFavoriteDialog: Boolean,
displayIgnoreDialog: Boolean,
+ displayMuteDialog: Boolean,
displayRemoveDialog: Boolean,
onDismissMenuRequest: () -> Unit,
onConfirmFavorite: (Node) -> Unit,
onConfirmIgnore: (Node) -> Unit,
+ onConfirmMute: (Node) -> Unit,
onConfirmRemove: (Node) -> Unit,
) {
if (displayFavoriteDialog) {
@@ -73,6 +78,18 @@ fun NodeActionDialogs(
onDismiss = onDismissMenuRequest,
)
}
+ if (displayMuteDialog) {
+ SimpleAlertDialog(
+ title = if (node.isMuted) Res.string.unmute else Res.string.mute_notifications,
+ text =
+ stringResource(if (node.isMuted) Res.string.mute_remove else Res.string.mute_add, node.user.longName),
+ onConfirm = {
+ onDismissMenuRequest()
+ onConfirmMute(node)
+ },
+ onDismiss = onDismissMenuRequest,
+ )
+ }
if (displayRemoveDialog) {
SimpleAlertDialog(
title = Res.string.remove,
@@ -91,6 +108,8 @@ sealed class NodeMenuAction {
data class Ignore(val node: Node) : NodeMenuAction()
+ data class Mute(val node: Node) : NodeMenuAction()
+
data class Favorite(val node: Node) : NodeMenuAction()
data class DirectMessage(val node: Node) : NodeMenuAction()
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt
index bfdaf6bc4..da8666e31 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeStatusIcons.kt
@@ -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,13 +14,13 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.feature.node.component
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.rounded.NoCell
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.twotone.Cloud
@@ -39,8 +39,11 @@ import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
@@ -49,6 +52,7 @@ import org.meshtastic.core.strings.connecting
import org.meshtastic.core.strings.device_sleeping
import org.meshtastic.core.strings.disconnected
import org.meshtastic.core.strings.favorite
+import org.meshtastic.core.strings.mute_always
import org.meshtastic.core.strings.unmessageable
import org.meshtastic.core.strings.unmonitored_or_infrastructure
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
@@ -56,102 +60,135 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
-@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NodeStatusIcons(
isThisNode: Boolean,
isUnmessageable: Boolean,
isFavorite: Boolean,
+ isMuted: Boolean,
connectionState: ConnectionState,
) {
Row(modifier = Modifier.padding(4.dp)) {
if (isThisNode) {
- TooltipBox(
- positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
- tooltip = {
- PlainTooltip {
- Text(
- stringResource(
- when (connectionState) {
- ConnectionState.Connected -> Res.string.connected
- ConnectionState.Connecting -> Res.string.connecting
- ConnectionState.Disconnected -> Res.string.disconnected
- ConnectionState.DeviceSleep -> Res.string.device_sleeping
- },
- ),
- )
- }
- },
- state = rememberTooltipState(),
- ) {
- when (connectionState) {
- ConnectionState.Connected -> {
- Icon(
- imageVector = Icons.TwoTone.CloudDone,
- contentDescription = stringResource(Res.string.connected),
- modifier = Modifier.size(24.dp), // Smaller size for badge
- tint = MaterialTheme.colorScheme.StatusGreen,
- )
- }
- ConnectionState.Connecting -> {
- Icon(
- imageVector = Icons.TwoTone.CloudSync,
- contentDescription = stringResource(Res.string.connecting),
- modifier = Modifier.size(24.dp), // Smaller size for badge
- tint = MaterialTheme.colorScheme.StatusOrange,
- )
- }
- ConnectionState.Disconnected -> {
- Icon(
- imageVector = Icons.TwoTone.CloudOff,
- contentDescription = stringResource(Res.string.connecting),
- modifier = Modifier.size(24.dp), // Smaller size for badge
- tint = MaterialTheme.colorScheme.StatusRed,
- )
- }
- ConnectionState.DeviceSleep -> {
- Icon(
- imageVector = Icons.TwoTone.Cloud,
- contentDescription = stringResource(Res.string.device_sleeping),
- modifier = Modifier.size(24.dp), // Smaller size for badge
- tint = MaterialTheme.colorScheme.StatusYellow,
- )
- }
- }
- }
+ ThisNodeStatusBadge(connectionState)
}
if (isUnmessageable) {
- TooltipBox(
- positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
- tooltip = { PlainTooltip { Text(stringResource(Res.string.unmonitored_or_infrastructure)) } },
- state = rememberTooltipState(),
- ) {
- IconButton(onClick = {}, modifier = Modifier.size(24.dp)) {
- Icon(
- imageVector = Icons.Rounded.NoCell,
- contentDescription = stringResource(Res.string.unmessageable),
- modifier = Modifier.size(24.dp), // Smaller size for badge
- )
- }
- }
+ StatusBadge(
+ imageVector = Icons.Rounded.NoCell,
+ contentDescription = Res.string.unmessageable,
+ tooltipText = Res.string.unmonitored_or_infrastructure,
+ )
+ }
+ if (isMuted && !isThisNode) {
+ StatusBadge(
+ imageVector = Icons.AutoMirrored.Filled.VolumeOff,
+ contentDescription = Res.string.mute_always,
+ tooltipText = Res.string.mute_always,
+ )
}
if (isFavorite && !isThisNode) {
- TooltipBox(
- positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
- tooltip = { PlainTooltip { Text(stringResource(Res.string.favorite)) } },
- state = rememberTooltipState(),
- ) {
- IconButton(onClick = {}, modifier = Modifier.size(24.dp)) {
- Icon(
- imageVector = Icons.Rounded.Star,
- contentDescription = stringResource(Res.string.favorite),
- modifier = Modifier.size(24.dp), // Smaller size for badge
- tint = MaterialTheme.colorScheme.StatusYellow,
- )
- }
+ StatusBadge(
+ imageVector = Icons.Rounded.Star,
+ contentDescription = Res.string.favorite,
+ tooltipText = Res.string.favorite,
+ tint = MaterialTheme.colorScheme.StatusYellow,
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ThisNodeStatusBadge(connectionState: ConnectionState) {
+ TooltipBox(
+ positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
+ tooltip = {
+ PlainTooltip {
+ Text(
+ stringResource(
+ when (connectionState) {
+ ConnectionState.Connected -> Res.string.connected
+ ConnectionState.Connecting -> Res.string.connecting
+ ConnectionState.Disconnected -> Res.string.disconnected
+ ConnectionState.DeviceSleep -> Res.string.device_sleeping
+ },
+ ),
+ )
}
+ },
+ state = rememberTooltipState(),
+ ) {
+ when (connectionState) {
+ ConnectionState.Connected -> ConnectedStatusIcon()
+ ConnectionState.Connecting -> ConnectingStatusIcon()
+ ConnectionState.Disconnected -> DisconnectedStatusIcon()
+ ConnectionState.DeviceSleep -> DeviceSleepStatusIcon()
+ }
+ }
+}
+
+@Composable
+private fun ConnectedStatusIcon() {
+ Icon(
+ imageVector = Icons.TwoTone.CloudDone,
+ contentDescription = stringResource(Res.string.connected),
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.StatusGreen,
+ )
+}
+
+@Composable
+private fun ConnectingStatusIcon() {
+ Icon(
+ imageVector = Icons.TwoTone.CloudSync,
+ contentDescription = stringResource(Res.string.connecting),
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.StatusOrange,
+ )
+}
+
+@Composable
+private fun DisconnectedStatusIcon() {
+ Icon(
+ imageVector = Icons.TwoTone.CloudOff,
+ contentDescription = stringResource(Res.string.disconnected),
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.StatusRed,
+ )
+}
+
+@Composable
+private fun DeviceSleepStatusIcon() {
+ Icon(
+ imageVector = Icons.TwoTone.Cloud,
+ contentDescription = stringResource(Res.string.device_sleeping),
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.StatusYellow,
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun StatusBadge(
+ imageVector: ImageVector,
+ contentDescription: StringResource,
+ tooltipText: StringResource,
+ tint: Color = Color.Unspecified,
+) {
+ TooltipBox(
+ positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
+ tooltip = { PlainTooltip { Text(stringResource(tooltipText)) } },
+ state = rememberTooltipState(),
+ ) {
+ IconButton(onClick = {}, modifier = Modifier.size(24.dp)) {
+ Icon(
+ imageVector = imageVector,
+ contentDescription = stringResource(contentDescription),
+ modifier = Modifier.size(24.dp),
+ tint = tint,
+ )
}
}
}
@@ -163,6 +200,7 @@ private fun StatusIconsPreview() {
isThisNode = true,
isUnmessageable = true,
isFavorite = true,
+ isMuted = true,
connectionState = ConnectionState.Connected,
)
}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt
index 12e8e6f22..2e7b27947 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt
@@ -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 .
*/
-
package org.meshtastic.feature.node.component
import androidx.compose.foundation.layout.Arrangement
@@ -80,10 +79,14 @@ internal fun RemoteDeviceActions(
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.TraceRoute(node))) },
)
- RequestNeighborsChip(
- lastRequestNeighborsTime = lastRequestNeighborsTime,
- onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestNeighborInfo(node))) },
- )
+ if (node.capabilities.canRequestNeighborInfo) {
+ RequestNeighborsChip(
+ lastRequestNeighborsTime = lastRequestNeighborsTime,
+ onClick = {
+ onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestNeighborInfo(node)))
+ },
+ )
+ }
AssistChip(
onClick = {
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailActions.kt
new file mode 100644
index 000000000..8c7051ef8
--- /dev/null
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailActions.kt
@@ -0,0 +1,79 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.feature.node.detail
+
+import kotlinx.coroutines.CoroutineScope
+import org.meshtastic.core.model.Position
+import org.meshtastic.core.model.TelemetryType
+import org.meshtastic.feature.node.component.NodeMenuAction
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class NodeDetailActions
+@Inject
+constructor(
+ private val nodeManagementActions: NodeManagementActions,
+ private val nodeRequestActions: NodeRequestActions,
+) {
+ private var scope: CoroutineScope? = null
+
+ fun start(coroutineScope: CoroutineScope) {
+ scope = coroutineScope
+ nodeManagementActions.start(coroutineScope)
+ nodeRequestActions.start(coroutineScope)
+ }
+
+ fun handleNodeMenuAction(action: NodeMenuAction) {
+ when (action) {
+ is NodeMenuAction.Remove -> nodeManagementActions.removeNode(action.node.num)
+ is NodeMenuAction.Ignore -> nodeManagementActions.ignoreNode(action.node)
+ is NodeMenuAction.Mute -> nodeManagementActions.muteNode(action.node)
+ is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(action.node)
+ is NodeMenuAction.RequestUserInfo -> nodeRequestActions.requestUserInfo(action.node.num)
+ is NodeMenuAction.RequestNeighborInfo -> nodeRequestActions.requestNeighborInfo(action.node.num)
+ is NodeMenuAction.RequestPosition -> nodeRequestActions.requestPosition(action.node.num)
+ is NodeMenuAction.RequestTelemetry -> nodeRequestActions.requestTelemetry(action.node.num, action.type)
+ is NodeMenuAction.TraceRoute -> nodeRequestActions.requestTraceroute(action.node.num)
+ else -> {}
+ }
+ }
+
+ fun setNodeNotes(nodeNum: Int, notes: String) {
+ nodeManagementActions.setNodeNotes(nodeNum, notes)
+ }
+
+ fun requestPosition(destNum: Int, position: Position) {
+ nodeRequestActions.requestPosition(destNum, position)
+ }
+
+ fun requestUserInfo(destNum: Int) {
+ nodeRequestActions.requestUserInfo(destNum)
+ }
+
+ fun requestNeighborInfo(destNum: Int) {
+ nodeRequestActions.requestNeighborInfo(destNum)
+ }
+
+ fun requestTelemetry(destNum: Int, type: TelemetryType) {
+ nodeRequestActions.requestTelemetry(destNum, type)
+ }
+
+ fun requestTraceroute(destNum: Int) {
+ nodeRequestActions.requestTraceroute(destNum)
+ }
+}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt
index ca3c66b1c..9cbd66e26 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt
@@ -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 .
*/
-
package org.meshtastic.feature.node.detail
import androidx.compose.foundation.layout.Box
@@ -45,40 +44,40 @@ import org.meshtastic.feature.node.model.NodeDetailAction
fun NodeDetailScreen(
nodeId: Int,
modifier: Modifier = Modifier,
- viewModel: MetricsViewModel = hiltViewModel(),
+ metricsViewModel: MetricsViewModel = hiltViewModel(),
nodeDetailViewModel: NodeDetailViewModel = hiltViewModel(),
navigateToMessages: (String) -> Unit = {},
onNavigate: (Route) -> Unit = {},
onNavigateUp: () -> Unit = {},
) {
- LaunchedEffect(nodeId) { viewModel.setNodeId(nodeId) }
+ LaunchedEffect(nodeId) { metricsViewModel.setNodeId(nodeId) }
- val state by viewModel.state.collectAsStateWithLifecycle()
- val environmentState by viewModel.environmentState.collectAsStateWithLifecycle()
+ val metricsState by metricsViewModel.state.collectAsStateWithLifecycle()
+ val environmentMetricsState by metricsViewModel.environmentState.collectAsStateWithLifecycle()
val lastTracerouteTime by nodeDetailViewModel.lastTraceRouteTime.collectAsStateWithLifecycle()
val lastRequestNeighborsTime by nodeDetailViewModel.lastRequestNeighborsTime.collectAsStateWithLifecycle()
val ourNode by nodeDetailViewModel.ourNodeInfo.collectAsStateWithLifecycle()
val availableLogs by
- remember(state, environmentState) {
+ remember(metricsState, environmentMetricsState) {
derivedStateOf {
buildSet {
- if (state.hasDeviceMetrics()) add(LogsType.DEVICE)
- if (state.hasPositionLogs()) {
+ if (metricsState.hasDeviceMetrics()) add(LogsType.DEVICE)
+ if (metricsState.hasPositionLogs()) {
add(LogsType.NODE_MAP)
add(LogsType.POSITIONS)
}
- if (environmentState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT)
- if (state.hasSignalMetrics()) add(LogsType.SIGNAL)
- if (state.hasPowerMetrics()) add(LogsType.POWER)
- if (state.hasTracerouteLogs()) add(LogsType.TRACEROUTE)
- if (state.hasHostMetrics()) add(LogsType.HOST)
- if (state.hasPaxMetrics()) add(LogsType.PAX)
+ if (environmentMetricsState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT)
+ if (metricsState.hasSignalMetrics()) add(LogsType.SIGNAL)
+ if (metricsState.hasPowerMetrics()) add(LogsType.POWER)
+ if (metricsState.hasTracerouteLogs()) add(LogsType.TRACEROUTE)
+ if (metricsState.hasHostMetrics()) add(LogsType.HOST)
+ if (metricsState.hasPaxMetrics()) add(LogsType.PAX)
}
}
}
- val node = state.node
+ val node = metricsState.node
@Suppress("ModifierNotUsedAtRoot")
Scaffold(
@@ -95,11 +94,10 @@ fun NodeDetailScreen(
},
) { paddingValues ->
if (node != null) {
- @Suppress("ViewModelForwarding")
NodeDetailContent(
node = node,
ourNode = ourNode,
- metricsState = state,
+ metricsState = metricsState,
lastTracerouteTime = lastTracerouteTime,
lastRequestNeighborsTime = lastRequestNeighborsTime,
availableLogs = availableLogs,
@@ -111,8 +109,8 @@ fun NodeDetailScreen(
navigateToMessages = navigateToMessages,
onNavigateUp = onNavigateUp,
onNavigate = onNavigate,
- viewModel = viewModel,
- handleNodeMenuAction = { nodeDetailViewModel.handleNodeMenuAction(it) },
+ metricsViewModel = metricsViewModel,
+ nodeDetailViewModel = nodeDetailViewModel,
)
},
modifier = modifier.padding(paddingValues),
@@ -133,26 +131,26 @@ private fun handleNodeAction(
navigateToMessages: (String) -> Unit,
onNavigateUp: () -> Unit,
onNavigate: (Route) -> Unit,
- viewModel: MetricsViewModel,
- handleNodeMenuAction: (NodeMenuAction) -> Unit,
+ metricsViewModel: MetricsViewModel,
+ nodeDetailViewModel: NodeDetailViewModel,
) {
when (action) {
is NodeDetailAction.Navigate -> onNavigate(action.route)
- is NodeDetailAction.TriggerServiceAction -> viewModel.onServiceAction(action.action)
+ is NodeDetailAction.TriggerServiceAction -> metricsViewModel.onServiceAction(action.action)
is NodeDetailAction.HandleNodeMenuAction -> {
when (val menuAction = action.action) {
is NodeMenuAction.DirectMessage -> {
val hasPKC = ourNode?.hasPKC == true
val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel
- navigateToMessages("$channel${node.user.id}")
+ navigateToMessages("${channel}${node.user.id}")
}
is NodeMenuAction.Remove -> {
- handleNodeMenuAction(menuAction)
+ nodeDetailViewModel.handleNodeMenuAction(menuAction)
onNavigateUp()
}
- else -> handleNodeMenuAction(menuAction)
+ else -> nodeDetailViewModel.handleNodeMenuAction(menuAction)
}
}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt
index f0816eea7..19db2b430 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt
@@ -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,25 +14,16 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
package org.meshtastic.feature.node.detail
-import android.os.RemoteException
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import co.touchlab.kermit.Logger
import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.launch
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
-import org.meshtastic.core.model.Position
-import org.meshtastic.core.model.TelemetryType
-import org.meshtastic.core.service.ServiceAction
-import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.feature.node.component.NodeMenuAction
import javax.inject.Inject
@@ -41,9 +32,15 @@ class NodeDetailViewModel
@Inject
constructor(
private val nodeRepository: NodeRepository,
- private val serviceRepository: ServiceRepository,
+ private val nodeManagementActions: NodeManagementActions,
+ private val nodeRequestActions: NodeRequestActions,
) : ViewModel() {
+ init {
+ nodeManagementActions.start(viewModelScope)
+ nodeRequestActions.start(viewModelScope)
+ }
+
val ourNodeInfo: StateFlow = nodeRepository.ourNodeInfo
private val _lastTraceRouteTime = MutableStateFlow(null)
@@ -54,107 +51,26 @@ constructor(
fun handleNodeMenuAction(action: NodeMenuAction) {
when (action) {
- is NodeMenuAction.Remove -> removeNode(action.node.num)
- is NodeMenuAction.Ignore -> ignoreNode(action.node)
- is NodeMenuAction.Favorite -> favoriteNode(action.node)
- is NodeMenuAction.RequestUserInfo -> requestUserInfo(action.node.num)
+ is NodeMenuAction.Remove -> nodeManagementActions.removeNode(action.node.num)
+ is NodeMenuAction.Ignore -> nodeManagementActions.ignoreNode(action.node)
+ is NodeMenuAction.Mute -> nodeManagementActions.muteNode(action.node)
+ is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(action.node)
+ is NodeMenuAction.RequestUserInfo -> nodeRequestActions.requestUserInfo(action.node.num)
is NodeMenuAction.RequestNeighborInfo -> {
- requestNeighborInfo(action.node.num)
+ nodeRequestActions.requestNeighborInfo(action.node.num)
_lastRequestNeighborsTime.value = System.currentTimeMillis()
}
- is NodeMenuAction.RequestPosition -> requestPosition(action.node.num)
- is NodeMenuAction.RequestTelemetry -> requestTelemetry(action.node.num, action.type)
+ is NodeMenuAction.RequestPosition -> nodeRequestActions.requestPosition(action.node.num)
+ is NodeMenuAction.RequestTelemetry -> nodeRequestActions.requestTelemetry(action.node.num, action.type)
is NodeMenuAction.TraceRoute -> {
- requestTraceroute(action.node.num)
+ nodeRequestActions.requestTraceroute(action.node.num)
_lastTraceRouteTime.value = System.currentTimeMillis()
}
-
else -> {}
}
}
- fun setNodeNotes(nodeNum: Int, notes: String) = viewModelScope.launch(Dispatchers.IO) {
- try {
- nodeRepository.setNodeNotes(nodeNum, notes)
- } catch (ex: java.io.IOException) {
- Logger.e { "Set node notes IO error: ${ex.message}" }
- } catch (ex: java.sql.SQLException) {
- Logger.e { "Set node notes SQL error: ${ex.message}" }
- }
- }
-
- private fun removeNode(nodeNum: Int) = viewModelScope.launch(Dispatchers.IO) {
- Logger.i { "Removing node '$nodeNum'" }
- try {
- val packetId = serviceRepository.meshService?.packetId ?: return@launch
- serviceRepository.meshService?.removeByNodenum(packetId, nodeNum)
- nodeRepository.deleteNode(nodeNum)
- } catch (ex: RemoteException) {
- Logger.e { "Remove node error: ${ex.message}" }
- }
- }
-
- private fun ignoreNode(node: Node) = viewModelScope.launch {
- try {
- serviceRepository.onServiceAction(ServiceAction.Ignore(node))
- } catch (ex: RemoteException) {
- Logger.e(ex) { "Ignore node error" }
- }
- }
-
- private fun favoriteNode(node: Node) = viewModelScope.launch {
- try {
- serviceRepository.onServiceAction(ServiceAction.Favorite(node))
- } catch (ex: RemoteException) {
- Logger.e(ex) { "Favorite node error" }
- }
- }
-
- private fun requestUserInfo(destNum: Int) {
- Logger.i { "Requesting UserInfo for '$destNum'" }
- try {
- serviceRepository.meshService?.requestUserInfo(destNum)
- } catch (ex: RemoteException) {
- Logger.e { "Request NodeInfo error: ${ex.message}" }
- }
- }
-
- private fun requestNeighborInfo(destNum: Int) {
- Logger.i { "Requesting NeighborInfo for '$destNum'" }
- try {
- val packetId = serviceRepository.meshService?.packetId ?: return
- serviceRepository.meshService?.requestNeighborInfo(packetId, destNum)
- } catch (ex: RemoteException) {
- Logger.e { "Request NeighborInfo error: ${ex.message}" }
- }
- }
-
- private fun requestPosition(destNum: Int, position: Position = Position(0.0, 0.0, 0)) {
- Logger.i { "Requesting position for '$destNum'" }
- try {
- serviceRepository.meshService?.requestPosition(destNum, position)
- } catch (ex: RemoteException) {
- Logger.e { "Request position error: ${ex.message}" }
- }
- }
-
- private fun requestTelemetry(destNum: Int, type: TelemetryType) {
- Logger.i { "Requesting telemetry for '$destNum'" }
- try {
- val packetId = serviceRepository.meshService?.packetId ?: return
- serviceRepository.meshService?.requestTelemetry(packetId, destNum, type.ordinal)
- } catch (ex: RemoteException) {
- Logger.e { "Request telemetry error: ${ex.message}" }
- }
- }
-
- private fun requestTraceroute(destNum: Int) {
- Logger.i { "Requesting traceroute for '$destNum'" }
- try {
- val packetId = serviceRepository.meshService?.packetId ?: return
- serviceRepository.meshService?.requestTraceroute(packetId, destNum)
- } catch (ex: RemoteException) {
- Logger.e { "Request traceroute error: ${ex.message}" }
- }
+ fun setNodeNotes(nodeNum: Int, notes: String) {
+ nodeManagementActions.setNodeNotes(nodeNum, notes)
}
}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt
new file mode 100644
index 000000000..6d4674558
--- /dev/null
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt
@@ -0,0 +1,98 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.feature.node.detail
+
+import android.os.RemoteException
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import org.meshtastic.core.data.repository.NodeRepository
+import org.meshtastic.core.database.model.Node
+import org.meshtastic.core.service.ServiceAction
+import org.meshtastic.core.service.ServiceRepository
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class NodeManagementActions
+@Inject
+constructor(
+ private val nodeRepository: NodeRepository,
+ private val serviceRepository: ServiceRepository,
+) {
+ private var scope: CoroutineScope? = null
+
+ fun start(coroutineScope: CoroutineScope) {
+ scope = coroutineScope
+ }
+
+ fun removeNode(nodeNum: Int) {
+ scope?.launch(Dispatchers.IO) {
+ Logger.i { "Removing node '$nodeNum'" }
+ try {
+ val packetId = serviceRepository.meshService?.packetId ?: return@launch
+ serviceRepository.meshService?.removeByNodenum(packetId, nodeNum)
+ nodeRepository.deleteNode(nodeNum)
+ } catch (ex: RemoteException) {
+ Logger.e { "Remove node error: ${ex.message}" }
+ }
+ }
+ }
+
+ fun ignoreNode(node: Node) {
+ scope?.launch(Dispatchers.IO) {
+ try {
+ serviceRepository.onServiceAction(ServiceAction.Ignore(node))
+ } catch (ex: RemoteException) {
+ Logger.e(ex) { "Ignore node error" }
+ }
+ }
+ }
+
+ fun muteNode(node: Node) {
+ scope?.launch(Dispatchers.IO) {
+ try {
+ serviceRepository.onServiceAction(ServiceAction.Mute(node))
+ } catch (ex: RemoteException) {
+ Logger.e(ex) { "Mute node error" }
+ }
+ }
+ }
+
+ fun favoriteNode(node: Node) {
+ scope?.launch(Dispatchers.IO) {
+ try {
+ serviceRepository.onServiceAction(ServiceAction.Favorite(node))
+ } catch (ex: RemoteException) {
+ Logger.e(ex) { "Favorite node error" }
+ }
+ }
+ }
+
+ fun setNodeNotes(nodeNum: Int, notes: String) {
+ scope?.launch(Dispatchers.IO) {
+ try {
+ nodeRepository.setNodeNotes(nodeNum, notes)
+ } catch (ex: java.io.IOException) {
+ Logger.e { "Set node notes IO error: ${ex.message}" }
+ } catch (ex: java.sql.SQLException) {
+ Logger.e { "Set node notes SQL error: ${ex.message}" }
+ }
+ }
+ }
+}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt
new file mode 100644
index 000000000..dd3413753
--- /dev/null
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt
@@ -0,0 +1,95 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.feature.node.detail
+
+import android.os.RemoteException
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import org.meshtastic.core.model.Position
+import org.meshtastic.core.model.TelemetryType
+import org.meshtastic.core.service.ServiceRepository
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class NodeRequestActions @Inject constructor(private val serviceRepository: ServiceRepository) {
+ private var scope: CoroutineScope? = null
+
+ fun start(coroutineScope: CoroutineScope) {
+ scope = coroutineScope
+ }
+
+ fun requestUserInfo(destNum: Int) {
+ scope?.launch(Dispatchers.IO) {
+ Logger.i { "Requesting UserInfo for '$destNum'" }
+ try {
+ serviceRepository.meshService?.requestUserInfo(destNum)
+ } catch (ex: RemoteException) {
+ Logger.e { "Request NodeInfo error: ${ex.message}" }
+ }
+ }
+ }
+
+ fun requestNeighborInfo(destNum: Int) {
+ scope?.launch(Dispatchers.IO) {
+ Logger.i { "Requesting NeighborInfo for '$destNum'" }
+ try {
+ val packetId = serviceRepository.meshService?.packetId ?: return@launch
+ serviceRepository.meshService?.requestNeighborInfo(packetId, destNum)
+ } catch (ex: RemoteException) {
+ Logger.e { "Request NeighborInfo error: ${ex.message}" }
+ }
+ }
+ }
+
+ fun requestPosition(destNum: Int, position: Position = Position(0.0, 0.0, 0)) {
+ scope?.launch(Dispatchers.IO) {
+ Logger.i { "Requesting position for '$destNum'" }
+ try {
+ serviceRepository.meshService?.requestPosition(destNum, position)
+ } catch (ex: RemoteException) {
+ Logger.e { "Request position error: ${ex.message}" }
+ }
+ }
+ }
+
+ fun requestTelemetry(destNum: Int, type: TelemetryType) {
+ scope?.launch(Dispatchers.IO) {
+ Logger.i { "Requesting telemetry for '$destNum'" }
+ try {
+ val packetId = serviceRepository.meshService?.packetId ?: return@launch
+ serviceRepository.meshService?.requestTelemetry(packetId, destNum, type.ordinal)
+ } catch (ex: RemoteException) {
+ Logger.e { "Request telemetry error: ${ex.message}" }
+ }
+ }
+ }
+
+ fun requestTraceroute(destNum: Int) {
+ scope?.launch(Dispatchers.IO) {
+ Logger.i { "Requesting traceroute for '$destNum'" }
+ try {
+ val packetId = serviceRepository.meshService?.packetId ?: return@launch
+ serviceRepository.meshService?.requestTraceroute(packetId, destNum)
+ } catch (ex: RemoteException) {
+ Logger.e { "Request traceroute error: ${ex.message}" }
+ }
+ }
+ }
+}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeActions.kt
index a5a91601e..e32851623 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeActions.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeActions.kt
@@ -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 .
*/
-
package org.meshtastic.feature.node.list
import android.os.RemoteException
@@ -49,6 +48,14 @@ constructor(
}
}
+ suspend fun muteNode(node: Node) {
+ try {
+ serviceRepository.onServiceAction(ServiceAction.Mute(node))
+ } catch (ex: RemoteException) {
+ Logger.e(ex) { "Mute node error" }
+ }
+ }
+
suspend fun removeNode(nodeNum: Int) = withContext(Dispatchers.IO) {
Logger.i { "Removing node '$nodeNum'" }
try {
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
index 3cb254eee..686114343 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt
@@ -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 .
*/
-
package org.meshtastic.feature.node.list
import androidx.compose.animation.core.animateFloatAsState
@@ -30,6 +29,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.VolumeOff
+import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.DoDisturbOn
import androidx.compose.material.icons.outlined.DoDisturbOn
import androidx.compose.material.icons.rounded.DeleteOutline
@@ -63,22 +64,22 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
-import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.add_favorite
import org.meshtastic.core.strings.ignore
+import org.meshtastic.core.strings.mute_always
import org.meshtastic.core.strings.node_count_template
import org.meshtastic.core.strings.nodes
import org.meshtastic.core.strings.remove
import org.meshtastic.core.strings.remove_favorite
import org.meshtastic.core.strings.remove_ignored
+import org.meshtastic.core.strings.unmute
import org.meshtastic.core.ui.component.AddContactFAB
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.rememberTimeTickWithLifecycle
import org.meshtastic.core.ui.component.smartScrollToTop
-import org.meshtastic.core.ui.component.supportsQrCodeSharing
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.feature.node.component.NodeActionDialogs
import org.meshtastic.feature.node.component.NodeFilterTextField
@@ -134,8 +135,7 @@ fun NodeListScreen(
)
},
floatingActionButton = {
- val firmwareVersion = DeviceVersion(ourNode?.metadata?.firmwareVersion ?: "0.0.0")
- val shareCapable = firmwareVersion.supportsQrCodeSharing()
+ val shareCapable = ourNode?.capabilities?.supportsQrCodeSharing ?: false
val sharedContact: AdminProtos.SharedContact? by
viewModel.sharedContactRequested.collectAsStateWithLifecycle(null)
AddContactFAB(
@@ -183,20 +183,24 @@ fun NodeListScreen(
items(nodes, key = { it.num }) { node ->
var displayFavoriteDialog by remember { mutableStateOf(false) }
var displayIgnoreDialog by remember { mutableStateOf(false) }
+ var displayMuteDialog by remember { mutableStateOf(false) }
var displayRemoveDialog by remember { mutableStateOf(false) }
NodeActionDialogs(
node = node,
displayFavoriteDialog = displayFavoriteDialog,
displayIgnoreDialog = displayIgnoreDialog,
+ displayMuteDialog = displayMuteDialog,
displayRemoveDialog = displayRemoveDialog,
onDismissMenuRequest = {
displayFavoriteDialog = false
displayIgnoreDialog = false
+ displayMuteDialog = false
displayRemoveDialog = false
},
onConfirmFavorite = viewModel::favoriteNode,
onConfirmIgnore = viewModel::ignoreNode,
+ onConfirmMute = viewModel::muteNode,
onConfirmRemove = { viewModel.removeNode(it.num) },
)
@@ -229,9 +233,10 @@ fun NodeListScreen(
ContextMenu(
expanded = expanded,
node = node,
- onClickFavorite = { displayFavoriteDialog = true },
- onClickIgnore = { displayIgnoreDialog = true },
- onClickRemove = { displayRemoveDialog = true },
+ onFavorite = { displayFavoriteDialog = true },
+ onIgnore = { displayIgnoreDialog = true },
+ onMute = { displayMuteDialog = true },
+ onRemove = { displayRemoveDialog = true },
onDismiss = { expanded = false },
)
}
@@ -247,69 +252,103 @@ fun NodeListScreen(
private fun ContextMenu(
expanded: Boolean,
node: Node,
- onClickFavorite: (Node) -> Unit,
- onClickIgnore: (Node) -> Unit,
- onClickRemove: (Node) -> Unit,
+ onFavorite: () -> Unit,
+ onIgnore: () -> Unit,
+ onMute: () -> Unit,
+ onRemove: () -> Unit,
onDismiss: () -> Unit,
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
- val isFavorite = node.isFavorite
- val isIgnored = node.isIgnored
-
- DropdownMenuItem(
- onClick = {
- onClickFavorite(node)
- onDismiss()
- },
- enabled = !isIgnored,
- leadingIcon = {
- Icon(
- imageVector = if (isFavorite) Icons.Rounded.Star else Icons.Rounded.StarBorder,
- contentDescription = null,
- )
- },
- text = { Text(stringResource(if (isFavorite) Res.string.remove_favorite else Res.string.add_favorite)) },
- )
-
- DropdownMenuItem(
- onClick = {
- onClickIgnore(node)
- onDismiss()
- },
- leadingIcon = {
- Icon(
- imageVector = if (isIgnored) Icons.Filled.DoDisturbOn else Icons.Outlined.DoDisturbOn,
- contentDescription = null,
- tint = MaterialTheme.colorScheme.StatusRed,
- )
- },
- text = {
- Text(
- text = stringResource(if (isIgnored) Res.string.remove_ignored else Res.string.ignore),
- color = MaterialTheme.colorScheme.StatusRed,
- )
- },
- )
-
- DropdownMenuItem(
- onClick = {
- onClickRemove(node)
- onDismiss()
- },
- enabled = !isIgnored,
- leadingIcon = {
- Icon(
- imageVector = Icons.Rounded.DeleteOutline,
- contentDescription = null,
- tint = if (isIgnored) LocalContentColor.current else MaterialTheme.colorScheme.StatusRed,
- )
- },
- text = {
- Text(
- text = stringResource(Res.string.remove),
- color = if (isIgnored) Color.Unspecified else MaterialTheme.colorScheme.StatusRed,
- )
- },
- )
+ FavoriteMenuItem(node, onFavorite, onDismiss)
+ IgnoreMenuItem(node, onIgnore, onDismiss)
+ if (node.capabilities.canMuteNode) {
+ MuteMenuItem(node, onMute, onDismiss)
+ }
+ RemoveMenuItem(node, onRemove, onDismiss)
}
}
+
+@Composable
+private fun FavoriteMenuItem(node: Node, onFavorite: () -> Unit, onDismiss: () -> Unit) {
+ val isFavorite = node.isFavorite
+ DropdownMenuItem(
+ onClick = {
+ onFavorite()
+ onDismiss()
+ },
+ enabled = !node.isIgnored,
+ leadingIcon = {
+ Icon(
+ imageVector = if (isFavorite) Icons.Rounded.Star else Icons.Rounded.StarBorder,
+ contentDescription = null,
+ )
+ },
+ text = { Text(stringResource(if (isFavorite) Res.string.remove_favorite else Res.string.add_favorite)) },
+ )
+}
+
+@Composable
+private fun IgnoreMenuItem(node: Node, onIgnore: () -> Unit, onDismiss: () -> Unit) {
+ val isIgnored = node.isIgnored
+ DropdownMenuItem(
+ onClick = {
+ onIgnore()
+ onDismiss()
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = if (isIgnored) Icons.Filled.DoDisturbOn else Icons.Outlined.DoDisturbOn,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.StatusRed,
+ )
+ },
+ text = {
+ Text(
+ text = stringResource(if (isIgnored) Res.string.remove_ignored else Res.string.ignore),
+ color = MaterialTheme.colorScheme.StatusRed,
+ )
+ },
+ )
+}
+
+@Composable
+private fun MuteMenuItem(node: Node, onMute: () -> Unit, onDismiss: () -> Unit) {
+ val isMuted = node.isMuted
+ DropdownMenuItem(
+ onClick = {
+ onMute()
+ onDismiss()
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = if (isMuted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
+ contentDescription = null,
+ )
+ },
+ text = { Text(text = stringResource(if (isMuted) Res.string.unmute else Res.string.mute_always)) },
+ )
+}
+
+@Composable
+private fun RemoveMenuItem(node: Node, onRemove: () -> Unit, onDismiss: () -> Unit) {
+ DropdownMenuItem(
+ onClick = {
+ onRemove()
+ onDismiss()
+ },
+ enabled = !node.isIgnored,
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Rounded.DeleteOutline,
+ contentDescription = null,
+ tint = if (node.isIgnored) LocalContentColor.current else MaterialTheme.colorScheme.StatusRed,
+ )
+ },
+ text = {
+ Text(
+ text = stringResource(Res.string.remove),
+ color = if (node.isIgnored) Color.Unspecified else MaterialTheme.colorScheme.StatusRed,
+ )
+ },
+ )
+}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt
index 430bb5c90..bc9c50621 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/list/NodeListViewModel.kt
@@ -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 .
*/
-
package org.meshtastic.feature.node.list
import androidx.lifecycle.SavedStateHandle
@@ -39,7 +38,6 @@ import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
import org.meshtastic.proto.AdminProtos
import org.meshtastic.proto.ConfigProtos
import javax.inject.Inject
-import kotlin.Boolean
@HiltViewModel
class NodeListViewModel
@@ -161,6 +159,8 @@ constructor(
fun ignoreNode(node: Node) = viewModelScope.launch { nodeActions.ignoreNode(node) }
+ fun muteNode(node: Node) = viewModelScope.launch { nodeActions.muteNode(node) }
+
fun removeNode(nodeNum: Int) = viewModelScope.launch { nodeActions.removeNode(nodeNum) }
companion object {
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TelemetryConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TelemetryConfigItemList.kt
index 321ecb443..875ab9f1a 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TelemetryConfigItemList.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/TelemetryConfigItemList.kt
@@ -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 .
*/
-
package org.meshtastic.feature.settings.radio.component
import androidx.compose.material3.CardDefaults
@@ -25,7 +24,7 @@ import androidx.compose.runtime.remember
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.model.DeviceVersion
+import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.air_quality_metrics_module_enabled
import org.meshtastic.core.strings.air_quality_metrics_update_interval_seconds
@@ -50,15 +49,13 @@ import org.meshtastic.feature.settings.util.toDisplayString
import org.meshtastic.proto.copy
import org.meshtastic.proto.moduleConfig
-private const val MIN_FW_FOR_TELEMETRY_TOGGLE = "2.7.12"
-
@Composable
fun TelemetryConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val telemetryConfig = state.moduleConfig.telemetry
val formState = rememberConfigState(initialValue = telemetryConfig)
- val firmwareVersion = state.metadata?.firmwareVersion ?: "1"
+ val capabilities = remember(state.metadata?.firmwareVersion) { Capabilities(state.metadata?.firmwareVersion) }
RadioConfigScreenList(
title = stringResource(Res.string.telemetry),
@@ -74,7 +71,7 @@ fun TelemetryConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onB
) {
item {
TitledCard(title = stringResource(Res.string.telemetry_config)) {
- if (DeviceVersion(firmwareVersion) >= DeviceVersion(MIN_FW_FOR_TELEMETRY_TOGGLE)) {
+ if (capabilities.canToggleTelemetryEnabled) {
SwitchPreference(
title = stringResource(Res.string.device_telemetry_enabled),
summary = stringResource(Res.string.device_telemetry_enabled_summary),
diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt
index b1489ddbe..af29d9df3 100644
--- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt
+++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/UserConfigItemList.kt
@@ -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 .
*/
-
package org.meshtastic.feature.settings.radio.component
import androidx.compose.foundation.text.KeyboardActions
@@ -23,6 +22,7 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
@@ -30,7 +30,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.isUnmessageableRole
-import org.meshtastic.core.model.DeviceVersion
+import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.hardware_model
import org.meshtastic.core.strings.licensed_amateur_radio
@@ -54,7 +54,7 @@ fun UserConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack:
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val userConfig = state.userConfig
val formState = rememberConfigState(initialValue = userConfig)
- val firmwareVersion = DeviceVersion(state.metadata?.firmwareVersion ?: "")
+ val capabilities = remember(state.metadata?.firmwareVersion) { Capabilities(state.metadata?.firmwareVersion) }
val validLongName = formState.value.longName.isNotBlank()
val validShortName = formState.value.shortName.isNotBlank()
@@ -113,8 +113,8 @@ fun UserConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack:
summary = stringResource(Res.string.unmonitored_or_infrastructure),
checked =
formState.value.isUnmessagable ||
- (firmwareVersion < DeviceVersion("2.6.9") && formState.value.role.isUnmessageableRole()),
- enabled = formState.value.hasIsUnmessagable() || firmwareVersion >= DeviceVersion("2.6.9"),
+ (!capabilities.canToggleUnmessageable && formState.value.role.isUnmessageableRole()),
+ enabled = formState.value.hasIsUnmessagable() || capabilities.canToggleUnmessageable,
onCheckedChange = { formState.value = formState.value.copy { isUnmessagable = it } },
containerColor = CardDefaults.cardColors().containerColor,
)