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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 496 additions and 43 deletions

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
}