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, )