Skip to content

Commit a538cee

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). - 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. DM and broadcast notifications now set setShortcutId/setLocusId for Conversations-section treatment (Android 11+). - Conversation shortcuts carry a rendered icon that distinguishes types instead of a generic head: a wide node-colored pill with the short name for DMs (mirroring the in-app node chip; shortLabel=short name, longLabel=long name), and a black rounded-square badge with the channel number for channels. Channel labels use the effective name so an empty primary shows its modem-preset name ("LongFast"), matching the in-app conversation list. The icon is set on the shortcut itself (not just the Person), which fixes the generic-head rendering. - Shortcuts are ranked by recency: DMs and channels are merged and sorted by last-activity time and setRank is applied, so launchers/Android Auto (which show only a small capped set) surface the recently-active conversations rather than an arbitrary slice of stale channels. - MessagingStyle sender avatars use the same node-colored pill and hold each participant's full short name (e.g. "WOLF", "2c3d") rather than a single initial, so senders are identifiable in a busy channel conversation. - Avatars are cached per node instead of re-rasterizing a bitmap per message; PersonIconFactory is shared in core/service (dedupes the car copy) and gained a shape-aware, auto-sizing label renderer. Also fixes an orphaned group summary that lingered after mark-as-read/reply: activeNotifications does not reflect a cancel() issued moments earlier, so showGroupSummary() rebuilt the summary from stale state. It now excludes the just-cancelled id so the summary is dropped once the last conversation is gone. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3c32ffa commit a538cee

8 files changed

