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