Skip to content

Commit fdf099a

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. - An inline reply now re-posts the conversation silently with the sent reply appended (refreshConversationAfterReply, default falls back to dismissal), so the RemoteInput spinner resolves with visible confirmation instead of the notification vanishing; a failed send still dismisses. - The group summary alerts via its children only (GROUP_ALERT_CHILDREN), the service notification shows immediately on Android 12+ instead of being deferred (FOREGROUND_SERVICE_IMMEDIATE), favorite nodes mark their Person important to feed conversation-priority ranking, and notifications use the Meshtastic brand accent color instead of raw blue. - 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. Adds Robolectric regression coverage: the historic-vs-new split, per-type (tag, id) namespacing, summary alert behavior and cleanup, both post-reply refresh paths, shortcut recency ranking/labels, and stale-shortcut pruning. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3c32ffa commit fdf099a

13 files changed

Lines changed: 889 additions & 319 deletions

File tree

core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshNotificationManager.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ interface MeshNotificationManager {
7373

7474
fun cancelMessageNotification(contactKey: String)
7575

76+
/**
77+
* Called after an inline notification reply has been sent and persisted. Platforms that can should re-post the
78+
* conversation notification silently with the sent reply appended — the MessagingStyle confirmation flow — so the
79+
* RemoteInput spinner resolves with visible feedback instead of the notification vanishing. The default falls back
80+
* to dismissing the conversation, which also resolves the spinner.
81+
*/
82+
suspend fun refreshConversationAfterReply(contactKey: String) = cancelMessageNotification(contactKey)
83+
7684
fun cancelLowBatteryNotification(node: Node)
7785

7886
fun clearClientNotification(notification: ClientNotification)
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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.pm.ShortcutManager
21+
import androidx.test.core.app.ApplicationProvider
22+
import androidx.test.ext.junit.runners.AndroidJUnit4
23+
import dev.mokkery.MockMode
24+
import dev.mokkery.answering.returns
25+
import dev.mokkery.every
26+
import dev.mokkery.mock
27+
import kotlinx.coroutines.flow.MutableStateFlow
28+
import kotlinx.coroutines.flow.flowOf
29+
import kotlinx.coroutines.test.advanceUntilIdle
30+
import kotlinx.coroutines.test.runTest
31+
import org.junit.Before
32+
import org.junit.Test
33+
import org.junit.runner.RunWith
34+
import org.meshtastic.core.model.DataPacket
35+
import org.meshtastic.core.model.Node
36+
import org.meshtastic.core.repository.NodeRepository
37+
import org.meshtastic.core.repository.PacketRepository
38+
import org.meshtastic.core.repository.RadioConfigRepository
39+
import org.meshtastic.proto.ChannelSet
40+
import org.meshtastic.proto.ChannelSettings
41+
import org.meshtastic.proto.User
42+
import org.robolectric.annotation.Config
43+
import kotlin.test.assertEquals
44+
import kotlin.test.assertNull
45+
import kotlin.test.assertTrue
46+
import org.meshtastic.core.model.Channel as MeshChannel
47+
48+
/**
49+
* Behavioral coverage for conversation-shortcut publishing: recency ranking across DMs and channels, effective channel
50+
* naming (empty primary → modem-preset name), and stale-shortcut removal.
51+
*/
52+
@RunWith(AndroidJUnit4::class)
53+
@Config(sdk = [34])
54+
class ConversationShortcutPublisherTest {
55+
56+
private val context: Context = ApplicationProvider.getApplicationContext()
57+
private val shortcutManager = context.getSystemService(ShortcutManager::class.java)!!
58+
59+
private val hawk = Node(num = 7, user = User(id = "!00000007", long_name = "Hawk Ridge", short_name = "HAWK"))
60+
61+
private val packetRepository: PacketRepository = mock(MockMode.autofill)
62+
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
63+
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
64+
65+
private val publisher =
66+
ConversationShortcutPublisher(context, nodeRepository, packetRepository, radioConfigRepository)
67+
68+
@Before
69+
fun setUp() {
70+
shortcutManager.removeAllDynamicShortcuts()
71+
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(7 to hawk))
72+
// Primary channel with an empty name (resolves to the preset name) plus one named secondary channel.
73+
every { radioConfigRepository.channelSetFlow } returns
74+
flowOf(
75+
ChannelSet(
76+
settings = listOf(MeshChannel.default.settings, ChannelSettings(name = "Beta")),
77+
lora_config = MeshChannel.default.loraConfig,
78+
),
79+
)
80+
}
81+
82+
private fun contact(from: String?, time: Long) = DataPacket(bytes = null, dataType = 1, from = from, time = time)
83+
84+
@Test
85+
fun `shortcuts are ranked by recency across dms and channels`() = runTest {
86+
every { packetRepository.getContacts() } returns
87+
flowOf(
88+
mapOf(
89+
"0!00000007" to contact(from = "!00000007", time = 3_000), // newest → rank 0
90+
"0^all" to contact(from = null, time = 2_000), // rank 1; "Beta" never messaged → last
91+
),
92+
)
93+
94+
publisher.startObserving(this)
95+
advanceUntilIdle()
96+
97+
val ranks = shortcutManager.dynamicShortcuts.associate { it.id to it.rank }
98+
assertEquals(0, ranks["0!00000007"], "most recently active conversation ranks first")
99+
assertEquals(1, ranks["0^all"])
100+
assertEquals(2, ranks["1^all"], "never-messaged channel sorts last")
101+
}
102+
103+
@Test
104+
fun `dm shortcuts carry node labels and the empty primary resolves to its preset name`() = runTest {
105+
every { packetRepository.getContacts() } returns
106+
flowOf(mapOf("0!00000007" to contact(from = "!00000007", time = 1_000)))
107+
108+
publisher.startObserving(this)
109+
advanceUntilIdle()
110+
111+
val byId = shortcutManager.dynamicShortcuts.associateBy { it.id }
112+
assertEquals("HAWK", byId.getValue("0!00000007").shortLabel)
113+
assertEquals("Hawk Ridge", byId.getValue("0!00000007").longLabel)
114+
assertEquals("LongFast", byId.getValue("0^all").shortLabel)
115+
assertEquals("Beta", byId.getValue("1^all").shortLabel)
116+
}
117+
118+
@Test
119+
fun `stale shortcuts are removed when no longer part of the conversation set`() = runTest {
120+
// Simulate a leftover shortcut from a previous device/channel config.
121+
publisher.ensureConversationShortcut("9^all", "Old Channel")
122+
assertTrue(shortcutManager.dynamicShortcuts.any { it.id == "9^all" })
123+
124+
every { packetRepository.getContacts() } returns flowOf(emptyMap())
125+
126+
publisher.startObserving(this)
127+
advanceUntilIdle()
128+
129+
assertNull(shortcutManager.dynamicShortcuts.find { it.id == "9^all" }, "stale shortcut should be pruned")
130+
assertTrue(shortcutManager.dynamicShortcuts.any { it.id == "0^all" }, "current channels are published")
131+
}
132+
}
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
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.app.Notification
20+
import android.app.NotificationManager
21+
import android.content.Context
22+
import androidx.test.core.app.ApplicationProvider
23+
import androidx.test.ext.junit.runners.AndroidJUnit4
24+
import dev.mokkery.MockMode
25+
import dev.mokkery.answering.returns
26+
import dev.mokkery.every
27+
import dev.mokkery.everySuspend
28+
import dev.mokkery.matcher.any
29+
import dev.mokkery.mock
30+
import kotlinx.coroutines.flow.MutableStateFlow
31+
import kotlinx.coroutines.flow.flowOf
32+
import kotlinx.coroutines.test.runTest
33+
import org.junit.Before
34+
import org.junit.Test
35+
import org.junit.runner.RunWith
36+
import org.meshtastic.core.model.ConnectionState
37+
import org.meshtastic.core.model.Message
38+
import org.meshtastic.core.model.MyNodeInfo
39+
import org.meshtastic.core.model.Node
40+
import org.meshtastic.core.repository.NodeRepository
41+
import org.meshtastic.core.repository.PacketRepository
42+
import org.meshtastic.core.repository.RadioConfigRepository
43+
import org.meshtastic.proto.ChannelSet
44+
import org.meshtastic.proto.User
45+
import org.robolectric.annotation.Config
46+
import kotlin.test.assertEquals
47+
import kotlin.test.assertNotNull
48+
import kotlin.test.assertNull
49+
import kotlin.test.assertTrue
50+
import org.meshtastic.core.model.Channel as MeshChannel
51+
52+
/**
53+
* Behavioral coverage for the conversation-notification pipeline: the historic-vs-new MessagingStyle split, per-type
54+
* (tag, id) namespacing, group-summary alerting/cleanup, and the post-reply refresh flow.
55+
*/
56+
@RunWith(AndroidJUnit4::class)
57+
@Config(sdk = [34])
58+
class MeshNotificationManagerImplConversationTest {
59+
60+
private val context: Context = ApplicationProvider.getApplicationContext()
61+
private val systemNotificationManager = context.getSystemService(NotificationManager::class.java)!!
62+
63+
private val sender = Node(num = 7, user = User(id = "!00000007", long_name = "Hawk Ridge", short_name = "HAWK"))
64+
private val me = Node(num = 42, user = User(id = "!0000002a", long_name = "Me Node", short_name = "ME"))
65+
66+
private val packetRepository: PacketRepository = mock(MockMode.autofill)
67+
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
68+
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
69+
70+
private val manager =
71+
MeshNotificationManagerImpl(
72+
context = context,
73+
packetRepository = lazy { packetRepository },
74+
nodeRepository = lazy { nodeRepository },
75+
conversationShortcutPublisher =
76+
lazy {
77+
ConversationShortcutPublisher(context, nodeRepository, packetRepository, radioConfigRepository)
78+
},
79+
radioConfigRepository = lazy { radioConfigRepository },
80+
)
81+
82+
@Before
83+
fun setUp() {
84+
registerStubMainActivity()
85+
systemNotificationManager.cancelAll()
86+
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(me)
87+
every { nodeRepository.myNodeInfo } returns MutableStateFlow<MyNodeInfo?>(null)
88+
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(7 to sender, 42 to me))
89+
everySuspend { nodeRepository.getNode(any()) } returns sender
90+
every { radioConfigRepository.channelSetFlow } returns
91+
flowOf(
92+
ChannelSet(
93+
settings = listOf(MeshChannel.default.settings),
94+
lora_config = MeshChannel.default.loraConfig,
95+
),
96+
)
97+
manager.initChannels()
98+
}
99+
100+
/** Newest-first message history, mirroring the repository's ordering. */
101+
private fun mockHistory(vararg messages: Message) {
102+
everySuspend { packetRepository.getMessagesFrom(any(), any(), any(), any()) } returns flowOf(messages.toList())
103+
}
104+
105+
private fun message(text: String, read: Boolean, receivedTime: Long): Message = Message(
106+
uuid = receivedTime,
107+
receivedTime = receivedTime,
108+
node = sender,
109+
text = text,
110+
fromLocal = false,
111+
time = "",
112+
read = read,
113+
status = null,
114+
routingError = 0,
115+
packetId = receivedTime.toInt(),
116+
emojis = emptyList(),
117+
snr = 0f,
118+
rssi = 0,
119+
hopsAway = 0,
120+
replyId = null,
121+
)
122+
123+
private fun activeByTag(tag: String) = systemNotificationManager.activeNotifications.filter { it.tag == tag }
124+
125+
/**
126+
* Registers a stub `org.meshtastic.app.MainActivity` with the Robolectric `PackageManager` so that
127+
* `TaskStackBuilder.addNextIntentWithParentStack` can resolve the deep-link host activity, which lives in
128+
* `:androidApp` and is intentionally not on this module's test classpath.
129+
*/
130+
private fun registerStubMainActivity() {
131+
val componentName = android.content.ComponentName(context, "org.meshtastic.app.MainActivity")
132+
val activityInfo =
133+
android.content.pm.ActivityInfo().apply {
134+
name = componentName.className
135+
packageName = componentName.packageName
136+
exported = true
137+
}
138+
org.robolectric.Shadows.shadowOf(context.packageManager).addOrUpdateActivity(activityInfo)
139+
}
140+
141+
@Test
142+
fun `read messages become historic context and unread messages stay alerting`() = runTest {
143+
mockHistory(
144+
message("new unread", read = false, receivedTime = 3_000),
145+
message("older read 2", read = true, receivedTime = 2_000),
146+
message("older read 1", read = true, receivedTime = 1_000),
147+
)
148+
149+
manager.updateMessageNotification(
150+
"0^all",
151+
"Hawk Ridge",
152+
"new unread",
153+
isBroadcast = true,
154+
channelName = "LongFast",
155+
)
156+
157+
val posted = activeByTag("message").single().notification
158+
val alerting = posted.extras.getParcelableArray(Notification.EXTRA_MESSAGES)
159+
val historic = posted.extras.getParcelableArray(Notification.EXTRA_HISTORIC_MESSAGES)
160+
assertEquals(1, alerting?.size, "only the unread message should be presented as new content")
161+
assertEquals(2, historic?.size, "read context should be carried as historic messages")
162+
assertEquals("new unread", posted.extras.getCharSequence(Notification.EXTRA_TEXT)?.toString())
163+
}
164+
165+
@Test
166+
fun `group summary alerts via children only and is dropped with the last conversation`() = runTest {
167+
mockHistory(message("hello", read = false, receivedTime = 1_000))
168+
169+
manager.updateMessageNotification("0^all", "Hawk Ridge", "hello", isBroadcast = true, channelName = "LongFast")
170+
171+
val summary = activeByTag("message_summary").single().notification
172+
assertEquals(Notification.GROUP_ALERT_CHILDREN, summary.groupAlertBehavior)
173+
174+
manager.cancelMessageNotification("0^all")
175+
176+
assertTrue(activeByTag("message").isEmpty(), "conversation should be cancelled")
177+
assertTrue(activeByTag("message_summary").isEmpty(), "summary must not outlive the last conversation")
178+
}
179+
180+
@Test
181+
fun `notification ids are namespaced per type so a node num cannot clobber the service notification`() = runTest {
182+
// SERVICE_NOTIFY_ID is 101; a node whose num is also 101 used to overwrite the foreground notification.
183+
manager.updateServiceStateNotification(ConnectionState.Connected, telemetry = null)
184+
manager.showOrUpdateLowBatteryNotification(Node(num = 101), isRemote = false)
185+
186+
val service = systemNotificationManager.activeNotifications.filter { it.id == 101 && it.tag == null }
187+
val battery = activeByTag("low_battery").filter { it.id == 101 }
188+
assertEquals(1, service.size, "service notification should survive")
189+
assertEquals(1, battery.size, "low-battery notification should coexist under its own tag")
190+
assertEquals(0xFF67EA94.toInt(), service.single().notification.color, "brand accent color")
191+
}
192+
193+
@Test
194+
fun `refreshConversationAfterReply reposts the conversation with the effective channel name`() = runTest {
195+
mockHistory(message("original", read = true, receivedTime = 1_000))
196+
197+
manager.refreshConversationAfterReply("0^all")
198+
199+
val posted = activeByTag("message").single().notification
200+
// Empty primary channel resolves to its modem-preset display name, matching the in-app conversation list.
201+
assertEquals("LongFast", posted.extras.getCharSequence(Notification.EXTRA_CONVERSATION_TITLE)?.toString())
202+
assertNotNull(posted.extras.getParcelableArray(Notification.EXTRA_MESSAGES))
203+
}
204+
205+
@Test
206+
fun `refreshConversationAfterReply resolves a dm peer name for the shortcut label`() = runTest {
207+
mockHistory(message("dm text", read = true, receivedTime = 1_000))
208+
209+
manager.refreshConversationAfterReply("0!00000007")
210+
211+
val posted = activeByTag("message").single().notification
212+
// A DM is not a group conversation, so no conversation title is set.
213+
assertNull(posted.extras.getCharSequence(Notification.EXTRA_CONVERSATION_TITLE))
214+
}
215+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ 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") },
63+
radioConfigRepository = lazy { error("Not used in this test") },
6264
)
6365

6466
notifications.initChannels()

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,14 +89,16 @@ class ReplyReceiverTest {
8989
}
9090

9191
@Test
92-
fun `reply goes through SendMessageUseCase and marks conversation read`() {
92+
fun `reply goes through SendMessageUseCase, marks read, and refreshes the notification in place`() {
9393
val contactKey = "0!12345678"
9494

9595
ReplyReceiver().onReceive(ApplicationProvider.getApplicationContext(), replyIntent(contactKey, "hello back"))
9696

9797
verifySuspend { sendMessageUseCase.invoke("hello back", contactKey, null) }
9898
verifySuspend { packetRepository.clearUnreadCount(contactKey, any()) }
99-
verify { notificationManager.cancelMessageNotification(contactKey) }
99+
// The conversation is re-posted with the sent reply (MessagingStyle confirmation flow), not dismissed.
100+
verifySuspend { notificationManager.refreshConversationAfterReply(contactKey) }
101+
verify(mode = VerifyMode.exactly(0)) { notificationManager.cancelMessageNotification(any()) }
100102
}
101103

102104
@Test

0 commit comments

Comments
 (0)