feat: add emoji reactions to message bubbles (#1421)

* Add tapback emojis to message bubbles

Added TapBackEmojiItem composable to display tapback emojis.
Included it in MessageItem composable for incoming messages.
Added a FlowRow to show tapback emojis below the message bubble.

* feat: Add EmojiPicker View

* feat: show emojis for local messages

* feat: Add emoji tapbacks to messages

This commit introduces the ability to send and receive emoji tapbacks for messages.

- Adds emoji and replyId fields to DataPacket.
- Adds emoji tapback support to the MeshService
- Modifies UIState to handle emojis in message lists.

* feat: store tapbacks in database

Store tapbacks in the database and display them in the message list.
- Add a new table to the database to store tapbacks.
- Add a new DAO method to insert and retrieve tapbacks.
- Update the message list UI to display tapbacks.

* refactor: relation db and other changes

---------

Co-authored-by: Andre K <andrekir@pm.me>
pull/1406/head
James Rich 2024-12-03 05:57:35 -06:00 zatwierdzone przez GitHub
rodzic b3f4929cf4
commit 2234f5a713
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
15 zmienionych plików z 1049 dodań i 187 usunięć

Wyświetl plik

@ -0,0 +1,515 @@
{
"formatVersion": 1,
"database": {
"version": 14,
"identityHash": "b610881191518f933ee6bb694d12d0a2",
"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, PRIMARY KEY(`myNodeNum`))",
"fields": [
{
"fieldPath": "myNodeNum",
"columnName": "myNodeNum",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "model",
"columnName": "model",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "firmwareVersion",
"columnName": "firmwareVersion",
"affinity": "TEXT",
"notNull": false
},
{
"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
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"myNodeNum"
]
},
"indices": [],
"foreignKeys": []
},
{
"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, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `paxcounter` BLOB NOT NULL, 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",
"notNull": false
},
{
"fieldPath": "shortName",
"columnName": "short_name",
"affinity": "TEXT",
"notNull": false
},
{
"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": "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
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"num"
]
},
"indices": [],
"foreignKeys": []
},
{
"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)",
"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"
}
],
"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`)"
}
],
"foreignKeys": []
},
{
"tableName": "contact_settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, PRIMARY KEY(`contact_key`))",
"fields": [
{
"fieldPath": "contact_key",
"columnName": "contact_key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "muteUntil",
"columnName": "muteUntil",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"contact_key"
]
},
"indices": [],
"foreignKeys": []
},
{
"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`)"
}
],
"foreignKeys": []
},
{
"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"
]
},
"indices": [],
"foreignKeys": []
},
{
"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, 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
}
],
"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`)"
}
],
"foreignKeys": []
}
],
"views": [],
"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, 'b610881191518f933ee6bb694d12d0a2')"
]
}
}

Wyświetl plik

@ -84,8 +84,8 @@ class PacketDaoTest {
}
packetDao = database.packetDao().apply {
generateTestPackets(42424243).forEach(::insert)
generateTestPackets(myNodeNum).forEach(::insert)
generateTestPackets(42424243).forEach { insert(it) }
generateTestPackets(myNodeNum).forEach { insert(it) }
}
}
@ -132,10 +132,10 @@ class PacketDaoTest {
val messages = packetDao.getMessagesFrom(contactKey).first()
assertEquals(SAMPLE_SIZE, messages.size)
val onlyFromContactKey = messages.all { it.contact_key == contactKey }
val onlyFromContactKey = messages.all { it.packet.contact_key == contactKey }
assertTrue(onlyFromContactKey)
val onlyMyNodeNum = messages.all { it.myNodeNum == myNodeNum }
val onlyMyNodeNum = messages.all { it.packet.myNodeNum == myNodeNum }
assertTrue(onlyMyNodeNum)
}
}

Wyświetl plik

