diff --git a/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/14.json b/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/14.json
new file mode 100644
index 000000000..93b3614dd
--- /dev/null
+++ b/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/14.json
@@ -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')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/com/geeksville/mesh/PacketDaoTest.kt b/app/src/androidTest/java/com/geeksville/mesh/PacketDaoTest.kt
index be9b58beb..4141fe007 100644
--- a/app/src/androidTest/java/com/geeksville/mesh/PacketDaoTest.kt
+++ b/app/src/androidTest/java/com/geeksville/mesh/PacketDaoTest.kt
@@ -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)
}
}
diff --git a/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt b/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt
index 4c160ee2d..03da56143 100644
--- a/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt
+++ b/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt
@@ -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 .
- */
-
-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 .
+ */
+
+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
diff --git a/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt b/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt
index f63ba3b5b..c362b66db 100644
--- a/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt
+++ b/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt
@@ -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, until: Long) = withContext(Dispatchers.IO) {
packetDao.setMuteUntil(contacts, until)
}
+
+ suspend fun insertReaction(reaction: ReactionEntity) = withContext(Dispatchers.IO) {
+ packetDao.insert(reaction)
+ }
}
diff --git a/app/src/main/java/com/geeksville/mesh/database/dao/PacketDao.kt b/app/src/main/java/com/geeksville/mesh/database/dao/PacketDao.kt
index 9e41fb0fe..ea7e59595 100644
--- a/app/src/main/java/com/geeksville/mesh/database/dao/PacketDao.kt
+++ b/app/src/main/java/com/geeksville/mesh/database/dao/PacketDao.kt
@@ -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>
+ @Transaction
+ fun getMessagesFrom(contact: String): Flow>
@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)
+ suspend fun deletePackets(uuidList: List)
@Query(
"""
@@ -113,27 +115,42 @@ interface PacketDao {
AND contact_key IN (:contactList)
"""
)
- fun deleteContacts(contactList: List)
+ suspend fun deleteContacts(contactList: List)
@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)
}
+ @Query("SELECT packet_id FROM packet WHERE uuid IN (:uuidList)")
+ suspend fun getPacketIdsFrom(uuidList: List): List
+
+ @Query("DELETE FROM reactions WHERE reply_id IN (:packetIds)")
+ suspend fun deleteReactions(packetIds: List)
+
+ @Transaction
+ suspend fun deleteMessages(uuidList: List) {
+ val packetIds = getPacketIdsFrom(uuidList)
+ if (packetIds.isNotEmpty()) {
+ deleteReactions(packetIds)
+ }
+ deletePackets(uuidList)
+ }
+
@Update
- fun update(packet: Packet)
+ suspend fun update(packet: Packet)
@Transaction
- fun updateMessageStatus(data: DataPacket, m: MessageStatus) {
+ 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
+ suspend fun getDataPackets(): List
@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? =
+ suspend fun getQueuedPackets(): List? =
getDataPackets().filter { it.status == MessageStatus.QUEUED }
@Query(
@@ -169,10 +186,10 @@ interface PacketDao {
ORDER BY received_time ASC
"""
)
- fun getAllWaypoints(): List
+ suspend fun getAllWaypoints(): List
@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)
+ suspend fun upsertContactSettings(contacts: List)
@Transaction
suspend fun setMuteUntil(contacts: List, until: Long) {
@@ -194,4 +211,7 @@ interface PacketDao {
}
upsertContactSettings(contactList)
}
+
+ @Upsert
+ suspend fun insert(reaction: ReactionEntity)
}
diff --git a/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt b/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt
index 4045ebf1c..3634826c0 100644
--- a/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt
+++ b/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt
@@ -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 = 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.toReaction(
+ getUser: suspend (userId: String?) -> User
+) = this.map { it.toReaction(getUser) }
diff --git a/app/src/main/java/com/geeksville/mesh/model/Message.kt b/app/src/main/java/com/geeksville/mesh/model/Message.kt
index df8b568fa..61ef926fc 100644
--- a/app/src/main/java/com/geeksville/mesh/model/Message.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/Message.kt
@@ -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,
) {
private fun getStatusStringRes(value: Int): Int {
val error = Routing.Error.forNumber(value) ?: Routing.Error.UNRECOGNIZED
diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt
index 4ca7688c5..a8917719e 100644
--- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt
+++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt
@@ -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) {
diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
index b6d2b7731..2968785bb 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
@@ -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() {
diff --git a/app/src/main/java/com/geeksville/mesh/ui/MessageListView.kt b/app/src/main/java/com/geeksville/mesh/ui/MessageListView.kt
index 5a6bbceec..08c58f16d 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/MessageListView.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/MessageListView.kt
@@ -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>,
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,
diff --git a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt
index 56a99b825..f021191c9 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt
@@ -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()
}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/EmojiPicker.kt b/app/src/main/java/com/geeksville/mesh/ui/components/EmojiPicker.kt
new file mode 100644
index 000000000..87336bfff
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/components/EmojiPicker.kt
@@ -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 .
+ */
+
+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)
+ )
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/Reaction.kt b/app/src/main/java/com/geeksville/mesh/ui/components/Reaction.kt
new file mode 100644
index 000000000..b6deecebe
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/ui/components/Reaction.kt
@@ -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 .
+ */
+
+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 = 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): Map = 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
+ ),
+ )
+ )
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/EditWaypointDialog.kt b/app/src/main/java/com/geeksville/mesh/ui/map/EditWaypointDialog.kt
index 33954af53..b1a035c3a 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/map/EditWaypointDialog.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/map/EditWaypointDialog.kt
@@ -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) }
}
}
}
diff --git a/config/detekt/detekt-baseline.xml b/config/detekt/detekt-baseline.xml
index 927f9a593..c41d1c503 100644
--- a/config/detekt/detekt-baseline.xml
+++ b/config/detekt/detekt-baseline.xml
@@ -133,7 +133,7 @@
FinalNewline:UsbRepositoryModule.kt$com.geeksville.mesh.repository.usb.UsbRepositoryModule.kt
ForbiddenComment:MapFragment.kt$// TODO: Accept filename input param from user
ForbiddenComment:SafeBluetooth.kt$SafeBluetooth$// TODO: display some kind of UI about restarting BLE
- FunctionNaming:PacketDao.kt$PacketDao$@Query("DELETE FROM packet WHERE uuid=:uuid") fun _delete(uuid: Long)
+ FunctionNaming:PacketDao.kt$PacketDao$@Query("DELETE FROM packet WHERE uuid=:uuid") suspend fun _delete(uuid: Long)
FunctionNaming:QuickChatActionDao.kt$QuickChatActionDao$@Query("Delete from quick_chat where uuid=:uuid") fun _delete(uuid: Long)
FunctionParameterNaming:LocationUtils.kt$_degIn: Double
FunctionParameterNaming:LocationUtils.kt$lat_a: Double
@@ -231,7 +231,6 @@
MagicNumber:EditListPreference.kt$12
MagicNumber:EditListPreference.kt$12345
MagicNumber:EditListPreference.kt$67890
- MagicNumber:EditWaypointDialog.kt$0.4f
MagicNumber:EditWaypointDialog.kt$123
MagicNumber:EditWaypointDialog.kt$128169
MagicNumber:EditWaypointDialog.kt$128205
@@ -284,7 +283,6 @@
MagicNumber:MeshService.kt$MeshService$32
MagicNumber:MeshService.kt$MeshService$60000
MagicNumber:MeshService.kt$MeshService$8
- MagicNumber:MessagesFragment.kt$MessagesFragment$200
MagicNumber:MetricsViewModel.kt$MetricsViewModel$1000L
MagicNumber:MetricsViewModel.kt$MetricsViewModel$1e-5
MagicNumber:MetricsViewModel.kt$MetricsViewModel$1e-7
@@ -369,7 +367,6 @@
MagicNumber:TCPInterface.kt$TCPInterface$500
MagicNumber:UIState.kt$4
MatchingDeclarationName:AnalyticsClient.kt$AnalyticsProvider
- MatchingDeclarationName:CompatExtensions.kt$PendingIntentCompat
MatchingDeclarationName:DistanceExtensions.kt$DistanceUnit
MatchingDeclarationName:LocationUtils.kt$GPSFormat
MatchingDeclarationName:MeshServiceStarter.kt$ServiceStarter : Worker
@@ -484,7 +481,6 @@
MultiLineIfElse:Channel.kt$Channel$when (loraConfig.modemPreset) { ModemPreset.SHORT_TURBO -> "ShortTurbo" ModemPreset.SHORT_FAST -> "ShortFast" ModemPreset.SHORT_SLOW -> "ShortSlow" ModemPreset.MEDIUM_FAST -> "MediumFast" ModemPreset.MEDIUM_SLOW -> "MediumSlow" ModemPreset.LONG_FAST -> "LongFast" ModemPreset.LONG_SLOW -> "LongSlow" ModemPreset.LONG_MODERATE -> "LongMod" ModemPreset.VERY_LONG_SLOW -> "VLongSlow" else -> "Invalid" }
MultiLineIfElse:ChannelFragment.kt$channelSet = copy { settings.add(it) }
MultiLineIfElse:ChannelFragment.kt$channelSet = copy { settings[index] = it }
- MultiLineIfElse:ChannelFragment.kt$item { PreferenceFooter( enabled = enabled, onCancelClicked = { focusManager.clearFocus() showChannelEditor = false channelSet = channels }, onSaveClicked = { focusManager.clearFocus() // viewModel.setRequestChannelUrl(channelUrl) sendButton() }) }
MultiLineIfElse:ChannelOption.kt$when (bandwidth) { 31 -> .03125f 62 -> .0625f 200 -> .203125f 400 -> .40625f 800 -> .8125f 1600 -> 1.6250f else -> bandwidth / 1000f }
MultiLineIfElse:ContextServices.kt$MaterialAlertDialogBuilder(this) .setTitle(title) .setMessage(rationale) .setNeutralButton(R.string.cancel) { _, _ -> } .setPositiveButton(R.string.accept) { _, _ -> invokeFun() } .show()
MultiLineIfElse:ContextServices.kt$invokeFun()
@@ -610,7 +606,6 @@
NoConsecutiveBlankLines:IRadioInterface.kt$
NoConsecutiveBlankLines:NOAAWmsTileSource.kt$NOAAWmsTileSource$
NoConsecutiveBlankLines:NodeInfo.kt$
- NoConsecutiveBlankLines:PositionTest.kt$
NoConsecutiveBlankLines:PreviewParameterProviders.kt$
NoConsecutiveBlankLines:SafeBluetooth.kt$
NoConsecutiveBlankLines:SafeBluetooth.kt$SafeBluetooth$
@@ -629,7 +624,6 @@
NoWildcardImports:SafeBluetooth.kt$import android.bluetooth.*
NoWildcardImports:SafeBluetooth.kt$import kotlinx.coroutines.*
NoWildcardImports:SettingsFragment.kt$import com.geeksville.mesh.android.*
- NoWildcardImports:UIState.kt$import com.geeksville.mesh.*
NoWildcardImports:UsbRepository.kt$import kotlinx.coroutines.flow.*
OptionalAbstractKeyword:SyncContinuation.kt$Continuation$abstract
ParameterListWrapping:AppPrefs.kt$FloatPref$(thisRef: AppPrefs, prop: KProperty<Float>)
@@ -641,7 +635,6 @@
RethrowCaughtException:SyncContinuation.kt$Continuation$throw ex
ReturnCount:ChannelOption.kt$internal fun LoRaConfig.radioFreq(channelNum: Int): Float
ReturnCount:MainActivity.kt$MainActivity$override fun onOptionsItemSelected(item: MenuItem): Boolean
- ReturnCount:MeshService.kt$MeshService$private fun handleReceivedData(packet: MeshPacket)
ReturnCount:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshProtos.MeshPacket)
SpacingAroundColon:PreviewParameterProviders.kt$NodeInfoPreviewParameterProvider$:
SpacingAroundCurly:AppPrefs.kt$FloatPref$}
@@ -652,7 +645,6 @@
SpacingAroundRangeOperator:BatteryInfo.kt$..
StringTemplate:NodeInfo.kt$Position$${time}
SwallowedException:BluetoothInterface.kt$BluetoothInterface$ex: CancellationException
- SwallowedException:ChannelFragment.kt$ex: Throwable
SwallowedException:ChannelSet.kt$ex: Throwable
SwallowedException:DeviceVersion.kt$DeviceVersion$e: Exception
SwallowedException:Exceptions.kt$ex: Throwable
@@ -672,7 +664,6 @@
TooGenericExceptionCaught:BTScanModel.kt$BTScanModel$ex: Throwable
TooGenericExceptionCaught:BluetoothInterface.kt$BluetoothInterface$ex: Exception
TooGenericExceptionCaught:ChannelFragment.kt$ex: Exception
- TooGenericExceptionCaught:ChannelFragment.kt$ex: Throwable
TooGenericExceptionCaught:ChannelSet.kt$ex: Throwable
TooGenericExceptionCaught:DeviceVersion.kt$DeviceVersion$e: Exception
TooGenericExceptionCaught:Exceptions.kt$ex: Throwable
@@ -748,7 +739,6 @@
WildcardImport:SafeBluetooth.kt$import android.bluetooth.*
WildcardImport:SafeBluetooth.kt$import kotlinx.coroutines.*
WildcardImport:SettingsFragment.kt$import com.geeksville.mesh.android.*
- WildcardImport:UIState.kt$import com.geeksville.mesh.*
WildcardImport:UsbRepository.kt$import kotlinx.coroutines.flow.*