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