@ -1,88 +1,91 @@
/*
* Copyright (c) 2024 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 com.geeksville.mesh.database
import android.content.Context
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.DeleteTable
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.AutoMigrationSpec
import com.geeksville.mesh.database.dao.PacketDao
import com.geeksville.mesh.database.dao.MeshLogDao
import com.geeksville.mesh.database.dao.NodeInfoDao
import com.geeksville.mesh.database.dao.QuickChatActionDao
import com.geeksville.mesh.database.entity.ContactSettings
import com.geeksville.mesh.database.entity.MeshLog
import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.database.entity.Packet
import com.geeksville.mesh.database.entity.QuickChatAction
@Database(
entities = [
MyNodeEntity::class,
NodeEntity::class,
Packet::class,
ContactSettings::class,
MeshLog::class,
QuickChatAction::class
],
autoMigrations = [
AutoMigration(from = 3, to = 4),
AutoMigration(from = 4, to = 5),
AutoMigration(from = 5, to = 6),
AutoMigration(from = 6, to = 7),
AutoMigration(from = 7, to = 8),
AutoMigration(from = 8, to = 9),
AutoMigration(from = 9, to = 10),
AutoMigration(from = 10, to = 11),
AutoMigration(from = 11, to = 12),
AutoMigration(from = 12, to = 13, spec = AutoMigration12to13::class),
],
version = 13,
exportSchema = true,
)
@TypeConverters(Converters::class)
abstract class MeshtasticDatabase : RoomDatabase() {
abstract fun nodeInfoDao(): NodeInfoDao
abstract fun packetDao(): PacketDao
abstract fun meshLogDao(): MeshLogDao
abstract fun quickChatActionDao(): QuickChatActionDao
companion object {
fun getDatabase(context: Context): MeshtasticDatabase {
return Room.databaseBuilder(
context.applicationContext,
MeshtasticDatabase::class.java,
"meshtastic_database"
)
.fallbackToDestructiveMigration()
.build()
}
}
}
@DeleteTable.Entries(
DeleteTable(tableName = "NodeInfo"),
DeleteTable(tableName = "MyNodeInfo")
)
class AutoMigration12to13 : AutoMigrationSpec
/*
* Copyright (c) 2024 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 com.geeksville.mesh.database
import android.content.Context
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.DeleteTable
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.AutoMigrationSpec
import com.geeksville.mesh.database.dao.PacketDao
import com.geeksville.mesh.database.dao.MeshLogDao
import com.geeksville.mesh.database.dao.NodeInfoDao
import com.geeksville.mesh.database.dao.QuickChatActionDao
import com.geeksville.mesh.database.entity.ContactSettings
import com.geeksville.mesh.database.entity.MeshLog
import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.database.entity.Packet
import com.geeksville.mesh.database.entity.QuickChatAction
import com.geeksville.mesh.database.entity.ReactionEntity
@Database(
entities = [
MyNodeEntity::class,
NodeEntity::class,
Packet::class,
ContactSettings::class,
MeshLog::class,
QuickChatAction::class,
ReactionEntity::class,
],
autoMigrations = [
AutoMigration(from = 3, to = 4),
AutoMigration(from = 4, to = 5),
AutoMigration(from = 5, to = 6),
AutoMigration(from = 6, to = 7),
AutoMigration(from = 7, to = 8),
AutoMigration(from = 8, to = 9),
AutoMigration(from = 9, to = 10),
AutoMigration(from = 10, to = 11),
AutoMigration(from = 11, to = 12),
AutoMigration(from = 12, to = 13, spec = AutoMigration12to13::class),
AutoMigration(from = 13, to = 14),
],
version = 14,
exportSchema = true,
)
@TypeConverters(Converters::class)
abstract class MeshtasticDatabase : RoomDatabase() {
abstract fun nodeInfoDao(): NodeInfoDao
abstract fun packetDao(): PacketDao
abstract fun meshLogDao(): MeshLogDao
abstract fun quickChatActionDao(): QuickChatActionDao
companion object {
fun getDatabase(context: Context): MeshtasticDatabase {
return Room.databaseBuilder(
context.applicationContext,
MeshtasticDatabase::class.java,
"meshtastic_database"
)
.fallbackToDestructiveMigration()
.build()
}
}
}
@DeleteTable.Entries(
DeleteTable(tableName = "NodeInfo"),
DeleteTable(tableName = "MyNodeInfo")
)
class AutoMigration12to13 : AutoMigrationSpec

Wyświetl plik

@ -23,6 +23,7 @@ import com.geeksville.mesh.Portnums.PortNum
import com.geeksville.mesh.database.dao.PacketDao
import com.geeksville.mesh.database.entity.ContactSettings
import com.geeksville.mesh.database.entity.Packet
import com.geeksville.mesh.database.entity.ReactionEntity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
@ -102,4 +103,8 @@ class PacketRepository @Inject constructor(private val packetDaoLazy: dagger.Laz
suspend fun setMuteUntil(contacts: List<String>, until: Long) = withContext(Dispatchers.IO) {
packetDao.setMuteUntil(contacts, until)
}
suspend fun insertReaction(reaction: ReactionEntity) = withContext(Dispatchers.IO) {
packetDao.insert(reaction)
}
}

Wyświetl plik

@ -18,7 +18,6 @@
package com.geeksville.mesh.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.MapColumn
import androidx.room.Update
import androidx.room.Query
@ -27,7 +26,9 @@ import androidx.room.Upsert
import com.geeksville.mesh.DataPacket
import com.geeksville.mesh.MessageStatus
import com.geeksville.mesh.database.entity.ContactSettings
import com.geeksville.mesh.database.entity.PacketEntity
import com.geeksville.mesh.database.entity.Packet
import com.geeksville.mesh.database.entity.ReactionEntity
import kotlinx.coroutines.flow.Flow
@Dao
@ -81,8 +82,8 @@ interface PacketDao {
)
suspend fun clearUnreadCount(contact: String, timestamp: Long)
@Insert
fun insert(packet: Packet)
@Upsert
suspend fun insert(packet: Packet)
@Query(
"""
@ -92,7 +93,8 @@ interface PacketDao {
ORDER BY received_time DESC
"""
)
fun getMessagesFrom(contact: String): Flow<List<Packet>>
@Transaction
fun getMessagesFrom(contact: String): Flow<List<PacketEntity>>
@Query(
"""
@ -101,10 +103,10 @@ interface PacketDao {
AND data = :data
"""
)
fun findDataPacket(data: DataPacket): Packet?
suspend fun findDataPacket(data: DataPacket): Packet?
@Query("DELETE FROM packet WHERE uuid in (:uuidList)")
fun deleteMessages(uuidList: List<Long>)
suspend fun deletePackets(uuidList: List<Long>)
@Query(
"""
@ -113,27 +115,42 @@ interface PacketDao {
AND contact_key IN (:contactList)
"""
)
fun deleteContacts(contactList: List<String>)
suspend fun deleteContacts(contactList: List<String>)
@Query("DELETE FROM packet WHERE uuid=:uuid")
fun _delete(uuid: Long)
suspend fun _delete(uuid: Long)
@Transaction
fun delete(packet: Packet) {
suspend fun delete(packet: Packet) {
_delete(packet.uuid)
}
@Update
fun update(packet: Packet)
@Query("SELECT packet_id FROM packet WHERE uuid IN (:uuidList)")
suspend fun getPacketIdsFrom(uuidList: List<Long>): List<Int>
@Query("DELETE FROM reactions WHERE reply_id IN (:packetIds)")
suspend fun deleteReactions(packetIds: List<Int>)
@Transaction
fun updateMessageStatus(data: DataPacket, m: MessageStatus) {
suspend fun deleteMessages(uuidList: List<Long>) {
val packetIds = getPacketIdsFrom(uuidList)
if (packetIds.isNotEmpty()) {
deleteReactions(packetIds)
}
deletePackets(uuidList)
}
@Update
suspend fun update(packet: Packet)
@Transaction
suspend fun updateMessageStatus(data: DataPacket, m: MessageStatus) {
val new = data.copy(status = m)
findDataPacket(data)?.let { update(it.copy(data = new)) }
}
@Transaction
fun updateMessageId(data: DataPacket, id: Int) {
suspend fun updateMessageId(data: DataPacket, id: Int) {
val new = data.copy(id = id)
findDataPacket(data)?.let { update(it.copy(data = new)) }
}
@ -145,7 +162,7 @@ interface PacketDao {
ORDER BY received_time ASC
"""
)
fun getDataPackets(): List<DataPacket>
suspend fun getDataPackets(): List<DataPacket>
@Query(
"""
@ -155,10 +172,10 @@ interface PacketDao {
ORDER BY received_time DESC
"""
)
fun getPacketById(requestId: Int): Packet?
suspend fun getPacketById(requestId: Int): Packet?
@Transaction
fun getQueuedPackets(): List<DataPacket>? =
suspend fun getQueuedPackets(): List<DataPacket>? =
getDataPackets().filter { it.status == MessageStatus.QUEUED }
@Query(
@ -169,10 +186,10 @@ interface PacketDao {
ORDER BY received_time ASC
"""
)
fun getAllWaypoints(): List<Packet>
suspend fun getAllWaypoints(): List<Packet>
@Transaction
fun deleteWaypoint(id: Int) {
suspend fun deleteWaypoint(id: Int) {
val uuidList = getAllWaypoints().filter { it.data.waypoint?.id == id }.map { it.uuid }
deleteMessages(uuidList)
}
@ -184,7 +201,7 @@ interface PacketDao {
suspend fun getContactSettings(contact: String): ContactSettings?
@Upsert
fun upsertContactSettings(contacts: List<ContactSettings>)
suspend fun upsertContactSettings(contacts: List<ContactSettings>)
@Transaction
suspend fun setMuteUntil(contacts: List<String>, until: Long) {
@ -194,4 +211,7 @@ interface PacketDao {
}
upsertContactSettings(contactList)
}
@Upsert
suspend fun insert(reaction: ReactionEntity)
}

Wyświetl plik

@ -18,10 +18,36 @@
package com.geeksville.mesh.database.entity
import androidx.room.ColumnInfo
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import androidx.room.Relation
import com.geeksville.mesh.DataPacket
import com.geeksville.mesh.MeshProtos.User
import com.geeksville.mesh.model.Message
import com.geeksville.mesh.util.getShortDateTime
data class PacketEntity(
@Embedded val packet: Packet,
@Relation(entity = ReactionEntity::class, parentColumn = "packet_id", entityColumn = "reply_id")
val reactions: List<ReactionEntity> = emptyList(),
) {
suspend fun toMessage(getUser: suspend (userId: String?) -> User) = with(packet) {
Message(
uuid = uuid,
receivedTime = received_time,
user = getUser(data.from),
text = data.text.orEmpty(),
time = getShortDateTime(data.time),
read = read,
status = data.status,
routingError = routingError,
packetId = packetId,
emojis = reactions.toReaction(getUser),
)
}
}
@Entity(
tableName = "packet",
@ -42,6 +68,7 @@ data class Packet(
@ColumnInfo(name = "data") val data: DataPacket,
@ColumnInfo(name = "packet_id", defaultValue = "0") val packetId: Int = 0,
@ColumnInfo(name = "routing_error", defaultValue = "-1") var routingError: Int = -1,
@ColumnInfo(name = "reply_id", defaultValue = "0") val replyId: Int = 0,
)
@Entity(tableName = "contact_settings")
@ -51,3 +78,37 @@ data class ContactSettings(
) {
val isMuted get() = System.currentTimeMillis() <= muteUntil
}
data class Reaction(
val replyId: Int,
val user: User,
val emoji: String,
val timestamp: Long,
)
@Entity(
tableName = "reactions",
primaryKeys = ["reply_id", "user_id", "emoji"],
indices = [
Index(value = ["reply_id"]),
],
)
data class ReactionEntity(
@ColumnInfo(name = "reply_id") val replyId: Int,
@ColumnInfo(name = "user_id") val userId: String,
val emoji: String,
val timestamp: Long,
)
private suspend fun ReactionEntity.toReaction(
getUser: suspend (userId: String?) -> User
) = Reaction(
replyId = replyId,
user = getUser(userId),
emoji = emoji,
timestamp = timestamp,
)
private suspend fun List<ReactionEntity>.toReaction(
getUser: suspend (userId: String?) -> User
) = this.map { it.toReaction(getUser) }

Wyświetl plik

@ -17,10 +17,11 @@
package com.geeksville.mesh.model
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.MeshProtos.Routing
import com.geeksville.mesh.MeshProtos.User
import com.geeksville.mesh.MessageStatus
import com.geeksville.mesh.R
import com.geeksville.mesh.database.entity.Reaction
val Routing.Error.stringRes: Int
get() = when (this) {
@ -46,12 +47,14 @@ val Routing.Error.stringRes: Int
data class Message(
val uuid: Long,
val receivedTime: Long,
val user: MeshProtos.User,
val user: User,
val text: String,
val time: String,
val read: Boolean,
val status: MessageStatus?,
val routingError: Int,
val packetId: Int,
val emojis: List<Reaction>,
) {
private fun getStatusStringRes(value: Int): Int {
val error = Routing.Error.forNumber(value) ?: Routing.Error.UNRECOGNIZED

Wyświetl plik

@ -61,7 +61,6 @@ import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.service.ServiceAction
import com.geeksville.mesh.ui.map.MAP_STYLE_ID
import com.geeksville.mesh.util.getShortDate
import com.geeksville.mesh.util.getShortDateTime
import com.geeksville.mesh.util.positionToMeter
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
@ -330,20 +329,8 @@ class UIViewModel @Inject constructor(
)
@OptIn(ExperimentalCoroutinesApi::class)
fun getMessagesFrom(contactKey: String) = packetRepository.getMessagesFrom(contactKey).mapLatest { list ->
list.map {
Message(
uuid = it.uuid,
receivedTime = it.received_time,
user = getUser(it.data.from),
text = it.data.text.orEmpty(),
time = getShortDateTime(it.data.time),
read = it.read,
status = it.data.status,
routingError = it.routingError,
)
}
}
fun getMessagesFrom(contactKey: String) = packetRepository.getMessagesFrom(contactKey)
.mapLatest { list -> list.map { it.toMessage(::getUser) } }
@OptIn(ExperimentalCoroutinesApi::class)
val waypoints = packetRepository.getWaypoints().mapLatest { list ->
@ -386,10 +373,8 @@ class UIViewModel @Inject constructor(
}
}
fun sendTapback(emoji: String, replyId: Int, contactKey: String) {
viewModelScope.launch {
radioConfigRepository.onServiceAction(ServiceAction.Tapback(emoji, replyId, contactKey))
}
fun sendReaction(emoji: String, replyId: Int, contactKey: String) = viewModelScope.launch {
radioConfigRepository.onServiceAction(ServiceAction.Reaction(emoji, replyId, contactKey))
}
fun requestTraceroute(destNum: Int) {

Wyświetl plik

@ -43,6 +43,7 @@ import com.geeksville.mesh.database.entity.MeshLog
import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.database.entity.Packet
import com.geeksville.mesh.database.entity.ReactionEntity
import com.geeksville.mesh.database.entity.toNodeInfo
import com.geeksville.mesh.model.DeviceVersion
import com.geeksville.mesh.model.getTracerouteResponse
@ -76,7 +77,7 @@ import javax.inject.Inject
import kotlin.math.absoluteValue
sealed class ServiceAction {
data class Tapback(val emoji: String, val replyId: Int, val contactKey: String) : ServiceAction()
data class Reaction(val emoji: String, val replyId: Int, val contactKey: String) : ServiceAction()
}
/**
@ -302,7 +303,7 @@ class MeshService : Service(), Logging {
.launchIn(serviceScope)
radioConfigRepository.serviceAction.onEach { action ->
when (action) {
is ServiceAction.Tapback -> sendTapback(action)
is ServiceAction.Reaction -> sendReaction(action)
}
}.launchIn(serviceScope)
@ -630,6 +631,16 @@ class MeshService : Service(), Logging {
Portnums.PortNum.WAYPOINT_APP_VALUE,
)
private fun rememberReaction(packet: MeshPacket) = serviceScope.handledLaunch {
val reaction = ReactionEntity(
replyId = packet.decoded.replyId,
userId = toNodeID(packet.from),
emoji = packet.decoded.payload.toByteArray().decodeToString(),
timestamp = System.currentTimeMillis(),
)
packetRepository.get().insertReaction(reaction)
}
private fun rememberDataPacket(dataPacket: DataPacket, updateNotification: Boolean = true) {
if (dataPacket.dataType !in rememberDataType) return
val fromLocal = dataPacket.from == DataPacket.ID_LOCAL
@ -682,8 +693,13 @@ class MeshService : Service(), Logging {
when (data.portnumValue) {
Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> {
debug("Received CLEAR_TEXT from $fromId")
rememberDataPacket(dataPacket)
if (data.emoji != 0) {
debug("Received EMOJI from $fromId")
rememberReaction(packet)
} else {
debug("Received CLEAR_TEXT from $fromId")
rememberDataPacket(dataPacket)
}
}
Portnums.PortNum.WAYPOINT_APP_VALUE -> {
@ -1741,19 +1757,22 @@ class MeshService : Service(), Logging {
}
}
private fun sendTapback(tapback: ServiceAction.Tapback) = toRemoteExceptions {
private fun sendReaction(reaction: ServiceAction.Reaction) = toRemoteExceptions {
// contactKey: unique contact key filter (channel)+(nodeId)
val channel = tapback.contactKey[0].digitToInt()
val destNum = tapback.contactKey.substring(1)
val channel = reaction.contactKey[0].digitToInt()
val destNum = reaction.contactKey.substring(1)
sendToRadio(newMeshPacketTo(destNum).buildMeshPacket(
val packet = newMeshPacketTo(destNum).buildMeshPacket(
channel = channel,
priority = MeshPacket.Priority.BACKGROUND,
) {
replyId = tapback.replyId
emoji = 1
replyId = reaction.replyId
portnumValue = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE
payload = ByteString.copyFrom(tapback.emoji.encodeToByteArray())
})
payload = ByteString.copyFrom(reaction.emoji.encodeToByteArray())
}
sendToRadio(packet)
rememberReaction(packet.copy { from = myNodeNum })
}
private val binder = object : IMeshService.Stub() {

Wyświetl plik

@ -35,6 +35,7 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import com.geeksville.mesh.DataPacket
import com.geeksville.mesh.model.Message
import com.geeksville.mesh.ui.components.ReactionRow
import com.geeksville.mesh.ui.components.SimpleAlertDialog
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.collectLatest
@ -46,6 +47,7 @@ internal fun MessageListView(
selectedIds: MutableState<Set<Long>>,
onUnreadChanged: (Long) -> Unit,
contentPadding: PaddingValues,
onSendReaction: (String, Int) -> Unit,
onClick: (Message) -> Unit = {}
) {
val inSelectionMode by remember { derivedStateOf { selectedIds.value.isNotEmpty() } }
@ -75,10 +77,12 @@ internal fun MessageListView(
contentPadding = contentPadding
) {
items(messages, key = { it.uuid }) { msg ->
val fromLocal = msg.user.id == DataPacket.ID_LOCAL
val selected by remember { derivedStateOf { selectedIds.value.contains(msg.uuid) } }
ReactionRow(fromLocal, msg.emojis) { onSendReaction(it, msg.packetId) }
MessageItem(
shortName = msg.user.shortName.takeIf { msg.user.id != DataPacket.ID_LOCAL },
shortName = msg.user.shortName.takeIf { !fromLocal },
messageText = msg.text,
messageTime = msg.time,
messageStatus = msg.status,

Wyświetl plik

@ -251,7 +251,8 @@ internal fun MessageScreen(
messages = messages,
selectedIds = selectedIds,
onUnreadChanged = { viewModel.clearUnreadCount(contactKey, it) },
contentPadding = innerPadding
contentPadding = innerPadding,
onSendReaction = { emoji, id -> viewModel.sendReaction(emoji, id, contactKey) },
) {
// TODO onCLick()
}

Wyświetl plik

@ -0,0 +1,63 @@
/*
* Copyright (c) 2024 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 com.geeksville.mesh.ui.components
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import androidx.emoji2.emojipicker.RecentEmojiProviderAdapter
import com.geeksville.mesh.util.CustomRecentEmojiProvider
@Composable
fun EmojiPicker(
onDismiss: () -> Unit = {},
onConfirm: (String) -> Unit
) {
Column(
verticalArrangement = Arrangement.Bottom
) {
BackHandler {
onDismiss()
}
AndroidView(
factory = { context ->
androidx.emoji2.emojipicker.EmojiPickerView(context).apply {
clipToOutline = true
setRecentEmojiProvider(
RecentEmojiProviderAdapter(CustomRecentEmojiProvider(context))
)
setOnEmojiPickedListener { emoji ->
onDismiss()
onConfirm(emoji.emoji)
}
}
},
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(fraction = 0.4f)
.background(MaterialTheme.colors.background)
)
}
}

Wyświetl plik

@ -0,0 +1,220 @@
/*
* Copyright (c) 2024 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 com.geeksville.mesh.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Badge
import androidx.compose.material.BadgedBox
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Add
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.draw.clip
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 androidx.compose.ui.window.Dialog
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.database.entity.Reaction
import com.geeksville.mesh.ui.theme.AppTheme
@Composable
private fun ReactionItem(
emoji: String,
isAddEmojiItem: Boolean = false,
emojiCount: Int = 1,
onClick: () -> Unit = {},
) {
BadgedBox(
modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp),
badge = {
if (emojiCount > 1) {
Badge(
backgroundColor = MaterialTheme.colors.onBackground,
contentColor = MaterialTheme.colors.background,
) {
Text(
fontWeight = FontWeight.Bold,
text = emojiCount.toString()
)
}
}
}
) {
Surface(
modifier = Modifier
.clickable { onClick() },
color = MaterialTheme.colors.surface,
shape = RoundedCornerShape(32.dp),
elevation = 4.dp,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
if (isAddEmojiItem) {
Icon(
imageVector = Icons.TwoTone.Add,
contentDescription = null,
modifier = Modifier.padding(start = 8.dp),
)
}
Text(
text = emoji,
modifier = Modifier
.padding(8.dp)
.clip(CircleShape),
)
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ReactionRow(
fromLocal: Boolean,
reactions: List<Reaction> = emptyList(),
onSendReaction: (String) -> Unit = {}
) {
val emojiList by remember(reactions) {
mutableStateOf(
reduceEmojis(
if (fromLocal) {
reactions.map { it.emoji }
} else {
reactions.map { it.emoji }.reversed()
}
).entries
)
}
var showEmojiPickerDialog by remember { mutableStateOf(false) }
if (showEmojiPickerDialog) {
EmojiPickerDialog(
onConfirm = {
showEmojiPickerDialog = false
onSendReaction(it)
},
onDismiss = { showEmojiPickerDialog = false }
)
}
@Composable
fun AddEmojiItem() {
ReactionItem(
emoji = "\uD83D\uDE42",
isAddEmojiItem = true,
onClick = {
showEmojiPickerDialog = true
}
)
}
@Composable
fun EmojiList() {
emojiList.forEach { entry ->
ReactionItem(
emoji = entry.key,
emojiCount = entry.value,
onClick = {
onSendReaction(entry.key)
}
)
}
}
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (fromLocal) Arrangement.End else Arrangement.Start
) {
EmojiList()
AddEmojiItem()
}
}
fun reduceEmojis(emojis: List<String>): Map<String, Int> = emojis.groupingBy { it }.eachCount()
@Composable
fun EmojiPickerDialog(
onConfirm: (String) -> Unit,
onDismiss: () -> Unit = {},
) {
Dialog(
onDismissRequest = onDismiss,
) {
EmojiPicker(
onConfirm = onConfirm,
onDismiss = onDismiss,
)
}
}
@PreviewLightDark
@Composable
fun ReactionItemPreview() {
AppTheme {
Column(
modifier = Modifier.background(MaterialTheme.colors.background)
) {
ReactionItem(emoji = "\uD83D\uDE42")
ReactionItem(emoji = "\uD83D\uDE42", emojiCount = 2)
ReactionItem(emoji = "\uD83D\uDE42", isAddEmojiItem = true)
}
}
}
@Preview
@Composable
fun ReactionRowPreview() {
AppTheme {
ReactionRow(
fromLocal = true, reactions = listOf(
Reaction(
replyId = 1,
user = MeshProtos.User.getDefaultInstance(),
emoji = "\uD83D\uDE42",
timestamp = 1L
),
Reaction(
replyId = 1,
user = MeshProtos.User.getDefaultInstance(),
emoji = "\uD83D\uDE42",
timestamp = 1L
),
)
)
}
}

Wyświetl plik

@ -17,7 +17,6 @@
package com.geeksville.mesh.ui.map
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@ -25,7 +24,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@ -58,15 +56,12 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.emoji2.emojipicker.EmojiPickerView
import androidx.emoji2.emojipicker.RecentEmojiProviderAdapter
import com.geeksville.mesh.MeshProtos.Waypoint
import com.geeksville.mesh.R
import com.geeksville.mesh.copy
import com.geeksville.mesh.ui.components.EditTextPreference
import com.geeksville.mesh.ui.components.EmojiPicker
import com.geeksville.mesh.ui.theme.AppTheme
import com.geeksville.mesh.util.CustomRecentEmojiProvider
import com.geeksville.mesh.waypoint
@Suppress("LongMethod")
@ -184,31 +179,9 @@ internal fun EditWaypointDialog(
}
},
) else {
Column(
verticalArrangement = Arrangement.Bottom
) {
BackHandler {
showEmojiPickerView = false
}
AndroidView(
factory = { context ->
EmojiPickerView(context).apply {
clipToOutline = true
setRecentEmojiProvider(
RecentEmojiProviderAdapter(CustomRecentEmojiProvider(context))
)
setOnEmojiPickedListener { emoji ->
showEmojiPickerView = false
waypointInput = waypointInput.copy { icon = emoji.emoji.codePointAt(0) }
}
}
},
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.4f)
.background(MaterialTheme.colors.background)
)
EmojiPicker(onDismiss = { showEmojiPickerView = false }) {
showEmojiPickerView = false
waypointInput = waypointInput.copy { icon = it.codePointAt(0) }
}
}
}

Wyświetl plik

@ -133,7 +133,7 @@
<ID>FinalNewline:UsbRepositoryModule.kt$com.geeksville.mesh.repository.usb.UsbRepositoryModule.kt</ID>
<ID>ForbiddenComment:MapFragment.kt$// TODO: Accept filename input param from user</ID>
<ID>ForbiddenComment:SafeBluetooth.kt$SafeBluetooth$// TODO: display some kind of UI about restarting BLE</ID>
<ID>FunctionNaming:PacketDao.kt$PacketDao$@Query("DELETE FROM packet WHERE uuid=:uuid") fun _delete(uuid: Long)</ID>
<ID>FunctionNaming:PacketDao.kt$PacketDao$@Query("DELETE FROM packet WHERE uuid=:uuid") suspend fun _delete(uuid: Long)</ID>
<ID>FunctionNaming:QuickChatActionDao.kt$QuickChatActionDao$@Query("Delete from quick_chat where uuid=:uuid") fun _delete(uuid: Long)</ID>
<ID>FunctionParameterNaming:LocationUtils.kt$_degIn: Double</ID>
<ID>FunctionParameterNaming:LocationUtils.kt$lat_a: Double</ID>
@ -231,7 +231,6 @@
<ID>MagicNumber:EditListPreference.kt$12</ID>
<ID>MagicNumber:EditListPreference.kt$12345</ID>
<ID>MagicNumber:EditListPreference.kt$67890</ID>
<ID>MagicNumber:EditWaypointDialog.kt$0.4f</ID>
<ID>MagicNumber:EditWaypointDialog.kt$123</ID>
<ID>MagicNumber:EditWaypointDialog.kt$128169</ID>
<ID>MagicNumber:EditWaypointDialog.kt$128205</ID>
@ -284,7 +283,6 @@
<ID>MagicNumber:MeshService.kt$MeshService$32</ID>
<ID>MagicNumber:MeshService.kt$MeshService$60000</ID>
<ID>MagicNumber:MeshService.kt$MeshService$8</ID>
<ID>MagicNumber:MessagesFragment.kt$MessagesFragment$200</ID>
<ID>MagicNumber:MetricsViewModel.kt$MetricsViewModel$1000L</ID>
<ID>MagicNumber:MetricsViewModel.kt$MetricsViewModel$1e-5</ID>
<ID>MagicNumber:MetricsViewModel.kt$MetricsViewModel$1e-7</ID>
@ -369,7 +367,6 @@
<ID>MagicNumber:TCPInterface.kt$TCPInterface$500</ID>
<ID>MagicNumber:UIState.kt$4</ID>
<ID>MatchingDeclarationName:AnalyticsClient.kt$AnalyticsProvider</ID>
<ID>MatchingDeclarationName:CompatExtensions.kt$PendingIntentCompat</ID>
<ID>MatchingDeclarationName:DistanceExtensions.kt$DistanceUnit</ID>
<ID>MatchingDeclarationName:LocationUtils.kt$GPSFormat</ID>
<ID>MatchingDeclarationName:MeshServiceStarter.kt$ServiceStarter : Worker</ID>
@ -484,7 +481,6 @@
<ID>MultiLineIfElse:Channel.kt$Channel$when (loraConfig.modemPreset) { ModemPreset.SHORT_TURBO -&gt; "ShortTurbo" ModemPreset.SHORT_FAST -&gt; "ShortFast" ModemPreset.SHORT_SLOW -&gt; "ShortSlow" ModemPreset.MEDIUM_FAST -&gt; "MediumFast" ModemPreset.MEDIUM_SLOW -&gt; "MediumSlow" ModemPreset.LONG_FAST -&gt; "LongFast" ModemPreset.LONG_SLOW -&gt; "LongSlow" ModemPreset.LONG_MODERATE -&gt; "LongMod" ModemPreset.VERY_LONG_SLOW -&gt; "VLongSlow" else -&gt; "Invalid" }</ID>
<ID>MultiLineIfElse:ChannelFragment.kt$channelSet = copy { settings.add(it) }</ID>
<ID>MultiLineIfElse:ChannelFragment.kt$channelSet = copy { settings[index] = it }</ID>
<ID>MultiLineIfElse:ChannelFragment.kt$item { PreferenceFooter( enabled = enabled, onCancelClicked = { focusManager.clearFocus() showChannelEditor = false channelSet = channels }, onSaveClicked = { focusManager.clearFocus() // viewModel.setRequestChannelUrl(channelUrl) sendButton() }) }</ID>
<ID>MultiLineIfElse:ChannelOption.kt$when (bandwidth) { 31 -&gt; .03125f 62 -&gt; .0625f 200 -&gt; .203125f 400 -&gt; .40625f 800 -&gt; .8125f 1600 -&gt; 1.6250f else -&gt; bandwidth / 1000f }</ID>
<ID>MultiLineIfElse:ContextServices.kt$MaterialAlertDialogBuilder(this) .setTitle(title) .setMessage(rationale) .setNeutralButton(R.string.cancel) { _, _ -&gt; } .setPositiveButton(R.string.accept) { _, _ -&gt; invokeFun() } .show()</ID>
<ID>MultiLineIfElse:ContextServices.kt$invokeFun()</ID>
@ -610,7 +606,6 @@
<ID>NoConsecutiveBlankLines:IRadioInterface.kt$ </ID>
<ID>NoConsecutiveBlankLines:NOAAWmsTileSource.kt$NOAAWmsTileSource$ </ID>
<ID>NoConsecutiveBlankLines:NodeInfo.kt$ </ID>
<ID>NoConsecutiveBlankLines:PositionTest.kt$ </ID>
<ID>NoConsecutiveBlankLines:PreviewParameterProviders.kt$ </ID>
<ID>NoConsecutiveBlankLines:SafeBluetooth.kt$ </ID>
<ID>NoConsecutiveBlankLines:SafeBluetooth.kt$SafeBluetooth$ </ID>
@ -629,7 +624,6 @@
<ID>NoWildcardImports:SafeBluetooth.kt$import android.bluetooth.*</ID>
<ID>NoWildcardImports:SafeBluetooth.kt$import kotlinx.coroutines.*</ID>
<ID>NoWildcardImports:SettingsFragment.kt$import com.geeksville.mesh.android.*</ID>
<ID>NoWildcardImports:UIState.kt$import com.geeksville.mesh.*</ID>
<ID>NoWildcardImports:UsbRepository.kt$import kotlinx.coroutines.flow.*</ID>
<ID>OptionalAbstractKeyword:SyncContinuation.kt$Continuation$abstract</ID>
<ID>ParameterListWrapping:AppPrefs.kt$FloatPref$(thisRef: AppPrefs, prop: KProperty&lt;Float&gt;)</ID>
@ -641,7 +635,6 @@
<ID>RethrowCaughtException:SyncContinuation.kt$Continuation$throw ex</ID>
<ID>ReturnCount:ChannelOption.kt$internal fun LoRaConfig.radioFreq(channelNum: Int): Float</ID>
<ID>ReturnCount:MainActivity.kt$MainActivity$override fun onOptionsItemSelected(item: MenuItem): Boolean</ID>
<ID>ReturnCount:MeshService.kt$MeshService$private fun handleReceivedData(packet: MeshPacket)</ID>
<ID>ReturnCount:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket)</ID>
<ID>SpacingAroundColon:PreviewParameterProviders.kt$NodeInfoPreviewParameterProvider$:</ID>
<ID>SpacingAroundCurly:AppPrefs.kt$FloatPref$}</ID>
@ -652,7 +645,6 @@
<ID>SpacingAroundRangeOperator:BatteryInfo.kt$..</ID>
<ID>StringTemplate:NodeInfo.kt$Position$${time}</ID>
<ID>SwallowedException:BluetoothInterface.kt$BluetoothInterface$ex: CancellationException</ID>
<ID>SwallowedException:ChannelFragment.kt$ex: Throwable</ID>
<ID>SwallowedException:ChannelSet.kt$ex: Throwable</ID>
<ID>SwallowedException:DeviceVersion.kt$DeviceVersion$e: Exception</ID>
<ID>SwallowedException:Exceptions.kt$ex: Throwable</ID>
@ -672,7 +664,6 @@
<ID>TooGenericExceptionCaught:BTScanModel.kt$BTScanModel$ex: Throwable</ID>
<ID>TooGenericExceptionCaught:BluetoothInterface.kt$BluetoothInterface$ex: Exception</ID>
<ID>TooGenericExceptionCaught:ChannelFragment.kt$ex: Exception</ID>
<ID>TooGenericExceptionCaught:ChannelFragment.kt$ex: Throwable</ID>
<ID>TooGenericExceptionCaught:ChannelSet.kt$ex: Throwable</ID>
<ID>TooGenericExceptionCaught:DeviceVersion.kt$DeviceVersion$e: Exception</ID>
<ID>TooGenericExceptionCaught:Exceptions.kt$ex: Throwable</ID>
@ -748,7 +739,6 @@
<ID>WildcardImport:SafeBluetooth.kt$import android.bluetooth.*</ID>
<ID>WildcardImport:SafeBluetooth.kt$import kotlinx.coroutines.*</ID>
<ID>WildcardImport:SettingsFragment.kt$import com.geeksville.mesh.android.*</ID>
<ID>WildcardImport:UIState.kt$import com.geeksville.mesh.*</ID>
<ID>WildcardImport:UsbRepository.kt$import kotlinx.coroutines.flow.*</ID>
</CurrentIssues>
</SmellBaseline>