feat: Add mute node functionality (#4181)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-01-10 15:35:01 -06:00
committed by GitHub
parent 42fe7e9b2e
commit a67b519abd
34 changed files with 2174 additions and 458 deletions

View File

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

View File

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

View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)

View File

@@ -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')"
]
}
}

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.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,

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,13 +14,13 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.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<Int, Int>
get() { // returns foreground and background @ColorInt for each 'num'
val r = (num and 0xFF0000) shr 16

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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")
}

View File

@@ -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
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View File

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

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.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()

View File

@@ -372,9 +372,11 @@
<string name="currently">Currently:</string>
<string name="mute_status_always">Always muted</string>
<string name="mute_status_unmuted">Not muted</string>
<string name="mute_status_muted_for_days">Muted for %1d days, %.1f hours</string>
<string name="mute_status_muted_for_hours">Muted for %.1f hours</string>
<string name="mute_status_muted_for_days">Muted for %1$d days, %2$.1f hours</string>
<string name="mute_status_muted_for_hours">Muted for %1$.1f hours</string>
<string name="mute_status_label">Mute status</string>
<string name="mute_add">Mute notifications for '%1$s'?</string>
<string name="mute_remove">Unmute notifications for '%1$s'?</string>
<string name="replace">Replace</string>
<string name="wifi_qr_code_scan">Scan WiFi QR code</string>
<string name="wifi_qr_code_error">Invalid WiFi Credential QR code format</string>

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import 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].
*

View File

@@ -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)
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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,
)
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.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<DialogType?>(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,

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.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,
)

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.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()

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,13 +14,13 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.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,
)
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.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 = {

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.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)
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,25 +14,16 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.detail
import android.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<Node?> = nodeRepository.ourNodeInfo
private val _lastTraceRouteTime = MutableStateFlow<Long?>(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)
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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}" }
}
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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}" }
}
}
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.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 {

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.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,
)
},
)
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.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 {

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.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),

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.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,
)