feat: Enhance message notifications with history and actions (#4133)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-01-04 15:46:07 -06:00
committed by GitHub
parent 43aca3c558
commit 49f6ffe6e5
11 changed files with 496 additions and 43 deletions

View File

@@ -221,6 +221,8 @@
</intent-filter>
</receiver>
<receiver android:name="com.geeksville.mesh.service.ReplyReceiver"/>
<receiver android:name="com.geeksville.mesh.service.MarkAsReadReceiver"/>
<receiver android:name="com.geeksville.mesh.service.ReactionReceiver"/>
<!-- allow for plugin discovery -->
<activity

View File

@@ -0,0 +1,59 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.service.MeshServiceNotifications
import javax.inject.Inject
/** A [BroadcastReceiver] that handles "Mark as read" actions from notifications. */
@AndroidEntryPoint
class MarkAsReadReceiver : BroadcastReceiver() {
@Inject lateinit var packetRepository: PacketRepository
@Inject lateinit var meshServiceNotifications: MeshServiceNotifications
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
companion object {
const val MARK_AS_READ_ACTION = "com.geeksville.mesh.MARK_AS_READ_ACTION"
const val CONTACT_KEY = "contactKey"
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == MARK_AS_READ_ACTION) {
val contactKey = intent.getStringExtra(CONTACT_KEY) ?: return
val pendingResult = goAsync()
scope.launch {
try {
packetRepository.clearUnreadCount(contactKey, System.currentTimeMillis())
meshServiceNotifications.cancelMessageNotification(contactKey)
} finally {
pendingResult.finish()
}
}
}
}
}

View File

