Skip to content

Commit 56d2b11

Browse files
jamesarichclaude
andcommitted
feat(notifications): align message notifications with Android best practices
Message notifications rebuilt the full recent conversation from the database on every arrival and presented already-read messages as new content, shared a single integer ID space across all notification types, and never linked to the conversation shortcuts that feature/car was publishing. - Read "context" messages now use MessagingStyle.addHistoricMessage() instead of addMessage(), so they provide context to Auto/Wear/accessibility without being re-presented as freshly-arrived. Unread messages and reactions remain the alerting content; a guard keeps the latest message alerting when all are read. - Every notification type posts via notify(tag, id)/cancel(tag, id), closing cross-type ID collisions (e.g. a node whose num == 101 clobbering the foreground-service notification, or num == 1 the group summary). The summary is now excluded from the active-conversation count by FLAG_GROUP_SUMMARY. - Conversation-shortcut publishing moves from feature/car (Auto-session only) into core/service ConversationShortcutPublisher, owned by the MeshService lifecycle so shortcuts exist on phones too. Conversation notifications now set setShortcutId/setLocusId for Conversations-section treatment (Android 11+). - Avatars are cached per node instead of re-rasterizing a bitmap per message; PersonIconFactory is shared in core/service (dedupes the car copy). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3c32ffa commit 56d2b11

6 files changed

Lines changed: 111 additions & 71 deletions

File tree

core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplTest.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ class MeshNotificationManagerImplTest {
5959
context = context,
6060
packetRepository = lazy<PacketRepository> { error("Not used in this test") },
6161
nodeRepository = lazy<NodeRepository> { error("Not used in this test") },
62+
conversationShortcutPublisher = lazy { error("Not used in this test") },
6263
)
6364

6465
notifications.initChannels()

feature/car/src/main/kotlin/org/meshtastic/feature/car/service/ConversationShortcutManager.kt renamed to core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutPublisher.kt

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* You should have received a copy of the GNU General Public License
1515
* along with this program. If not, see <https://www.gnu.org/licenses/>.
1616
*/
17-
package org.meshtastic.feature.car.service
17+
package org.meshtastic.core.service
1818

1919
import android.content.Context
2020
import android.content.Intent
@@ -34,17 +34,26 @@ import kotlinx.coroutines.launch
3434
import org.koin.core.annotation.Single
3535
import org.meshtastic.core.model.NodeAddress
3636
import org.meshtastic.core.model.nodeColorsFromNum
37+
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
3738
import org.meshtastic.core.repository.NodeRepository
3839
import org.meshtastic.core.repository.PacketRepository
3940
import org.meshtastic.core.repository.RadioConfigRepository
40-
import org.meshtastic.feature.car.util.PersonIconFactory
4141

4242
/**
43-
* Publishes dynamic shortcuts for active DM conversations and channels so that Android Auto can surface Meshtastic
44-
* conversations as messaging destinations and link notifications to template conversations via [LocusIdCompat].
43+
* Single owner of the dynamic conversation shortcuts that link Meshtastic conversations to their notifications.
44+
*
45+
* Publishing lives here in `core:service` (rather than in a feature module) so that shortcuts exist whenever the mesh
46+
* service is running — not only during an Android Auto session. This is what lets [MeshNotificationManagerImpl] attach
47+
* `setShortcutId`/`setLocusId` to message notifications so Android ranks them in the shade's Conversations section and
48+
* surfaces them to Android Auto/Wear.
49+
*
50+
* Two publishing paths, both keyed by `contactKey`:
51+
* - [startObserving] keeps a full set of DM + channel shortcuts in sync with the database for the service lifetime.
52+
* - [ensureConversationShortcut] publishes a single shortcut on demand when a notification is about to reference one
53+
* that the observer has not emitted yet (e.g. the very first message of a brand-new conversation).
4554
*/
4655
@Single
47-
class ConversationShortcutManager(
56+
class ConversationShortcutPublisher(
4857
private val context: Context,
4958
private val nodeRepository: NodeRepository,
5059
private val packetRepository: PacketRepository,
@@ -136,7 +145,7 @@ class ConversationShortcutManager(
136145
}
137146

138147
private fun buildChannelShortcut(index: Int, name: String): ShortcutInfoCompat {
139-
val contactKey = "${index}${NodeAddress.ID_BROADCAST}"
148+
val contactKey = "$index${NodeAddress.ID_BROADCAST}"
140149
val channelName = name.ifEmpty { "Primary Channel" }
141150
val person = Person.Builder().setName(channelName).setKey("channel-$index").build()
142151
return ShortcutInfoCompat.Builder(context, contactKey)
@@ -151,13 +160,15 @@ class ConversationShortcutManager(
151160
}
152161

153162
private fun conversationIntent(contactKey: String): Intent =
154-
Intent(Intent.ACTION_VIEW, "meshtastic://messages/$contactKey".toUri()).apply {
163+
Intent(Intent.ACTION_VIEW, "$DEEP_LINK_BASE_URI/messages/$contactKey".toUri()).apply {
155164
setPackage(context.packageName)
156165
}
157166

158167
/**
159-
* Ensures a long-lived conversation shortcut exists for [contactKey]. Called on demand when a notification is about
160-
* to reference a shortcut ID that may not have been pre-published.
168+
* Ensures a long-lived conversation shortcut exists for [contactKey] before a notification references it. A no-op
169+
* when [startObserving] has already published the shortcut; otherwise publishes a minimal one so the notification's
170+
* `setShortcutId`/`setLocusId` resolve immediately (the observer replaces it with a richer version on its next
171+
* emission).
161172
*/
162173
fun ensureConversationShortcut(contactKey: String, displayName: String) {
163174
val alreadyPublished = ShortcutManagerCompat.getDynamicShortcuts(context).any { it.id == contactKey }

core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt

Lines changed: 82 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,14 @@ import android.app.TaskStackBuilder
2424
import android.content.ContentResolver.SCHEME_ANDROID_RESOURCE
2525
import android.content.Context
2626
import android.content.Intent
27-
import android.graphics.Canvas
2827
import android.graphics.Color
29-
import android.graphics.Paint
3028
import android.media.AudioAttributes
3129
import android.media.RingtoneManager
3230
import androidx.core.app.NotificationCompat
3331
import androidx.core.app.Person
3432
import androidx.core.app.RemoteInput
33+
import androidx.core.content.LocusIdCompat
3534
import androidx.core.content.getSystemService
36-
import androidx.core.graphics.createBitmap
3735
import androidx.core.graphics.drawable.IconCompat
3836
import androidx.core.net.toUri
3937
import kotlinx.coroutines.flow.first
@@ -96,6 +94,7 @@ import org.meshtastic.proto.ClientNotification
9694
import org.meshtastic.proto.DeviceMetrics
9795
import org.meshtastic.proto.LocalStats
9896
import org.meshtastic.proto.Telemetry
97+
import java.util.concurrent.ConcurrentHashMap
9998
import 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

Comments
 (0)