From 8a73bfb7101a68b90c9827520963e3a2c2780ccf Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 14:01:03 -0400 Subject: [PATCH 1/2] fix(chat): cancel a group's summary with its last chat notification A chat push with a group key posts the chat's notification and a group summary under groupKey.hashCode(). The summary has no title or text; the system only folds it away while it has a child. dismissNotifications cancelled chatId.hashCode() and nothing else, so opening the chat in the app or tapping Mark as read left the summary on screen as an empty notification. The next push in that group gave it a child again, which is when it turned into the group chat notification. dismissNotifications now reads the active notifications and also cancels the group's summary when the dismissed chat was its last child. planNotificationDismissal holds that rule and finds the summary by FLAG_GROUP_SUMMARY rather than recomputing its id, so it does not depend on how NotificationService numbers summaries. --- .../internal/NotificationDismissalPlanner.kt | 36 +++++++++++ .../internal/delegates/MessagingDelegate.kt | 12 +++- .../NotificationDismissalPlannerTest.kt | 60 +++++++++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/NotificationDismissalPlanner.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/internal/NotificationDismissalPlannerTest.kt diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/NotificationDismissalPlanner.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/NotificationDismissalPlanner.kt new file mode 100644 index 0000000000..fa2a02b931 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/NotificationDismissalPlanner.kt @@ -0,0 +1,36 @@ +package com.flipcash.shared.chat.internal + +/** A notification this app has on screen, reduced to what deciding a dismissal needs. */ +internal data class PostedNotification( + val id: Int, + val group: String?, + val isGroupSummary: Boolean, +) + +/** + * The notification ids to cancel when a chat's notification is dismissed. + * + * Always the chat's own [chatNotificationId], and its group's summary too when the chat was the + * last member of that group. The summary a chat push posts has no title or text of its own: the + * platform only folds it away while it has a child to show, and cancelling the child from the app + * does not take the summary with it. Left behind, it renders as an empty notification until the + * next push in the group gives it a child again. + * + * The summary is found by its flag rather than by recomputing the id it was posted under, so this + * does not depend on how the push side numbers summaries. + * + * @param posted the app's active notifications, as read just before the cancel + */ +internal fun planNotificationDismissal( + chatNotificationId: Int, + posted: List, +): List { + val group = posted.firstOrNull { it.id == chatNotificationId && !it.isGroupSummary }?.group + ?: return listOf(chatNotificationId) + + val inGroup = posted.filter { it.group == group } + val othersRemain = inGroup.any { !it.isGroupSummary && it.id != chatNotificationId } + if (othersRemain) return listOf(chatNotificationId) + + return listOf(chatNotificationId) + inGroup.filter { it.isGroupSummary }.map { it.id } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index 4e690fc6db..6ab8a957dc 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -2,6 +2,7 @@ package com.flipcash.shared.chat.internal.delegates +import android.app.Notification import androidx.core.app.NotificationManagerCompat import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager @@ -34,6 +35,8 @@ import com.flipcash.shared.chat.ChatMembership import com.flipcash.shared.chat.MessagingOperations import com.flipcash.shared.chat.PendingMutation import com.flipcash.shared.chat.internal.ChatStateHolder +import com.flipcash.shared.chat.internal.PostedNotification +import com.flipcash.shared.chat.internal.planNotificationDismissal import com.flipcash.shared.chat.replacingText import com.flipcash.services.user.UserManager import com.getcode.opencode.model.core.ID @@ -119,7 +122,14 @@ class MessagingDelegate @Inject constructor( } override fun dismissNotifications(chatId: ChatId) { - notificationManager.cancel(chatId.hashCode()) + val posted = notificationManager.activeNotifications.map { + PostedNotification( + id = it.id, + group = it.notification.group, + isGroupSummary = (it.notification.flags and Notification.FLAG_GROUP_SUMMARY) != 0, + ) + } + planNotificationDismissal(chatId.hashCode(), posted).forEach(notificationManager::cancel) } override suspend fun hydrateChat(chatId: ChatId): ChatMembership? { diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/internal/NotificationDismissalPlannerTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/internal/NotificationDismissalPlannerTest.kt new file mode 100644 index 0000000000..b49f932f35 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/internal/NotificationDismissalPlannerTest.kt @@ -0,0 +1,60 @@ +package com.flipcash.shared.chat.internal + +import kotlin.test.Test +import kotlin.test.assertEquals + +class NotificationDismissalPlannerTest { + + private val chat = 1 + private val summary = 100 + + @Test + fun `last chat in a group takes its summary with it`() { + // A summary has no content of its own, so left behind it renders as an empty notification. + val posted = listOf( + PostedNotification(id = chat, group = "g", isGroupSummary = false), + PostedNotification(id = summary, group = "g", isGroupSummary = true), + ) + + assertEquals(listOf(chat, summary), planNotificationDismissal(chat, posted)) + } + + @Test + fun `summary stays while another chat is still in its group`() { + val posted = listOf( + PostedNotification(id = chat, group = "g", isGroupSummary = false), + PostedNotification(id = 2, group = "g", isGroupSummary = false), + PostedNotification(id = summary, group = "g", isGroupSummary = true), + ) + + assertEquals(listOf(chat), planNotificationDismissal(chat, posted)) + } + + @Test + fun `ungrouped chat cancels only itself`() { + val posted = listOf( + PostedNotification(id = chat, group = null, isGroupSummary = false), + PostedNotification(id = summary, group = "g", isGroupSummary = true), + ) + + assertEquals(listOf(chat), planNotificationDismissal(chat, posted)) + } + + @Test + fun `chat with nothing on screen still cancels its own id`() { + // Cancelling an id that isn't posted is a no-op, and the list may be stale by the time + // the cancel lands. + assertEquals(listOf(chat), planNotificationDismissal(chat, emptyList())) + } + + @Test + fun `summaries of other groups are left alone`() { + val posted = listOf( + PostedNotification(id = chat, group = "g", isGroupSummary = false), + PostedNotification(id = summary, group = "g", isGroupSummary = true), + PostedNotification(id = 200, group = "other", isGroupSummary = true), + ) + + assertEquals(listOf(chat, summary), planNotificationDismissal(chat, posted)) + } +} From e6f86981963c6f91e5b28ff1548af2521c67d48c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 14:12:56 -0400 Subject: [PATCH 2/2] fix(notifications): stop grouping chat notifications Each chat posted its notification under its own group key, plus a group summary with no title or text. That group only ever held one child, and Android 16 force-groups that shape: it cancels the summary and moves the child into a system group. Every new message moved the child back out for about three seconds, and during that window the empty summary showed on its own before turning into the chat notification. Chat notifications are now posted with no group and no summary. planNotificationGroup makes the call; non-chat pushes such as CONTACT_JOIN keep the payload's key, since they post a new notification each time. The Reply and Mark as read actions no longer carry the group key. This drops the summary cancel from dismissNotifications that the previous commit added: with no chat summaries posted, there is nothing for it to cancel. --- .../internal/NotificationDismissalPlanner.kt | 36 ----------- .../internal/delegates/MessagingDelegate.kt | 12 +--- .../NotificationDismissalPlannerTest.kt | 60 ------------------- .../NotificationActionReceiver.kt | 16 ++--- .../notifications/NotificationGroupPlanner.kt | 21 +++++++ .../app/notifications/NotificationService.kt | 20 +++---- .../NotificationGroupPlannerTest.kt | 28 +++++++++ 7 files changed, 64 insertions(+), 129 deletions(-) delete mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/NotificationDismissalPlanner.kt delete mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/internal/NotificationDismissalPlannerTest.kt create mode 100644 apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationGroupPlanner.kt create mode 100644 apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/NotificationGroupPlannerTest.kt diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/NotificationDismissalPlanner.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/NotificationDismissalPlanner.kt deleted file mode 100644 index fa2a02b931..0000000000 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/NotificationDismissalPlanner.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.flipcash.shared.chat.internal - -/** A notification this app has on screen, reduced to what deciding a dismissal needs. */ -internal data class PostedNotification( - val id: Int, - val group: String?, - val isGroupSummary: Boolean, -) - -/** - * The notification ids to cancel when a chat's notification is dismissed. - * - * Always the chat's own [chatNotificationId], and its group's summary too when the chat was the - * last member of that group. The summary a chat push posts has no title or text of its own: the - * platform only folds it away while it has a child to show, and cancelling the child from the app - * does not take the summary with it. Left behind, it renders as an empty notification until the - * next push in the group gives it a child again. - * - * The summary is found by its flag rather than by recomputing the id it was posted under, so this - * does not depend on how the push side numbers summaries. - * - * @param posted the app's active notifications, as read just before the cancel - */ -internal fun planNotificationDismissal( - chatNotificationId: Int, - posted: List, -): List { - val group = posted.firstOrNull { it.id == chatNotificationId && !it.isGroupSummary }?.group - ?: return listOf(chatNotificationId) - - val inGroup = posted.filter { it.group == group } - val othersRemain = inGroup.any { !it.isGroupSummary && it.id != chatNotificationId } - if (othersRemain) return listOf(chatNotificationId) - - return listOf(chatNotificationId) + inGroup.filter { it.isGroupSummary }.map { it.id } -} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index 6ab8a957dc..4e690fc6db 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -2,7 +2,6 @@ package com.flipcash.shared.chat.internal.delegates -import android.app.Notification import androidx.core.app.NotificationManagerCompat import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager @@ -35,8 +34,6 @@ import com.flipcash.shared.chat.ChatMembership import com.flipcash.shared.chat.MessagingOperations import com.flipcash.shared.chat.PendingMutation import com.flipcash.shared.chat.internal.ChatStateHolder -import com.flipcash.shared.chat.internal.PostedNotification -import com.flipcash.shared.chat.internal.planNotificationDismissal import com.flipcash.shared.chat.replacingText import com.flipcash.services.user.UserManager import com.getcode.opencode.model.core.ID @@ -122,14 +119,7 @@ class MessagingDelegate @Inject constructor( } override fun dismissNotifications(chatId: ChatId) { - val posted = notificationManager.activeNotifications.map { - PostedNotification( - id = it.id, - group = it.notification.group, - isGroupSummary = (it.notification.flags and Notification.FLAG_GROUP_SUMMARY) != 0, - ) - } - planNotificationDismissal(chatId.hashCode(), posted).forEach(notificationManager::cancel) + notificationManager.cancel(chatId.hashCode()) } override suspend fun hydrateChat(chatId: ChatId): ChatMembership? { diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/internal/NotificationDismissalPlannerTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/internal/NotificationDismissalPlannerTest.kt deleted file mode 100644 index b49f932f35..0000000000 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/internal/NotificationDismissalPlannerTest.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.flipcash.shared.chat.internal - -import kotlin.test.Test -import kotlin.test.assertEquals - -class NotificationDismissalPlannerTest { - - private val chat = 1 - private val summary = 100 - - @Test - fun `last chat in a group takes its summary with it`() { - // A summary has no content of its own, so left behind it renders as an empty notification. - val posted = listOf( - PostedNotification(id = chat, group = "g", isGroupSummary = false), - PostedNotification(id = summary, group = "g", isGroupSummary = true), - ) - - assertEquals(listOf(chat, summary), planNotificationDismissal(chat, posted)) - } - - @Test - fun `summary stays while another chat is still in its group`() { - val posted = listOf( - PostedNotification(id = chat, group = "g", isGroupSummary = false), - PostedNotification(id = 2, group = "g", isGroupSummary = false), - PostedNotification(id = summary, group = "g", isGroupSummary = true), - ) - - assertEquals(listOf(chat), planNotificationDismissal(chat, posted)) - } - - @Test - fun `ungrouped chat cancels only itself`() { - val posted = listOf( - PostedNotification(id = chat, group = null, isGroupSummary = false), - PostedNotification(id = summary, group = "g", isGroupSummary = true), - ) - - assertEquals(listOf(chat), planNotificationDismissal(chat, posted)) - } - - @Test - fun `chat with nothing on screen still cancels its own id`() { - // Cancelling an id that isn't posted is a no-op, and the list may be stale by the time - // the cancel lands. - assertEquals(listOf(chat), planNotificationDismissal(chat, emptyList())) - } - - @Test - fun `summaries of other groups are left alone`() { - val posted = listOf( - PostedNotification(id = chat, group = "g", isGroupSummary = false), - PostedNotification(id = summary, group = "g", isGroupSummary = true), - PostedNotification(id = 200, group = "other", isGroupSummary = true), - ) - - assertEquals(listOf(chat, summary), planNotificationDismissal(chat, posted)) - } -} diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationActionReceiver.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationActionReceiver.kt index 0c2933d5dc..d63372c6af 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationActionReceiver.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationActionReceiver.kt @@ -32,7 +32,6 @@ class NotificationActionReceiver : BroadcastReceiver() { const val ACTION_MARK_READ = "com.flipcash.app.notifications.ACTION_MARK_READ" const val KEY_CHAT_ID_HEX = "chat_id_hex" const val KEY_NOTIFICATION_ID = "notification_id" - const val KEY_GROUP_KEY = "group_key" const val KEY_TEXT_REPLY = "key_text_reply" } @@ -70,8 +69,6 @@ class NotificationActionReceiver : BroadcastReceiver() { ?.toString() ?: return - val groupKey = intent.getStringExtra(KEY_GROUP_KEY) - chatCoordinator.sendMessage(chatId, replyText) .onSuccess { val notificationId = intent.getIntExtra(KEY_NOTIFICATION_ID, chatId.hashCode()) @@ -96,12 +93,9 @@ class NotificationActionReceiver : BroadcastReceiver() { .setColor(context.getColor(R.color.notification_color)) .setStyle(existingStyle) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) - .addAction(buildReplyAction(context, chatId, notificationId, groupKey)) - .addAction(buildMarkAsReadAction(context, chatId, groupKey)) + .addAction(buildReplyAction(context, chatId, notificationId)) + .addAction(buildMarkAsReadAction(context, chatId)) .setAutoCancel(true) - .apply { - if (groupKey != null) setGroup(groupKey) - } .build() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && @@ -129,7 +123,7 @@ class NotificationActionReceiver : BroadcastReceiver() { } @OptIn(ExperimentalStdlibApi::class) - private fun buildReplyAction(context: Context, chatId: ChatId, notificationId: Int, groupKey: String?): NotificationCompat.Action { + private fun buildReplyAction(context: Context, chatId: ChatId, notificationId: Int): NotificationCompat.Action { val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY) .setLabel(context.getString(R.string.notification_action_reply)) .build() @@ -138,7 +132,6 @@ class NotificationActionReceiver : BroadcastReceiver() { action = ACTION_REPLY putExtra(KEY_CHAT_ID_HEX, chatId.bytes.toHexString()) putExtra(KEY_NOTIFICATION_ID, notificationId) - if (groupKey != null) putExtra(KEY_GROUP_KEY, groupKey) } val pendingIntent = PendingIntent.getBroadcast( @@ -156,11 +149,10 @@ class NotificationActionReceiver : BroadcastReceiver() { } @OptIn(ExperimentalStdlibApi::class) - private fun buildMarkAsReadAction(context: Context, chatId: ChatId, groupKey: String?): NotificationCompat.Action { + private fun buildMarkAsReadAction(context: Context, chatId: ChatId): NotificationCompat.Action { val intent = Intent(context, NotificationActionReceiver::class.java).apply { action = ACTION_MARK_READ putExtra(KEY_CHAT_ID_HEX, chatId.bytes.toHexString()) - if (groupKey != null) putExtra(KEY_GROUP_KEY, groupKey) } val pendingIntent = PendingIntent.getBroadcast( diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationGroupPlanner.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationGroupPlanner.kt new file mode 100644 index 0000000000..95ed1cfd2e --- /dev/null +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationGroupPlanner.kt @@ -0,0 +1,21 @@ +package com.flipcash.app.notifications + +/** + * The group a notification is posted under, or null to post it ungrouped with no summary. + * + * A chat is never grouped. It posts one notification, under its chat id, and folds every new + * message into it, so a group of its own would only ever hold that one child. Android 16 force + * groups exactly that shape: it cancels the app's summary and moves the child into a system + * group, and every new message moves the child back out for a few seconds before it is moved in + * again. The summary has no content, so for those seconds it shows as an empty notification. + * + * Other pushes keep the payload's key, because they post a new notification each time and several + * can share one group. + * + * @param payloadGroupKey `NotificationPayload.groupKey`, where blank means none + * @param isChat whether the notification is rendered as a chat conversation + */ +fun planNotificationGroup(payloadGroupKey: String?, isChat: Boolean): String? { + if (isChat) return null + return payloadGroupKey?.takeIf { it.isNotEmpty() } +} diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt index 2458a1c60d..a090fc7ba3 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt @@ -270,7 +270,9 @@ class NotificationService : FirebaseMessagingService(), ) } + val isChat = chatId != null && styling != null val groupKey = payload?.groupKey?.takeIf { it.isNotEmpty() } + val group = planNotificationGroup(payloadGroupKey = groupKey, isChat = isChat) val builder = NotificationCompat.Builder(this, channel.id) .setPriority(NotificationCompat.PRIORITY_HIGH) @@ -280,7 +282,7 @@ class NotificationService : FirebaseMessagingService(), .setAutoCancel(true) .setContentIntent(buildContentIntent(styling?.chatType, payload?.navigation)) .apply { - if (groupKey != null) setGroup(groupKey) + if (group != null) setGroup(group) } val notificationId = if (chatId != null && styling != null) { @@ -292,16 +294,16 @@ class NotificationService : FirebaseMessagingService(), notificationManager.notify(notificationId, builder.build()) - if (groupKey != null) { + if (group != null) { val summary = NotificationCompat.Builder(this, channel.id) .setSmallIcon(R.drawable.flipcash_logo) .setColor(getColor(R.color.notification_color)) - .setGroup(groupKey) + .setGroup(group) .setGroupSummary(true) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) .setAutoCancel(true) .build() - notificationManager.notify(groupKey.hashCode(), summary) + notificationManager.notify(group.hashCode(), summary) } } @@ -413,8 +415,8 @@ class NotificationService : FirebaseMessagingService(), setStyle(style) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) - .addAction(buildReplyAction(chatId, notificationId, groupKey)) - .addAction(buildMarkAsReadAction(chatId, groupKey)) + .addAction(buildReplyAction(chatId, notificationId)) + .addAction(buildMarkAsReadAction(chatId)) return notificationId } @@ -529,7 +531,7 @@ class NotificationService : FirebaseMessagingService(), } @OptIn(ExperimentalStdlibApi::class) - private fun buildReplyAction(chatId: ChatId, notificationId: Int, groupKey: String?): NotificationCompat.Action { + private fun buildReplyAction(chatId: ChatId, notificationId: Int): NotificationCompat.Action { val remoteInput = RemoteInput.Builder(NotificationActionReceiver.KEY_TEXT_REPLY) .setLabel(getString(R.string.notification_action_reply)) .build() @@ -538,7 +540,6 @@ class NotificationService : FirebaseMessagingService(), action = NotificationActionReceiver.ACTION_REPLY putExtra(NotificationActionReceiver.KEY_CHAT_ID_HEX, chatId.bytes.toHexString()) putExtra(NotificationActionReceiver.KEY_NOTIFICATION_ID, notificationId) - if (groupKey != null) putExtra(NotificationActionReceiver.KEY_GROUP_KEY, groupKey) } val pendingIntent = PendingIntent.getBroadcast( @@ -556,11 +557,10 @@ class NotificationService : FirebaseMessagingService(), } @OptIn(ExperimentalStdlibApi::class) - private fun buildMarkAsReadAction(chatId: ChatId, groupKey: String?): NotificationCompat.Action { + private fun buildMarkAsReadAction(chatId: ChatId): NotificationCompat.Action { val intent = Intent(this, NotificationActionReceiver::class.java).apply { action = NotificationActionReceiver.ACTION_MARK_READ putExtra(NotificationActionReceiver.KEY_CHAT_ID_HEX, chatId.bytes.toHexString()) - if (groupKey != null) putExtra(NotificationActionReceiver.KEY_GROUP_KEY, groupKey) } val pendingIntent = PendingIntent.getBroadcast( diff --git a/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/NotificationGroupPlannerTest.kt b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/NotificationGroupPlannerTest.kt new file mode 100644 index 0000000000..62b1a2e299 --- /dev/null +++ b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/NotificationGroupPlannerTest.kt @@ -0,0 +1,28 @@ +package com.flipcash.app.notifications + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class NotificationGroupPlannerTest { + + @Test + fun `chat notification is not grouped`() { + // One notification per chat, so its group would only ever hold that one. Android 16 cancels + // the summary of a group like that and regroups the child, and the summary shows empty + // in between. + assertNull(planNotificationGroup(payloadGroupKey = "chat-a", isChat = true)) + } + + @Test + fun `non-chat notification keeps the payload's group`() { + // Pushes such as CONTACT_JOIN post a fresh notification each time under one key. + assertEquals("CONTACT_JOIN", planNotificationGroup(payloadGroupKey = "CONTACT_JOIN", isChat = false)) + } + + @Test + fun `blank group key is no group`() { + assertNull(planNotificationGroup(payloadGroupKey = "", isChat = false)) + assertNull(planNotificationGroup(payloadGroupKey = null, isChat = false)) + } +}