feat: support for switching between devices (#1078)

This commit is contained in:
Andre K 2024-06-08 10:25:47 -03:00 committed by GitHub
parent 9ba44ad087
commit 5b3c78316b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 934 additions and 206 deletions

View file

@ -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')"
]
}
}

View file

@ -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
}
}

View file

@ -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(" ")
}

View file

@ -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)

View file

@ -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<List<Packet>> = withContext(Dispatchers.IO) {
packetDao.getAllPackets()
}
fun getWaypoints(): Flow<List<Packet>> = packetDao.getAllPackets(PortNum.WAYPOINT_APP_VALUE)
fun getContacts(): Flow<Map<String, Packet>> = packetDao.getContactKeys()
suspend fun getMessageCount(contact: String): Int = withContext(Dispatchers.IO) {
packetDao.getMessageCount(contact)
}
suspend fun getQueuedPackets(): List<DataPacket>? = 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<Long>) = withContext(Dispatchers.IO) {
for (chunk in uuidList.chunked(500)) { // limit number of UUIDs per query
packetDao.deleteMessages(chunk)
}
}
suspend fun deleteContacts(contactList: List<String>) = withContext(Dispatchers.IO) {
packetDao.deleteContacts(contactList)
}
suspend fun deleteWaypoint(id: Int) = withContext(Dispatchers.IO) {
packetDao.deleteWaypoint(id)
}

View file

@ -16,28 +16,70 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface PacketDao {
@Query("Select * from packet order by received_time asc")
fun getAllPackets(): Flow<List<Packet>>
@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<List<Packet>>
@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<Map<@MapColumn(columnName = "contact_key") String, Packet>>
@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<List<Packet>>
@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<Long>)
@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<String>)
@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<DataPacket>
@Transaction
@ -72,7 +120,14 @@ interface PacketDao {
fun getQueuedPackets(): List<DataPacket>? =
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<Packet>
@Transaction

View file

@ -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
)

View file

@ -36,11 +36,13 @@ fun Uri.toChannelSet(): ChannelSet {
val ChannelSet.subscribeList: List<String>
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]

View file

@ -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<String>, until: Long) = viewModelScope.launch(Dispatchers.IO) {
packetRepository.setMuteUntil(contacts, until)
}
fun deleteContacts(contacts: List<String>) = viewModelScope.launch(Dispatchers.IO) {
packetRepository.deleteContacts(contacts)
}
}

View file

@ -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<List<Packet>>(emptyList())
val packets: StateFlow<List<Packet>> = _packets
private val _localConfig = MutableStateFlow<LocalConfig>(LocalConfig.getDefaultInstance())
val localConfig: StateFlow<LocalConfig> = _localConfig
val config get() = _localConfig.value
@ -160,7 +156,7 @@ class UIViewModel @Inject constructor(
includeUnknown.value = !includeUnknown.value
}
val nodeViewState: StateFlow<NodesUiState> = combine(
val nodesUiState: StateFlow<NodesUiState> = combine(
nodeFilterText,
nodeSortOption,
includeUnknown,
@ -177,7 +173,7 @@ class UIViewModel @Inject constructor(
)
@OptIn(ExperimentalCoroutinesApi::class)
val filteredNodes: StateFlow<List<NodeInfo>> = nodeViewState.flatMapLatest { state ->
val nodeList: StateFlow<List<NodeInfo>> = 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<String> = _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<List<Packet>> = 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<String>, until: Long) = viewModelScope.launch(Dispatchers.IO) {
packetRepository.setMuteUntil(contacts, until)
}
@OptIn(ExperimentalCoroutinesApi::class)
val waypoints: LiveData<Map<Int, Packet>> = _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<Long>) = viewModelScope.launch(Dispatchers.IO) {
packetRepository.deleteMessages(uuidList)
}

View file

@ -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 {

View file

@ -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<Packet>()
var contacts = arrayOf<Contact>()
var selectedList = ArrayList<String>()
var contactSettings = mapOf<String, ContactSettings>()
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<String, Packet>) {
this.contacts = contacts.values.toTypedArray()
fun onContactsChanged(contacts: List<Contact>) {
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<Packet>()
// 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()

View file

@ -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) { _, _ ->

View file

@ -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(

View file

@ -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<Waypoint?>(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

View file

@ -28,7 +28,8 @@ class NodeInfoPreviewParameterProvider: PreviewParameterProvider<NodeInfo> {
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<NodeInfo> {
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<NodeInfo> {
barometricPressure = 1013.25F,
gasResistance = 0.0F,
voltage = 3.7F,
current = 0.0F
current = 0.0F,
iaq = 100,
),
hopsAway = 2
)