diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshNotificationManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshNotificationManager.kt
index 26eaf0289a..098843b09b 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshNotificationManager.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshNotificationManager.kt
@@ -73,6 +73,14 @@ interface MeshNotificationManager {
fun cancelMessageNotification(contactKey: String)
+ /**
+ * Called after an inline notification reply has been sent and persisted. Platforms that can should re-post the
+ * conversation notification silently with the sent reply appended — the MessagingStyle confirmation flow — so the
+ * RemoteInput spinner resolves with visible feedback instead of the notification vanishing. The default falls back
+ * to dismissing the conversation, which also resolves the spinner.
+ */
+ suspend fun refreshConversationAfterReply(contactKey: String) = cancelMessageNotification(contactKey)
+
fun cancelLowBatteryNotification(node: Node)
fun clearClientNotification(notification: ClientNotification)
diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ConversationShortcutPublisherTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ConversationShortcutPublisherTest.kt
new file mode 100644
index 0000000000..785a17c74f
--- /dev/null
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ConversationShortcutPublisherTest.kt
@@ -0,0 +1,166 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.service
+
+import android.content.Context
+import android.content.pm.ShortcutManager
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import dev.mokkery.MockMode
+import dev.mokkery.answering.returns
+import dev.mokkery.every
+import dev.mokkery.mock
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.proto.ChannelSet
+import org.meshtastic.proto.ChannelSettings
+import org.meshtastic.proto.User
+import org.robolectric.annotation.Config
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import org.meshtastic.core.model.Channel as MeshChannel
+
+/**
+ * Behavioral coverage for conversation-shortcut publishing: recency ranking across DMs and channels, effective channel
+ * naming (empty primary → modem-preset name), and stale-shortcut removal.
+ */
+@RunWith(AndroidJUnit4::class)
+@Config(sdk = [34])
+class ConversationShortcutPublisherTest {
+
+ private val context: Context = ApplicationProvider.getApplicationContext()
+ private val shortcutManager = context.getSystemService(ShortcutManager::class.java)!!
+
+ private val hawk = Node(num = 7, user = User(id = "!00000007", long_name = "Hawk Ridge", short_name = "HAWK"))
+
+ private val packetRepository: PacketRepository = mock(MockMode.autofill)
+ private val nodeRepository: NodeRepository = mock(MockMode.autofill)
+ private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
+ private val dispatchers =
+ CoroutineDispatchers(
+ io = Dispatchers.Unconfined,
+ main = Dispatchers.Unconfined,
+ default = Dispatchers.Unconfined,
+ )
+
+ private val publisher =
+ ConversationShortcutPublisher(context, nodeRepository, packetRepository, radioConfigRepository, dispatchers)
+
+ @Before
+ fun setUp() {
+ shortcutManager.removeAllDynamicShortcuts()
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(7 to hawk))
+ // Primary channel with an empty name (resolves to the preset name) plus one named secondary channel.
+ every { radioConfigRepository.channelSetFlow } returns
+ flowOf(
+ ChannelSet(
+ settings = listOf(MeshChannel.default.settings, ChannelSettings(name = "Beta")),
+ lora_config = MeshChannel.default.loraConfig,
+ ),
+ )
+ }
+
+ private fun contact(from: String?, time: Long) = DataPacket(bytes = null, dataType = 1, from = from, time = time)
+
+ @Test
+ fun `shortcuts are ranked by recency across dms and channels`() = runTest {
+ every { packetRepository.getContacts() } returns
+ flowOf(
+ mapOf(
+ "0!00000007" to contact(from = "!00000007", time = 3_000), // newest → rank 0
+ "0^all" to contact(from = null, time = 2_000), // rank 1; "Beta" never messaged → last
+ ),
+ )
+
+ publisher.startObserving(this)
+ advanceUntilIdle()
+
+ val ranks = shortcutManager.dynamicShortcuts.associate { it.id to it.rank }
+ assertEquals(0, ranks["0!00000007"], "most recently active conversation ranks first")
+ assertEquals(1, ranks["0^all"])
+ assertEquals(2, ranks["1^all"], "never-messaged channel sorts last")
+ }
+
+ @Test
+ fun `dm shortcuts carry node labels and the empty primary resolves to its preset name`() = runTest {
+ every { packetRepository.getContacts() } returns
+ flowOf(mapOf("0!00000007" to contact(from = "!00000007", time = 1_000)))
+
+ publisher.startObserving(this)
+ advanceUntilIdle()
+
+ val byId = shortcutManager.dynamicShortcuts.associateBy { it.id }
+ assertEquals("HAWK", byId.getValue("0!00000007").shortLabel)
+ assertEquals("Hawk Ridge", byId.getValue("0!00000007").longLabel)
+ assertEquals("LongFast", byId.getValue("0^all").shortLabel)
+ assertEquals("Beta", byId.getValue("1^all").shortLabel)
+ }
+
+ @Test
+ fun `stale shortcuts are removed when no longer part of the conversation set`() = runTest {
+ // Simulate a leftover shortcut from a previous session (raw push: the in-memory on-demand protection does not
+ // survive a process restart, so the observer owns its cleanup).
+ pushRawShortcut("9^all", "Old Channel")
+ assertTrue(shortcutManager.dynamicShortcuts.any { it.id == "9^all" })
+
+ every { packetRepository.getContacts() } returns flowOf(emptyMap())
+
+ publisher.startObserving(this)
+ advanceUntilIdle()
+
+ assertNull(shortcutManager.dynamicShortcuts.find { it.id == "9^all" }, "stale shortcut should be pruned")
+ assertTrue(shortcutManager.dynamicShortcuts.any { it.id == "0^all" }, "current channels are published")
+ }
+
+ @Test
+ fun `on-demand shortcuts survive pruning until a snapshot includes their conversation`() = runTest {
+ // A brand-new conversation's first notification publishes its shortcut before the contacts flow emits it.
+ publisher.ensureConversationShortcut("0!000000ff", "New Peer")
+
+ every { packetRepository.getContacts() } returns flowOf(emptyMap())
+
+ publisher.startObserving(this)
+ advanceUntilIdle()
+
+ assertTrue(
+ shortcutManager.dynamicShortcuts.any { it.id == "0!000000ff" },
+ "pending on-demand shortcut must not be pruned before the snapshot catches up",
+ )
+ }
+
+ private fun pushRawShortcut(id: String, label: String) {
+ val shortcut =
+ androidx.core.content.pm.ShortcutInfoCompat.Builder(context, id)
+ .setShortLabel(label)
+ .setIntent(android.content.Intent(android.content.Intent.ACTION_VIEW))
+ .build()
+ androidx.core.content.pm.ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
+ }
+}
diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplConversationTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplConversationTest.kt
new file mode 100644
index 0000000000..7551dd1859
--- /dev/null
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplConversationTest.kt
@@ -0,0 +1,240 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.service
+
+import android.app.Notification
+import android.app.NotificationManager
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import dev.mokkery.MockMode
+import dev.mokkery.answering.returns
+import dev.mokkery.every
+import dev.mokkery.everySuspend
+import dev.mokkery.matcher.any
+import dev.mokkery.mock
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.Message
+import org.meshtastic.core.model.MyNodeInfo
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.proto.ChannelSet
+import org.meshtastic.proto.User
+import org.robolectric.annotation.Config
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import org.meshtastic.core.model.Channel as MeshChannel
+
+/**
+ * Behavioral coverage for the conversation-notification pipeline: the historic-vs-new MessagingStyle split, per-type
+ * (tag, id) namespacing, group-summary alerting/cleanup, and the post-reply refresh flow.
+ */
+@RunWith(AndroidJUnit4::class)
+@Config(sdk = [34])
+class MeshNotificationManagerImplConversationTest {
+
+ private val context: Context = ApplicationProvider.getApplicationContext()
+ private val systemNotificationManager = context.getSystemService(NotificationManager::class.java)!!
+
+ private val sender = Node(num = 7, user = User(id = "!00000007", long_name = "Hawk Ridge", short_name = "HAWK"))
+ private val me = Node(num = 42, user = User(id = "!0000002a", long_name = "Me Node", short_name = "ME"))
+
+ private val packetRepository: PacketRepository = mock(MockMode.autofill)
+ private val nodeRepository: NodeRepository = mock(MockMode.autofill)
+ private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
+
+ private val manager =
+ MeshNotificationManagerImpl(
+ context = context,
+ packetRepository = lazy { packetRepository },
+ nodeRepository = lazy { nodeRepository },
+ conversationShortcutPublisher =
+ lazy {
+ ConversationShortcutPublisher(
+ context,
+ nodeRepository,
+ packetRepository,
+ radioConfigRepository,
+ CoroutineDispatchers(
+ io = Dispatchers.Unconfined,
+ main = Dispatchers.Unconfined,
+ default = Dispatchers.Unconfined,
+ ),
+ )
+ },
+ radioConfigRepository = lazy { radioConfigRepository },
+ )
+
+ @Before
+ fun setUp() {
+ registerStubMainActivity()
+ systemNotificationManager.cancelAll()
+ every { nodeRepository.ourNodeInfo } returns MutableStateFlow(me)
+ every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(7 to sender, 42 to me))
+ everySuspend { nodeRepository.getNode(any()) } returns sender
+ every { radioConfigRepository.channelSetFlow } returns
+ flowOf(
+ ChannelSet(
+ settings = listOf(MeshChannel.default.settings),
+ lora_config = MeshChannel.default.loraConfig,
+ ),
+ )
+ manager.initChannels()
+ }
+
+ /** Newest-first message history, mirroring the repository's ordering. */
+ private fun mockHistory(vararg messages: Message) {
+ everySuspend { packetRepository.getMessagesFrom(any(), any(), any(), any()) } returns flowOf(messages.toList())
+ }
+
+ private fun message(text: String, read: Boolean, receivedTime: Long): Message = Message(
+ uuid = receivedTime,
+ receivedTime = receivedTime,
+ node = sender,
+ text = text,
+ fromLocal = false,
+ time = "",
+ read = read,
+ status = null,
+ routingError = 0,
+ packetId = receivedTime.toInt(),
+ emojis = emptyList(),
+ snr = 0f,
+ rssi = 0,
+ hopsAway = 0,
+ replyId = null,
+ )
+
+ private fun activeByTag(tag: String) = systemNotificationManager.activeNotifications.filter { it.tag == tag }
+
+ /**
+ * Registers a stub `org.meshtastic.app.MainActivity` with the Robolectric `PackageManager` so that
+ * `TaskStackBuilder.addNextIntentWithParentStack` can resolve the deep-link host activity, which lives in
+ * `:androidApp` and is intentionally not on this module's test classpath.
+ */
+ private fun registerStubMainActivity() {
+ val componentName = android.content.ComponentName(context, "org.meshtastic.app.MainActivity")
+ val activityInfo =
+ android.content.pm.ActivityInfo().apply {
+ name = componentName.className
+ packageName = componentName.packageName
+ exported = true
+ }
+ org.robolectric.Shadows.shadowOf(context.packageManager).addOrUpdateActivity(activityInfo)
+ }
+
+ @Test
+ fun `read messages become historic context and unread messages stay alerting`() = runTest {
+ mockHistory(
+ message("new unread", read = false, receivedTime = 3_000),
+ message("older read 2", read = true, receivedTime = 2_000),
+ message("older read 1", read = true, receivedTime = 1_000),
+ )
+
+ manager.updateMessageNotification(
+ "0^all",
+ "Hawk Ridge",
+ "new unread",
+ isBroadcast = true,
+ channelName = "LongFast",
+ )
+
+ val posted = activeByTag("message").single().notification
+ val alerting = posted.extras.getParcelableArray(Notification.EXTRA_MESSAGES)
+ val historic = posted.extras.getParcelableArray(Notification.EXTRA_HISTORIC_MESSAGES)
+ assertEquals(1, alerting?.size, "only the unread message should be presented as new content")
+ assertEquals(2, historic?.size, "read context should be carried as historic messages")
+ assertEquals("new unread", posted.extras.getCharSequence(Notification.EXTRA_TEXT)?.toString())
+ }
+
+ @Test
+ fun `group summary alerts via children only and is dropped with the last conversation`() = runTest {
+ mockHistory(message("hello", read = false, receivedTime = 1_000))
+
+ manager.updateMessageNotification("0^all", "Hawk Ridge", "hello", isBroadcast = true, channelName = "LongFast")
+
+ val summary = activeByTag("message_summary").single().notification
+ assertEquals(Notification.GROUP_ALERT_CHILDREN, summary.groupAlertBehavior)
+ // The summary line is rebuilt from the child's real MessagingStyle, so it carries the actual sender.
+ val summaryLatest =
+ androidx.core.app.NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(summary)
+ ?.messages
+ ?.lastOrNull()
+ assertEquals("hello", summaryLatest?.text?.toString())
+ assertEquals("Hawk Ridge", summaryLatest?.person?.name?.toString())
+
+ manager.cancelMessageNotification("0^all")
+
+ assertTrue(activeByTag("message").isEmpty(), "conversation should be cancelled")
+ assertTrue(activeByTag("message_summary").isEmpty(), "summary must not outlive the last conversation")
+ }
+
+ @Test
+ fun `notification ids are namespaced per type so a node num cannot clobber the service notification`() = runTest {
+ // SERVICE_NOTIFY_ID is 101; a node whose num is also 101 used to overwrite the foreground notification.
+ manager.updateServiceStateNotification(ConnectionState.Connected, telemetry = null)
+ manager.showOrUpdateLowBatteryNotification(Node(num = 101), isRemote = false)
+
+ val service = systemNotificationManager.activeNotifications.filter { it.id == 101 && it.tag == null }
+ val battery = activeByTag("low_battery").filter { it.id == 101 }
+ assertEquals(1, service.size, "service notification should survive")
+ assertEquals(1, battery.size, "low-battery notification should coexist under its own tag")
+ assertEquals(0xFF67EA94.toInt(), service.single().notification.color, "brand accent color")
+ }
+
+ @Test
+ fun `refreshConversationAfterReply reposts the conversation with the effective channel name`() = runTest {
+ mockHistory(message("original", read = true, receivedTime = 1_000))
+
+ manager.refreshConversationAfterReply("0^all")
+
+ val posted = activeByTag("message").single().notification
+ // Empty primary channel resolves to its modem-preset display name, matching the in-app conversation list.
+ assertEquals("LongFast", posted.extras.getCharSequence(Notification.EXTRA_CONVERSATION_TITLE)?.toString())
+ assertNotNull(posted.extras.getParcelableArray(Notification.EXTRA_MESSAGES))
+ }
+
+ @Test
+ fun `refreshConversationAfterReply resolves a dm peer name for the shortcut label`() = runTest {
+ mockHistory(message("dm text", read = true, receivedTime = 1_000))
+
+ manager.refreshConversationAfterReply("0!00000007")
+
+ val posted = activeByTag("message").single().notification
+ // A DM is not a group conversation, so no conversation title is set.
+ assertNull(posted.extras.getCharSequence(Notification.EXTRA_CONVERSATION_TITLE))
+ // The on-demand shortcut published for the notification carries the peer's resolved name.
+ val shortcut =
+ context.getSystemService(android.content.pm.ShortcutManager::class.java)!!.dynamicShortcuts.single {
+ it.id == "0!00000007"
+ }
+ assertEquals("Hawk Ridge", shortcut.shortLabel)
+ }
+}
diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplTest.kt
index b0e8b9bae5..3c120b9ba5 100644
--- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplTest.kt
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplTest.kt
@@ -59,6 +59,8 @@ class MeshNotificationManagerImplTest {
context = context,
packetRepository = lazy { error("Not used in this test") },
nodeRepository = lazy { error("Not used in this test") },
+ conversationShortcutPublisher = lazy { error("Not used in this test") },
+ radioConfigRepository = lazy { error("Not used in this test") },
)
notifications.initChannels()
diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReplyReceiverTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReplyReceiverTest.kt
index d222eb0f33..09d0df60ed 100644
--- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReplyReceiverTest.kt
+++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReplyReceiverTest.kt
@@ -89,14 +89,16 @@ class ReplyReceiverTest {
}
@Test
- fun `reply goes through SendMessageUseCase and marks conversation read`() {
+ fun `reply goes through SendMessageUseCase, marks read, and refreshes the notification in place`() {
val contactKey = "0!12345678"
ReplyReceiver().onReceive(ApplicationProvider.getApplicationContext(), replyIntent(contactKey, "hello back"))
verifySuspend { sendMessageUseCase.invoke("hello back", contactKey, null) }
verifySuspend { packetRepository.clearUnreadCount(contactKey, any()) }
- verify { notificationManager.cancelMessageNotification(contactKey) }
+ // The conversation is re-posted with the sent reply (MessagingStyle confirmation flow), not dismissed.
+ verifySuspend { notificationManager.refreshConversationAfterReply(contactKey) }
+ verify(mode = VerifyMode.exactly(0)) { notificationManager.cancelMessageNotification(any()) }
}
@Test
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutPublisher.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutPublisher.kt
new file mode 100644
index 0000000000..966d089742
--- /dev/null
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutPublisher.kt
@@ -0,0 +1,301 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.service
+
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ShortcutInfo
+import android.graphics.Color
+import androidx.core.app.Person
+import androidx.core.content.LocusIdCompat
+import androidx.core.content.pm.ShortcutInfoCompat
+import androidx.core.content.pm.ShortcutManagerCompat
+import androidx.core.graphics.drawable.IconCompat
+import androidx.core.net.toUri
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import org.koin.core.annotation.Single
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.Channel
+import org.meshtastic.core.model.DataPacket
+import org.meshtastic.core.model.NodeAddress
+import org.meshtastic.core.model.nodeColorsFromNum
+import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
+import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.getString
+import org.meshtastic.core.resources.unknown_username
+import org.meshtastic.proto.ChannelSet
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Single owner of the dynamic conversation shortcuts that link Meshtastic conversations to their notifications.
+ *
+ * Publishing lives here in `core:service` (rather than in a feature module) so that shortcuts exist whenever the mesh
+ * service is running — not only during an Android Auto session. This is what lets [MeshNotificationManagerImpl] attach
+ * `setShortcutId`/`setLocusId` to message notifications so Android ranks them in the shade's Conversations section and
+ * surfaces them to Android Auto/Wear.
+ *
+ * Two publishing paths, both keyed by `contactKey`:
+ * - [startObserving] keeps a full set of DM + channel shortcuts in sync with the database for the service lifetime.
+ * - [ensureConversationShortcut] publishes a single shortcut on demand when a notification is about to reference one
+ * that the observer has not emitted yet (e.g. the very first message of a brand-new conversation).
+ */
+@Single
+class ConversationShortcutPublisher(
+ private val context: Context,
+ private val nodeRepository: NodeRepository,
+ private val packetRepository: PacketRepository,
+ private val radioConfigRepository: RadioConfigRepository,
+ private val dispatchers: CoroutineDispatchers,
+) {
+
+ private var observeJob: Job? = null
+
+ /**
+ * On-demand shortcut ids published ahead of the observed contacts snapshot (a brand-new conversation's first
+ * notification). Protected from pruning until a snapshot includes them, so [publishShortcuts] can't sever a live
+ * notification's shortcut link during the window before the flow catches up.
+ */
+ private val pendingOnDemandIds = ConcurrentHashMap.newKeySet()
+
+ fun startObserving(scope: CoroutineScope) {
+ observeJob?.cancel()
+ observeJob =
+ // The caller's scope may be Main-bound (MeshService); shortcut publishing does ShortcutManager IPC and
+ // rasterizes avatar bitmaps on every recency-affecting DB change, so keep it off the Main thread.
+ scope.launch(dispatchers.io) {
+ // Combine the message DB (recency + DM peers) with the channel config (names). Ranking both by
+ // last-activity time lets launchers / Android Auto surface the *recently active* conversations first
+ // (the shortcut display cap is small), instead of an arbitrary slice of stale channels.
+ combine(packetRepository.getContacts(), radioConfigRepository.channelSetFlow) { contacts, channelSet ->
+ rankConversationsByRecency(contacts, channelSet)
+ }
+ .distinctUntilChanged()
+ .collect { conversations -> publishShortcuts(conversations) }
+ }
+ }
+
+ private fun rankConversationsByRecency(
+ contacts: Map,
+ channelSet: ChannelSet,
+ ): List {
+ val lora = channelSet.lora_config ?: Channel.default.loraConfig
+ val dms =
+ contacts.entries
+ // DM contacts are those whose key does NOT contain the broadcast ID.
+ .filter { (key, _) -> !key.contains(NodeAddress.ID_BROADCAST) }
+ .map { (key, packet) -> Conversation.Dm(key, packet.from.orEmpty(), packet.time) }
+ val channels =
+ channelSet.settings.mapIndexedNotNull { index, settings ->
+ if (index == 0 || settings.name.isNotEmpty()) {
+ val contactKey = "$index${NodeAddress.ID_BROADCAST}"
+ // Effective name: an empty primary shows its modem-preset name ("LongFast", …). Recency comes from
+ // the channel's broadcast contact entry, if any.
+ Conversation.Channel(
+ contactKey,
+ index,
+ Channel(settings, lora).name,
+ contacts[contactKey]?.time ?: 0L,
+ )
+ } else {
+ null
+ }
+ }
+ // Most recently active first; conversations with no messages (time 0) sort last.
+ return (dms + channels).sortedByDescending { it.time }
+ }
+
+ fun stopObserving() {
+ observeJob?.cancel()
+ observeJob = null
+ }
+
+ private fun publishShortcuts(conversations: List) {
+ val limit = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context)
+ // rank == list position, so the most recent conversation is rank 0 and shown first.
+ val shortcuts =
+ conversations.take(limit).mapIndexedNotNull { rank, conversation ->
+ when (conversation) {
+ is Conversation.Dm -> buildDmShortcut(conversation, rank)
+ is Conversation.Channel -> buildChannelShortcut(conversation, rank)
+ }
+ }
+
+ try {
+ val currentKeys = shortcuts.map { it.id }.toSet()
+ // The snapshot now covers these conversations; normal pruning applies to them from here on.
+ pendingOnDemandIds.removeAll(currentKeys)
+ val stale =
+ ShortcutManagerCompat.getDynamicShortcuts(context)
+ .map { it.id }
+ .filter { it !in currentKeys && it !in pendingOnDemandIds }
+ if (stale.isNotEmpty()) {
+ ShortcutManagerCompat.removeDynamicShortcuts(context, stale)
+ }
+ for (shortcut in shortcuts) {
+ ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
+ }
+ Logger.d(tag = TAG) { "Published ${shortcuts.size} conversation shortcuts (recency-ranked)" }
+ } catch (e: IllegalArgumentException) {
+ Logger.e(tag = TAG, throwable = e) { "Failed to publish conversation shortcuts" }
+ } catch (e: IllegalStateException) {
+ Logger.e(tag = TAG, throwable = e) { "Failed to publish conversation shortcuts" }
+ }
+ }
+
+ private fun buildDmShortcut(dm: Conversation.Dm, rank: Int): ShortcutInfoCompat? {
+ val node = nodeRepository.nodeDBbyNum.value.values.find { it.user.id == dm.userId }
+ // shortLabel is the compact 4-char short name; longLabel the full node name. Fall back to a localized generic
+ // name when node metadata is missing: the raw contactKey is a user-traceable identifier and must stay confined
+ // to internal ids and deep-link metadata (privacy-first convention).
+ val shortName = node?.user?.short_name?.takeIf { it.isNotBlank() }
+ val longName = node?.user?.long_name?.takeIf { it.isNotBlank() }
+ val fallbackName by lazy { getString(Res.string.unknown_username) }
+ val shortLabel = shortName ?: longName ?: fallbackName
+ val longLabel = longName ?: shortName ?: fallbackName
+
+ // A node-colored pill avatar showing the short name identifies the person and matches the in-app node chip.
+ // Set it on the shortcut itself (not just the Person) so launchers/Android Auto render it instead of a generic
+ // head silhouette.
+ val icon =
+ node?.let {
+ val (foregroundColor, backgroundColor) = nodeColorsFromNum(it.num)
+ PersonIconFactory.createLabel(shortLabel, backgroundColor, foregroundColor, rounded = false)
+ }
+ val person =
+ Person.Builder()
+ .setName(longLabel)
+ .setKey(dm.contactKey)
+ // Favorite nodes feed the system's conversation-priority ranking.
+ .setImportant(node?.isFavorite == true)
+ .apply { icon?.let { setIcon(it) } }
+ .build()
+
+ return ShortcutInfoCompat.Builder(context, dm.contactKey)
+ .setShortLabel(shortLabel)
+ .setLongLabel(longLabel)
+ .setRank(rank)
+ .setLocusId(LocusIdCompat(dm.contactKey))
+ .setPerson(person)
+ .setLongLived(true)
+ .setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
+ .setIntent(conversationIntent(dm.contactKey))
+ .apply { icon?.let { setIcon(it) } }
+ .build()
+ }
+
+ private fun buildChannelShortcut(channel: Conversation.Channel, rank: Int): ShortcutInfoCompat {
+ // channel.name is the effective name and never empty: Channel.name resolves an empty settings.name to the
+ // modem-preset display name (or "Custom" for non-preset configs), so no fallback placeholder is needed.
+ val channelName = channel.name
+ // A black rounded-square badge with the channel number visually separates channels from the node-colored DM
+ // pills.
+ val icon = channelIcon(channel.index)
+ val person = Person.Builder().setName(channelName).setKey("channel-${channel.index}").setIcon(icon).build()
+ return ShortcutInfoCompat.Builder(context, channel.contactKey)
+ .setShortLabel(channelName)
+ .setLongLabel(channelName)
+ .setRank(rank)
+ .setLocusId(LocusIdCompat(channel.contactKey))
+ .setPerson(person)
+ .setLongLived(true)
+ .setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
+ .setIntent(conversationIntent(channel.contactKey))
+ .setIcon(icon)
+ .build()
+ }
+
+ /** Black chip with a white channel [index] — a consistent, uncolored channel marker. */
+ private fun channelIcon(index: Int): IconCompat = PersonIconFactory.createLabel(
+ label = index.toString(),
+ backgroundColor = Color.BLACK,
+ foregroundColor = Color.WHITE,
+ rounded = true,
+ )
+
+ private fun conversationIntent(contactKey: String): Intent =
+ Intent(Intent.ACTION_VIEW, "$DEEP_LINK_BASE_URI/messages/$contactKey".toUri()).apply {
+ setPackage(context.packageName)
+ }
+
+ /**
+ * Ensures a long-lived conversation shortcut exists for [contactKey] before a notification references it. A no-op
+ * when [startObserving] has already published the shortcut; otherwise publishes a minimal one so the notification's
+ * `setShortcutId`/`setLocusId` resolve immediately (the observer replaces it with a richer version on its next
+ * emission).
+ */
+ fun ensureConversationShortcut(contactKey: String, displayName: String) {
+ // Protect this conversation from the observer's pruning until a contacts snapshot includes it.
+ pendingOnDemandIds += contactKey
+ val alreadyPublished = ShortcutManagerCompat.getDynamicShortcuts(context).any { it.id == contactKey }
+ if (alreadyPublished) return
+ // Match the styling the observer will republish so there is no generic-head flash: rounded channel badge with
+ // its number, or a circular initial for a DM. Color is derived from the key (the node object may not be known
+ // yet at on-demand time).
+ val isChannel = contactKey.contains(NodeAddress.ID_BROADCAST)
+ val icon =
+ if (isChannel) {
+ channelIcon(contactKey.substringBefore(NodeAddress.ID_BROADCAST).toIntOrNull() ?: 0)
+ } else {
+ val (foregroundColor, backgroundColor) = nodeColorsFromNum(contactKey.hashCode())
+ PersonIconFactory.create(displayName, backgroundColor, foregroundColor)
+ }
+ val person = Person.Builder().setName(displayName).setKey(contactKey).setIcon(icon).build()
+ val shortcut =
+ ShortcutInfoCompat.Builder(context, contactKey)
+ .setShortLabel(displayName)
+ .setLongLabel(displayName)
+ .setLocusId(LocusIdCompat(contactKey))
+ .setPerson(person)
+ .setLongLived(true)
+ .setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
+ .setIntent(conversationIntent(contactKey))
+ .setIcon(icon)
+ .build()
+ try {
+ ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
+ } catch (e: IllegalArgumentException) {
+ // Don't log the contactKey: node/channel identifiers are user-traceable (privacy-first convention).
+ Logger.e(tag = TAG, throwable = e) { "Failed to publish on-demand shortcut" }
+ } catch (e: IllegalStateException) {
+ Logger.e(tag = TAG, throwable = e) { "Failed to publish on-demand shortcut" }
+ }
+ }
+
+ /** A publishable conversation with its last-activity [time], used to rank shortcuts by recency. */
+ private sealed interface Conversation {
+ val contactKey: String
+ val time: Long
+
+ data class Dm(override val contactKey: String, val userId: String, override val time: Long) : Conversation
+
+ data class Channel(override val contactKey: String, val index: Int, val name: String, override val time: Long) :
+ Conversation
+ }
+
+ companion object {
+ private const val TAG = "ConversationShortcuts"
+ }
+}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt
index d9b0d0c998..9b080b3131 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt
@@ -24,16 +24,13 @@ 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.LocusIdCompat
import androidx.core.content.getSystemService
-import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import kotlinx.coroutines.flow.first
@@ -41,6 +38,7 @@ import org.jetbrains.compose.resources.StringResource
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.model.Channel
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.Message
import org.meshtastic.core.model.Node
@@ -50,10 +48,12 @@ import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.SERVICE_NOTIFY_ID
import org.meshtastic.core.resources.R.drawable
import org.meshtastic.core.resources.R.raw
import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.channel
import org.meshtastic.core.resources.client_notification
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting
@@ -88,6 +88,7 @@ import org.meshtastic.core.resources.new_node_seen
import org.meshtastic.core.resources.no_local_stats
import org.meshtastic.core.resources.powered
import org.meshtastic.core.resources.reply
+import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.resources.you
import org.meshtastic.core.service.MarkAsReadReceiver.Companion.MARK_AS_READ_ACTION
import org.meshtastic.core.service.ReactionReceiver.Companion.REACT_ACTION
@@ -96,6 +97,7 @@ import org.meshtastic.proto.ClientNotification
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.Telemetry
+import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.minutes
/**
@@ -110,6 +112,8 @@ class MeshNotificationManagerImpl(
private val context: Context,
private val packetRepository: Lazy,
private val nodeRepository: Lazy,
+ private val conversationShortcutPublisher: Lazy,
+ private val radioConfigRepository: Lazy,
) : MeshNotificationManager {
private val notificationManager =
@@ -117,19 +121,45 @@ class MeshNotificationManagerImpl(
companion object {
const val MAX_BATTERY_LEVEL = 100
- private val NOTIFICATION_LIGHT_COLOR = Color.BLUE
+
+ // Meshtastic brand accent (Green 500, see .skills/design-standards) — used as the notification accent color
+ // (small-icon tint) and the notification LED color.
+ private val NOTIFICATION_COLOR = 0xFF67EA94.toInt()
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 = "org.meshtastic.app.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
private const val STATS_UPDATE_MINUTES = 15
private val STATS_UPDATE_INTERVAL = STATS_UPDATE_MINUTES.minutes
private const val BULLET = "• "
+
+ // Notification tags namespace the numeric IDs per notification type. Without them every type shares one
+ // integer ID space, so unrelated notifications can clobber each other (e.g. a node whose num == 101 would
+ // overwrite the foreground-service notification, or num == 1 the group summary). notify()/cancel() must use
+ // the same (tag, id) pair.
+ private const val TAG_MESSAGE = "message"
+ private const val TAG_MESSAGE_SUMMARY = "message_summary"
+ private const val TAG_WAYPOINT = "waypoint"
+ private const val TAG_ALERT = "alert"
+ private const val TAG_NEW_NODE = "new_node"
+ private const val TAG_LOW_BATTERY = "low_battery"
+ private const val TAG_CLIENT = "client"
}
+ /**
+ * Caches generated avatar icons keyed by (person id + short name + colors) so a conversation rebuild does not
+ * re-rasterize an avatar bitmap for every message on every update. Bounded by the number of distinct nodes seen;
+ * entries are cheap and colors/names rarely change.
+ */
+ private val personIconCache = ConcurrentHashMap()
+
+ /** Circular, node-colored avatar holding the sender's full short name (e.g. "2c3d"), not just its first letter. */
+ private fun cachedPersonIcon(key: String, shortName: String, backgroundColor: Int, foregroundColor: Int) =
+ personIconCache.getOrPut("$key|$shortName|$backgroundColor|$foregroundColor") {
+ PersonIconFactory.createLabel(shortName, backgroundColor, foregroundColor, rounded = false)
+ }
+
/**
* Sealed class to define the properties of each notification channel. This centralizes channel configuration and
* makes it type-safe.
@@ -237,7 +267,7 @@ class MeshNotificationManagerImpl(
val channelName = getString(type.channelNameRes)
val channel =
NotificationChannel(type.channelId, channelName, type.importance).apply {
- lightColor = NOTIFICATION_LIGHT_COLOR
+ lightColor = NOTIFICATION_COLOR
lockscreenVisibility = Notification.VISIBILITY_PUBLIC // Default, can be overridden
// Type-specific configurations
@@ -385,7 +415,7 @@ class MeshNotificationManagerImpl(
channelName: String?,
isSilent: Boolean,
) {
- showConversationNotification(contactKey, isBroadcast, channelName, isSilent = isSilent)
+ showConversationNotification(contactKey, isBroadcast, channelName, conversationName = name, isSilent = isSilent)
}
override suspend fun updateReactionNotification(
@@ -396,7 +426,7 @@ class MeshNotificationManagerImpl(
channelName: String?,
isSilent: Boolean,
) {
- showConversationNotification(contactKey, isBroadcast, channelName, isSilent = isSilent)
+ showConversationNotification(contactKey, isBroadcast, channelName, conversationName = name, isSilent = isSilent)
}
override suspend fun updateWaypointNotification(
@@ -407,15 +437,21 @@ class MeshNotificationManagerImpl(
isSilent: Boolean,
) {
val notification = createWaypointNotification(name, message, waypointId, isSilent)
- notificationManager.notify(contactKey.hashCode(), notification)
+ notificationManager.notify(TAG_WAYPOINT, contactKey.hashCode(), notification)
}
private suspend fun showConversationNotification(
contactKey: String,
isBroadcast: Boolean,
channelName: String?,
+ conversationName: String,
isSilent: Boolean = false,
) {
+ // Publish (or refresh) a long-lived conversation shortcut before the notification references it, so Android can
+ // rank the notification in the shade's Conversations section and expose it to Android Auto/Wear. A channel name
+ // labels a broadcast conversation; a direct message is labelled by the other participant's name.
+ conversationShortcutPublisher.value.ensureConversationShortcut(contactKey, channelName ?: conversationName)
+
val ourNode = nodeRepository.value.ourNodeInfo.value
val history =
packetRepository.value
@@ -446,20 +482,27 @@ class MeshNotificationManagerImpl(
history = displayHistory,
isSilent = isSilent,
)
- notificationManager.notify(contactKey.hashCode(), notification)
+ notificationManager.notify(TAG_MESSAGE, contactKey.hashCode(), notification)
showGroupSummary()
}
- private fun showGroupSummary() {
+ private fun showGroupSummary(justCancelledId: Int? = null) {
+ // Exclude the summary itself by its group-summary flag rather than by id, so a conversation whose
+ // contactKey.hashCode() happens to equal SUMMARY_ID is still counted as an active conversation.
+ // Also exclude a conversation we just cancelled: activeNotifications does not reflect a cancel() issued
+ // moments earlier, so without this the summary would be rebuilt from stale state and orphaned in the shade
+ // (and Android Auto) after the last message is dismissed via reply / mark-as-read.
val activeNotifications =
notificationManager.activeNotifications.filter {
- it.id != SUMMARY_ID && it.notification.group == GROUP_KEY_MESSAGES
+ it.notification.group == GROUP_KEY_MESSAGES &&
+ (it.notification.flags and Notification.FLAG_GROUP_SUMMARY) == 0 &&
+ !(it.tag == TAG_MESSAGE && it.id == justCancelledId)
}
// No conversations left — drop the summary too, otherwise it lingers in Android Auto after the
// last message notification is cancelled (e.g. on reply / mark-as-read).
if (activeNotifications.isEmpty()) {
- notificationManager.cancel(SUMMARY_ID)
+ notificationManager.cancel(TAG_MESSAGE_SUMMARY, SUMMARY_ID)
return
}
@@ -469,7 +512,11 @@ class MeshNotificationManagerImpl(
Person.Builder()
.setName(meName)
.setKey(ourNode?.user?.id ?: NodeAddress.ID_LOCAL)
- .apply { ourNode?.let { setIcon(createPersonIcon(meName, it.colors.second, it.colors.first)) } }
+ .apply {
+ ourNode?.let {
+ setIcon(cachedPersonIcon(it.user.id, it.user.short_name, it.colors.second, it.colors.first))
+ }
+ }
.build()
val messagingStyle =
@@ -478,15 +525,22 @@ class MeshNotificationManagerImpl(
.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)
+ // Prefer the child's real MessagingStyle: its latest message carries the actual sender (Person, icon) and
+ // timestamp, so the summary line reads "Hawk Ridge: …" rather than the conversation title.
+ val latest =
+ NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(sbn.notification)
+ ?.messages
+ ?.lastOrNull()
+ if (latest?.text != null) {
+ val senderPerson = latest.person ?: Person.Builder().setName(getString(Res.string.you)).build()
+ messagingStyle.addMessage(latest.text, latest.timestamp, senderPerson)
+ } else {
+ // Fallback for children without an extractable style: rebuild a generic line from the extras.
+ val senderTitle = sbn.notification.extras.getCharSequence(Notification.EXTRA_TITLE)
+ val messageText = sbn.notification.extras.getCharSequence(Notification.EXTRA_TEXT)
+ if (senderTitle != null && messageText != null) {
+ messagingStyle.addMessage(messageText, sbn.postTime, Person.Builder().setName(senderTitle).build())
+ }
}
}
@@ -496,44 +550,76 @@ class MeshNotificationManagerImpl(
.setStyle(messagingStyle)
.setGroup(GROUP_KEY_MESSAGES)
.setGroupSummary(true)
+ // Only the child conversation notifications alert; without this the summary (posted on a HIGH channel)
+ // can buzz a second time for every message.
+ .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
.setAutoCancel(true)
.build()
- notificationManager.notify(SUMMARY_ID, summaryNotification)
+ notificationManager.notify(TAG_MESSAGE_SUMMARY, SUMMARY_ID, summaryNotification)
}
override fun showAlertNotification(contactKey: String, name: String, alert: String) {
val notification = createAlertNotification(contactKey, name, alert)
// Use a consistent, unique ID for each alert source.
- notificationManager.notify(name.hashCode(), notification)
+ notificationManager.notify(TAG_ALERT, name.hashCode(), notification)
}
override fun showNewNodeSeenNotification(node: Node) {
val notification = createNewNodeSeenNotification(node.user.short_name, node.user.long_name, node.num)
- notificationManager.notify(node.num, notification)
+ notificationManager.notify(TAG_NEW_NODE, node.num, notification)
}
override fun showOrUpdateLowBatteryNotification(node: Node, isRemote: Boolean) {
val notification = createLowBatteryNotification(node, isRemote)
- notificationManager.notify(node.num, notification)
+ notificationManager.notify(TAG_LOW_BATTERY, node.num, notification)
}
override fun showClientNotification(clientNotification: ClientNotification) {
val notification =
createClientNotification(getString(Res.string.client_notification), clientNotification.message)
- notificationManager.notify(clientNotification.toString().hashCode(), notification)
+ notificationManager.notify(TAG_CLIENT, clientNotification.toString().hashCode(), notification)
}
override fun cancelMessageNotification(contactKey: String) {
- notificationManager.cancel(contactKey.hashCode())
+ val id = contactKey.hashCode()
+ notificationManager.cancel(TAG_MESSAGE, id)
// Rebuild (or clear) the group summary so it doesn't keep showing the dismissed conversation in Android Auto.
- showGroupSummary()
+ // Pass the id we just cancelled so a stale activeNotifications snapshot doesn't keep the summary alive.
+ showGroupSummary(justCancelledId = id)
}
- override fun cancelLowBatteryNotification(node: Node) = notificationManager.cancel(node.num)
+ /**
+ * Re-posts the conversation silently after an inline reply, so the notification updates in place with the sent
+ * reply appended (the read context becomes historic, the reply is the visible latest message) instead of
+ * disappearing. Re-posting under the same (tag, id) is also what resolves the RemoteInput spinner.
+ */
+ override suspend fun refreshConversationAfterReply(contactKey: String) {
+ val isBroadcast = contactKey.contains(NodeAddress.ID_BROADCAST)
+ val conversationName: String
+ var channelName: String? = null
+ if (isBroadcast) {
+ // Resolve the effective channel name (an empty primary shows its modem-preset name, e.g. "LongFast").
+ val channelSet = radioConfigRepository.value.channelSetFlow.first()
+ val lora = channelSet.lora_config ?: Channel.default.loraConfig
+ val index = contactKey.substringBefore(NodeAddress.ID_BROADCAST).toIntOrNull()
+ channelName = index?.let { channelSet.settings.getOrNull(it) }?.let { Channel(it, lora).name }
+ // Never fall back to the raw contactKey for user-facing labels (privacy-first convention).
+ conversationName = channelName ?: getString(Res.string.channel)
+ } else {
+ // DM contactKey is "" where userId starts at the '!'.
+ val userId = "!" + contactKey.substringAfter("!", missingDelimiterValue = "")
+ val peer = nodeRepository.value.nodeDBbyNum.value.values.find { it.user.id == userId }
+ conversationName =
+ peer?.user?.long_name?.takeIf { it.isNotBlank() } ?: getString(Res.string.unknown_username)
+ }
+ showConversationNotification(contactKey, isBroadcast, channelName, conversationName, isSilent = true)
+ }
+
+ override fun cancelLowBatteryNotification(node: Node) = notificationManager.cancel(TAG_LOW_BATTERY, node.num)
override fun clearClientNotification(notification: ClientNotification) =
- notificationManager.cancel(notification.toString().hashCode())
+ notificationManager.cancel(TAG_CLIENT, notification.toString().hashCode())
// endregion
@@ -544,6 +630,9 @@ class MeshNotificationManagerImpl(
.setPriority(NotificationCompat.PRIORITY_MIN)
.setCategory(Notification.CATEGORY_SERVICE)
.setOngoing(true)
+ // Android 12+ may defer FGS notifications ~10s; show immediately so the user watching a
+ // "Connecting…" state gets feedback right away.
+ .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.setContentTitle(name)
.setShowWhen(true)
@@ -585,7 +674,11 @@ class MeshNotificationManagerImpl(
Person.Builder()
.setName(meName)
.setKey(ourNode?.user?.id ?: NodeAddress.ID_LOCAL)
- .apply { ourNode?.let { setIcon(createPersonIcon(meName, it.colors.second, it.colors.first)) } }
+ .apply {
+ ourNode?.let {
+ setIcon(cachedPersonIcon(it.user.id, it.user.short_name, it.colors.second, it.colors.first))
+ }
+ }
.build()
val style =
@@ -593,13 +686,29 @@ class MeshNotificationManagerImpl(
.setGroupConversation(channelName != null)
.setConversationTitle(channelName)
- history.forEach { msg ->
+ // Already-read messages are added as *historic* context rather than as new content: MessagingStyle keeps them
+ // available to accessibility services and Android Auto/Wear for conversation context without re-presenting them
+ // as freshly-arrived messages. Unread messages (and reactions, which are themselves new events) are the actual
+ // alerting content. When every message in the window is read (e.g. a reaction arrived on a read message) the
+ // most recent one is kept as a normal message so the notification still has alerting content to show.
+ val anyUnread = history.any { !it.read }
+ val lastIndex = history.lastIndex
+ history.forEachIndexed { index, msg ->
// Use the node attached to the message directly to ensure correct identification
val person =
Person.Builder()
.setName(msg.node.user.long_name)
.setKey(msg.node.user.id)
- .setIcon(createPersonIcon(msg.node.user.short_name, msg.node.colors.second, msg.node.colors.first))
+ // Favorite nodes feed the system's conversation-priority ranking (shade ordering, suggestions).
+ .setImportant(msg.node.isFavorite)
+ .setIcon(
+ cachedPersonIcon(
+ msg.node.user.id,
+ msg.node.user.short_name,
+ msg.node.colors.second,
+ msg.node.colors.first,
+ ),
+ )
.build()
val text =
@@ -607,7 +716,11 @@ class MeshNotificationManagerImpl(
"↩️ \"${original.node.user.short_name}: ${original.text.take(SNIPPET_LENGTH)}...\": ${msg.text}"
} ?: msg.text
- style.addMessage(text, msg.receivedTime, person)
+ if (msg.read && (anyUnread || index != lastIndex)) {
+ style.addHistoricMessage(NotificationCompat.MessagingStyle.Message(text, msg.receivedTime, person))
+ } else {
+ style.addMessage(text, msg.receivedTime, person)
+ }
// Add reactions as separate "messages" in history if they exist
msg.emojis.forEach { reaction ->
@@ -617,7 +730,8 @@ class MeshNotificationManagerImpl(
.setName(reaction.user.long_name)
.setKey(reaction.user.id)
.setIcon(
- createPersonIcon(
+ cachedPersonIcon(
+ reaction.user.id,
reaction.user.short_name,
reactorNode.colors.second,
reactorNode.colors.first,
@@ -635,6 +749,9 @@ class MeshNotificationManagerImpl(
builder
.setCategory(Notification.CATEGORY_MESSAGE)
+ // Link to the conversation shortcut so this is treated as a Conversation notification (Android 11+).
+ .setShortcutId(contactKey)
+ .setLocusId(LocusIdCompat(contactKey))
.setAutoCancel(true)
.setStyle(style)
.setGroup(GROUP_KEY_MESSAGES)
@@ -868,38 +985,11 @@ class MeshNotificationManagerImpl(
return NotificationCompat.Builder(context, type.channelId)
.setSmallIcon(smallIcon)
- .setColor(NOTIFICATION_LIGHT_COLOR)
+ .setColor(NOTIFICATION_COLOR)
.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
// region Extension Functions (Localized)
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
index bdccd89728..065b53636f 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshService.kt
@@ -66,6 +66,8 @@ class MeshService : Service() {
private val orchestrator: MeshServiceOrchestrator by inject()
+ private val conversationShortcutPublisher: ConversationShortcutPublisher by inject()
+
private var isServiceInitialized = false
/**
@@ -116,6 +118,10 @@ class MeshService : Service() {
stopSelf()
return
}
+
+ // Keep conversation shortcuts published for the whole service lifetime (not only during an Android Auto
+ // session) so message notifications can link to them and get Conversations-section treatment on phones.
+ conversationShortcutPublisher.startObserving(serviceScope)
}
@Suppress("ReturnCount")
@@ -265,6 +271,7 @@ class MeshService : Service() {
override fun onDestroy() {
Logger.i { "Destroying mesh service" }
+ conversationShortcutPublisher.stopObserving()
addressWaitJob?.cancel()
addressWaitJob = null
serviceScope.cancel()
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/PersonIconFactory.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/PersonIconFactory.kt
new file mode 100644
index 0000000000..5ba4c42dc9
--- /dev/null
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/PersonIconFactory.kt
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.service
+
+import android.graphics.Canvas
+import android.graphics.Paint
+import androidx.core.graphics.createBitmap
+import androidx.core.graphics.drawable.IconCompat
+
+/**
+ * Renders avatar icons for [androidx.core.app.Person] icons in MessagingStyle notifications and for conversation
+ * shortcuts. Two shapes give conversations a recognizable identity at a glance:
+ * - a **wide pill** for people / direct messages (holds the node initial or short name), mirroring the in-app node
+ * chip, and
+ * - a **rounded square** for channels (holds the channel number),
+ *
+ * both filled with the caller-supplied colors (node colors for people, a channel-derived color for channels).
+ */
+internal object PersonIconFactory {
+
+ private const val ICON_SIZE = 128
+ private const val TEXT_SIZE_RATIO = 0.5f
+
+ // Leave a margin so multi-character labels (e.g. a 4-char node short name) don't touch the edge.
+ private const val MAX_TEXT_WIDTH_RATIO = 0.72f
+ private const val CORNER_RADIUS_RATIO = 0.28f
+
+ // Height of the people pill relative to the square canvas; the rest is transparent top/bottom margin so the pill
+ // reads as a wide chip like the in-app node chip.
+ private const val PILL_HEIGHT_RATIO = 0.56f
+
+ // Corner radius of the people pill as a fraction of its height. Less than 0.5 (a full stadium) so it reads as a
+ // rounded-rectangle chip like the M3 AssistChip, not a lozenge.
+ private const val PILL_CORNER_RATIO = 0.3f
+
+ /** Wide-pill avatar with a single uppercase initial taken from [name]. */
+ fun create(name: String, backgroundColor: Int, foregroundColor: Int): IconCompat =
+ render(firstInitial(name), backgroundColor, foregroundColor, rounded = false)
+
+ /**
+ * Avatar showing a short [label] verbatim (e.g. a node's 4-character short name, or a channel number), auto-sized
+ * to fit. [rounded] draws a rounded square (channels) instead of the people pill.
+ */
+ fun createLabel(label: String, backgroundColor: Int, foregroundColor: Int, rounded: Boolean): IconCompat =
+ render(label.ifBlank { "?" }, backgroundColor, foregroundColor, rounded)
+
+ private fun firstInitial(name: String): String =
+ if (name.isEmpty()) "?" else String(Character.toChars(name.codePointAt(0))).uppercase()
+
+ private fun render(text: String, backgroundColor: Int, foregroundColor: Int, rounded: Boolean): IconCompat {
+ val bitmap = createBitmap(ICON_SIZE, ICON_SIZE)
+ val canvas = Canvas(bitmap)
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG)
+ val size = ICON_SIZE.toFloat()
+
+ paint.color = backgroundColor
+ if (rounded) {
+ val radius = ICON_SIZE * CORNER_RADIUS_RATIO
+ canvas.drawRoundRect(0f, 0f, size, size, radius, radius, paint)
+ } else {
+ // Wide rounded-rectangle chip spanning the full width, vertically centered — matches the in-app node chip.
+ val pillHeight = size * PILL_HEIGHT_RATIO
+ val top = (size - pillHeight) / 2f
+ val cap = pillHeight * PILL_CORNER_RATIO
+ canvas.drawRoundRect(0f, top, size, size - top, cap, cap, paint)
+ }
+
+ paint.color = foregroundColor
+ paint.textAlign = Paint.Align.CENTER
+ paint.textSize = ICON_SIZE * TEXT_SIZE_RATIO
+ // Shrink the text if it would overflow the icon (keeps 4-char short names inside the circle).
+ val measured = paint.measureText(text)
+ val maxWidth = ICON_SIZE * MAX_TEXT_WIDTH_RATIO
+ if (measured > maxWidth) paint.textSize *= maxWidth / measured
+
+ val xPos = canvas.width / 2f
+ val yPos = canvas.height / 2f - (paint.descent() + paint.ascent()) / 2f
+ canvas.drawText(text, xPos, yPos, paint)
+
+ return IconCompat.createWithBitmap(bitmap)
+ }
+}
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
index 3c6ed70f83..a1ade2f75a 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt
@@ -21,12 +21,14 @@ import android.content.Context
import android.content.Intent
import androidx.core.app.RemoteInput
import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.PacketRepository
@@ -37,7 +39,8 @@ import org.meshtastic.core.repository.usecase.SendMessageUseCase
*
* This receiver is triggered when a user replies to a message directly from a notification. It extracts the reply text
* and the contact key from the intent, sends the message through [SendMessageUseCase] — so notification replies get the
- * same pipeline as in-app sends (history save, durable queue, transforms) — and then cancels the notification.
+ * same pipeline as in-app sends (history save, durable queue, transforms) — and then refreshes the conversation
+ * notification so the sent reply appears in place (falling back to dismissal if the send or refresh fails).
*/
class ReplyReceiver :
BroadcastReceiver(),
@@ -74,17 +77,29 @@ class ReplyReceiver :
val pendingResult: PendingResult? = goAsync()
scope.launch {
try {
- // Send first so the reply isn't lost; then dismiss. The cancel can't break the send.
+ // Send first so the reply isn't lost; the notification update can't break the send.
sendMessageUseCase(message, contactKey)
// Replying implies the conversation has been read — mark it so, like the mark-as-read action.
// Android Auto keys notification dismissal off read state, not just cancel().
packetRepository.clearUnreadCount(contactKey, nowMillis)
Logger.i(tag = TAG) { "reply sent + marked read" }
+ // Re-post the conversation silently with the sent reply appended — the MessagingStyle confirmation
+ // flow. This resolves the RemoteInput spinner with visible feedback instead of the notification
+ // vanishing. Fall back to dismissal so the spinner never hangs if the refresh itself fails.
+ safeCatching { meshServiceNotifications.refreshConversationAfterReply(contactKey) }
+ .onFailure {
+ Logger.e(tag = TAG, throwable = it) { "refresh after reply failed" }
+ safeCatching { meshServiceNotifications.cancelMessageNotification(contactKey) }
+ }
+ } catch (e: CancellationException) {
+ // Preserve structured concurrency — never treat cancellation as a failed send.
+ throw e
} catch (e: Exception) {
Logger.e(tag = TAG, throwable = e) { "reply send failed" }
- } finally {
- runCatching { meshServiceNotifications.cancelMessageNotification(contactKey) }
+ // The send failed; dismiss so the RemoteInput spinner resolves rather than hanging forever.
+ safeCatching { meshServiceNotifications.cancelMessageNotification(contactKey) }
.onFailure { Logger.e(tag = TAG, throwable = it) { "cancel notification failed" } }
+ } finally {
pendingResult?.finish()
}
}
diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/ConversationShortcutManager.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/ConversationShortcutManager.kt
deleted file mode 100644
index ee58ea9a9c..0000000000
--- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/ConversationShortcutManager.kt
+++ /dev/null
@@ -1,190 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.feature.car.service
-
-import android.content.Context
-import android.content.Intent
-import android.content.pm.ShortcutInfo
-import androidx.core.app.Person
-import androidx.core.content.LocusIdCompat
-import androidx.core.content.pm.ShortcutInfoCompat
-import androidx.core.content.pm.ShortcutManagerCompat
-import androidx.core.net.toUri
-import co.touchlab.kermit.Logger
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.launch
-import org.koin.core.annotation.Single
-import org.meshtastic.core.model.NodeAddress
-import org.meshtastic.core.model.nodeColorsFromNum
-import org.meshtastic.core.repository.NodeRepository
-import org.meshtastic.core.repository.PacketRepository
-import org.meshtastic.core.repository.RadioConfigRepository
-import org.meshtastic.feature.car.util.PersonIconFactory
-
-/**
- * Publishes dynamic shortcuts for active DM conversations and channels so that Android Auto can surface Meshtastic
- * conversations as messaging destinations and link notifications to template conversations via [LocusIdCompat].
- */
-@Single
-class ConversationShortcutManager(
- private val context: Context,
- private val nodeRepository: NodeRepository,
- private val packetRepository: PacketRepository,
- private val radioConfigRepository: RadioConfigRepository,
-) {
-
- private var observeJob: Job? = null
-
- fun startObserving(scope: CoroutineScope) {
- observeJob?.cancel()
- observeJob =
- scope.launch {
- val dmContactsFlow =
- packetRepository
- .getContacts()
- .map { contacts ->
- // DM contacts are those whose key does NOT contain the broadcast ID
- contacts.entries
- .filter { (key, _) -> !key.contains(NodeAddress.ID_BROADCAST) }
- .sortedByDescending { (_, packet) -> packet.time }
- .map { (key, packet) -> DmContact(key, packet.from.orEmpty(), packet.time) }
- }
- .distinctUntilChanged()
-
- val channelsFlow =
- radioConfigRepository.channelSetFlow
- .map { channelSet ->
- channelSet.settings.mapIndexedNotNull { index, settings ->
- if (index == 0 || settings.name.isNotEmpty()) {
- index to settings.name
- } else {
- null
- }
- }
- }
- .distinctUntilChanged()
-
- combine(dmContactsFlow, channelsFlow) { dms, channels -> dms to channels }
- .collect { (dms, channels) -> publishShortcuts(dms, channels) }
- }
- }
-
- fun stopObserving() {
- observeJob?.cancel()
- observeJob = null
- }
-
- private fun publishShortcuts(dmContacts: List, channels: List>) {
- val shortcuts =
- dmContacts.mapNotNull { buildDmShortcut(it) } +
- channels.map { (index, name) -> buildChannelShortcut(index, name) }
-
- try {
- val limit = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context)
- val currentKeys = shortcuts.map { it.id }.toSet()
- val stale = ShortcutManagerCompat.getDynamicShortcuts(context).map { it.id }.filter { it !in currentKeys }
- if (stale.isNotEmpty()) {
- ShortcutManagerCompat.removeDynamicShortcuts(context, stale)
- }
- for (shortcut in shortcuts.take(limit)) {
- ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
- }
- Logger.d(tag = TAG) { "Published ${shortcuts.size.coerceAtMost(limit)} conversation shortcuts" }
- } catch (e: IllegalArgumentException) {
- Logger.e(tag = TAG, throwable = e) { "Failed to publish conversation shortcuts" }
- } catch (e: IllegalStateException) {
- Logger.e(tag = TAG, throwable = e) { "Failed to publish conversation shortcuts" }
- }
- }
-
- private fun buildDmShortcut(dm: DmContact): ShortcutInfoCompat? {
- val node = nodeRepository.nodeDBbyNum.value.values.find { it.user.id == dm.userId }
- val label = node?.user?.long_name?.ifEmpty { node.user.short_name } ?: dm.contactKey
- val personBuilder = Person.Builder().setName(label).setKey(dm.contactKey)
- if (node != null) {
- val (foregroundColor, backgroundColor) = nodeColorsFromNum(node.num)
- personBuilder.setIcon(PersonIconFactory.create(node.user.short_name, backgroundColor, foregroundColor))
- }
- val person = personBuilder.build()
- return ShortcutInfoCompat.Builder(context, dm.contactKey)
- .setShortLabel(label)
- .setLongLabel(label)
- .setLocusId(LocusIdCompat(dm.contactKey))
- .setPerson(person)
- .setLongLived(true)
- .setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
- .setIntent(conversationIntent(dm.contactKey))
- .build()
- }
-
- private fun buildChannelShortcut(index: Int, name: String): ShortcutInfoCompat {
- val contactKey = "${index}${NodeAddress.ID_BROADCAST}"
- val channelName = name.ifEmpty { "Primary Channel" }
- val person = Person.Builder().setName(channelName).setKey("channel-$index").build()
- return ShortcutInfoCompat.Builder(context, contactKey)
- .setShortLabel(channelName)
- .setLongLabel(channelName)
- .setLocusId(LocusIdCompat(contactKey))
- .setPerson(person)
- .setLongLived(true)
- .setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
- .setIntent(conversationIntent(contactKey))
- .build()
- }
-
- private fun conversationIntent(contactKey: String): Intent =
- Intent(Intent.ACTION_VIEW, "meshtastic://messages/$contactKey".toUri()).apply {
- setPackage(context.packageName)
- }
-
- /**
- * Ensures a long-lived conversation shortcut exists for [contactKey]. Called on demand when a notification is about
- * to reference a shortcut ID that may not have been pre-published.
- */
- fun ensureConversationShortcut(contactKey: String, displayName: String) {
- val alreadyPublished = ShortcutManagerCompat.getDynamicShortcuts(context).any { it.id == contactKey }
- if (alreadyPublished) return
- val person = Person.Builder().setName(displayName).setKey(contactKey).build()
- val shortcut =
- ShortcutInfoCompat.Builder(context, contactKey)
- .setShortLabel(displayName)
- .setLongLabel(displayName)
- .setLocusId(LocusIdCompat(contactKey))
- .setPerson(person)
- .setLongLived(true)
- .setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
- .setIntent(conversationIntent(contactKey))
- .build()
- try {
- ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
- } catch (e: IllegalArgumentException) {
- Logger.e(tag = TAG, throwable = e) { "Failed to publish on-demand shortcut $contactKey" }
- } catch (e: IllegalStateException) {
- Logger.e(tag = TAG, throwable = e) { "Failed to publish on-demand shortcut $contactKey" }
- }
- }
-
- private data class DmContact(val contactKey: String, val userId: String, val lastMessageTime: Long)
-
- companion object {
- private const val TAG = "ConversationShortcuts"
- }
-}
diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarSession.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarSession.kt
index 225d4a42b7..41c518dc4b 100644
--- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarSession.kt
+++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/MeshtasticCarSession.kt
@@ -22,10 +22,6 @@ import androidx.car.app.Screen
import androidx.car.app.Session
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.cancel
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.meshtastic.feature.car.alerts.EmergencyHandler
@@ -39,13 +35,10 @@ class MeshtasticCarSession :
private val crashlyticsCarTagger: CrashlyticsCarTagger by inject()
private val stateCoordinator: CarStateCoordinator by inject()
private val emergencyHandler: EmergencyHandler by inject()
- private val conversationShortcutManager: ConversationShortcutManager by inject()
- private val sessionScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
override fun onCreateScreen(intent: Intent): Screen {
crashlyticsCarTagger.setCarSession(true)
stateCoordinator.start()
- conversationShortcutManager.startObserving(sessionScope)
emergencyHandler.startCollecting(stateCoordinator.emergencyAlerts)
lifecycle.addObserver(
@@ -68,8 +61,6 @@ class MeshtasticCarSession :
}
private fun destroy() {
- conversationShortcutManager.stopObserving()
- sessionScope.cancel()
emergencyHandler.stopCollecting()
stateCoordinator.destroy()
crashlyticsCarTagger.setCarSession(false)
diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/PersonIconFactory.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/PersonIconFactory.kt
deleted file mode 100644
index bde0b65dfd..0000000000
--- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/PersonIconFactory.kt
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * Copyright (c) 2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.feature.car.util
-
-import android.graphics.Canvas
-import android.graphics.Paint
-import androidx.core.graphics.createBitmap
-import androidx.core.graphics.drawable.IconCompat
-
-/**
- * Renders a circular avatar with a single uppercase initial — used for [androidx.core.app.Person] icons in
- * MessagingStyle notifications and for conversation shortcut avatars.
- */
-internal object PersonIconFactory {
-
- private const val ICON_SIZE = 128
- private const val TEXT_SIZE_RATIO = 0.5f
-
- fun create(name: String, backgroundColor: Int, foregroundColor: Int): IconCompat {
- val bitmap = createBitmap(ICON_SIZE, ICON_SIZE)
- val canvas = Canvas(bitmap)
- val paint = Paint(Paint.ANTI_ALIAS_FLAG)
-
- paint.color = backgroundColor
- canvas.drawCircle(ICON_SIZE / 2f, ICON_SIZE / 2f, ICON_SIZE / 2f, paint)
-
- paint.color = foregroundColor
- paint.textSize = ICON_SIZE * 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)
- }
-}