mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-29 11:13:41 -04:00
feat: Add mute node functionality (#4181)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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].
|
||||
*
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
261
feature/node/component/DeviceActions.kt
Normal file
261
feature/node/component/DeviceActions.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user