@@ -521,11 +521,12 @@ constructor(
}
private fun rememberReaction(packet: MeshPacket) = scope.handledLaunch {
val emoji = packet.decoded.payload.toByteArray().decodeToString()
val reaction =
ReactionEntity(
replyId = packet.decoded.replyId,
userId = dataMapper.toNodeID(packet.from),
emoji = packet.decoded.payload.toByteArray().decodeToString(),
emoji = emoji,
timestamp = System.currentTimeMillis(),
snr = packet.rxSnr,
rssi = packet.rxRssi,
@@ -537,6 +538,31 @@ constructor(
},
)
packetRepository.get().insertReaction(reaction)
// Find the original packet to get the contactKey
packetRepository.get().getPacketByPacketId(packet.decoded.replyId)?.let { original ->
val contactKey = original.packet.contact_key
val isMuted = packetRepository.get().getContactSettings(contactKey).isMuted
if (!isMuted) {
val channelName =
if (original.packet.data.to == DataPacket.ID_BROADCAST) {
radioConfigRepository.channelSetFlow
.first()
.settingsList
.getOrNull(original.packet.data.channel)
?.name
} else {
null
}
serviceNotifications.updateReactionNotification(
contactKey,
getSenderName(dataMapper.toDataPacket(packet)!!),
emoji,
original.packet.data.to == DataPacket.ID_BROADCAST,
channelName,
)
}
}
}
private fun currentTransport(address: String? = meshPrefs.deviceAddress): String = when (address?.firstOrNull()) {

View File

@@ -24,21 +24,33 @@ import android.app.TaskStackBuilder
import android.content.ContentResolver.SCHEME_ANDROID_RESOURCE
import android.content.Context
import android.content.Intent
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.media.AudioAttributes
import android.media.RingtoneManager
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput
import androidx.core.content.getSystemService
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import com.geeksville.mesh.MainActivity
import com.geeksville.mesh.R.raw
import com.geeksville.mesh.service.MarkAsReadReceiver.Companion.MARK_AS_READ_ACTION
import com.geeksville.mesh.service.ReactionReceiver.Companion.REACT_ACTION
import com.geeksville.mesh.service.ReplyReceiver.Companion.KEY_TEXT_REPLY
import com.meshtastic.core.strings.getString
import dagger.Lazy
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.database.model.Message
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.service.MeshServiceNotifications
@@ -47,7 +59,9 @@ import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.client_notification
import org.meshtastic.core.strings.low_battery_message
import org.meshtastic.core.strings.low_battery_title
import org.meshtastic.core.strings.mark_as_read
import org.meshtastic.core.strings.meshtastic_alerts_notifications
import org.meshtastic.core.strings.meshtastic_app_name
import org.meshtastic.core.strings.meshtastic_broadcast_notifications
import org.meshtastic.core.strings.meshtastic_low_battery_notifications
import org.meshtastic.core.strings.meshtastic_low_battery_temporary_remote_notifications
@@ -58,6 +72,7 @@ import org.meshtastic.core.strings.meshtastic_waypoints_notifications
import org.meshtastic.core.strings.new_node_seen
import org.meshtastic.core.strings.no_local_stats
import org.meshtastic.core.strings.reply
import org.meshtastic.core.strings.you
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.proto.TelemetryProtos.LocalStats
@@ -69,9 +84,14 @@ import javax.inject.Inject
* This class centralizes notification logic, including channel creation, builder configuration, and displaying
* notifications for various events like new messages, alerts, and service status changes.
*/
@Suppress("TooManyFunctions")
class MeshServiceNotificationsImpl @Inject constructor(@ApplicationContext private val context: Context) :
MeshServiceNotifications {
@Suppress("TooManyFunctions", "LongParameterList")
class MeshServiceNotificationsImpl
@Inject
constructor(
@ApplicationContext private val context: Context,
private val packetRepository: Lazy<PacketRepository>,
private val nodeRepository: Lazy<NodeRepository>,
) : MeshServiceNotifications {
private val notificationManager = context.getSystemService<NotificationManager>()!!
@@ -79,6 +99,13 @@ class MeshServiceNotificationsImpl @Inject constructor(@ApplicationContext priva
private const val FIFTEEN_MINUTES_IN_MILLIS = 15L * 60 * 1000
const val MAX_BATTERY_LEVEL = 100
private val NOTIFICATION_LIGHT_COLOR = Color.BLUE
private const val MAX_HISTORY_MESSAGES = 10
private const val MIN_CONTEXT_MESSAGES = 3
private const val SNIPPET_LENGTH = 30
private const val GROUP_KEY_MESSAGES = "com.geeksville.mesh.GROUP_MESSAGES"
private const val SUMMARY_ID = 1
private const val PERSON_ICON_SIZE = 128
private const val PERSON_ICON_TEXT_SIZE_RATIO = 0.5f
}
/**
@@ -280,21 +307,108 @@ class MeshServiceNotificationsImpl @Inject constructor(@ApplicationContext priva
return notification
}
override fun updateMessageNotification(
override suspend fun updateMessageNotification(
contactKey: String,
name: String,
message: String,
isBroadcast: Boolean,
channelName: String?,
) {
val notification = createMessageNotification(contactKey, name, message, isBroadcast, channelName)
// Use a consistent, unique ID for each message conversation.
showConversationNotification(contactKey, isBroadcast, channelName)
}
override suspend fun updateReactionNotification(
contactKey: String,
name: String,
emoji: String,
isBroadcast: Boolean,
channelName: String?,
) {
showConversationNotification(contactKey, isBroadcast, channelName)
}
override suspend fun updateWaypointNotification(
contactKey: String,
name: String,
message: String,
waypointId: Int,
) {
val notification = createWaypointNotification(name, message, waypointId)
notificationManager.notify(contactKey.hashCode(), notification)
}
override fun updateWaypointNotification(contactKey: String, name: String, message: String, waypointId: Int) {
val notification = createWaypointNotification(name, message, waypointId)
private suspend fun showConversationNotification(contactKey: String, isBroadcast: Boolean, channelName: String?) {
val ourNode = nodeRepository.get().ourNodeInfo.value
val history =
packetRepository
.get()
.getMessagesFrom(contactKey) { nodeId ->
if (nodeId == DataPacket.ID_LOCAL) {
ourNode ?: nodeRepository.get().getNode(nodeId)
} else {
nodeRepository.get().getNode(nodeId ?: "")
}
}
.first()
val unread = history.filter { !it.read }
val displayHistory =
if (unread.size < MIN_CONTEXT_MESSAGES) {
history.take(MIN_CONTEXT_MESSAGES).reversed()
} else {
unread.take(MAX_HISTORY_MESSAGES).reversed()
}
if (displayHistory.isEmpty()) return
val notification = createConversationNotification(contactKey, isBroadcast, channelName, displayHistory)
notificationManager.notify(contactKey.hashCode(), notification)
showGroupSummary()
}
private fun showGroupSummary() {
val activeNotifications =
notificationManager.activeNotifications.filter {
it.id != SUMMARY_ID && it.notification.group == GROUP_KEY_MESSAGES
}
val ourNode = nodeRepository.get().ourNodeInfo.value
val meName = ourNode?.user?.longName ?: getString(Res.string.you)
val me =
Person.Builder()
.setName(meName)
.setKey(ourNode?.user?.id ?: DataPacket.ID_LOCAL)
.apply { ourNode?.let { setIcon(createPersonIcon(meName, it.colors.second, it.colors.first)) } }
.build()
val messagingStyle =
NotificationCompat.MessagingStyle(me)
.setGroupConversation(true)
.setConversationTitle(getString(Res.string.meshtastic_app_name))
activeNotifications.forEach { sbn ->
val senderTitle = sbn.notification.extras.getCharSequence(Notification.EXTRA_TITLE)
val messageText = sbn.notification.extras.getCharSequence(Notification.EXTRA_TEXT)
val postTime = sbn.postTime
if (senderTitle != null && messageText != null) {
// For the summary, we're creating a generic Person for the sender from the active notification's title.
// We don't have the original Person object or its colors/ID, so we're just using the name.
val senderPerson = Person.Builder().setName(senderTitle).build()
messagingStyle.addMessage(messageText, postTime, senderPerson)
}
}
val summaryNotification =
commonBuilder(NotificationType.DirectMessage)
.setSmallIcon(com.geeksville.mesh.R.drawable.app_icon)
.setStyle(messagingStyle)
.setGroup(GROUP_KEY_MESSAGES)
.setGroupSummary(true)
.setAutoCancel(true)
.build()
notificationManager.notify(SUMMARY_ID, summaryNotification)
}
override fun showAlertNotification(contactKey: String, name: String, alert: String) {
@@ -354,35 +468,88 @@ class MeshServiceNotificationsImpl @Inject constructor(@ApplicationContext priva
return builder.build()
}
private fun createMessageNotification(
@Suppress("LongMethod")
private fun createConversationNotification(
contactKey: String,
name: String,
message: String,
isBroadcast: Boolean,
channelName: String? = null,
channelName: String?,
history: List<Message>,
): Notification {
val type = if (isBroadcast) NotificationType.BroadcastMessage else NotificationType.DirectMessage
val builder = commonBuilder(type, createOpenMessageIntent(contactKey))
val person = Person.Builder().setName(name).build()
val ourNode = nodeRepository.get().ourNodeInfo.value
val meName = ourNode?.user?.longName ?: getString(Res.string.you)
val me =
Person.Builder()
.setName(meName)
.setKey(ourNode?.user?.id ?: DataPacket.ID_LOCAL)
.apply { ourNode?.let { setIcon(createPersonIcon(meName, it.colors.second, it.colors.first)) } }
.build()
val style =
NotificationCompat.MessagingStyle(person)
NotificationCompat.MessagingStyle(me)
.setGroupConversation(channelName != null)
.setConversationTitle(channelName)
.addMessage(message, System.currentTimeMillis(), person)
history.forEach { msg ->
// Use the node attached to the message directly to ensure correct identification
val person =
Person.Builder()
.setName(msg.node.user.longName)
.setKey(msg.node.user.id)
.setIcon(createPersonIcon(msg.node.user.shortName, msg.node.colors.second, msg.node.colors.first))
.build()
val text =
msg.originalMessage?.let { original ->
"↩️ \"${original.node.user.shortName}: ${original.text.take(SNIPPET_LENGTH)}...\": ${msg.text}"
} ?: msg.text
style.addMessage(text, msg.receivedTime, person)
// Add reactions as separate "messages" in history if they exist
msg.emojis.forEach { reaction ->
val reactorNode = nodeRepository.get().getNode(reaction.user.id)
val reactor =
Person.Builder()
.setName(reaction.user.longName)
.setKey(reaction.user.id)
.setIcon(
createPersonIcon(
reaction.user.shortName,
reactorNode.colors.second,
reactorNode.colors.first,
),
)
.build()
style.addMessage(
"${reaction.emoji} to \"${msg.text.take(SNIPPET_LENGTH)}...\"",
reaction.timestamp,
reactor,
)
}
}
val lastMessage = history.last()
builder
.setCategory(Notification.CATEGORY_MESSAGE)
.setAutoCancel(true)
.setStyle(style)
.setGroup(GROUP_KEY_MESSAGES)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setWhen(System.currentTimeMillis())
.setWhen(lastMessage.receivedTime)
.setShowWhen(true)
// Only add reply action for direct messages, not broadcasts
if (!isBroadcast) {
builder.addAction(createReplyAction(contactKey))
}
.addAction(createReplyAction(contactKey))
.addAction(createMarkAsReadAction(contactKey))
.addAction(
createReactionAction(
contactKey = contactKey,
packetId = lastMessage.packetId,
toId = lastMessage.node.user.id,
channelIndex = lastMessage.node.channel,
),
)
return builder.build()
}
@@ -395,6 +562,7 @@ class MeshServiceNotificationsImpl @Inject constructor(@ApplicationContext priva
.setCategory(Notification.CATEGORY_MESSAGE)
.setAutoCancel(true)
.setStyle(style)
.setGroup(GROUP_KEY_MESSAGES)
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setWhen(System.currentTimeMillis())
.setShowWhen(true)
@@ -517,6 +685,51 @@ class MeshServiceNotificationsImpl @Inject constructor(@ApplicationContext priva
.build()
}
private fun createMarkAsReadAction(contactKey: String): NotificationCompat.Action {
val label = getString(Res.string.mark_as_read)
val intent =
Intent(context, MarkAsReadReceiver::class.java).apply {
action = MARK_AS_READ_ACTION
putExtra(MarkAsReadReceiver.CONTACT_KEY, contactKey)
}
val pendingIntent =
PendingIntent.getBroadcast(
context,
contactKey.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_view, label, pendingIntent).build()
}
private fun createReactionAction(
contactKey: String,
packetId: Int,
toId: String,
channelIndex: Int,
): NotificationCompat.Action {
val label = "👍"
val intent =
Intent(context, ReactionReceiver::class.java).apply {
action = REACT_ACTION
putExtra(ReactionReceiver.EXTRA_CONTACT_KEY, contactKey)
putExtra(ReactionReceiver.EXTRA_PACKET_ID, packetId)
putExtra(ReactionReceiver.EXTRA_TO_ID, toId)
putExtra(ReactionReceiver.EXTRA_CHANNEL_INDEX, channelIndex)
putExtra(ReactionReceiver.EXTRA_EMOJI, "👍")
}
val pendingIntent =
PendingIntent.getBroadcast(
context,
packetId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_add, label, pendingIntent).build()
}
private fun commonBuilder(
type: NotificationType,
contentIntent: PendingIntent? = null,
@@ -529,6 +742,33 @@ class MeshServiceNotificationsImpl @Inject constructor(@ApplicationContext priva
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(contentIntent ?: openAppIntent)
}
private fun createPersonIcon(name: String, backgroundColor: Int, foregroundColor: Int): IconCompat {
val bitmap = createBitmap(PERSON_ICON_SIZE, PERSON_ICON_SIZE)
val canvas = Canvas(bitmap)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
// Draw background circle
paint.color = backgroundColor
canvas.drawCircle(PERSON_ICON_SIZE / 2f, PERSON_ICON_SIZE / 2f, PERSON_ICON_SIZE / 2f, paint)
// Draw initials
paint.color = foregroundColor
paint.textSize = PERSON_ICON_SIZE * PERSON_ICON_TEXT_SIZE_RATIO
paint.textAlign = Paint.Align.CENTER
val initial =
if (name.isNotEmpty()) {
val codePoint = name.codePointAt(0)
String(Character.toChars(codePoint)).uppercase()
} else {
"?"
}
val xPos = canvas.width / 2f
val yPos = (canvas.height / 2f - (paint.descent() + paint.ascent()) / 2f)
canvas.drawText(initial, xPos, yPos, paint)
return IconCompat.createWithBitmap(bitmap)
}
// endregion
}

View File

@@ -0,0 +1,85 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.service.MeshServiceNotifications
import org.meshtastic.proto.Portnums
import javax.inject.Inject
@AndroidEntryPoint
class ReactionReceiver : BroadcastReceiver() {
@Inject lateinit var commandSender: MeshCommandSender
@Inject lateinit var meshServiceNotifications: MeshServiceNotifications
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
companion object {
const val REACT_ACTION = "com.geeksville.mesh.REACT_ACTION"
const val EXTRA_PACKET_ID = "packetId"
const val EXTRA_EMOJI = "emoji"
const val EXTRA_CONTACT_KEY = "contactKey"
const val EXTRA_TO_ID = "toId"
const val EXTRA_CHANNEL_INDEX = "channelIndex"
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != REACT_ACTION) return
val pendingResult = goAsync()
scope.launch {
try {
val packetId = intent.getIntExtra(EXTRA_PACKET_ID, 0)
val emoji = intent.getStringExtra(EXTRA_EMOJI)
val toId = intent.getStringExtra(EXTRA_TO_ID)
val channelIndex = intent.getIntExtra(EXTRA_CHANNEL_INDEX, 0)
val contactKey = intent.getStringExtra(EXTRA_CONTACT_KEY)
@Suppress("ComplexCondition")
if (packetId == 0 || emoji.isNullOrEmpty() || toId.isNullOrEmpty() || contactKey.isNullOrEmpty()) {
return@launch
}
// Reactions are text messages with a replyId and emoji set
val reactionPacket =
DataPacket(
to = toId,
channel = channelIndex,
bytes = emoji.toByteArray(Charsets.UTF_8),
dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
replyId = packetId,
emoji = emoji.codePointAt(0),
)
commandSender.sendData(reactionPacket)
// Dismiss the notification after reacting
meshServiceNotifications.cancelMessageNotification(contactKey)
} finally {
pendingResult.finish()
}
}
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.service
import android.content.BroadcastReceiver
@@ -59,7 +58,7 @@ class ReplyReceiver : BroadcastReceiver() {
val contactKey = intent.getStringExtra(CONTACT_KEY) ?: ""
val message = remoteInput.getCharSequence(KEY_TEXT_REPLY)?.toString() ?: ""
sendMessage(message, contactKey)
MeshServiceNotificationsImpl(context).cancelMessageNotification(contactKey)
meshServiceNotifications.cancelMessageNotification(contactKey)
}
}
}

View File

@@ -81,7 +81,7 @@ class FakeMeshServiceNotifications : MeshServiceNotifications {
telemetry: TelemetryProtos.Telemetry?,
): Notification = null as Notification
override fun updateMessageNotification(
override suspend fun updateMessageNotification(
contactKey: String,
name: String,
message: String,
@@ -89,7 +89,20 @@ class FakeMeshServiceNotifications : MeshServiceNotifications {
channelName: String?,
) {}
override fun updateWaypointNotification(contactKey: String, name: String, message: String, waypointId: Int) {}
override suspend fun updateWaypointNotification(
contactKey: String,
name: String,
message: String,
waypointId: Int,
) {}
override suspend fun updateReactionNotification(
contactKey: String,
name: String,
emoji: String,
isBroadcast: Boolean,
channelName: String?,
) {}
override fun showAlertNotification(contactKey: String, name: String, alert: String) {}

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -14,7 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.repository
import androidx.paging.Pager
@@ -46,7 +45,7 @@ constructor(
private val dispatchers: CoroutineDispatchers,
) {
fun getWaypoints(): Flow<List<Packet>> =
dbManager.currentDb.flatMapLatest { db -> db.packetDao().getAllPackets(PortNum.WAYPOINT_APP_VALUE) }
dbManager.currentDb.flatMapLatest { db -> db.packetDao().getAllWaypointsFlow() }
fun getContacts(): Flow<Map<String, Packet>> =
dbManager.currentDb.flatMapLatest { db -> db.packetDao().getContactKeys() }
@@ -97,18 +96,21 @@ constructor(
suspend fun insert(packet: Packet) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().insert(packet) }
suspend fun getMessagesFrom(contact: String, getNode: suspend (String?) -> Node) = withContext(dispatchers.io) {
dbManager.currentDb.value.packetDao().getMessagesFrom(contact).mapLatest { packets ->
packets.map { packet ->
val message = packet.toMessage(getNode)
message.replyId
.takeIf { it != null && it != 0 }
?.let { getPacketByPacketId(it) }
?.toMessage(getNode)
?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
suspend fun getMessagesFrom(contact: String, limit: Int? = null, getNode: suspend (String?) -> Node) =
withContext(dispatchers.io) {
val dao = dbManager.currentDb.value.packetDao()
val flow = if (limit != null) dao.getMessagesFrom(contact, limit) else dao.getMessagesFrom(contact)
flow.mapLatest { packets ->
packets.map { packet ->
val message = packet.toMessage(getNode)
message.replyId
.takeIf { it != null && it != 0 }
?.let { getPacketByPacketId(it) }
?.toMessage(getNode)
?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
}
}
}
}
fun getMessagesFromPaged(contact: String, getNode: suspend (String?) -> Node): Flow<PagingData<Message>> = Pager(
config = PagingConfig(pageSize = 50, enablePlaceholders = false, initialLoadSize = 50),
@@ -176,4 +178,7 @@ constructor(
withContext(dispatchers.io) {
dbManager.currentDb.value.packetDao().migrateChannelsByPSK(oldSettings, newSettings)
}
private fun org.meshtastic.core.database.dao.PacketDao.getAllWaypointsFlow(): Flow<List<Packet>> =
getAllPackets(PortNum.WAYPOINT_APP_VALUE)
}

View File

@@ -149,6 +149,18 @@ interface PacketDao {
)
fun getMessagesFrom(contact: String): Flow<List<PacketEntity>>
@Transaction
@Query(
"""
SELECT * FROM packet
WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
AND port_num = 1 AND contact_key = :contact
ORDER BY received_time DESC
LIMIT :limit
""",
)
fun getMessagesFrom(contact: String, limit: Int): Flow<List<PacketEntity>>
@Transaction
@Query(
"""

View File

@@ -31,7 +31,7 @@ interface MeshServiceNotifications {
fun updateServiceStateNotification(summaryString: String?, telemetry: TelemetryProtos.Telemetry?): Notification
fun updateMessageNotification(
suspend fun updateMessageNotification(
contactKey: String,
name: String,
message: String,
@@ -39,7 +39,15 @@ interface MeshServiceNotifications {
channelName: String?,
)
fun updateWaypointNotification(contactKey: String, name: String, message: String, waypointId: Int)
suspend fun updateWaypointNotification(contactKey: String, name: String, message: String, waypointId: Int)
suspend fun updateReactionNotification(
contactKey: String,
name: String,
emoji: String,
isBroadcast: Boolean,
channelName: String?,
)
fun showAlertNotification(contactKey: String, name: String, alert: String)

View File

@@ -16,6 +16,8 @@
-->
<resources>
<string name="meshtastic_app_name">Meshtastic</string>
<!-- Language tags native names (not available via .getDisplayLanguage) -->
<string name="fr_HT" translatable="false">Kreyòl ayisyen</string>
<string name="pt_BR" translatable="false">Português do Brasil</string>
@@ -357,6 +359,7 @@
<string name="remove">Remove</string>
<string name="remove_node_text">This node will be removed from your list until your node receives data from it again.</string>
<string name="mute_notifications">Mute notifications</string>
<string name="mute_1_hour">1 hour</string>
<string name="mute_8_hours">8 hours</string>
<string name="mute_1_week">1 week</string>
<string name="mute_always">Always</string>
@@ -575,7 +578,7 @@
<string name="output_led_gpio">Output LED (GPIO)</string>
<string name="output_led_active_high">Output LED active high</string>
<string name="output_buzzer_gpio">Output buzzer (GPIO)</string>
<string name="use_pwm_buzzer">Use PWM buzzer</string>
<string name="use_view_buzzer">Use VIEW buzzer</string>
<string name="output_vibra_gpio">Output vibra (GPIO)</string>
<string name="output_duration_milliseconds">Output duration (milliseconds)</string>
<string name="nag_timeout_seconds">Nag timeout (seconds)</string>
@@ -1076,4 +1079,5 @@
<string name="compass_no_location_fix">Waiting for a GPS fix to calculate distance and bearing.</string>
<string name="compass_uncertainty">Estimated area: \u00b1%1$s (\u00b1%2$s)</string>
<string name="compass_uncertainty_unknown">Estimated area: unknown accuracy</string>
<string name="mark_as_read">Mark as read</string>
</resources>