@@ -24,16 +24,14 @@ import android.app.TaskStackBuilder
2424import android.content.ContentResolver.SCHEME_ANDROID_RESOURCE
2525import android.content.Context
2626import android.content.Intent
27- import android.graphics.Canvas
2827import android.graphics.Color
29- import android.graphics.Paint
3028import android.media.AudioAttributes
3129import android.media.RingtoneManager
3230import androidx.core.app.NotificationCompat
3331import androidx.core.app.Person
3432import androidx.core.app.RemoteInput
33+ import androidx.core.content.LocusIdCompat
3534import androidx.core.content.getSystemService
36- import androidx.core.graphics.createBitmap
3735import androidx.core.graphics.drawable.IconCompat
3836import androidx.core.net.toUri
3937import kotlinx.coroutines.flow.first
@@ -96,6 +94,7 @@ import org.meshtastic.proto.ClientNotification
9694import org.meshtastic.proto.DeviceMetrics
9795import org.meshtastic.proto.LocalStats
9896import org.meshtastic.proto.Telemetry
97+ import java.util.concurrent.ConcurrentHashMap
9998import kotlin.time.Duration.Companion.minutes
10099
101100/* *
@@ -110,6 +109,7 @@ class MeshNotificationManagerImpl(
110109 private val context : Context ,
111110 private val packetRepository : Lazy <PacketRepository >,
112111 private val nodeRepository : Lazy <NodeRepository >,
112+ private val conversationShortcutPublisher : Lazy <ConversationShortcutPublisher >,
113113) : MeshNotificationManager {
114114
115115 private val notificationManager =
@@ -123,13 +123,35 @@ class MeshNotificationManagerImpl(
123123 private const val SNIPPET_LENGTH = 30
124124 private const val GROUP_KEY_MESSAGES = " org.meshtastic.app.GROUP_MESSAGES"
125125 private const val SUMMARY_ID = 1
126- private const val PERSON_ICON_SIZE = 128
127- private const val PERSON_ICON_TEXT_SIZE_RATIO = 0.5f
128126 private const val STATS_UPDATE_MINUTES = 15
129127 private val STATS_UPDATE_INTERVAL = STATS_UPDATE_MINUTES .minutes
130128 private const val BULLET = " • "
129+
130+ // Notification tags namespace the numeric IDs per notification type. Without them every type shares one
131+ // integer ID space, so unrelated notifications can clobber each other (e.g. a node whose num == 101 would
132+ // overwrite the foreground-service notification, or num == 1 the group summary). notify()/cancel() must use
133+ // the same (tag, id) pair.
134+ private const val TAG_MESSAGE = " message"
135+ private const val TAG_MESSAGE_SUMMARY = " message_summary"
136+ private const val TAG_WAYPOINT = " waypoint"
137+ private const val TAG_ALERT = " alert"
138+ private const val TAG_NEW_NODE = " new_node"
139+ private const val TAG_LOW_BATTERY = " low_battery"
140+ private const val TAG_CLIENT = " client"
131141 }
132142
143+ /* *
144+ * Caches generated avatar icons keyed by (person id + initial source + colors) so a conversation rebuild does not
145+ * re-rasterize an avatar bitmap for every message on every update. Bounded by the number of distinct nodes seen;
146+ * entries are cheap and colors/names rarely change.
147+ */
148+ private val personIconCache = ConcurrentHashMap <String , IconCompat >()
149+
150+ private fun cachedPersonIcon (key : String , initialSource : String , backgroundColor : Int , foregroundColor : Int ) =
151+ personIconCache.getOrPut(" $key |$initialSource |$backgroundColor |$foregroundColor " ) {
152+ PersonIconFactory .create(initialSource, backgroundColor, foregroundColor)
153+ }
154+
133155 /* *
134156 * Sealed class to define the properties of each notification channel. This centralizes channel configuration and
135157 * makes it type-safe.
@@ -385,7 +407,7 @@ class MeshNotificationManagerImpl(
385407 channelName : String? ,
386408 isSilent : Boolean ,
387409 ) {
388- showConversationNotification(contactKey, isBroadcast, channelName, isSilent = isSilent)
410+ showConversationNotification(contactKey, isBroadcast, channelName, conversationName = name, isSilent = isSilent)
389411 }
390412
391413 override suspend fun updateReactionNotification (
@@ -396,7 +418,7 @@ class MeshNotificationManagerImpl(
396418 channelName : String? ,
397419 isSilent : Boolean ,
398420 ) {
399- showConversationNotification(contactKey, isBroadcast, channelName, isSilent = isSilent)
421+ showConversationNotification(contactKey, isBroadcast, channelName, conversationName = name, isSilent = isSilent)
400422 }
401423
402424 override suspend fun updateWaypointNotification (
@@ -407,15 +429,21 @@ class MeshNotificationManagerImpl(
407429 isSilent : Boolean ,
408430 ) {
409431 val notification = createWaypointNotification(name, message, waypointId, isSilent)
410- notificationManager.notify(contactKey.hashCode(), notification)
432+ notificationManager.notify(TAG_WAYPOINT , contactKey.hashCode(), notification)
411433 }
412434
413435 private suspend fun showConversationNotification (
414436 contactKey : String ,
415437 isBroadcast : Boolean ,
416438 channelName : String? ,
439+ conversationName : String ,
417440 isSilent : Boolean = false,
418441 ) {
442+ // Publish (or refresh) a long-lived conversation shortcut before the notification references it, so Android can
443+ // rank the notification in the shade's Conversations section and expose it to Android Auto/Wear. A channel name
444+ // labels a broadcast conversation; a direct message is labelled by the other participant's name.
445+ conversationShortcutPublisher.value.ensureConversationShortcut(contactKey, channelName ? : conversationName)
446+
419447 val ourNode = nodeRepository.value.ourNodeInfo.value
420448 val history =
421449 packetRepository.value
@@ -446,20 +474,23 @@ class MeshNotificationManagerImpl(
446474 history = displayHistory,
447475 isSilent = isSilent,
448476 )
449- notificationManager.notify(contactKey.hashCode(), notification)
477+ notificationManager.notify(TAG_MESSAGE , contactKey.hashCode(), notification)
450478 showGroupSummary()
451479 }
452480
453481 private fun showGroupSummary () {
482+ // Exclude the summary itself by its group-summary flag rather than by id, so a conversation whose
483+ // contactKey.hashCode() happens to equal SUMMARY_ID is still counted as an active conversation.
454484 val activeNotifications =
455485 notificationManager.activeNotifications.filter {
456- it.id != SUMMARY_ID && it.notification.group == GROUP_KEY_MESSAGES
486+ it.notification.group == GROUP_KEY_MESSAGES &&
487+ (it.notification.flags and Notification .FLAG_GROUP_SUMMARY ) == 0
457488 }
458489
459490 // No conversations left — drop the summary too, otherwise it lingers in Android Auto after the
460491 // last message notification is cancelled (e.g. on reply / mark-as-read).
461492 if (activeNotifications.isEmpty()) {
462- notificationManager.cancel(SUMMARY_ID )
493+ notificationManager.cancel(TAG_MESSAGE_SUMMARY , SUMMARY_ID )
463494 return
464495 }
465496
@@ -469,7 +500,9 @@ class MeshNotificationManagerImpl(
469500 Person .Builder ()
470501 .setName(meName)
471502 .setKey(ourNode?.user?.id ? : NodeAddress .ID_LOCAL )
472- .apply { ourNode?.let { setIcon(createPersonIcon(meName, it.colors.second, it.colors.first)) } }
503+ .apply {
504+ ourNode?.let { setIcon(cachedPersonIcon(it.user.id, meName, it.colors.second, it.colors.first)) }
505+ }
473506 .build()
474507
475508 val messagingStyle =
@@ -499,41 +532,41 @@ class MeshNotificationManagerImpl(
499532 .setAutoCancel(true )
500533 .build()
501534
502- notificationManager.notify(SUMMARY_ID , summaryNotification)
535+ notificationManager.notify(TAG_MESSAGE_SUMMARY , SUMMARY_ID , summaryNotification)
503536 }
504537
505538 override fun showAlertNotification (contactKey : String , name : String , alert : String ) {
506539 val notification = createAlertNotification(contactKey, name, alert)
507540 // Use a consistent, unique ID for each alert source.
508- notificationManager.notify(name.hashCode(), notification)
541+ notificationManager.notify(TAG_ALERT , name.hashCode(), notification)
509542 }
510543
511544 override fun showNewNodeSeenNotification (node : Node ) {
512545 val notification = createNewNodeSeenNotification(node.user.short_name, node.user.long_name, node.num)
513- notificationManager.notify(node.num, notification)
546+ notificationManager.notify(TAG_NEW_NODE , node.num, notification)
514547 }
515548
516549 override fun showOrUpdateLowBatteryNotification (node : Node , isRemote : Boolean ) {
517550 val notification = createLowBatteryNotification(node, isRemote)
518- notificationManager.notify(node.num, notification)
551+ notificationManager.notify(TAG_LOW_BATTERY , node.num, notification)
519552 }
520553
521554 override fun showClientNotification (clientNotification : ClientNotification ) {
522555 val notification =
523556 createClientNotification(getString(Res .string.client_notification), clientNotification.message)
524- notificationManager.notify(clientNotification.toString().hashCode(), notification)
557+ notificationManager.notify(TAG_CLIENT , clientNotification.toString().hashCode(), notification)
525558 }
526559
527560 override fun cancelMessageNotification (contactKey : String ) {
528- notificationManager.cancel(contactKey.hashCode())
561+ notificationManager.cancel(TAG_MESSAGE , contactKey.hashCode())
529562 // Rebuild (or clear) the group summary so it doesn't keep showing the dismissed conversation in Android Auto.
530563 showGroupSummary()
531564 }
532565
533- override fun cancelLowBatteryNotification (node : Node ) = notificationManager.cancel(node.num)
566+ override fun cancelLowBatteryNotification (node : Node ) = notificationManager.cancel(TAG_LOW_BATTERY , node.num)
534567
535568 override fun clearClientNotification (notification : ClientNotification ) =
536- notificationManager.cancel(notification.toString().hashCode())
569+ notificationManager.cancel(TAG_CLIENT , notification.toString().hashCode())
537570
538571 // endregion
539572
@@ -585,29 +618,49 @@ class MeshNotificationManagerImpl(
585618 Person .Builder ()
586619 .setName(meName)
587620 .setKey(ourNode?.user?.id ? : NodeAddress .ID_LOCAL )
588- .apply { ourNode?.let { setIcon(createPersonIcon(meName, it.colors.second, it.colors.first)) } }
621+ .apply {
622+ ourNode?.let { setIcon(cachedPersonIcon(it.user.id, meName, it.colors.second, it.colors.first)) }
623+ }
589624 .build()
590625
591626 val style =
592627 NotificationCompat .MessagingStyle (me)
593628 .setGroupConversation(channelName != null )
594629 .setConversationTitle(channelName)
595630
596- history.forEach { msg ->
631+ // Already-read messages are added as *historic* context rather than as new content: MessagingStyle keeps them
632+ // available to accessibility services and Android Auto/Wear for conversation context without re-presenting them
633+ // as freshly-arrived messages. Unread messages (and reactions, which are themselves new events) are the actual
634+ // alerting content. When every message in the window is read (e.g. a reaction arrived on a read message) the
635+ // most recent one is kept as a normal message so the notification still has alerting content to show.
636+ val anyUnread = history.any { ! it.read }
637+ val lastIndex = history.lastIndex
638+ history.forEachIndexed { index, msg ->
597639 // Use the node attached to the message directly to ensure correct identification
598640 val person =
599641 Person .Builder ()
600642 .setName(msg.node.user.long_name)
601643 .setKey(msg.node.user.id)
602- .setIcon(createPersonIcon(msg.node.user.short_name, msg.node.colors.second, msg.node.colors.first))
644+ .setIcon(
645+ cachedPersonIcon(
646+ msg.node.user.id,
647+ msg.node.user.short_name,
648+ msg.node.colors.second,
649+ msg.node.colors.first,
650+ ),
651+ )
603652 .build()
604653
605654 val text =
606655 msg.originalMessage?.let { original ->
607656 " ↩️ \" ${original.node.user.short_name} : ${original.text.take(SNIPPET_LENGTH )} ...\" : ${msg.text} "
608657 } ? : msg.text
609658
610- style.addMessage(text, msg.receivedTime, person)
659+ if (msg.read && (anyUnread || index != lastIndex)) {
660+ style.addHistoricMessage(NotificationCompat .MessagingStyle .Message (text, msg.receivedTime, person))
661+ } else {
662+ style.addMessage(text, msg.receivedTime, person)
663+ }
611664
612665 // Add reactions as separate "messages" in history if they exist
613666 msg.emojis.forEach { reaction ->
@@ -617,7 +670,8 @@ class MeshNotificationManagerImpl(
617670 .setName(reaction.user.long_name)
618671 .setKey(reaction.user.id)
619672 .setIcon(
620- createPersonIcon(
673+ cachedPersonIcon(
674+ reaction.user.id,
621675 reaction.user.short_name,
622676 reactorNode.colors.second,
623677 reactorNode.colors.first,
@@ -635,6 +689,9 @@ class MeshNotificationManagerImpl(
635689
636690 builder
637691 .setCategory(Notification .CATEGORY_MESSAGE )
692+ // Link to the conversation shortcut so this is treated as a Conversation notification (Android 11+).
693+ .setShortcutId(contactKey)
694+ .setLocusId(LocusIdCompat (contactKey))
638695 .setAutoCancel(true )
639696 .setStyle(style)
640697 .setGroup(GROUP_KEY_MESSAGES )
@@ -873,33 +930,6 @@ class MeshNotificationManagerImpl(
873930 .setContentIntent(contentIntent ? : openAppIntent)
874931 }
875932
876- private fun createPersonIcon (name : String , backgroundColor : Int , foregroundColor : Int ): IconCompat {
877- val bitmap = createBitmap(PERSON_ICON_SIZE , PERSON_ICON_SIZE )
878- val canvas = Canvas (bitmap)
879- val paint = Paint (Paint .ANTI_ALIAS_FLAG )
880-
881- // Draw background circle
882- paint.color = backgroundColor
883- canvas.drawCircle(PERSON_ICON_SIZE / 2f , PERSON_ICON_SIZE / 2f , PERSON_ICON_SIZE / 2f , paint)
884-
885- // Draw initials
886- paint.color = foregroundColor
887- paint.textSize = PERSON_ICON_SIZE * PERSON_ICON_TEXT_SIZE_RATIO
888- paint.textAlign = Paint .Align .CENTER
889- val initial =
890- if (name.isNotEmpty()) {
891- val codePoint = name.codePointAt(0 )
892- String (Character .toChars(codePoint)).uppercase()
893- } else {
894- " ?"
895- }
896- val xPos = canvas.width / 2f
897- val yPos = (canvas.height / 2f - (paint.descent() + paint.ascent()) / 2f )
898- canvas.drawText(initial, xPos, yPos, paint)
899-
900- return IconCompat .createWithBitmap(bitmap)
901- }
902-
903933 // endregion
904934
905935 // region Extension Functions (Localized)
0 commit comments