mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: Add acknowledgement status and retry for emoji reactions (#4142)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
41c5992158
commit
2526728859
11 changed files with 1257 additions and 83 deletions
|
|
@ -28,6 +28,7 @@ import org.meshtastic.core.data.repository.PacketRepository
|
|||
import org.meshtastic.core.database.DatabaseManager
|
||||
import org.meshtastic.core.database.entity.ReactionEntity
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.MessageStatus
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.prefs.mesh.MeshPrefs
|
||||
import org.meshtastic.core.service.MeshServiceNotifications
|
||||
|
|
@ -112,11 +113,11 @@ constructor(
|
|||
bytes = action.emoji.encodeToByteArray(),
|
||||
channel = channel,
|
||||
replyId = action.replyId,
|
||||
wantAck = false,
|
||||
wantAck = true,
|
||||
emoji = action.emoji.codePointAt(0),
|
||||
)
|
||||
commandSender.sendData(dataPacket)
|
||||
rememberReaction(action)
|
||||
rememberReaction(action, dataPacket.id)
|
||||
}
|
||||
|
||||
private fun handleImportContact(action: ServiceAction.ImportContact, myNodeNum: Int) {
|
||||
|
|
@ -125,7 +126,7 @@ constructor(
|
|||
nodeManager.handleReceivedUser(verifiedContact.nodeNum, verifiedContact.user, manuallyVerified = true)
|
||||
}
|
||||
|
||||
private fun rememberReaction(action: ServiceAction.Reaction) {
|
||||
private fun rememberReaction(action: ServiceAction.Reaction, packetId: Int) {
|
||||
scope.handledLaunch {
|
||||
val reaction =
|
||||
ReactionEntity(
|
||||
|
|
@ -136,6 +137,8 @@ constructor(
|
|||
snr = 0f,
|
||||
rssi = 0,
|
||||
hopsAway = 0,
|
||||
packetId = packetId,
|
||||
status = MessageStatus.QUEUED,
|
||||
)
|
||||
packetRepository.get().insertReaction(reaction)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -333,10 +333,13 @@ constructor(
|
|||
packetHandler.removeResponse(packet.decoded.requestId, complete = true)
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod", "LongMethod")
|
||||
private fun handleAckNak(requestId: Int, fromId: String, routingError: Int, relayNode: Int?) {
|
||||
scope.handledLaunch {
|
||||
val isAck = routingError == MeshProtos.Routing.Error.NONE_VALUE
|
||||
val p = packetRepository.get().getPacketById(requestId)
|
||||
val reaction = packetRepository.get().getReactionByPacketId(requestId)
|
||||
|
||||
val isMaxRetransmit = routingError == MeshProtos.Routing.Error.MAX_RETRANSMIT_VALUE
|
||||
val shouldRetry =
|
||||
isMaxRetransmit &&
|
||||
|
|
@ -345,14 +348,22 @@ constructor(
|
|||
p.data.from == DataPacket.ID_LOCAL &&
|
||||
p.data.retryCount < MAX_RETRY_ATTEMPTS
|
||||
|
||||
val shouldRetryReaction =
|
||||
isMaxRetransmit &&
|
||||
reaction != null &&
|
||||
reaction.userId == DataPacket.ID_LOCAL &&
|
||||
reaction.retryCount < MAX_RETRY_ATTEMPTS &&
|
||||
reaction.to != null
|
||||
@Suppress("MaxLineLength")
|
||||
Logger.d {
|
||||
val retryInfo = "packetId=${p?.packetId} dataId=${p?.data?.id} retry=${p?.data?.retryCount}"
|
||||
val statusInfo = "status=${p?.data?.status}"
|
||||
val retryInfo =
|
||||
"packetId=${p?.packetId ?: reaction?.packetId} dataId=${p?.data?.id} retry=${p?.data?.retryCount ?: reaction?.retryCount}"
|
||||
val statusInfo = "status=${p?.data?.status ?: reaction?.status}"
|
||||
"[ackNak] req=$requestId routeErr=$routingError isAck=$isAck " +
|
||||
"maxRetransmit=$isMaxRetransmit shouldRetry=$shouldRetry $retryInfo $statusInfo"
|
||||
"maxRetransmit=$isMaxRetransmit shouldRetry=$shouldRetry reaction=$shouldRetryReaction $retryInfo $statusInfo"
|
||||
}
|
||||
|
||||
if (shouldRetry && p != null) {
|
||||
if (shouldRetry) {
|
||||
val newRetryCount = p.data.retryCount + 1
|
||||
val newId = commandSender.generatePacketId()
|
||||
val updatedData =
|
||||
|
|
@ -368,21 +379,66 @@ constructor(
|
|||
return@handledLaunch
|
||||
}
|
||||
|
||||
if (shouldRetryReaction && reaction != null) {
|
||||
val newRetryCount = reaction.retryCount + 1
|
||||
val newId = commandSender.generatePacketId()
|
||||
|
||||
val reactionPacket =
|
||||
DataPacket(
|
||||
to = reaction.to,
|
||||
channel = reaction.channel,
|
||||
bytes = reaction.emoji.toByteArray(Charsets.UTF_8),
|
||||
dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
|
||||
replyId = reaction.replyId,
|
||||
wantAck = true,
|
||||
emoji = reaction.emoji.codePointAt(0),
|
||||
id = newId,
|
||||
retryCount = newRetryCount,
|
||||
)
|
||||
|
||||
val updatedReaction =
|
||||
reaction.copy(
|
||||
packetId = newId,
|
||||
status = MessageStatus.QUEUED,
|
||||
retryCount = newRetryCount,
|
||||
relayNode = null,
|
||||
routingError = MeshProtos.Routing.Error.NONE_VALUE,
|
||||
)
|
||||
packetRepository.get().updateReaction(updatedReaction)
|
||||
|
||||
Logger.w { "[ackNak] retrying reaction req=$requestId newId=$newId retry=$newRetryCount" }
|
||||
|
||||
delay(RETRY_DELAY_MS)
|
||||
commandSender.sendData(reactionPacket)
|
||||
return@handledLaunch
|
||||
}
|
||||
|
||||
val m =
|
||||
when {
|
||||
isAck && fromId == p?.data?.to -> MessageStatus.RECEIVED
|
||||
isAck && (fromId == p?.data?.to || fromId == reaction?.to) -> MessageStatus.RECEIVED
|
||||
isAck -> MessageStatus.DELIVERED
|
||||
else -> MessageStatus.ERROR
|
||||
}
|
||||
if (p != null && p.data.status != MessageStatus.RECEIVED) {
|
||||
p.data.status = m
|
||||
p.routingError = routingError
|
||||
p.data.relayNode = relayNode
|
||||
if (isAck) {
|
||||
p.data.relays += 1
|
||||
}
|
||||
p.data.relayNode = relayNode
|
||||
packetRepository.get().update(p)
|
||||
}
|
||||
|
||||
reaction?.let { r ->
|
||||
if (r.status != MessageStatus.RECEIVED) {
|
||||
var updated = r.copy(status = m, routingError = routingError, relayNode = relayNode)
|
||||
if (isAck) {
|
||||
updated = updated.copy(relays = updated.relays + 1)
|
||||
}
|
||||
packetRepository.get().updateReaction(updated)
|
||||
}
|
||||
}
|
||||
|
||||
serviceBroadcasts.broadcastMessageStatus(requestId, m)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import kotlinx.coroutines.CoroutineScope
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.data.repository.PacketRepository
|
||||
import org.meshtastic.core.database.entity.ReactionEntity
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.service.MeshServiceNotifications
|
||||
import org.meshtastic.proto.Portnums
|
||||
|
|
@ -35,6 +37,8 @@ class ReactionReceiver : BroadcastReceiver() {
|
|||
|
||||
@Inject lateinit var meshServiceNotifications: MeshServiceNotifications
|
||||
|
||||
@Inject lateinit var packetRepository: PacketRepository
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
companion object {
|
||||
|
|
@ -71,10 +75,24 @@ class ReactionReceiver : BroadcastReceiver() {
|
|||
bytes = emoji.toByteArray(Charsets.UTF_8),
|
||||
dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
|
||||
replyId = packetId,
|
||||
wantAck = true,
|
||||
emoji = emoji.codePointAt(0),
|
||||
)
|
||||
commandSender.sendData(reactionPacket)
|
||||
|
||||
val reaction =
|
||||
ReactionEntity(
|
||||
replyId = packetId,
|
||||
userId = DataPacket.ID_LOCAL,
|
||||
emoji = emoji,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
packetId = reactionPacket.id,
|
||||
status = org.meshtastic.core.model.MessageStatus.QUEUED,
|
||||
to = toId,
|
||||
channel = channelIndex,
|
||||
)
|
||||
packetRepository.insertReaction(reaction)
|
||||
|
||||
// Dismiss the notification after reacting
|
||||
meshServiceNotifications.cancelMessageNotification(contactKey)
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -172,6 +172,12 @@ constructor(
|
|||
suspend fun insertReaction(reaction: ReactionEntity) =
|
||||
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().insert(reaction) }
|
||||
|
||||
suspend fun updateReaction(reaction: ReactionEntity) =
|
||||
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().update(reaction) }
|
||||
|
||||
suspend fun getReactionByPacketId(packetId: Int) =
|
||||
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getReactionByPacketId(packetId) }
|
||||
|
||||
suspend fun clearPacketDB() = withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().deleteAll() }
|
||||
|
||||
suspend fun migrateChannelsByPSK(oldSettings: List<ChannelSettings>, newSettings: List<ChannelSettings>) =
|
||||
|
|
|
|||
|
|
@ -0,0 +1,965 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 28,
|
||||
"identityHash": "36622b8fdfb35d71c5d4385b2f833f97",
|
||||
"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, `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": "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, `reply_id` INTEGER NOT NULL DEFAULT 0, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1)",
|
||||
"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": "replyId",
|
||||
"columnName": "reply_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"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`)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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}` (`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` TEXT 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, PRIMARY KEY(`reply_id`, `user_id`, `emoji`))",
|
||||
"fields": [
|
||||
{
|
||||
"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": "TEXT",
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"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, '36622b8fdfb35d71c5d4385b2f833f97')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
import android.content.Context
|
||||
|
|
@ -85,8 +84,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
|
|||
AutoMigration(from = 24, to = 25),
|
||||
AutoMigration(from = 25, to = 26),
|
||||
AutoMigration(from = 26, to = 27),
|
||||
AutoMigration(from = 27, to = 28),
|
||||
],
|
||||
version = 27,
|
||||
version = 28,
|
||||
exportSchema = true,
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
|
|
|
|||
|
|
@ -309,6 +309,11 @@ interface PacketDao {
|
|||
|
||||
@Upsert suspend fun insert(reaction: ReactionEntity)
|
||||
|
||||
@Update suspend fun update(reaction: ReactionEntity)
|
||||
|
||||
@Query("SELECT * FROM reactions WHERE packet_id = :packetId LIMIT 1")
|
||||
suspend fun getReactionByPacketId(packetId: Int): ReactionEntity?
|
||||
|
||||
@Transaction
|
||||
suspend fun deleteAll() {
|
||||
deleteAllPackets()
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import androidx.room.Relation
|
|||
import org.meshtastic.core.database.model.Message
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.MessageStatus
|
||||
import org.meshtastic.core.model.util.getShortDateTime
|
||||
import org.meshtastic.proto.MeshProtos.User
|
||||
|
||||
|
|
@ -130,12 +131,20 @@ data class Reaction(
|
|||
val snr: Float,
|
||||
val rssi: Int,
|
||||
val hopsAway: Int,
|
||||
val packetId: Int = 0,
|
||||
val status: MessageStatus = MessageStatus.UNKNOWN,
|
||||
val routingError: Int = 0,
|
||||
val retryCount: Int = 0,
|
||||
val relays: Int = 0,
|
||||
val relayNode: Int? = null,
|
||||
val to: String? = null,
|
||||
val channel: Int = 0,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "reactions",
|
||||
primaryKeys = ["reply_id", "user_id", "emoji"],
|
||||
indices = [Index(value = ["reply_id"])],
|
||||
indices = [Index(value = ["reply_id"]), Index(value = ["packet_id"])],
|
||||
)
|
||||
data class ReactionEntity(
|
||||
@ColumnInfo(name = "reply_id") val replyId: Int,
|
||||
|
|
@ -145,6 +154,14 @@ data class ReactionEntity(
|
|||
@ColumnInfo(name = "snr", defaultValue = "0") val snr: Float = 0f,
|
||||
@ColumnInfo(name = "rssi", defaultValue = "0") val rssi: Int = 0,
|
||||
@ColumnInfo(name = "hopsAway", defaultValue = "-1") val hopsAway: Int = -1,
|
||||
@ColumnInfo(name = "packet_id", defaultValue = "0") val packetId: Int = 0,
|
||||
@ColumnInfo(name = "status", defaultValue = "0") val status: MessageStatus = MessageStatus.UNKNOWN,
|
||||
@ColumnInfo(name = "routing_error", defaultValue = "0") val routingError: Int = 0,
|
||||
@ColumnInfo(name = "retry_count", defaultValue = "0") val retryCount: Int = 0,
|
||||
@ColumnInfo(name = "relays", defaultValue = "0") val relays: Int = 0,
|
||||
@ColumnInfo(name = "relay_node") val relayNode: Int? = null,
|
||||
@ColumnInfo(name = "to") val to: String? = null,
|
||||
@ColumnInfo(name = "channel", defaultValue = "0") val channel: Int = 0,
|
||||
)
|
||||
|
||||
private suspend fun ReactionEntity.toReaction(getNode: suspend (userId: String?) -> Node) = Reaction(
|
||||
|
|
@ -155,6 +172,14 @@ private suspend fun ReactionEntity.toReaction(getNode: suspend (userId: String?)
|
|||
snr = snr,
|
||||
rssi = rssi,
|
||||
hopsAway = hopsAway,
|
||||
packetId = packetId,
|
||||
status = status,
|
||||
routingError = routingError,
|
||||
retryCount = retryCount,
|
||||
relays = relays,
|
||||
relayNode = relayNode,
|
||||
to = to,
|
||||
channel = channel,
|
||||
)
|
||||
|
||||
private suspend fun List<ReactionEntity>.toReaction(getNode: suspend (userId: String?) -> Node) =
|
||||
|
|
|
|||
|
|
@ -130,7 +130,17 @@ internal fun MessageListPaged(
|
|||
|
||||
var showReactionDialog by remember { mutableStateOf<List<Reaction>?>(null) }
|
||||
showReactionDialog?.let { reactions ->
|
||||
ReactionDialog(reactions = reactions, myId = state.ourNode?.user?.id, onDismiss = { showReactionDialog = null })
|
||||
ReactionDialog(
|
||||
reactions = reactions,
|
||||
myId = state.ourNode?.user?.id,
|
||||
onDismiss = { showReactionDialog = null },
|
||||
onResend = { reaction ->
|
||||
handlers.onSendReaction(reaction.emoji, reaction.replyId)
|
||||
showReactionDialog = null
|
||||
},
|
||||
nodes = state.nodes,
|
||||
ourNode = state.ourNode,
|
||||
)
|
||||
}
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
|
@ -270,7 +280,18 @@ private fun LazyItemScope.renderPagedChatMessageRow(
|
|||
onStatusClick = { onShowStatusDialog(message) },
|
||||
onReply = { handlers.onReply(message) },
|
||||
emojis = message.emojis,
|
||||
sendReaction = { handlers.onSendReaction(it, message.packetId) },
|
||||
sendReaction = { emoji ->
|
||||
val hasReacted =
|
||||
message.emojis.any { reaction ->
|
||||
(
|
||||
reaction.user.id == ourNode.user.id ||
|
||||
reaction.user.id == org.meshtastic.core.model.DataPacket.ID_LOCAL
|
||||
) && reaction.emoji == emoji
|
||||
}
|
||||
if (!hasReacted) {
|
||||
handlers.onSendReaction(emoji, message.packetId)
|
||||
}
|
||||
},
|
||||
onShowReactions = { onShowReactions(message.emojis) },
|
||||
onNavigateToOriginalMessage = {
|
||||
coroutineScope.launch {
|
||||
|
|
|
|||
|
|
@ -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.messaging.component
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
|
|
@ -73,7 +72,7 @@ private fun ReplyButton(onClick: () -> Unit = {}) = IconButton(
|
|||
)
|
||||
|
||||
@Composable
|
||||
private fun MessageStatusButton(onStatusClick: () -> Unit = {}, status: MessageStatus, fromLocal: Boolean) =
|
||||
internal fun MessageStatusButton(onStatusClick: () -> Unit = {}, status: MessageStatus, fromLocal: Boolean) =
|
||||
AnimatedVisibility(visible = fromLocal) {
|
||||
IconButton(onClick = onStatusClick) {
|
||||
Crossfade(targetState = status, label = "MessageStatusIcon") { currentStatus ->
|
||||
|
|
|
|||
|
|
@ -47,25 +47,45 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.database.entity.Packet
|
||||
import org.meshtastic.core.database.entity.Reaction
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.database.model.getStringResFrom
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.MessageStatus
|
||||
import org.meshtastic.core.model.util.getShortDateTime
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.delivery_confirmed
|
||||
import org.meshtastic.core.strings.error
|
||||
import org.meshtastic.core.strings.hops_away_template
|
||||
import org.meshtastic.core.strings.message_delivery_status
|
||||
import org.meshtastic.core.strings.message_status_enroute
|
||||
import org.meshtastic.core.strings.message_status_queued
|
||||
import org.meshtastic.core.strings.you
|
||||
import org.meshtastic.core.ui.component.BottomSheetDialog
|
||||
import org.meshtastic.core.ui.component.Rssi
|
||||
import org.meshtastic.core.ui.component.Snr
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.feature.messaging.DeliveryInfo
|
||||
import org.meshtastic.proto.MeshProtos
|
||||
|
||||
@Composable
|
||||
private fun ReactionItem(emoji: String, emojiCount: Int = 1, onClick: () -> Unit = {}, onLongClick: () -> Unit = {}) {
|
||||
private fun ReactionItem(
|
||||
emoji: String,
|
||||
emojiCount: Int = 1,
|
||||
status: MessageStatus = MessageStatus.UNKNOWN,
|
||||
onClick: () -> Unit = {},
|
||||
onLongClick: () -> Unit = {},
|
||||
) {
|
||||
val isSending = status == MessageStatus.QUEUED || status == MessageStatus.ENROUTE
|
||||
val isError = status == MessageStatus.ERROR
|
||||
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (emojiCount > 1) {
|
||||
|
|
@ -74,8 +94,14 @@ private fun ReactionItem(emoji: String, emojiCount: Int = 1, onClick: () -> Unit
|
|||
},
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
modifier =
|
||||
Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
.then(if (isSending) Modifier.graphicsLayer(alpha = 0.5f) else Modifier),
|
||||
color =
|
||||
when {
|
||||
isError -> MaterialTheme.colorScheme.errorContainer
|
||||
else -> MaterialTheme.colorScheme.primaryContainer
|
||||
},
|
||||
shape = CircleShape,
|
||||
) {
|
||||
Text(text = emoji, modifier = Modifier.padding(4.dp).clip(CircleShape))
|
||||
|
|
@ -91,20 +117,21 @@ internal fun ReactionRow(
|
|||
onSendReaction: (String) -> Unit = {},
|
||||
onShowReactions: () -> Unit = {},
|
||||
) {
|
||||
val emojiList = reduceEmojis(reactions.reversed().map { it.emoji }).entries
|
||||
val emojiGroups = reactions.groupBy { it.emoji }
|
||||
|
||||
AnimatedVisibility(emojiList.isNotEmpty()) {
|
||||
AnimatedVisibility(emojiGroups.isNotEmpty()) {
|
||||
LazyRow(
|
||||
modifier = modifier.padding(horizontal = 4.dp),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
items(emojiList.size) { index ->
|
||||
val entry = emojiList.elementAt(index)
|
||||
items(emojiGroups.entries.toList()) { (emoji, reactions) ->
|
||||
val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL }
|
||||
ReactionItem(
|
||||
emoji = entry.key,
|
||||
emojiCount = entry.value,
|
||||
onClick = { onSendReaction(entry.key) },
|
||||
emoji = emoji,
|
||||
emojiCount = reactions.size,
|
||||
status = localReaction?.status ?: MessageStatus.RECEIVED,
|
||||
onClick = { onSendReaction(emoji) },
|
||||
onLongClick = onShowReactions,
|
||||
)
|
||||
}
|
||||
|
|
@ -112,76 +139,125 @@ internal fun ReactionRow(
|
|||
}
|
||||
}
|
||||
|
||||
private fun reduceEmojis(emojis: List<String>): Map<String, Int> = emojis.groupingBy { it }.eachCount()
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
internal fun ReactionDialog(reactions: List<Reaction>, myId: String? = null, onDismiss: () -> Unit = {}) =
|
||||
BottomSheetDialog(onDismiss = onDismiss, modifier = Modifier.fillMaxHeight(fraction = .3f)) {
|
||||
val groupedEmojis = reactions.groupBy { it.emoji }
|
||||
var selectedEmoji by remember { mutableStateOf<String?>(null) }
|
||||
val filteredReactions = selectedEmoji?.let { groupedEmojis[it] ?: emptyList() } ?: reactions
|
||||
internal fun ReactionDialog(
|
||||
reactions: List<Reaction>,
|
||||
onDismiss: () -> Unit = {},
|
||||
myId: String? = null,
|
||||
onResend: (Reaction) -> Unit = {},
|
||||
nodes: List<Node> = emptyList(),
|
||||
ourNode: Node? = null,
|
||||
) = BottomSheetDialog(onDismiss = onDismiss, modifier = Modifier.fillMaxHeight(fraction = .3f)) {
|
||||
val groupedEmojis = reactions.groupBy { it.emoji }
|
||||
var selectedEmoji by remember { mutableStateOf<String?>(null) }
|
||||
val filteredReactions = selectedEmoji?.let { groupedEmojis[it] ?: emptyList() } ?: reactions
|
||||
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
items(groupedEmojis.entries.toList()) { (emoji, reactions) ->
|
||||
Text(
|
||||
text = "$emoji${reactions.size}",
|
||||
modifier =
|
||||
Modifier.clip(CircleShape)
|
||||
.background(if (selectedEmoji == emoji) Color.Gray else Color.Transparent)
|
||||
.padding(8.dp)
|
||||
.clickable { selectedEmoji = if (selectedEmoji == emoji) null else emoji },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
var showStatusDialog by remember { mutableStateOf<Reaction?>(null) }
|
||||
showStatusDialog?.let { reaction ->
|
||||
val title = if (reaction.routingError > 0) Res.string.error else Res.string.message_delivery_status
|
||||
val text =
|
||||
when (reaction.status) {
|
||||
MessageStatus.RECEIVED -> Res.string.delivery_confirmed
|
||||
MessageStatus.QUEUED -> Res.string.message_status_queued
|
||||
MessageStatus.ENROUTE -> Res.string.message_status_enroute
|
||||
else -> getStringResFrom(reaction.routingError)
|
||||
}
|
||||
|
||||
val relayNodeName =
|
||||
reaction.relayNode?.let { relayNodeId ->
|
||||
Packet.getRelayNode(relayNodeId, nodes, ourNode?.num)?.user?.longName
|
||||
}
|
||||
|
||||
DeliveryInfo(
|
||||
title = title,
|
||||
text = text,
|
||||
resendOption = reaction.status == MessageStatus.ERROR,
|
||||
onConfirm = {
|
||||
onResend(reaction)
|
||||
showStatusDialog = null
|
||||
},
|
||||
onDismiss = { showStatusDialog = null },
|
||||
relayNodeName = relayNodeName,
|
||||
relays = reaction.relays,
|
||||
retryCount = reaction.retryCount,
|
||||
maxRetries = 5,
|
||||
)
|
||||
}
|
||||
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
items(groupedEmojis.entries.toList()) { (emoji, reactions) ->
|
||||
val localReaction = reactions.find { it.user.id == DataPacket.ID_LOCAL }
|
||||
val isSending =
|
||||
localReaction?.status == MessageStatus.QUEUED || localReaction?.status == MessageStatus.ENROUTE
|
||||
Text(
|
||||
text = "$emoji${reactions.size}",
|
||||
modifier =
|
||||
Modifier.clip(CircleShape)
|
||||
.background(if (selectedEmoji == emoji) Color.Gray else Color.Transparent)
|
||||
.then(if (isSending) Modifier.graphicsLayer(alpha = 0.5f) else Modifier)
|
||||
.padding(8.dp)
|
||||
.clickable { selectedEmoji = if (selectedEmoji == emoji) null else emoji },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
items(filteredReactions) { reaction ->
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val isLocal = reaction.user.id == myId || reaction.user.id == DataPacket.ID_LOCAL
|
||||
val displayName =
|
||||
if (isLocal) {
|
||||
"${reaction.user.longName} (${stringResource(Res.string.you)})"
|
||||
} else {
|
||||
reaction.user.longName
|
||||
}
|
||||
Text(text = displayName, style = MaterialTheme.typography.titleMedium)
|
||||
LazyColumn(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
items(filteredReactions) { reaction ->
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val isLocal = reaction.user.id == myId || reaction.user.id == DataPacket.ID_LOCAL
|
||||
val displayName =
|
||||
if (isLocal) {
|
||||
"${reaction.user.longName} (${stringResource(Res.string.you)})"
|
||||
} else {
|
||||
reaction.user.longName
|
||||
}
|
||||
Text(text = displayName, style = MaterialTheme.typography.titleMedium)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (isLocal) {
|
||||
MessageStatusButton(
|
||||
status = reaction.status,
|
||||
fromLocal = true,
|
||||
onStatusClick = { showStatusDialog = reaction },
|
||||
)
|
||||
}
|
||||
Text(text = reaction.emoji, style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 0.dp, bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val isLocalOrPreDbUpdateReaction = (reaction.rssi == 0)
|
||||
if (!isLocalOrPreDbUpdateReaction) {
|
||||
if (reaction.hopsAway == 0) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Snr(reaction.snr)
|
||||
Rssi(reaction.rssi)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(Res.string.hops_away_template, reaction.hopsAway),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 0.dp, bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val isLocalOrPreDbUpdateReaction = (reaction.rssi == 0)
|
||||
if (!isLocalOrPreDbUpdateReaction) {
|
||||
if (reaction.hopsAway == 0) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Snr(reaction.snr)
|
||||
Rssi(reaction.rssi)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(Res.string.hops_away_template, reaction.hopsAway),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(text = getShortDateTime(reaction.timestamp), style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(text = getShortDateTime(reaction.timestamp), style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue