From eb3a27a3d3f25b560b8e453dd1fcce728159dd73 Mon Sep 17 00:00:00 2001 From: James Rich Date: Fri, 17 Apr 2026 09:44:08 -0500 Subject: [PATCH] feat(auto): append outgoing reply to MessagingStyle for brief confirmation Before cancelling a conversation notification in response to an inline reply, post one final update that appends the outgoing text to the MessagingStyle history, attributed to the local user. This gives assistants such as Android Auto a tick to observe the sent message in the notification's message history and surface a 'reply sent' style confirmation before markConversationRead cancels the notification. Extract the 'me' Person construction into buildMePerson() and share it between showGroupSummary and createConversationNotification. The conversation builder now optionally takes an extraOutgoingMessage which is appended to the MessagingStyle (actions and when-timestamp continue to be anchored on the last incoming message). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../org/meshtastic/app/service/Fakes.kt | 2 + .../repository/MeshServiceNotifications.kt | 7 ++ .../service/MeshServiceNotificationsImpl.kt | 65 ++++++++++++++----- .../meshtastic/core/service/ReplyReceiver.kt | 1 + .../testing/FakeMeshServiceNotifications.kt | 2 + .../DesktopMeshServiceNotifications.kt | 4 ++ 6 files changed, 65 insertions(+), 16 deletions(-) diff --git a/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt b/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt index 46d5ed27c..05562bb9d 100644 --- a/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt +++ b/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt @@ -76,6 +76,8 @@ class FakeMeshServiceNotifications : MeshServiceNotifications { override suspend fun markConversationRead(contactKey: String) {} + override suspend fun appendOutgoingMessage(contactKey: String, text: String) {} + override fun cancelLowBatteryNotification(node: Node) {} override fun clearClientNotification(notification: ClientNotification) {} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt index 1216f29a3..b73b44ab0 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/MeshServiceNotifications.kt @@ -74,6 +74,13 @@ interface MeshServiceNotifications { */ suspend fun markConversationRead(contactKey: String) + /** + * Appends an outgoing [text] message attributed to the local user to the currently posted conversation notification + * for [contactKey]. Used so that assistants such as Android Auto can briefly observe the reply in the + * MessagingStyle history before the notification is cancelled. No-op when there is nothing to update. + */ + suspend fun appendOutgoingMessage(contactKey: String, text: String) + fun cancelLowBatteryNotification(node: Node) fun clearClientNotification(notification: ClientNotification) diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt index 97ff46766..bc982fa25 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshServiceNotificationsImpl.kt @@ -440,20 +440,23 @@ class MeshServiceNotificationsImpl( showGroupSummary() } + private fun buildMePerson(): Person { + val ourNode = nodeRepository.value.ourNodeInfo.value + val meName = ourNode?.user?.long_name ?: getString(Res.string.you) + return Person.Builder() + .setName(meName) + .setKey(ourNode?.user?.id ?: DataPacket.ID_LOCAL) + .apply { ourNode?.let { setIcon(createPersonIcon(meName, it.colors.second, it.colors.first)) } } + .build() + } + private fun showGroupSummary() { val activeNotifications = notificationManager.activeNotifications.filter { it.id != SUMMARY_ID && it.notification.group == GROUP_KEY_MESSAGES } - val ourNode = nodeRepository.value.ourNodeInfo.value - val meName = ourNode?.user?.long_name ?: getString(Res.string.you) - val me = - Person.Builder() - .setName(meName) - .setKey(ourNode?.user?.id ?: DataPacket.ID_LOCAL) - .apply { ourNode?.let { setIcon(createPersonIcon(meName, it.colors.second, it.colors.first)) } } - .build() + val me = buildMePerson() val messagingStyle = NotificationCompat.MessagingStyle(me) @@ -520,6 +523,39 @@ class MeshServiceNotificationsImpl( cancelMessageNotification(contactKey) } + override suspend fun appendOutgoingMessage(contactKey: String, text: String) { + if (text.isEmpty()) return + val ourNode = nodeRepository.value.ourNodeInfo.value + val history = + packetRepository.value + .getMessagesFrom(contactKey, includeFiltered = false) { nodeId -> + if (nodeId == DataPacket.ID_LOCAL) { + ourNode ?: nodeRepository.value.getNode(nodeId) + } else { + nodeRepository.value.getNode(nodeId ?: "") + } + } + .first() + + val unread = history.filter { !it.read } + if (unread.isEmpty()) return + val displayHistory = unread.take(MAX_HISTORY_MESSAGES).reversed() + + val dest = if (contactKey.isNotEmpty()) contactKey.substring(1) else contactKey + val isBroadcast = dest == DataPacket.ID_BROADCAST + + val notification = + createConversationNotification( + contactKey = contactKey, + isBroadcast = isBroadcast, + channelName = null, + history = displayHistory, + isSilent = true, + extraOutgoingMessage = text, + ) + notificationManager.notify(contactKey.hashCode(), notification) + } + override fun cancelLowBatteryNotification(node: Node) = notificationManager.cancel(node.num) override fun clearClientNotification(notification: ClientNotification) = @@ -561,6 +597,7 @@ class MeshServiceNotificationsImpl( channelName: String?, history: List, isSilent: Boolean = false, + extraOutgoingMessage: String? = null, ): Notification { val type = if (isBroadcast) NotificationType.BroadcastMessage else NotificationType.DirectMessage val builder = commonBuilder(type, createOpenMessageIntent(contactKey)) @@ -569,14 +606,7 @@ class MeshServiceNotificationsImpl( builder.setSilent(true) } - val ourNode = nodeRepository.value.ourNodeInfo.value - val meName = ourNode?.user?.long_name ?: getString(Res.string.you) - val me = - Person.Builder() - .setName(meName) - .setKey(ourNode?.user?.id ?: DataPacket.ID_LOCAL) - .apply { ourNode?.let { setIcon(createPersonIcon(meName, it.colors.second, it.colors.first)) } } - .build() + val me = buildMePerson() val style = NotificationCompat.MessagingStyle(me) @@ -621,6 +651,9 @@ class MeshServiceNotificationsImpl( ) } } + if (!extraOutgoingMessage.isNullOrEmpty()) { + style.addMessage(extraOutgoingMessage, nowMillis, me) + } val lastMessage = history.last() ensureShortcutForNotification(contactKey, isBroadcast, channelName, lastMessage) 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 8ab6590b7..b00c3c255 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 @@ -65,6 +65,7 @@ class ReplyReceiver : scope.launch { try { sendMessage(message, contactKey) + meshServiceNotifications.appendOutgoingMessage(contactKey, message) meshServiceNotifications.markConversationRead(contactKey) } finally { pendingResult.finish() diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt index 923d2e8aa..e1c1c7659 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeMeshServiceNotifications.kt @@ -69,6 +69,8 @@ class FakeMeshServiceNotifications : MeshServiceNotifications { override suspend fun markConversationRead(contactKey: String) {} + override suspend fun appendOutgoingMessage(contactKey: String, text: String) {} + override fun cancelLowBatteryNotification(node: Node) {} override fun clearClientNotification(notification: ClientNotification) {} diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt index fd30a5be0..f2ad6ca3e 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/notification/DesktopMeshServiceNotifications.kt @@ -158,6 +158,10 @@ class DesktopMeshServiceNotifications(private val notificationManager: Notificat notificationManager.cancel(contactKey.hashCode()) } + override suspend fun appendOutgoingMessage(contactKey: String, text: String) { + // No-op: desktop tray notifications don't carry MessagingStyle history to augment. + } + override fun cancelLowBatteryNotification(node: Node) { notificationManager.cancel(node.num) }