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

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