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) - } -}