Lines changed: 468 additions & 310 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()
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package org.meshtastic.core.service
18+
19+
import android.content.Context
20+
import android.content.Intent
21+
import android.content.pm.ShortcutInfo
22+
import android.graphics.Color
23+
import androidx.core.app.Person
24+
import androidx.core.content.LocusIdCompat
25+
import androidx.core.content.pm.ShortcutInfoCompat
26+
import androidx.core.content.pm.ShortcutManagerCompat
27+
import androidx.core.graphics.drawable.IconCompat
28+
import androidx.core.net.toUri
29+
import co.touchlab.kermit.Logger
30+
import kotlinx.coroutines.CoroutineScope
31+
import kotlinx.coroutines.Job
32+
import kotlinx.coroutines.flow.combine
33+
import kotlinx.coroutines.flow.distinctUntilChanged
34+
import kotlinx.coroutines.flow.map
35+
import kotlinx.coroutines.launch
36+
import org.koin.core.annotation.Single
37+
import org.meshtastic.core.model.Channel
38+
import org.meshtastic.core.model.DataPacket
39+
import org.meshtastic.core.model.NodeAddress
40+
import org.meshtastic.core.model.nodeColorsFromNum
41+
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
42+
import org.meshtastic.core.repository.NodeRepository
43+
import org.meshtastic.core.repository.PacketRepository
44+
import org.meshtastic.core.repository.RadioConfigRepository
45+
import org.meshtastic.proto.ChannelSet
46+
47+
/**
48+
* Single owner of the dynamic conversation shortcuts that link Meshtastic conversations to their notifications.
49+
*
50+
* Publishing lives here in `core:service` (rather than in a feature module) so that shortcuts exist whenever the mesh
51+
* service is running — not only during an Android Auto session. This is what lets [MeshNotificationManagerImpl] attach
52+
* `setShortcutId`/`setLocusId` to message notifications so Android ranks them in the shade's Conversations section and
53+
* surfaces them to Android Auto/Wear.
54+
*
55+
* Two publishing paths, both keyed by `contactKey`:
56+
* - [startObserving] keeps a full set of DM + channel shortcuts in sync with the database for the service lifetime.
57+
* - [ensureConversationShortcut] publishes a single shortcut on demand when a notification is about to reference one
58+
* that the observer has not emitted yet (e.g. the very first message of a brand-new conversation).
59+
*/
60+
@Single
61+
class ConversationShortcutPublisher(
62+
private val context: Context,
63+
private val nodeRepository: NodeRepository,
64+
private val packetRepository: PacketRepository,
65+
private val radioConfigRepository: RadioConfigRepository,
66+
) {
67+
68+
private var observeJob: Job? = null
69+
70+
fun startObserving(scope: CoroutineScope) {
71+
observeJob?.cancel()
72+
observeJob =
73+
scope.launch {
74+
// Combine the message DB (recency + DM peers) with the channel config (names). Ranking both by
75+
// last-activity time lets launchers / Android Auto surface the *recently active* conversations first
76+
// (the shortcut display cap is small), instead of an arbitrary slice of stale channels.
77+
combine(packetRepository.getContacts(), radioConfigRepository.channelSetFlow) { contacts, channelSet ->
78+
rankConversationsByRecency(contacts, channelSet)
79+
}
80+
.distinctUntilChanged()
81+
.collect { conversations -> publishShortcuts(conversations) }
82+
}
83+
}
84+
85+
private fun rankConversationsByRecency(
86+
contacts: Map<String, DataPacket>,
87+
channelSet: ChannelSet,
88+
): List<Conversation> {
89+
val lora = channelSet.lora_config ?: Channel.default.loraConfig
90+
val dms =
91+
contacts.entries
92+
// DM contacts are those whose key does NOT contain the broadcast ID.
93+
.filter { (key, _) -> !key.contains(NodeAddress.ID_BROADCAST) }
94+
.map { (key, packet) -> Conversation.Dm(key, packet.from.orEmpty(), packet.time) }
95+
val channels =
96+
channelSet.settings.mapIndexedNotNull { index, settings ->
97+
if (index == 0 || settings.name.isNotEmpty()) {
98+
val contactKey = "$index${NodeAddress.ID_BROADCAST}"
99+
// Effective name: an empty primary shows its modem-preset name ("LongFast", …). Recency comes from
100+
// the channel's broadcast contact entry, if any.
101+
Conversation.Channel(
102+
contactKey,
103+
index,
104+
Channel(settings, lora).name,
105+
contacts[contactKey]?.time ?: 0L,
106+
)
107+
} else {
108+
null
109+
}
110+
}
111+
// Most recently active first; conversations with no messages (time 0) sort last.
112+
return (dms + channels).sortedByDescending { it.time }
113+
}
114+
115+
fun stopObserving() {
116+
observeJob?.cancel()
117+
observeJob = null
118+
}
119+
120+
private fun publishShortcuts(conversations: List<Conversation>) {
121+
val limit = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context)
122+
// rank == list position, so the most recent conversation is rank 0 and shown first.
123+
val shortcuts =
124+
conversations.take(limit).mapIndexedNotNull { rank, conversation ->
125+
when (conversation) {
126+
is Conversation.Dm -> buildDmShortcut(conversation, rank)
127+
is Conversation.Channel -> buildChannelShortcut(conversation, rank)
128+
}
129+
}
130+
131+
try {
132+
val currentKeys = shortcuts.map { it.id }.toSet()
133+
val stale = ShortcutManagerCompat.getDynamicShortcuts(context).map { it.id }.filter { it !in currentKeys }
134+
if (stale.isNotEmpty()) {
135+
ShortcutManagerCompat.removeDynamicShortcuts(context, stale)
136+
}
137+
for (shortcut in shortcuts) {
138+
ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
139+
}
140+
Logger.d(tag = TAG) { "Published ${shortcuts.size} conversation shortcuts (recency-ranked)" }
141+
} catch (e: IllegalArgumentException) {
142+
Logger.e(tag = TAG, throwable = e) { "Failed to publish conversation shortcuts" }
143+
} catch (e: IllegalStateException) {
144+
Logger.e(tag = TAG, throwable = e) { "Failed to publish conversation shortcuts" }
145+
}
146+
}
147+
148+
private fun buildDmShortcut(dm: Conversation.Dm, rank: Int): ShortcutInfoCompat? {
149+
val node = nodeRepository.nodeDBbyNum.value.values.find { it.user.id == dm.userId }
150+
// shortLabel is the compact 4-char short name; longLabel the full node name. Fall back sensibly when a name is
151+
// missing so the shortcut is never blank.
152+
val shortName = node?.user?.short_name?.takeIf { it.isNotBlank() }
153+
val longName = node?.user?.long_name?.takeIf { it.isNotBlank() }
154+
val shortLabel = shortName ?: longName ?: dm.contactKey
155+
val longLabel = longName ?: shortName ?: dm.contactKey
156+
157+
// A node-colored pill avatar showing the short name identifies the person and matches the in-app node chip.
158+
// Set it on the shortcut itself (not just the Person) so launchers/Android Auto render it instead of a generic
159+
// head silhouette.
160+
val icon =
161+
node?.let {
162+
val (foregroundColor, backgroundColor) = nodeColorsFromNum(it.num)
163+
PersonIconFactory.createLabel(shortLabel, backgroundColor, foregroundColor, rounded = false)
164+
}
165+
val person =
166+
Person.Builder().setName(longLabel).setKey(dm.contactKey).apply { icon?.let { setIcon(it) } }.build()
167+
168+
return ShortcutInfoCompat.Builder(context, dm.contactKey)
169+
.setShortLabel(shortLabel)
170+
.setLongLabel(longLabel)
171+
.setRank(rank)
172+
.setLocusId(LocusIdCompat(dm.contactKey))
173+
.setPerson(person)
174+
.setLongLived(true)
175+
.setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
176+
.setIntent(conversationIntent(dm.contactKey))
177+
.apply { icon?.let { setIcon(it) } }
178+
.build()
179+
}
180+
181+
private fun buildChannelShortcut(channel: Conversation.Channel, rank: Int): ShortcutInfoCompat {
182+
// channel.name is already the effective name (preset-derived for an empty primary), so no placeholder here.
183+
val channelName = channel.name.ifEmpty { "Channel ${channel.index}" }
184+
// A black rounded-square badge with the channel number visually separates channels from the node-colored DM
185+
// pills.
186+
val icon = channelIcon(channel.index)
187+
val person = Person.Builder().setName(channelName).setKey("channel-${channel.index}").setIcon(icon).build()
188+
return ShortcutInfoCompat.Builder(context, channel.contactKey)
189+
.setShortLabel(channelName)
190+
.setLongLabel(channelName)
191+
.setRank(rank)
192+
.setLocusId(LocusIdCompat(channel.contactKey))
193+
.setPerson(person)
194+
.setLongLived(true)
195+
.setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
196+
.setIntent(conversationIntent(channel.contactKey))
197+
.setIcon(icon)
198+
.build()
199+
}
200+
201+
/** Black chip with a white channel [index] — a consistent, uncolored channel marker. */
202+
private fun channelIcon(index: Int): IconCompat = PersonIconFactory.createLabel(
203+
label = index.toString(),
204+
backgroundColor = Color.BLACK,
205+
foregroundColor = Color.WHITE,
206+
rounded = true,
207+
)
208+
209+
private fun conversationIntent(contactKey: String): Intent =
210+
Intent(Intent.ACTION_VIEW, "$DEEP_LINK_BASE_URI/messages/$contactKey".toUri()).apply {
211+
setPackage(context.packageName)
212+
}
213+
214+
/**
215+
* Ensures a long-lived conversation shortcut exists for [contactKey] before a notification references it. A no-op
216+
* when [startObserving] has already published the shortcut; otherwise publishes a minimal one so the notification's
217+
* `setShortcutId`/`setLocusId` resolve immediately (the observer replaces it with a richer version on its next
218+
* emission).
219+
*/
220+
fun ensureConversationShortcut(contactKey: String, displayName: String) {
221+
val alreadyPublished = ShortcutManagerCompat.getDynamicShortcuts(context).any { it.id == contactKey }
222+
if (alreadyPublished) return
223+
// Match the styling the observer will republish so there is no generic-head flash: rounded channel badge with
224+
// its number, or a circular initial for a DM. Color is derived from the key (the node object may not be known
225+
// yet at on-demand time).
226+
val isChannel = contactKey.contains(NodeAddress.ID_BROADCAST)
227+
val icon =
228+
if (isChannel) {
229+
channelIcon(contactKey.substringBefore(NodeAddress.ID_BROADCAST).toIntOrNull() ?: 0)
230+
} else {
231+
val (foregroundColor, backgroundColor) = nodeColorsFromNum(contactKey.hashCode())
232+
PersonIconFactory.create(displayName, backgroundColor, foregroundColor)
233+
}
234+
val person = Person.Builder().setName(displayName).setKey(contactKey).setIcon(icon).build()
235+
val shortcut =
236+
ShortcutInfoCompat.Builder(context, contactKey)
237+
.setShortLabel(displayName)
238+
.setLongLabel(displayName)
239+
.setLocusId(LocusIdCompat(contactKey))
240+
.setPerson(person)
241+
.setLongLived(true)
242+
.setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
243+
.setIntent(conversationIntent(contactKey))
244+
.setIcon(icon)
245+
.build()
246+
try {
247+
ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
248+
} catch (e: IllegalArgumentException) {
249+
Logger.e(tag = TAG, throwable = e) { "Failed to publish on-demand shortcut $contactKey" }
250+
} catch (e: IllegalStateException) {
251+
Logger.e(tag = TAG, throwable = e) { "Failed to publish on-demand shortcut $contactKey" }
252+
}
253+
}
254+
255+
/** A publishable conversation with its last-activity [time], used to rank shortcuts by recency. */
256+
private sealed interface Conversation {
257+
val contactKey: String
258+
val time: Long
259+
260+
data class Dm(override val contactKey: String, val userId: String, override val time: Long) : Conversation
261+
262+
data class Channel(override val contactKey: String, val index: Int, val name: String, override val time: Long) :
263+
Conversation
264+
}
265+
266+
companion object {
267+
private const val TAG = "ConversationShortcuts"
268+
}
269+
}

0 commit comments

Comments
 (0)