mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-09-15 06:39:33 -04:00
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:
1 parent
aca4321fd0
commit
d12fc8ebcf
13 files changed
+1000
-329
No files matched your search
+8
@@ -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)
|
||||
|
||||
+166
@@ -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)
|
||||
}
|
||||
}
|
||||
+240
@@ -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)
|
||||
}
|
||||
}
|
||||
+2
@@ -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()
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
+301
@@ -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"
|
||||
}
|
||||
}
|
||||
+157
-67
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
-190
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user