From 5e7814ba26872218352f390dba4df9cfcd375393 Mon Sep 17 00:00:00 2001 From: scueZ Date: Sat, 18 Jul 2026 21:19:26 +0530 Subject: [PATCH] Preserve bubbles when cancelling notifications. Cancelling a notification also dismisses its bubble. Collapsed bubble notifications are now re-posted with suppression instead, which clears the shade and badge while keeping the bubble. Bubble notifications report as not cancelled so the group summary, whose cancellation can take its bubbled children down with it, is kept alive. Fixes #14699 --- .../notifications/MessageNotifier.java | 1 + .../NotificationCancellationHelper.java | 108 ++++++++++++++---- .../OptimizedMessageNotifier.java | 5 + .../v2/DefaultMessageNotifier.kt | 2 + 4 files changed, 91 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/MessageNotifier.java index edce4fce204..21da676b9ef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MessageNotifier.java @@ -22,6 +22,7 @@ public interface MessageNotifier { void clearVisibleThread(@NonNull ConversationId conversationId); void setVisibleBubbleThread(@Nullable ConversationId conversationId); void clearVisibleBubbleThread(); + @Nullable ConversationId getVisibleBubbleThread(); void notifyMessageDeliveryFailed(@NonNull Context context, @NonNull Recipient recipient, @NonNull ConversationId conversationId); void notifyStoryDeliveryFailed(@NonNull Context context, @NonNull Recipient recipient, @NonNull ConversationId conversationId); void notifyProofRequired(@NonNull Context context, @NonNull Recipient recipient, @NonNull ConversationId conversationId); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationCancellationHelper.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationCancellationHelper.java index e52e3a91295..7e7821e7a5d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationCancellationHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationCancellationHelper.java @@ -15,7 +15,6 @@ import org.thoughtcrime.securesms.notifications.v2.ConversationId; import org.thoughtcrime.securesms.notifications.v2.DefaultMessageNotifier; import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.util.BubbleUtil; import org.thoughtcrime.securesms.util.ConversationUtil; import org.signal.core.util.ServiceUtil; @@ -111,8 +110,9 @@ private static boolean isSingleThreadNotification(@NonNull StatusBarNotification } /** - * Attempts to cancel the given notification. If the notification is allowed to be displayed as a - * bubble, we do not cancel it. + * Attempts to cancel the given notification. Notifications tied to an expanded bubble are left + * untouched; notifications tied to a collapsed bubble are suppressed rather than cancelled, so + * the bubble itself is preserved. * * @return Whether or not the notification is considered cancelled. */ @@ -138,26 +138,16 @@ public static void cancelLegacy(@NonNull Context context, int notificationId) { * Cancel method which first checks whether the notification in question is tied to a bubble that * may or may not be displayed by the user. * - * @return true if the notification was cancelled. + * Cancelling a notification also dismisses its bubble, so collapsed bubble notifications are + * re-posted with suppression instead: removed from the shade and badge cleared, bubble kept. + * + * @return true if the notification was cancelled. Bubble notifications are reported as not + * cancelled so the summary stays alive; cancelling the summary can cancel its bubbled children. */ @RequiresApi(ConversationUtil.CONVERSATION_SUPPORT_VERSION) private static boolean cancelWithConversationSupport(@NonNull Context context, int notificationId) { Log.d(TAG, "cancelWithConversationSupport() called with: notificationId = [" + notificationId + "]"); - if (isCancellable(context, notificationId)) { - cancelLegacy(context, notificationId); - return true; - } else { - return false; - } - } - /** - * Checks whether the conversation for the given notification is allowed to be represented as a bubble. - * - * see {@link BubbleUtil#canBubble} for more information. - */ - @RequiresApi(ConversationUtil.CONVERSATION_SUPPORT_VERSION) - private static boolean isCancellable(@NonNull Context context, int notificationId) { NotificationManager manager = ServiceUtil.getNotificationManager(context); StatusBarNotification[] notifications = manager.getActiveNotifications(); Notification notification = Stream.of(notifications) @@ -166,16 +156,19 @@ private static boolean isCancellable(@NonNull Context context, int notificationI .map(StatusBarNotification::getNotification) .orElse(null); - if (notification == null || - notification.getShortcutId() == null || - notification.getBubbleMetadata() == null) { - Log.d(TAG, "isCancellable: bubbles not available or notification does not exist"); + if (notification == null || + notification.getShortcutId() == null || + notification.getBubbleMetadata() == null) + { + Log.d(TAG, "cancelWithConversationSupport: bubbles not available or notification does not exist, cancelling."); + cancelLegacy(context, notificationId); return true; } RecipientId recipientId = ConversationUtil.getRecipientId(notification.getShortcutId()); if (recipientId == null) { - Log.d(TAG, "isCancellable: Unable to get recipient from shortcut id"); + Log.d(TAG, "cancelWithConversationSupport: unable to get recipient from shortcut id, cancelling."); + cancelLegacy(context, notificationId); return true; } @@ -185,10 +178,75 @@ private static boolean isCancellable(@NonNull Context context, int notificationI Long focusedGroupStoryId = focusedThread.map(ConversationId::getGroupStoryId).orElse(null); if (Objects.equals(threadId, focusedThreadId) && focusedGroupStoryId == null) { - Log.d(TAG, "isCancellable: user entered full screen thread."); + Log.d(TAG, "cancelWithConversationSupport: user entered full screen thread, cancelling."); + cancelLegacy(context, notificationId); return true; } - return !BubbleUtil.canBubble(context, recipientId, threadId); + ConversationId activeBubble = AppDependencies.getMessageNotifier().getVisibleBubbleThread(); + Long activeBubbleThreadId = activeBubble != null ? activeBubble.getThreadId() : null; + + if (Objects.equals(threadId, activeBubbleThreadId)) { + Log.d(TAG, "cancelWithConversationSupport: bubble is currently expanded, not cancelling."); + return false; + } + + if ((notification.flags & Notification.FLAG_BUBBLE) == 0) { + // No active bubble to preserve; suppression only has an effect while bubbled. + Log.d(TAG, "cancelWithConversationSupport: not currently bubbled, cancelling."); + cancelLegacy(context, notificationId); + return true; + } + + if (notification.getBubbleMetadata().isNotificationSuppressed()) { + Log.d(TAG, "cancelWithConversationSupport: bubble notification already suppressed."); + return false; + } + + if (Build.VERSION.SDK_INT < 31) { + // Background suppression has no effect on API 30, preserve previous behaviour. + Log.d(TAG, "cancelWithConversationSupport: background suppression unsupported on API 30, not cancelling."); + return false; + } + + Log.d(TAG, "cancelWithConversationSupport: suppressing collapsed bubble notification."); + suppressNotification(context, notificationId, notification); + return false; + } + + /** + * Re-posts the notification with suppression flags set: removed from the shade, bubble kept. + * Mirrors the system-handled swipe-dismiss of a bubbled notification. The original metadata is + * cloned with only the presentation flags changed. + */ + @RequiresApi(31) + private static void suppressNotification(@NonNull Context context, int notificationId, @NonNull Notification notification) { + Notification.BubbleMetadata original = notification.getBubbleMetadata(); + if (original == null) { + Log.w(TAG, "suppressNotification: no bubble metadata, skipping."); + return; + } + + Notification.BubbleMetadata.Builder bubbleBuilder; + if (original.getIntent() != null && original.getIcon() != null) { + bubbleBuilder = new Notification.BubbleMetadata.Builder(original.getIntent(), original.getIcon()); + } else if (original.getShortcutId() != null) { + bubbleBuilder = new Notification.BubbleMetadata.Builder(original.getShortcutId()); + } else { + Log.w(TAG, "suppressNotification: bubble metadata incomplete, skipping."); + return; + } + + Notification.BubbleMetadata bubbleMetadata = bubbleBuilder.setAutoExpandBubble(false) + .setSuppressNotification(true) + .setDesiredHeight(original.getDesiredHeight()) + .setDeleteIntent(original.getDeleteIntent()) + .build(); + + Notification.Builder builder = Notification.Builder.recoverBuilder(context, notification) + .setBubbleMetadata(bubbleMetadata) + .setOnlyAlertOnce(true); + + ServiceUtil.getNotificationManager(context).notify(notificationId, builder.build()); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java index c3b1577708f..661c13b828b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java @@ -67,6 +67,11 @@ public void clearVisibleBubbleThread() { getNotifier().clearVisibleBubbleThread(); } + @Override + public @Nullable ConversationId getVisibleBubbleThread() { + return getNotifier().getVisibleBubbleThread(); + } + @Override public void notifyMessageDeliveryFailed(@NonNull Context context, @NonNull Recipient recipient, @NonNull ConversationId conversationId) { SignalDatabase.runPostSuccessfulTransaction(() -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/DefaultMessageNotifier.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/DefaultMessageNotifier.kt index ef54be39104..f0d730f437e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/DefaultMessageNotifier.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/DefaultMessageNotifier.kt @@ -93,6 +93,8 @@ class DefaultMessageNotifier(context: Application) : MessageNotifier { setVisibleBubbleThread(null) } + override fun getVisibleBubbleThread(): ConversationId? = visibleBubbleThread + override fun notifyMessageDeliveryFailed(context: Context, recipient: Recipient, conversationId: ConversationId) { NotificationFactory.notifyMessageDeliveryFailed(context, recipient, conversationId, visibleThread.get(), visibleBubbleThread) }