feat(notifications): align message notifications with Android best practices (#6371)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James RichandClaude Opus 4.8 authored and GitHub committed 2026-07-22 23:28:29 +00:00
1 parent aca4321fd0
commit d12fc8ebcf
13 files changed
+1000 -329

No files matched your search

@@ -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)
@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<MyNodeInfo?>(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)
}
}
@@ -59,6 +59,8 @@ class MeshNotificationManagerImplTest {
context = context,
packetRepository = lazy<PacketRepository> { error("Not used in this test") },
nodeRepository = lazy<NodeRepository> { error("Not used in this test") },
conversationShortcutPublisher = lazy { error("Not used in this test") },
radioConfigRepository = lazy { error("Not used in this test") },
)
notifications.initChannels()
@@ -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
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<String>()
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<String, DataPacket>,
channelSet: ChannelSet,
): List<Conversation> {
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<Conversation>) {
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"
}
}
@@ -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<PacketRepository>,
private val nodeRepository: Lazy<NodeRepository>,
private val conversationShortcutPublisher: Lazy<ConversationShortcutPublisher>,
private val radioConfigRepository: Lazy<RadioConfigRepository>,
) : 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<String, IconCompat>()
/** 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 "<channelIndex><userId>" 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)
@@ -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()
@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
@@ -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()
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<DmContact>, channels: List<Pair<Int, String>>) {
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"
}
}
@@ -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)
@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}