kopia lustrzana https://github.com/meshtastic/Meshtastic-Android
feat: support for switching between devices (#1078)
rodzic
9ba44ad087
commit
5b3c78316b
|
@ -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')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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(" ")
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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) { _, _ ->
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
Ładowanie…
Reference in New Issue