From 5b3c78316b8accc789471f7ebdcd36e295c76682 Mon Sep 17 00:00:00 2001 From: Andre K Date: Sat, 8 Jun 2024 10:25:47 -0300 Subject: [PATCH] feat: support for switching between devices (#1078) --- .../8.json | 513 ++++++++++++++++++ .../java/com/geeksville/mesh/PacketDaoTest.kt | 141 +++++ .../main/java/com/geeksville/mesh/NodeInfo.kt | 22 +- .../mesh/database/MeshtasticDatabase.kt | 3 +- .../mesh/database/PacketRepository.kt | 21 +- .../geeksville/mesh/database/dao/PacketDao.kt | 79 ++- .../geeksville/mesh/database/entity/Packet.kt | 13 +- .../com/geeksville/mesh/model/ChannelSet.kt | 6 +- .../mesh/model/ContactsViewModel.kt | 104 ++++ .../java/com/geeksville/mesh/model/UIState.kt | 70 +-- .../geeksville/mesh/service/MeshService.kt | 2 + .../geeksville/mesh/ui/ContactsFragment.kt | 102 +--- .../geeksville/mesh/ui/MessagesFragment.kt | 35 +- .../com/geeksville/mesh/ui/UsersFragment.kt | 13 +- .../com/geeksville/mesh/ui/map/MapFragment.kt | 7 +- .../ui/preview/PreviewParameterProviders.kt | 9 +- 16 files changed, 934 insertions(+), 206 deletions(-) create mode 100644 app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/8.json create mode 100644 app/src/androidTest/java/com/geeksville/mesh/PacketDaoTest.kt create mode 100644 app/src/main/java/com/geeksville/mesh/model/ContactsViewModel.kt diff --git a/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/8.json b/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/8.json new file mode 100644 index 000000000..4ebf266ac --- /dev/null +++ b/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/8.json @@ -0,0 +1,513 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "4bc80e30d6ff7782394dddc7aafb75ba", + "entities": [ + { + "tableName": "MyNodeInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `hasGPS` 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, `channelUtilization` REAL NOT NULL, `airUtilTx` REAL NOT NULL, PRIMARY KEY(`myNodeNum`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasGPS", + "columnName": "hasGPS", + "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 + }, + { + "fieldPath": "channelUtilization", + "columnName": "channelUtilization", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "airUtilTx", + "columnName": "airUtilTx", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "NodeInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `lastHeard` INTEGER NOT NULL, `channel` INTEGER NOT NULL, `hopsAway` INTEGER NOT NULL DEFAULT 0, `user_id` TEXT, `user_longName` TEXT, `user_shortName` TEXT, `user_hwModel` TEXT, `user_isLicensed` INTEGER, `position_latitude` REAL, `position_longitude` REAL, `position_altitude` INTEGER, `position_time` INTEGER, `position_satellitesInView` INTEGER, `position_groundSpeed` INTEGER, `position_groundTrack` INTEGER, `position_precisionBits` INTEGER, `devMetrics_time` INTEGER, `devMetrics_batteryLevel` INTEGER, `devMetrics_voltage` REAL, `devMetrics_channelUtilization` REAL, `devMetrics_airUtilTx` REAL, `devMetrics_uptimeSeconds` INTEGER, `envMetrics_time` INTEGER, `envMetrics_temperature` REAL, `envMetrics_relativeHumidity` REAL, `envMetrics_barometricPressure` REAL, `envMetrics_gasResistance` REAL, `envMetrics_voltage` REAL, `envMetrics_current` REAL, `envMetrics_iaq` INTEGER, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastHeard", + "columnName": "lastHeard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "user.id", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "user.longName", + "columnName": "user_longName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "user.shortName", + "columnName": "user_shortName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "user.hwModel", + "columnName": "user_hwModel", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "user.isLicensed", + "columnName": "user_isLicensed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "position.latitude", + "columnName": "position_latitude", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "position.longitude", + "columnName": "position_longitude", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "position.altitude", + "columnName": "position_altitude", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "position.time", + "columnName": "position_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "position.satellitesInView", + "columnName": "position_satellitesInView", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "position.groundSpeed", + "columnName": "position_groundSpeed", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "position.groundTrack", + "columnName": "position_groundTrack", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "position.precisionBits", + "columnName": "position_precisionBits", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "deviceMetrics.time", + "columnName": "devMetrics_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "deviceMetrics.batteryLevel", + "columnName": "devMetrics_batteryLevel", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "deviceMetrics.voltage", + "columnName": "devMetrics_voltage", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "deviceMetrics.channelUtilization", + "columnName": "devMetrics_channelUtilization", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "deviceMetrics.airUtilTx", + "columnName": "devMetrics_airUtilTx", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "deviceMetrics.uptimeSeconds", + "columnName": "devMetrics_uptimeSeconds", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "environmentMetrics.time", + "columnName": "envMetrics_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "environmentMetrics.temperature", + "columnName": "envMetrics_temperature", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "environmentMetrics.relativeHumidity", + "columnName": "envMetrics_relativeHumidity", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "environmentMetrics.barometricPressure", + "columnName": "envMetrics_barometricPressure", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "environmentMetrics.gasResistance", + "columnName": "envMetrics_gasResistance", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "environmentMetrics.voltage", + "columnName": "envMetrics_voltage", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "environmentMetrics.current", + "columnName": "envMetrics_current", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "environmentMetrics.iaq", + "columnName": "envMetrics_iaq", + "affinity": "INTEGER", + "notNull": false + } + ], + "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)", + "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 + } + ], + "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, 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 + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [], + "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": [] + } + ], + "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, '4bc80e30d6ff7782394dddc7aafb75ba')" + ] + } +} \ 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 new file mode 100644 index 000000000..bb2e4ac72 --- /dev/null +++ b/app/src/androidTest/java/com/geeksville/mesh/PacketDaoTest.kt @@ -0,0 +1,141 @@ +package com.geeksville.mesh + +import androidx.room.Room +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.geeksville.mesh.database.MeshtasticDatabase +import com.geeksville.mesh.database.dao.NodeInfoDao +import com.geeksville.mesh.database.dao.PacketDao +import com.geeksville.mesh.database.entity.Packet +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PacketDaoTest { + private lateinit var database: MeshtasticDatabase + private lateinit var nodeInfoDao: NodeInfoDao + private lateinit var packetDao: PacketDao + + private val myNodeInfo: MyNodeInfo = MyNodeInfo( + myNodeNum = 42424242, + hasGPS = false, + model = null, + firmwareVersion = null, + couldUpdate = false, + shouldUpdate = false, + currentPacketId = 1L, + messageTimeoutMsec = 5 * 60 * 1000, + minAppVersion = 1, + maxChannels = 8, + hasWifi = false, + channelUtilization = 0f, + airUtilTx = 0f, + ) + + private val myNodeNum: Int get() = myNodeInfo.myNodeNum + + private val testContactKeys = listOf( + "0${DataPacket.ID_BROADCAST}", + "1!test1234", + ) + + private fun generateTestPackets(myNodeNum: Int) = testContactKeys.flatMap { contactKey -> + List(SAMPLE_SIZE) { + Packet( + uuid = 0L, + myNodeNum = myNodeNum, + port_num = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, + contact_key = contactKey, + received_time = System.currentTimeMillis(), + read = false, + DataPacket(DataPacket.ID_BROADCAST, 0, "Message $it!"), + ) + } + } + + @Before + fun createDb(): Unit = runBlocking { + val context = InstrumentationRegistry.getInstrumentation().targetContext + database = Room.inMemoryDatabaseBuilder(context, MeshtasticDatabase::class.java).build() + + nodeInfoDao = database.nodeInfoDao().apply { + setMyNodeInfo(myNodeInfo) + } + + packetDao = database.packetDao().apply { + generateTestPackets(42424243).forEach(::insert) + generateTestPackets(myNodeNum).forEach(::insert) + } + } + + @After + fun closeDb() { + database.close() + } + + @Test + fun test_myNodeNum() = runBlocking { + val myNodeInfo = nodeInfoDao.getMyNodeInfo().first() + assertEquals(myNodeNum, myNodeInfo?.myNodeNum) + } + + @Test + fun test_getAllPackets() = runBlocking { + val packets = packetDao.getAllPackets(Portnums.PortNum.TEXT_MESSAGE_APP_VALUE).first() + assertEquals(testContactKeys.size * SAMPLE_SIZE, packets.size) + + val onlyMyNodeNum = packets.all { it.myNodeNum == myNodeNum } + assertTrue(onlyMyNodeNum) + } + + @Test + fun test_getContactKeys() = runBlocking { + val contactKeys = packetDao.getContactKeys().first() + assertEquals(testContactKeys.size, contactKeys.size) + + val onlyMyNodeNum = contactKeys.values.all { it.myNodeNum == myNodeNum } + assertTrue(onlyMyNodeNum) + } + + @Test + fun test_getMessageCount() = runBlocking { + testContactKeys.forEach { contactKey -> + val messageCount = packetDao.getMessageCount(contactKey) + assertEquals(SAMPLE_SIZE, messageCount) + } + } + + @Test + fun test_getMessagesFrom() = runBlocking { + testContactKeys.forEach { contactKey -> + val messages = packetDao.getMessagesFrom(contactKey).first() + assertEquals(SAMPLE_SIZE, messages.size) + + val onlyFromContactKey = messages.all { it.contact_key == contactKey } + assertTrue(onlyFromContactKey) + + val onlyMyNodeNum = messages.all { it.myNodeNum == myNodeNum } + assertTrue(onlyMyNodeNum) + } + } + + @Test + fun test_deleteContacts() = runBlocking { + packetDao.deleteContacts(testContactKeys) + + testContactKeys.forEach { contactKey -> + val messages = packetDao.getMessagesFrom(contactKey).first() + assertTrue(messages.isEmpty()) + } + } + + companion object { + private const val SAMPLE_SIZE = 10 + } +} diff --git a/app/src/main/java/com/geeksville/mesh/NodeInfo.kt b/app/src/main/java/com/geeksville/mesh/NodeInfo.kt index 11a756977..f4ff1c97f 100644 --- a/app/src/main/java/com/geeksville/mesh/NodeInfo.kt +++ b/app/src/main/java/com/geeksville/mesh/NodeInfo.kt @@ -130,7 +130,8 @@ data class DeviceMetrics( val batteryLevel: Int = 0, val voltage: Float, val channelUtilization: Float, - val airUtilTx: Float + val airUtilTx: Float, + val uptimeSeconds: Int, ) : Parcelable { companion object { fun currentTime() = (System.currentTimeMillis() / 1000).toInt() @@ -143,12 +144,9 @@ data class DeviceMetrics( p.batteryLevel, p.voltage, p.channelUtilization, - p.airUtilTx + p.airUtilTx, + p.uptimeSeconds, ) - - override fun toString(): String { - return "DeviceMetrics(time=${time}, batteryLevel=${batteryLevel}, voltage=${voltage}, channelUtilization=${channelUtilization}, airUtilTx=${airUtilTx})" - } } @Parcelize @@ -160,6 +158,7 @@ data class EnvironmentMetrics( val gasResistance: Float, val voltage: Float, val current: Float, + val iaq: Int, ) : Parcelable { companion object { fun currentTime() = (System.currentTimeMillis() / 1000).toInt() @@ -174,13 +173,10 @@ data class EnvironmentMetrics( t.barometricPressure, t.gasResistance, t.voltage, - t.current + t.current, + t.iaq, ) - override fun toString(): String { - return "EnvironmentMetrics(time=${time}, temperature=${temperature}, humidity=${relativeHumidity}, pressure=${barometricPressure}), resistance=${gasResistance}, voltage=${voltage}, current=${current}" - } - fun getDisplayString(inFahrenheit: Boolean = false): String { val temp = if (temperature != 0f) { if (inFahrenheit) { @@ -195,6 +191,7 @@ data class EnvironmentMetrics( val gas = if (gasResistance != 0f) String.format("%.0fMΩ", gasResistance) else null val voltage = if (voltage != 0f) String.format("%.2fV", voltage) else null val current = if (current != 0f) String.format("%.1fmA", current) else null + val iaq = if (iaq != 0) "IAQ: $iaq" else null return listOfNotNull( temp, @@ -202,7 +199,8 @@ data class EnvironmentMetrics( pressure, gas, voltage, - current + current, + iaq, ).joinToString(" ") } 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 47093b4b8..fd196a014 100644 --- a/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt +++ b/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt @@ -31,8 +31,9 @@ import com.geeksville.mesh.database.entity.QuickChatAction AutoMigration (from = 4, to = 5), AutoMigration (from = 5, to = 6), AutoMigration (from = 6, to = 7), + AutoMigration (from = 7, to = 8), ], - version = 7, + version = 8, exportSchema = true, ) @TypeConverters(Converters::class) 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 c3520728d..0e81f2686 100644 --- a/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt @@ -2,6 +2,7 @@ package com.geeksville.mesh.database import com.geeksville.mesh.DataPacket import com.geeksville.mesh.MessageStatus +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 @@ -15,12 +16,14 @@ class PacketRepository @Inject constructor(private val packetDaoLazy: dagger.Laz packetDaoLazy.get() } - suspend fun getAllPackets(): Flow> = withContext(Dispatchers.IO) { - packetDao.getAllPackets() - } + fun getWaypoints(): Flow> = packetDao.getAllPackets(PortNum.WAYPOINT_APP_VALUE) fun getContacts(): Flow> = packetDao.getContactKeys() + suspend fun getMessageCount(contact: String): Int = withContext(Dispatchers.IO) { + packetDao.getMessageCount(contact) + } + suspend fun getQueuedPackets(): List? = withContext(Dispatchers.IO) { packetDao.getQueuedPackets() } @@ -29,9 +32,7 @@ class PacketRepository @Inject constructor(private val packetDaoLazy: dagger.Laz packetDao.insert(packet) } - suspend fun getMessagesFrom(contact: String) = withContext(Dispatchers.IO) { - packetDao.getMessagesFrom(contact) - } + fun getMessagesFrom(contact: String) = packetDao.getMessagesFrom(contact) suspend fun updateMessageStatus(d: DataPacket, m: MessageStatus) = withContext(Dispatchers.IO) { packetDao.updateMessageStatus(d, m) @@ -45,16 +46,16 @@ class PacketRepository @Inject constructor(private val packetDaoLazy: dagger.Laz packetDao.getDataPacketById(requestId) } - suspend fun deleteAllMessages() = withContext(Dispatchers.IO) { - packetDao.deleteAllMessages() - } - suspend fun deleteMessages(uuidList: List) = withContext(Dispatchers.IO) { for (chunk in uuidList.chunked(500)) { // limit number of UUIDs per query packetDao.deleteMessages(chunk) } } + suspend fun deleteContacts(contactList: List) = withContext(Dispatchers.IO) { + packetDao.deleteContacts(contactList) + } + suspend fun deleteWaypoint(id: Int) = withContext(Dispatchers.IO) { packetDao.deleteWaypoint(id) } 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 75a709a3c..e95567275 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 @@ -16,28 +16,70 @@ import kotlinx.coroutines.flow.Flow @Dao interface PacketDao { - @Query("Select * from packet order by received_time asc") - fun getAllPackets(): Flow> + @Query( + """ + SELECT * FROM packet + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM MyNodeInfo)) + AND port_num = :portNum + ORDER BY received_time ASC + """ + ) + fun getAllPackets(portNum: Int): Flow> - @Query("Select * from packet where port_num = 1 order by received_time desc") + @Query( + """ + SELECT * FROM packet + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM MyNodeInfo)) + AND port_num = 1 + ORDER BY received_time DESC + """ + ) fun getContactKeys(): Flow> + @Query( + """ + SELECT COUNT(*) FROM packet + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM MyNodeInfo)) + AND port_num = 1 AND contact_key = :contact + """ + ) + suspend fun getMessageCount(contact: String): Int + @Insert fun insert(packet: Packet) - @Query("Select * from packet where port_num = 1 and contact_key = :contact order by received_time asc") + @Query( + """ + SELECT * FROM packet + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM MyNodeInfo)) + AND port_num = 1 AND contact_key = :contact + ORDER BY received_time ASC + """ + ) fun getMessagesFrom(contact: String): Flow> - @Query("Select * from packet where data = :data") + @Query( + """ + SELECT * FROM packet + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM MyNodeInfo)) + AND data = :data + """ + ) fun findDataPacket(data: DataPacket): Packet? - @Query("Delete from packet where port_num = 1") - fun deleteAllMessages() - - @Query("Delete from packet where uuid in (:uuidList)") + @Query("DELETE FROM packet WHERE uuid in (:uuidList)") fun deleteMessages(uuidList: List) - @Query("Delete from packet where uuid=:uuid") + @Query( + """ + DELETE FROM packet + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM MyNodeInfo)) + AND contact_key IN (:contactList) + """ + ) + fun deleteContacts(contactList: List) + + @Query("DELETE FROM packet WHERE uuid=:uuid") fun _delete(uuid: Long) @Transaction @@ -60,7 +102,13 @@ interface PacketDao { findDataPacket(data)?.let { update(it.copy(data = new)) } } - @Query("Select data from packet order by received_time asc") + @Query( + """ + SELECT data FROM packet + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM MyNodeInfo)) + ORDER BY received_time ASC + """ + ) fun getDataPackets(): List @Transaction @@ -72,7 +120,14 @@ interface PacketDao { fun getQueuedPackets(): List? = getDataPackets().filter { it.status == MessageStatus.QUEUED } - @Query("Select * from packet where port_num = 8 order by received_time asc") + @Query( + """ + SELECT * FROM packet + WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM MyNodeInfo)) + AND port_num = 8 + ORDER BY received_time ASC + """ + ) fun getAllWaypoints(): List @Transaction 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 e87533bcb..69479af63 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 @@ -2,15 +2,26 @@ package com.geeksville.mesh.database.entity import androidx.room.ColumnInfo import androidx.room.Entity +import androidx.room.Index import androidx.room.PrimaryKey import com.geeksville.mesh.DataPacket -@Entity(tableName = "packet") +@Entity( + tableName = "packet", + indices = [ + Index(value = ["myNodeNum"]), + Index(value = ["port_num"]), + Index(value = ["contact_key"]), + ] +) + data class Packet( @PrimaryKey(autoGenerate = true) val uuid: Long, + @ColumnInfo(name = "myNodeNum", defaultValue = "0") val myNodeNum: Int, @ColumnInfo(name = "port_num") val port_num: Int, @ColumnInfo(name = "contact_key") val contact_key: String, @ColumnInfo(name = "received_time") val received_time: Long, + @ColumnInfo(name = "read", defaultValue = "1") val read: Boolean, @ColumnInfo(name = "data") val data: DataPacket ) diff --git a/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt b/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt index 5254f7d88..379f58e3b 100644 --- a/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt +++ b/app/src/main/java/com/geeksville/mesh/model/ChannelSet.kt @@ -36,11 +36,13 @@ fun Uri.toChannelSet(): ChannelSet { val ChannelSet.subscribeList: List get() = settingsList.filter { it.downlinkEnabled }.map { Channel(it, loraConfig).name } +fun ChannelSet.getChannel(index: Int): Channel? = + if (settingsCount > index) Channel(getSettings(index), loraConfig) else null + /** * Return the primary channel info */ -val ChannelSet.primaryChannel: Channel? - get() = if (settingsCount > 0) Channel(getSettings(0), loraConfig) else null +val ChannelSet.primaryChannel: Channel? get() = getChannel(0) /** * Return a URL that represents the [ChannelSet] diff --git a/app/src/main/java/com/geeksville/mesh/model/ContactsViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/ContactsViewModel.kt new file mode 100644 index 000000000..e6f201794 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/ContactsViewModel.kt @@ -0,0 +1,104 @@ +package com.geeksville.mesh.model + +import android.app.Application +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +import com.geeksville.mesh.DataPacket +import com.geeksville.mesh.R +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.database.PacketRepository +import com.geeksville.mesh.database.entity.Packet +import com.geeksville.mesh.repository.datastore.ChannelSetRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import java.text.DateFormat +import java.util.Date +import javax.inject.Inject + +data class Contact( + val contactKey: String, + val shortName: String, + val longName: String, + val lastMessageTime: String?, + val lastMessageText: String?, + val unreadCount: Int, + val messageCount: Int, + val isMuted: Boolean, +) + +// return time if within 24 hours, otherwise date/time +internal fun getShortDateTime(time: Long): String? { + val date = if (time != 0L) Date(time) else return null + val isWithin24Hours = System.currentTimeMillis() - date.time <= 24 * 60 * 60 * 1000L + + return if (isWithin24Hours) { + DateFormat.getTimeInstance(DateFormat.SHORT).format(date) + } else { + DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(date) + } +} + +@HiltViewModel +class ContactsViewModel @Inject constructor( + private val app: Application, + private val nodeDB: NodeDB, + channelSetRepository: ChannelSetRepository, + private val packetRepository: PacketRepository, +) : ViewModel(), Logging { + + val contactList = combine( + nodeDB.myNodeInfo, + packetRepository.getContacts(), + channelSetRepository.channelSetFlow, + packetRepository.getContactSettings(), + ) { myNodeInfo, contacts, channelSet, settings -> + val myNodeNum = myNodeInfo?.myNodeNum ?: return@combine emptyList() + // Add empty channel placeholders (always show Broadcast contacts, even when empty) + val placeholder = (0 until channelSet.settingsCount).associate { ch -> + val contactKey = "$ch${DataPacket.ID_BROADCAST}" + val data = DataPacket(bytes = null, dataType = 1, time = 0L, channel = ch) + contactKey to Packet(0L, myNodeNum, 1, contactKey, 0L, true, data) + } + + (placeholder + contacts).values.map { packet -> + val data = packet.data + val contactKey = packet.contact_key + + // Determine if this is my message (originated on this device) + val fromLocal = data.from == DataPacket.ID_LOCAL + val toBroadcast = data.to == DataPacket.ID_BROADCAST + + // grab usernames from NodeInfo + val node = nodeDB.nodes.value[if (fromLocal) data.to else data.from] + + val shortName = node?.user?.shortName ?: app.getString(R.string.unknown_node_short_name) + val longName = if (toBroadcast) { + channelSet.getChannel(data.channel)?.name ?: app.getString(R.string.channel_name) + } else { + node?.user?.longName ?: app.getString(R.string.unknown_username) + } + + Contact( + contactKey = contactKey, + shortName = if (toBroadcast) "${data.channel}" else shortName, + longName = longName, + lastMessageTime = getShortDateTime(data.time), + lastMessageText = if (fromLocal) data.text else "$shortName: ${data.text}", + unreadCount = 0, + messageCount = packetRepository.getMessageCount(contactKey), + isMuted = settings[contactKey]?.isMuted == true, + ) + } + }.asLiveData() + + fun setMuteUntil(contacts: List, until: Long) = viewModelScope.launch(Dispatchers.IO) { + packetRepository.setMuteUntil(contacts, until) + } + + fun deleteContacts(contacts: List) = viewModelScope.launch(Dispatchers.IO) { + packetRepository.deleteContacts(contacts) + } +} \ No newline at end of file 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 85c798557..7b20ade62 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -18,7 +18,6 @@ import com.geeksville.mesh.ChannelProtos.ChannelSettings import com.geeksville.mesh.ConfigProtos.Config import com.geeksville.mesh.database.MeshLogRepository import com.geeksville.mesh.database.QuickChatActionRepository -import com.geeksville.mesh.database.entity.Packet import com.geeksville.mesh.database.entity.QuickChatAction import com.geeksville.mesh.LocalOnlyProtos.LocalConfig import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig @@ -127,9 +126,6 @@ class UIViewModel @Inject constructor( val bondedAddress get() = radioInterfaceService.getBondedDeviceAddress() val selectedBluetooth get() = radioInterfaceService.getDeviceAddress()?.getOrNull(0) == 'x' - private val _packets = MutableStateFlow>(emptyList()) - val packets: StateFlow> = _packets - private val _localConfig = MutableStateFlow(LocalConfig.getDefaultInstance()) val localConfig: StateFlow = _localConfig val config get() = _localConfig.value @@ -160,7 +156,7 @@ class UIViewModel @Inject constructor( includeUnknown.value = !includeUnknown.value } - val nodeViewState: StateFlow = combine( + val nodesUiState: StateFlow = combine( nodeFilterText, nodeSortOption, includeUnknown, @@ -177,7 +173,7 @@ class UIViewModel @Inject constructor( ) @OptIn(ExperimentalCoroutinesApi::class) - val filteredNodes: StateFlow> = nodeViewState.flatMapLatest { state -> + val nodeList: StateFlow> = nodesUiState.flatMapLatest { state -> nodeDB.getNodes(state.sort, state.filter, state.includeUnknown) }.stateIn( scope = viewModelScope, @@ -198,11 +194,6 @@ class UIViewModel @Inject constructor( radioConfigRepository.clearErrorMessage() }.launchIn(viewModelScope) - viewModelScope.launch { - packetRepository.getAllPackets().collect { packets -> - _packets.value = packets - } - } radioConfigRepository.localConfigFlow.onEach { config -> _localConfig.value = config }.launchIn(viewModelScope) @@ -221,56 +212,13 @@ class UIViewModel @Inject constructor( debug("ViewModel created") } - private val _contactKey = MutableStateFlow("0${DataPacket.ID_BROADCAST}") - val contactKey: StateFlow = _contactKey - fun setContactKey(contact: String) { - _contactKey.value = contact - } - - fun getContactName(contactKey: String): String { - val (channel, dest) = contactKey[0].digitToIntOrNull() to contactKey.substring(1) - - return if (channel == null || dest == DataPacket.ID_BROADCAST) { - // grab channel names from ChannelSet - val channelName = with(channelSet) { - if (channel != null && settingsCount > channel) - Channel(settingsList[channel], loraConfig).name else null - } - channelName ?: app.getString(R.string.channel_name) - } else { - // grab usernames from NodeInfo - val node = nodeDB.nodes.value[dest] - node?.user?.longName ?: app.getString(R.string.unknown_username) - } - } + fun getMessagesFrom(contactKey: String) = packetRepository.getMessagesFrom(contactKey) @OptIn(ExperimentalCoroutinesApi::class) - val messages: LiveData> = contactKey.flatMapLatest { contactKey -> - packetRepository.getMessagesFrom(contactKey) - }.asLiveData() - - val contacts = combine(packetRepository.getContacts(), channels) { contacts, channelSet -> - // Add empty channel placeholders (always show Broadcast contacts, even when empty) - val placeholder = (0 until channelSet.settingsCount).associate { ch -> - val contactKey = "$ch${DataPacket.ID_BROADCAST}" - val data = DataPacket(bytes = null, dataType = 1, time = 0L, channel = ch) - contactKey to Packet(0L, 1, contactKey, 0L, data) - } - contacts + (placeholder - contacts.keys) - }.asLiveData() - - val contactSettings get() = packetRepository.getContactSettings() - - fun setMuteUntil(contacts: List, until: Long) = viewModelScope.launch(Dispatchers.IO) { - packetRepository.setMuteUntil(contacts, until) - } - - @OptIn(ExperimentalCoroutinesApi::class) - val waypoints: LiveData> = _packets.mapLatest { list -> - list.filter { it.port_num == Portnums.PortNum.WAYPOINT_APP_VALUE } - .associateBy { packet -> packet.data.waypoint!!.id } + val waypoints = packetRepository.getWaypoints().mapLatest { list -> + list.associateBy { packet -> packet.data.waypoint!!.id } .filterValues { it.data.waypoint!!.expire > System.currentTimeMillis() / 1000 } - }.asLiveData() + } fun generatePacketId(): Int? { return try { @@ -281,7 +229,7 @@ class UIViewModel @Inject constructor( } } - fun sendMessage(str: String, contactKey: String = this.contactKey.value) { + fun sendMessage(str: String, contactKey: String = "0${DataPacket.ID_BROADCAST}") { // contactKey: unique contact key filter (channel)+(nodeId) val channel = contactKey[0].digitToIntOrNull() val dest = if (channel != null) contactKey.substring(1) else contactKey @@ -334,10 +282,6 @@ class UIViewModel @Inject constructor( } } - fun deleteAllMessages() = viewModelScope.launch(Dispatchers.IO) { - packetRepository.deleteAllMessages() - } - fun deleteMessages(uuidList: List) = viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteMessages(uuidList) } 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 c14ecdbcc..859a41333 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -595,9 +595,11 @@ class MeshService : Service(), Logging { val packetToSave = Packet( 0L, // autoGenerated + myNodeNum, dataPacket.dataType, contactKey, System.currentTimeMillis(), + true, // TODO isLocal dataPacket ) serviceScope.handledLaunch { diff --git a/app/src/main/java/com/geeksville/mesh/ui/ContactsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/ContactsFragment.kt index 1ce28d487..28499ba74 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/ContactsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/ContactsFragment.kt @@ -9,21 +9,16 @@ import androidx.appcompat.view.ActionMode import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels -import androidx.lifecycle.asLiveData import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.geeksville.mesh.android.Logging -import com.geeksville.mesh.DataPacket import com.geeksville.mesh.R -import com.geeksville.mesh.database.entity.ContactSettings -import com.geeksville.mesh.database.entity.Packet import com.geeksville.mesh.databinding.AdapterContactLayoutBinding import com.geeksville.mesh.databinding.FragmentContactsBinding -import com.geeksville.mesh.model.Channel -import com.geeksville.mesh.model.UIViewModel +import com.geeksville.mesh.model.Contact +import com.geeksville.mesh.model.ContactsViewModel import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint -import java.util.Date import java.util.concurrent.TimeUnit @AndroidEntryPoint @@ -36,7 +31,7 @@ class ContactsFragment : ScreenFragment("Messages"), Logging { // This property is only valid between onCreateView and onDestroyView. private val binding get() = _binding!! - private val model: UIViewModel by activityViewModels() + private val model: ContactsViewModel by activityViewModels() // Provide a direct reference to each of the views within a data item // Used to cache the views within the item layout for fast access @@ -61,50 +56,31 @@ class ContactsFragment : ScreenFragment("Messages"), Logging { return ViewHolder(contactsView) } - var contacts = arrayOf() + var contacts = arrayOf() var selectedList = ArrayList() - var contactSettings = mapOf() - val isAllMuted get() = selectedList.all { contactSettings[it]?.isMuted == true } + private val selectedContacts get() = contacts.filter { it.contactKey in selectedList } + val isAllMuted get() = selectedContacts.all { it.isMuted } + val selectedCount get() = selectedContacts.sumOf { it.messageCount } override fun getItemCount(): Int = contacts.size override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val packet = contacts[position] - val contact = packet.data + val contact = contacts[position] - // Determine if this is my message (originated on this device) - val fromLocal = contact.from == DataPacket.ID_LOCAL - val toBroadcast = contact.to == DataPacket.ID_BROADCAST + holder.shortName.text = contact.shortName + holder.longName.text = contact.longName + holder.lastMessageText.text = contact.lastMessageText - // grab usernames from NodeInfo - val nodes = model.nodeDB.nodes.value - val node = nodes[if (fromLocal) contact.to else contact.from] - - //grab channel names from DeviceConfig - val channels = model.channelSet - val channelName = if (channels.settingsCount > contact.channel) - Channel(channels.settingsList[contact.channel], channels.loraConfig).name else null - - val shortName = node?.user?.shortName ?: "???" - val longName = if (toBroadcast) channelName ?: getString(R.string.channel_name) - else node?.user?.longName ?: getString(R.string.unknown_username) - - holder.shortName.text = if (toBroadcast) "${contact.channel}" else shortName - holder.longName.text = longName - - val text = if (fromLocal) contact.text else "$shortName: ${contact.text}" - holder.lastMessageText.text = text - - if (contact.time != 0L) { + if (contact.lastMessageTime != null) { holder.lastMessageTime.visibility = View.VISIBLE - holder.lastMessageTime.text = getShortDateTime(Date(contact.time)) + holder.lastMessageTime.text = contact.lastMessageTime } else holder.lastMessageTime.visibility = View.INVISIBLE - holder.mutedIcon.isVisible = contactSettings[packet.contact_key]?.isMuted == true + holder.mutedIcon.isVisible = contact.isMuted holder.itemView.setOnLongClickListener { - clickItem(holder, packet.contact_key) + clickItem(holder, contact.contactKey) if (actionMode == null) { actionMode = (activity as AppCompatActivity).startSupportActionMode(actionModeCallback) @@ -112,18 +88,14 @@ class ContactsFragment : ScreenFragment("Messages"), Logging { true } holder.itemView.setOnClickListener { - if (actionMode != null) clickItem(holder, packet.contact_key) + if (actionMode != null) clickItem(holder, contact.contactKey) else { - debug("calling MessagesFragment filter:${packet.contact_key}") - model.setContactKey(packet.contact_key) - parentFragmentManager.beginTransaction() - .replace(R.id.mainActivityLayout, MessagesFragment()) - .addToBackStack(null) - .commit() + debug("calling MessagesFragment filter:${contact.contactKey}") + parentFragmentManager.navigateToMessages(contact.contactKey, contact.longName) } } - if (selectedList.contains(packet.contact_key)) { + if (selectedList.contains(contact.contactKey)) { holder.itemView.background = GradientDrawable().apply { shape = GradientDrawable.RECTANGLE cornerRadius = 32f @@ -161,8 +133,8 @@ class ContactsFragment : ScreenFragment("Messages"), Logging { notifyItemChanged(position) } - fun onContactsChanged(contacts: Map) { - this.contacts = contacts.values.toTypedArray() + fun onContactsChanged(contacts: List) { + this.contacts = contacts.toTypedArray() notifyDataSetChanged() // FIXME, this is super expensive and redraws all nodes } } @@ -186,19 +158,10 @@ class ContactsFragment : ScreenFragment("Messages"), Logging { binding.contactsView.adapter = contactsAdapter binding.contactsView.layoutManager = LinearLayoutManager(requireContext()) - model.nodeDB.nodes.asLiveData().observe(viewLifecycleOwner) { - contactsAdapter.notifyDataSetChanged() - } - - model.contacts.observe(viewLifecycleOwner) { + model.contactList.observe(viewLifecycleOwner) { debug("New contacts received: ${it.size}") contactsAdapter.onContactsChanged(it) } - - model.contactSettings.asLiveData().observe(viewLifecycleOwner) { - contactsAdapter.contactSettings = it - contactsAdapter.notifyDataSetChanged() - } } override fun onDestroyView() { @@ -261,28 +224,17 @@ class ContactsFragment : ScreenFragment("Messages"), Logging { } R.id.deleteButton -> { - val messagesTotal = model.packets.value.filter { it.port_num == 1 } - val selectedList = contactsAdapter.selectedList - val deleteList = ArrayList() - // find messages for each contactId - selectedList.forEach { contact -> - deleteList += messagesTotal.filter { it.contact_key == contact } - } + val selectedCount = contactsAdapter.selectedCount val deleteMessagesString = resources.getQuantityString( R.plurals.delete_messages, - deleteList.size, - deleteList.size + selectedCount, + selectedCount ) MaterialAlertDialogBuilder(requireContext()) .setMessage(deleteMessagesString) .setPositiveButton(getString(R.string.delete)) { _, _ -> debug("User clicked deleteButton") - // all items selected --> deleteAllMessages() - if (deleteList.size == messagesTotal.size) { - model.deleteAllMessages() - } else { - model.deleteMessages(deleteList.map { it.uuid }) - } + model.deleteContacts(contactsAdapter.selectedList.toList()) mode.finish() } .setNeutralButton(R.string.cancel) { _, _ -> @@ -298,7 +250,7 @@ class ContactsFragment : ScreenFragment("Messages"), Logging { // else --> select all contactsAdapter.selectedList.clear() contactsAdapter.contacts.forEach { - contactsAdapter.selectedList.add(it.contact_key) + contactsAdapter.selectedList.add(it.contactKey) } } actionMode?.title = contactsAdapter.selectedList.size.toString() 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 c8c78cc14..838786160 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt @@ -9,8 +9,10 @@ import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode import androidx.cardview.widget.CardView import androidx.core.content.ContextCompat +import androidx.core.os.bundleOf import androidx.core.view.allViews import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager import androidx.fragment.app.activityViewModels import androidx.lifecycle.asLiveData import androidx.recyclerview.widget.LinearLayoutManager @@ -43,6 +45,16 @@ internal fun getShortDateTime(date: Date): String { } } +internal fun FragmentManager.navigateToMessages(contactKey: String, contactName: String) { + val messagesFragment = MessagesFragment().apply { + arguments = bundleOf("contactKey" to contactKey, "contactName" to contactName) + } + beginTransaction() + .add(R.id.mainActivityLayout, messagesFragment) + .addToBackStack(null) + .commit() +} + @AndroidEntryPoint class MessagesFragment : Fragment(), Logging { @@ -244,10 +256,14 @@ class MessagesFragment : Fragment(), Logging { parentFragmentManager.popBackStack() } + val contactKey = arguments?.getString("contactKey").toString() + val contactName = arguments?.getString("contactName").toString() + binding.messageTitle.text = contactName + fun sendMessageInputText() { val str = binding.messageInputText.text.toString().trim() if (str.isNotEmpty()) { - model.sendMessage(str) + model.sendMessage(str, contactKey) messagesAdapter.scrollToBottom() } binding.messageInputText.setText("") // blow away the string the user just entered @@ -267,8 +283,7 @@ class MessagesFragment : Fragment(), Logging { layoutManager.stackFromEnd = true // We want the last rows to always be shown binding.messageListView.layoutManager = layoutManager - model.messages.observe(viewLifecycleOwner) { - if (it.isNotEmpty() && it.first().contact_key != model.contactKey.value) return@observe + model.getMessagesFrom(contactKey).asLiveData().observe(viewLifecycleOwner) { debug("New messages received: ${it.size}") messagesAdapter.onMessagesChanged(it) } @@ -286,10 +301,6 @@ class MessagesFragment : Fragment(), Logging { } } - model.contactKey.asLiveData().observe(viewLifecycleOwner) { - binding.messageTitle.text = model.getContactName(it) - } - model.quickChatActions.asLiveData().observe(viewLifecycleOwner) { actions -> actions?.let { // This seems kinda hacky it might be better to replace with a recycler view @@ -313,7 +324,7 @@ class MessagesFragment : Fragment(), Logging { binding.messageInputText.setText(newText) binding.messageInputText.setSelection(newText.length) } else { - model.sendMessage(action.message) + model.sendMessage(action.message, contactKey) messagesAdapter.scrollToBottom() } } @@ -355,13 +366,7 @@ class MessagesFragment : Fragment(), Logging { .setMessage(deleteMessagesString) .setPositiveButton(getString(R.string.delete)) { _, _ -> debug("User clicked deleteButton") - // all items selected --> deleteAllMessages() - val messagesTotal = model.packets.value.filter { it.port_num == 1 } - if (selectedList.size == messagesTotal.size) { - model.deleteAllMessages() - } else { - model.deleteMessages(selectedList.map { it.uuid }) - } + model.deleteMessages(selectedList.map { it.uuid }) mode.finish() } .setNeutralButton(R.string.cancel) { _, _ -> diff --git a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt index 96a9f5c1a..423511947 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt @@ -126,12 +126,9 @@ class UsersFragment : ScreenFragment("Users"), Logging { popup.setOnMenuItemClickListener { item: MenuItem -> when (item.itemId) { R.id.direct_message -> { - debug("calling MessagesFragment filter: ${node.channel}${user.id}") - model.setContactKey("${node.channel}${user.id}") - parentFragmentManager.beginTransaction() - .replace(R.id.mainActivityLayout, MessagesFragment()) - .addToBackStack(null) - .commit() + val contactKey = "${node.channel}${user.id}" + debug("calling MessagesFragment filter: $contactKey") + parentFragmentManager.navigateToMessages(contactKey, user.longName) } R.id.request_position -> { debug("requesting position for '${user.longName}'") @@ -259,7 +256,7 @@ class UsersFragment : ScreenFragment("Users"), Logging { binding.nodeFilter.initFilter() - model.filteredNodes.asLiveData().observe(viewLifecycleOwner) { nodeMap -> + model.nodeList.asLiveData().observe(viewLifecycleOwner) { nodeMap -> nodesAdapter.onNodesChanged(nodeMap.toTypedArray()) } @@ -341,7 +338,7 @@ class UsersFragment : ScreenFragment("Users"), Logging { private fun ComposeView.initFilter() { this.setContent { - val nodeViewState by model.nodeViewState.collectAsStateWithLifecycle() + val nodeViewState by model.nodesUiState.collectAsStateWithLifecycle() AppTheme { Row( diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt index e890756eb..3a633b1a0 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/map/MapFragment.kt @@ -18,7 +18,6 @@ import androidx.compose.material.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -186,8 +185,8 @@ fun MapView( requestPermissionAndToggleLauncher.launch(context.getLocationPermissions()) } - val nodes by model.filteredNodes.collectAsStateWithLifecycle(emptyList()) - val waypoints by model.waypoints.observeAsState(emptyMap()) + val nodes by model.nodeList.collectAsStateWithLifecycle() + val waypoints by model.waypoints.collectAsStateWithLifecycle(emptyMap()) var showDownloadButton: Boolean by remember { mutableStateOf(false) } var showEditWaypointDialog by remember { mutableStateOf(null) } @@ -255,7 +254,7 @@ fun MapView( fun showMarkerLongPressDialog(id: Int) { performHapticFeedback() debug("marker long pressed id=${id}") - val waypoint = model.waypoints.value?.get(id)?.data?.waypoint ?: return + val waypoint = waypoints[id]?.data?.waypoint ?: return // edit only when unlocked or lockedTo myNodeNum if (waypoint.lockedTo in setOf(0, model.myNodeNum ?: 0) && model.isConnected()) showEditWaypointDialog = waypoint diff --git a/app/src/main/java/com/geeksville/mesh/ui/preview/PreviewParameterProviders.kt b/app/src/main/java/com/geeksville/mesh/ui/preview/PreviewParameterProviders.kt index 5dccf4d23..3150ec780 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/preview/PreviewParameterProviders.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/preview/PreviewParameterProviders.kt @@ -28,7 +28,8 @@ class NodeInfoPreviewParameterProvider: PreviewParameterProvider { channelUtilization = 2.4F, airUtilTx = 3.5F, batteryLevel = 85, - voltage = 3.7F + voltage = 3.7F, + uptimeSeconds = 3600, ), user = MeshUser( longName = "Micky Mouse", @@ -68,7 +69,8 @@ class NodeInfoPreviewParameterProvider: PreviewParameterProvider { channelUtilization = 2.4F, airUtilTx = 3.5F, batteryLevel = 85, - voltage = 3.7F + voltage = 3.7F, + uptimeSeconds = 3600, ), user = MeshUser( longName = "Donald Duck, the Grand Duck of the Ducks", @@ -82,7 +84,8 @@ class NodeInfoPreviewParameterProvider: PreviewParameterProvider { barometricPressure = 1013.25F, gasResistance = 0.0F, voltage = 3.7F, - current = 0.0F + current = 0.0F, + iaq = 100, ), hopsAway = 2 )