From ecaf35d7f3ee2064f8906df39a9f89e708982e84 Mon Sep 17 00:00:00 2001 From: Andre K Date: Sun, 28 Apr 2024 16:18:16 -0300 Subject: [PATCH] feat: add `ActionMenu` option to mute contacts (#1003) --- .../7.json | 459 ++++++++++++++++++ .../mesh/database/MeshtasticDatabase.kt | 5 +- .../mesh/database/PacketRepository.kt | 11 + .../geeksville/mesh/database/dao/PacketDao.kt | 20 + .../geeksville/mesh/database/entity/Packet.kt | 7 + .../java/com/geeksville/mesh/model/UIState.kt | 6 + .../geeksville/mesh/service/MeshService.kt | 23 +- .../geeksville/mesh/ui/ContactsFragment.kt | 63 ++- .../geeksville/mesh/ui/MessagesFragment.kt | 1 + .../res/drawable/ic_twotone_volume_off_24.xml | 16 + .../res/drawable/ic_twotone_volume_up_24.xml | 16 + .../res/layout/adapter_contact_layout.xml | 14 +- app/src/main/res/menu/menu_messages.xml | 5 + app/src/main/res/values/strings.xml | 5 + 14 files changed, 626 insertions(+), 25 deletions(-) create mode 100644 app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/7.json create mode 100644 app/src/main/res/drawable/ic_twotone_volume_off_24.xml create mode 100644 app/src/main/res/drawable/ic_twotone_volume_up_24.xml diff --git a/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/7.json b/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/7.json new file mode 100644 index 000000000..0b8ef8e9f --- /dev/null +++ b/app/schemas/com.geeksville.mesh.database.MeshtasticDatabase/7.json @@ -0,0 +1,459 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "7493c554cd0cf342aeaddb745d49e4b5", + "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, `envMetrics_time` INTEGER, `envMetrics_temperature` REAL, `envMetrics_relativeHumidity` REAL, `envMetrics_barometricPressure` REAL, `envMetrics_gasResistance` REAL, `envMetrics_voltage` REAL, `envMetrics_current` REAL, 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": "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 + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "packet", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `data` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "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": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + }, + "indices": [], + "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, '7493c554cd0cf342aeaddb745d49e4b5')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt b/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt index db242cb99..47093b4b8 100644 --- a/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt +++ b/app/src/main/java/com/geeksville/mesh/database/MeshtasticDatabase.kt @@ -12,6 +12,7 @@ import com.geeksville.mesh.database.dao.PacketDao import com.geeksville.mesh.database.dao.MeshLogDao import com.geeksville.mesh.database.dao.NodeInfoDao import com.geeksville.mesh.database.dao.QuickChatActionDao +import com.geeksville.mesh.database.entity.ContactSettings import com.geeksville.mesh.database.entity.MeshLog import com.geeksville.mesh.database.entity.Packet import com.geeksville.mesh.database.entity.QuickChatAction @@ -21,6 +22,7 @@ import com.geeksville.mesh.database.entity.QuickChatAction MyNodeInfo::class, NodeInfo::class, Packet::class, + ContactSettings::class, MeshLog::class, QuickChatAction::class ], @@ -28,8 +30,9 @@ import com.geeksville.mesh.database.entity.QuickChatAction AutoMigration (from = 3, to = 4), AutoMigration (from = 4, to = 5), AutoMigration (from = 5, to = 6), + AutoMigration (from = 6, to = 7), ], - version = 6, + version = 7, exportSchema = true, ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt b/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt index a6b61d643..c3520728d 100644 --- a/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/database/PacketRepository.kt @@ -3,6 +3,7 @@ package com.geeksville.mesh.database import com.geeksville.mesh.DataPacket import com.geeksville.mesh.MessageStatus import com.geeksville.mesh.database.dao.PacketDao +import com.geeksville.mesh.database.entity.ContactSettings import com.geeksville.mesh.database.entity.Packet import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -65,4 +66,14 @@ class PacketRepository @Inject constructor(private val packetDaoLazy: dagger.Laz suspend fun update(packet: Packet) = withContext(Dispatchers.IO) { packetDao.update(packet) } + + fun getContactSettings(): Flow> = packetDao.getContactSettings() + + suspend fun getContactSettings(contact: String) = withContext(Dispatchers.IO) { + packetDao.getContactSettings(contact) ?: ContactSettings(contact) + } + + suspend fun setMuteUntil(contacts: List, until: Long) = withContext(Dispatchers.IO) { + packetDao.setMuteUntil(contacts, until) + } } diff --git a/app/src/main/java/com/geeksville/mesh/database/dao/PacketDao.kt b/app/src/main/java/com/geeksville/mesh/database/dao/PacketDao.kt index 5d7ae134e..75a709a3c 100644 --- a/app/src/main/java/com/geeksville/mesh/database/dao/PacketDao.kt +++ b/app/src/main/java/com/geeksville/mesh/database/dao/PacketDao.kt @@ -6,8 +6,10 @@ import androidx.room.MapColumn import androidx.room.Update import androidx.room.Query import androidx.room.Transaction +import androidx.room.Upsert import com.geeksville.mesh.DataPacket import com.geeksville.mesh.MessageStatus +import com.geeksville.mesh.database.entity.ContactSettings import com.geeksville.mesh.database.entity.Packet import kotlinx.coroutines.flow.Flow @@ -78,4 +80,22 @@ interface PacketDao { val uuidList = getAllWaypoints().filter { it.data.waypoint?.id == id }.map { it.uuid } deleteMessages(uuidList) } + + @Query("SELECT * FROM contact_settings") + fun getContactSettings(): Flow> + + @Query("SELECT * FROM contact_settings WHERE contact_key = :contact") + suspend fun getContactSettings(contact:String): ContactSettings? + + @Upsert + fun upsertContactSettings(contacts: List) + + @Transaction + suspend fun setMuteUntil(contacts: List, until: Long) { + val contactList = contacts.map { contact -> + getContactSettings(contact)?.copy(muteUntil = until) + ?: ContactSettings(contact_key = contact, muteUntil = until) + } + upsertContactSettings(contactList) + } } diff --git a/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt b/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt index c9d1b995a..e87533bcb 100644 --- a/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt +++ b/app/src/main/java/com/geeksville/mesh/database/entity/Packet.kt @@ -12,5 +12,12 @@ data class Packet( @ColumnInfo(name = "contact_key") val contact_key: String, @ColumnInfo(name = "received_time") val received_time: Long, @ColumnInfo(name = "data") val data: DataPacket +) + +@Entity(tableName = "contact_settings") +data class ContactSettings( + @PrimaryKey val contact_key: String, + val muteUntil: Long = 0L, ) { + val isMuted get() = System.currentTimeMillis() <= muteUntil } diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index 5ae7f2236..cf4a57f2f 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -222,6 +222,12 @@ class UIViewModel @Inject constructor( contacts + (placeholder - contacts.keys) }.asLiveData() + val contactSettings get() = packetRepository.getContactSettings() + + fun setMuteUntil(contacts: List, until: Long) = viewModelScope.launch(Dispatchers.IO) { + packetRepository.setMuteUntil(contacts, until) + } + @OptIn(ExperimentalCoroutinesApi::class) val waypoints: LiveData> = _packets.mapLatest { list -> list.filter { it.port_num == Portnums.PortNum.WAYPOINT_APP_VALUE } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index 8c8e288ce..c99242228 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -574,7 +574,7 @@ class MeshService : Service(), Logging { Portnums.PortNum.WAYPOINT_APP_VALUE, ) - private fun rememberDataPacket(dataPacket: DataPacket) { + private fun rememberDataPacket(dataPacket: DataPacket, updateNotification: Boolean = true) { if (dataPacket.dataType !in rememberDataType) return val fromLocal = dataPacket.from == DataPacket.ID_LOCAL val toBroadcast = dataPacket.to == DataPacket.ID_BROADCAST @@ -590,7 +590,13 @@ class MeshService : Service(), Logging { System.currentTimeMillis(), dataPacket ) - insertPacket(packetToSave) + serviceScope.handledLaunch { + packetRepository.get().apply { + insert(packetToSave) + val isMuted = getContactSettings(contactKey).isMuted + if (updateNotification && !isMuted) updateMessageNotification(dataPacket) + } + } } /// Update our model and resend as needed for a MeshPacket we just received from the radio @@ -625,15 +631,13 @@ class MeshService : Service(), Logging { debug("Received CLEAR_TEXT from $fromId") rememberDataPacket(dataPacket) - updateMessageNotification(dataPacket) } Portnums.PortNum.WAYPOINT_APP_VALUE -> { val u = MeshProtos.Waypoint.parseFrom(data.payload) // Validate locked Waypoints from the original sender if (u.lockedTo != 0 && u.lockedTo != packet.from) return - rememberDataPacket(dataPacket) - if (u.expire > currentSecond()) updateMessageNotification(dataPacket) + rememberDataPacket(dataPacket, u.expire > currentSecond()) } // Handle new style position info @@ -694,13 +698,11 @@ class MeshService : Service(), Logging { if (!moduleConfig.rangeTest.enabled) return val u = dataPacket.copy(dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) rememberDataPacket(u) - updateMessageNotification(u) } Portnums.PortNum.DETECTION_SENSOR_APP_VALUE -> { val u = dataPacket.copy(dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) rememberDataPacket(u) - updateMessageNotification(u) } Portnums.PortNum.TRACEROUTE_APP_VALUE -> { @@ -849,7 +851,6 @@ class MeshService : Service(), Logging { dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, ) rememberDataPacket(u) - updateMessageNotification(u) } else -> {} @@ -1030,12 +1031,6 @@ class MeshService : Service(), Logging { } } - private fun insertPacket(packet: Packet) { - serviceScope.handledLaunch { - packetRepository.get().insert(packet) - } - } - private fun insertMeshLog(packetToSave: MeshLog) { serviceScope.handledLaunch { // Do not log, because might contain PII diff --git a/app/src/main/java/com/geeksville/mesh/ui/ContactsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/ContactsFragment.kt index af6fca96d..1ce28d487 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/ContactsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/ContactsFragment.kt @@ -7,6 +7,7 @@ import android.view.* import androidx.appcompat.app.AppCompatActivity 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 @@ -14,6 +15,7 @@ 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 @@ -21,7 +23,8 @@ import com.geeksville.mesh.model.Channel import com.geeksville.mesh.model.UIViewModel import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint -import java.util.* +import java.util.Date +import java.util.concurrent.TimeUnit @AndroidEntryPoint class ContactsFragment : ScreenFragment("Messages"), Logging { @@ -43,6 +46,7 @@ class ContactsFragment : ScreenFragment("Messages"), Logging { val longName = itemView.longName val lastMessageTime = itemView.lastMessageTime val lastMessageText = itemView.lastMessageText + val mutedIcon = itemView.mutedIcon } private val contactsAdapter = object : RecyclerView.Adapter() { @@ -60,6 +64,9 @@ class ContactsFragment : ScreenFragment("Messages"), Logging { var contacts = arrayOf() var selectedList = ArrayList() + var contactSettings = mapOf() + val isAllMuted get() = selectedList.all { contactSettings[it]?.isMuted == true } + override fun getItemCount(): Int = contacts.size override fun onBindViewHolder(holder: ViewHolder, position: Int) { @@ -94,6 +101,8 @@ class ContactsFragment : ScreenFragment("Messages"), Logging { holder.lastMessageTime.text = getShortDateTime(Date(contact.time)) } else holder.lastMessageTime.visibility = View.INVISIBLE + holder.mutedIcon.isVisible = contactSettings[packet.contact_key]?.isMuted == true + holder.itemView.setOnLongClickListener { clickItem(holder, packet.contact_key) if (actionMode == null) { @@ -148,6 +157,7 @@ class ContactsFragment : ScreenFragment("Messages"), Logging { // show total items selected on action mode title actionMode?.title = selectedList.size.toString() } + actionMode?.invalidate() notifyItemChanged(position) } @@ -155,10 +165,6 @@ class ContactsFragment : ScreenFragment("Messages"), Logging { this.contacts = contacts.values.toTypedArray() notifyDataSetChanged() // FIXME, this is super expensive and redraws all nodes } - - fun onChannelsChanged() { - onContactsChanged(contacts.associateBy { it.contact_key }) - } } override fun onPause() { @@ -180,10 +186,6 @@ class ContactsFragment : ScreenFragment("Messages"), Logging { binding.contactsView.adapter = contactsAdapter binding.contactsView.layoutManager = LinearLayoutManager(requireContext()) - model.channels.asLiveData().observe(viewLifecycleOwner) { - contactsAdapter.onChannelsChanged() - } - model.nodeDB.nodes.asLiveData().observe(viewLifecycleOwner) { contactsAdapter.notifyDataSetChanged() } @@ -192,6 +194,11 @@ class ContactsFragment : ScreenFragment("Messages"), Logging { debug("New contacts received: ${it.size}") contactsAdapter.onContactsChanged(it) } + + model.contactSettings.asLiveData().observe(viewLifecycleOwner) { + contactsAdapter.contactSettings = it + contactsAdapter.notifyDataSetChanged() + } } override fun onDestroyView() { @@ -210,11 +217,49 @@ class ContactsFragment : ScreenFragment("Messages"), Logging { } override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + menu.findItem(R.id.muteButton).setIcon( + if (contactsAdapter.isAllMuted) { + R.drawable.ic_twotone_volume_up_24 + } else { + R.drawable.ic_twotone_volume_off_24 + } + ) return false } override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { when (item.itemId) { + R.id.muteButton -> if (contactsAdapter.isAllMuted) { + model.setMuteUntil(contactsAdapter.selectedList.toList(), 0L) + mode.finish() + } else { + var muteUntil: Long = Long.MAX_VALUE + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.mute_notifications) + .setSingleChoiceItems( + setOf( + R.string.mute_8_hours, + R.string.mute_1_week, + R.string.mute_always, + ).map(::getString).toTypedArray(), + 2 + ) { _, which -> + muteUntil = when (which) { + 0 -> System.currentTimeMillis() + TimeUnit.HOURS.toMillis(8) + 1 -> System.currentTimeMillis() + TimeUnit.DAYS.toMillis(7) + else -> Long.MAX_VALUE // always + } + } + .setPositiveButton(getString(R.string.okay)) { _, _ -> + debug("User clicked muteButton") + model.setMuteUntil(contactsAdapter.selectedList.toList(), muteUntil) + mode.finish() + } + .setNeutralButton(R.string.cancel) { _, _ -> + } + .show() + } + R.id.deleteButton -> { val messagesTotal = model.packets.value.filter { it.port_num == 1 } val selectedList = contactsAdapter.selectedList diff --git a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt index 4f1ab26ab..c8c78cc14 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt @@ -333,6 +333,7 @@ class MessagesFragment : Fragment(), Logging { private inner class ActionModeCallback : ActionMode.Callback { override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { mode.menuInflater.inflate(R.menu.menu_messages, menu) + menu.findItem(R.id.muteButton).isVisible = false mode.title = "1" return true } diff --git a/app/src/main/res/drawable/ic_twotone_volume_off_24.xml b/app/src/main/res/drawable/ic_twotone_volume_off_24.xml new file mode 100644 index 000000000..a62bb87b1 --- /dev/null +++ b/app/src/main/res/drawable/ic_twotone_volume_off_24.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_twotone_volume_up_24.xml b/app/src/main/res/drawable/ic_twotone_volume_up_24.xml new file mode 100644 index 000000000..94f1e4c67 --- /dev/null +++ b/app/src/main/res/drawable/ic_twotone_volume_up_24.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/layout/adapter_contact_layout.xml b/app/src/main/res/layout/adapter_contact_layout.xml index 4ce928946..147959dea 100644 --- a/app/src/main/res/layout/adapter_contact_layout.xml +++ b/app/src/main/res/layout/adapter_contact_layout.xml @@ -44,7 +44,7 @@ android:maxLines="2" android:text="@string/sample_message" app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintEnd_toStartOf="@id/mutedIcon" app:layout_constraintStart_toEndOf="@id/shortName" app:layout_constraintTop_toBottomOf="@id/longName" /> @@ -57,6 +57,18 @@ android:text="3 minutes ago" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" /> + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_messages.xml b/app/src/main/res/menu/menu_messages.xml index 902b9fdd8..11f534152 100644 --- a/app/src/main/res/menu/menu_messages.xml +++ b/app/src/main/res/menu/menu_messages.xml @@ -1,6 +1,11 @@ + Duty Cycle limit reached. Cannot send messages right now, please try again later. Forget Node This node will be removed from your list until your node receives NodeInfo data from it again. + Mute + Mute notifications + 8 hours + 1 week + Always