From 49f6ffe6e50964df5c44283c8df040de4ceaa854 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 4 Jan 2026 15:46:07 -0600 Subject: [PATCH] feat: Enhance message notifications with history and actions (#4133) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- app/src/main/AndroidManifest.xml | 2 + .../mesh/service/MarkAsReadReceiver.kt | 59 ++++ .../mesh/service/MeshDataHandler.kt | 28 +- .../service/MeshServiceNotificationsImpl.kt | 282 ++++++++++++++++-- .../mesh/service/ReactionReceiver.kt | 85 ++++++ .../geeksville/mesh/service/ReplyReceiver.kt | 5 +- .../java/com/geeksville/mesh/service/Fakes.kt | 17 +- .../core/data/repository/PacketRepository.kt | 31 +- .../meshtastic/core/database/dao/PacketDao.kt | 12 + .../core/service/MeshServiceNotifications.kt | 12 +- .../composeResources/values/strings.xml | 6 +- 11 files changed, 496 insertions(+), 43 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/service/MarkAsReadReceiver.kt create mode 100644 app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d486b3dd4..b1fdde668 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -221,6 +221,8 @@ + + . + */ +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() + } + } + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt index 5cc46bc67..8366c2977 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt @@ -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()) { diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt index 223d6505e..5721e1db9 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotificationsImpl.kt @@ -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, + private val nodeRepository: Lazy, +) : MeshServiceNotifications { private val notificationManager = context.getSystemService()!! @@ -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, ): 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 } diff --git a/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt b/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt new file mode 100644 index 000000000..6482659cc --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/ReactionReceiver.kt @@ -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 . + */ +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() + } + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/service/ReplyReceiver.kt b/app/src/main/java/com/geeksville/mesh/service/ReplyReceiver.kt index d3032b4bf..a80839176 100644 --- a/app/src/main/java/com/geeksville/mesh/service/ReplyReceiver.kt +++ b/app/src/main/java/com/geeksville/mesh/service/ReplyReceiver.kt @@ -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 . */ - 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) } } } diff --git a/app/src/test/java/com/geeksville/mesh/service/Fakes.kt b/app/src/test/java/com/geeksville/mesh/service/Fakes.kt index 8e1f784b3..e243e0d27 100644 --- a/app/src/test/java/com/geeksville/mesh/service/Fakes.kt +++ b/app/src/test/java/com/geeksville/mesh/service/Fakes.kt @@ -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) {} diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt index ff65e44ac..d69914f11 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/PacketRepository.kt @@ -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 . */ - package org.meshtastic.core.data.repository import androidx.paging.Pager @@ -46,7 +45,7 @@ constructor( private val dispatchers: CoroutineDispatchers, ) { fun getWaypoints(): Flow> = - dbManager.currentDb.flatMapLatest { db -> db.packetDao().getAllPackets(PortNum.WAYPOINT_APP_VALUE) } + dbManager.currentDb.flatMapLatest { db -> db.packetDao().getAllWaypointsFlow() } fun getContacts(): Flow> = 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> = 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> = + getAllPackets(PortNum.WAYPOINT_APP_VALUE) } diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt index c61cf96dd..73b29f9fe 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/dao/PacketDao.kt @@ -149,6 +149,18 @@ interface PacketDao { ) fun getMessagesFrom(contact: String): Flow> + @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> + @Transaction @Query( """ diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/MeshServiceNotifications.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/MeshServiceNotifications.kt index 54ce8031a..297711d9e 100644 --- a/core/service/src/main/kotlin/org/meshtastic/core/service/MeshServiceNotifications.kt +++ b/core/service/src/main/kotlin/org/meshtastic/core/service/MeshServiceNotifications.kt @@ -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) diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index ab66293c0..03de04c96 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -16,6 +16,8 @@ --> + Meshtastic + Kreyòl ayisyen Português do Brasil @@ -357,6 +359,7 @@ Remove This node will be removed from your list until your node receives data from it again. Mute notifications + 1 hour 8 hours 1 week Always @@ -575,7 +578,7 @@ Output LED (GPIO) Output LED active high Output buzzer (GPIO) - Use PWM buzzer + Use VIEW buzzer Output vibra (GPIO) Output duration (milliseconds) Nag timeout (seconds) @@ -1076,4 +1079,5 @@ Waiting for a GPS fix to calculate distance and bearing. Estimated area: \u00b1%1$s (\u00b1%2$s) Estimated area: unknown accuracy + Mark as read