From 7051d10cef28bd9ad5e9d8378f809b40f65cc7bb Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Fri, 25 Sep 2026 12:39:01 -0400 Subject: [PATCH] Fix conversation bubbles disappearing. --- .../conversation/v2/ConversationFragment.kt | 12 ++++ .../notifications/MarkReadReceiver.java | 4 +- .../notifications/MessageNotifier.java | 5 +- .../NotificationCancellationHelper.java | 57 +++++++++++++------ .../OptimizedMessageNotifier.java | 19 +++++-- .../v2/DefaultMessageNotifier.kt | 18 ++++-- .../securesms/util/BubbleUtil.java | 4 +- .../securesms/util/ConversationUtil.java | 19 +++++++ 8 files changed, 111 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 692fc931c5..a3f81a1e5f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -840,6 +840,18 @@ class ConversationFragment : super.onCreate(savedInstanceState) SignalLocalMetrics.ConversationOpen.start() registerForResults() + + if (args.conversationScreenType.isInBubble) { + AppDependencies.messageNotifier.addActiveBubbleThread(ConversationId.forConversation(args.threadId)) + } + } + + override fun onDestroy() { + super.onDestroy() + + if (args.conversationScreenType.isInBubble) { + AppDependencies.messageNotifier.removeActiveBubbleThread(ConversationId.forConversation(args.threadId)) + } } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java index 1f92e2487c..988b700a87 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java @@ -49,10 +49,12 @@ public class MarkReadReceiver extends BroadcastReceiver { notifier.removeStickyThread(thread); } - NotificationCancellationHelper.cancelLegacy(context, intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1)); + int notificationId = intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1); PendingResult finisher = goAsync(); SignalExecutors.BOUNDED.execute(() -> { + NotificationCancellationHelper.cancel(context, notificationId); + List messageIdsCollection = new LinkedList<>(); for (ConversationId thread : threads) { 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 9d15521d3a..6b1e7e63fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MessageNotifier.java @@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.notifications.v2.ConversationId; import org.thoughtcrime.securesms.recipients.Recipient; import java.util.Optional; +import java.util.Set; public interface MessageNotifier { void setVisibleThread(@Nullable ConversationId conversationId); @@ -21,8 +22,10 @@ public interface MessageNotifier { void clearVisibleThread(); void clearVisibleThread(@NonNull ConversationId conversationId); void setVisibleBubbleThread(@Nullable ConversationId conversationId); - @NonNull Optional getVisibleBubbleThread(); void clearVisibleBubbleThread(); + void addActiveBubbleThread(@NonNull ConversationId conversationId); + void removeActiveBubbleThread(@NonNull ConversationId conversationId); + @NonNull Set getActiveBubbleThreads(); 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 f611bb003a..41904e71a6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationCancellationHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationCancellationHelper.java @@ -21,7 +21,6 @@ import org.signal.core.util.ServiceUtil; import java.util.Collections; import java.util.Objects; -import java.util.Optional; import java.util.Set; import java.util.stream.Stream; @@ -111,8 +110,8 @@ public final class NotificationCancellationHelper { } /** - * Attempts to cancel the given notification. If the notification is backing a bubble the user - * currently has open, we do not cancel it. + * Attempts to cancel the given notification. If the notification is backing an active bubble, we + * suppress it from the shade instead. * * @return Whether or not the notification is considered cancelled. */ @@ -135,8 +134,7 @@ public final class NotificationCancellationHelper { } /** - * Cancel method which first checks whether the notification in question is tied to a bubble the - * user currently has open. + * Cancel method which first checks whether the notification in question is tied to an active bubble. * * @return true if the notification was cancelled. */ @@ -152,8 +150,8 @@ public final class NotificationCancellationHelper { } /** - * Cancelling a notification tears down the bubble it backs, so we hold off while the user has that - * conversation open as a bubble. + * Cancelling a notification tears down the bubble it backs, so we hold off while it is showing as a + * bubble, even if it is collapsed. */ @RequiresApi(ConversationUtil.CONVERSATION_SUPPORT_VERSION) private static boolean isCancellable(@NonNull Context context, int notificationId) { @@ -180,25 +178,52 @@ public final class NotificationCancellationHelper { Long threadId = SignalDatabase.threads().getThreadIdFor(recipientId); - if (isVisible(AppDependencies.getMessageNotifier().getVisibleThread(), threadId)) { + if (isThread(AppDependencies.getMessageNotifier().getVisibleThread().orElse(null), threadId)) { Log.d(TAG, "isCancellable: user entered full screen thread."); return true; } - if (isVisible(AppDependencies.getMessageNotifier().getVisibleBubbleThread(), threadId)) { - Log.d(TAG, "isCancellable: conversation is currently open as a bubble."); + boolean showingAsBubble = (notification.flags & Notification.FLAG_BUBBLE) != 0; + boolean hasActiveBubble = AppDependencies.getMessageNotifier().getActiveBubbleThreads().stream().anyMatch(bubbleThread -> isThread(bubbleThread, threadId)); + + if (showingAsBubble || hasActiveBubble) { + Log.d(TAG, "isCancellable: bubble is active (flag: " + showingAsBubble + ", opened: " + hasActiveBubble + "), suppressing instead."); + suppressNotification(context, notificationId, notification); return false; } return true; } - /** - * @return whether the given conversation is the non-story conversation the user is currently looking at. - */ - private static boolean isVisible(@NonNull Optional visibleThread, @Nullable Long threadId) { - ConversationId visible = visibleThread.orElse(null); + private static boolean isThread(@Nullable ConversationId conversationId, @Nullable Long threadId) { + return conversationId != null && conversationId.getGroupStoryId() == null && Objects.equals(threadId, conversationId.getThreadId()); + } - return visible != null && visible.getGroupStoryId() == null && Objects.equals(threadId, visible.getThreadId()); + /** + * Re-posts the notification with its shade entry suppressed, which keeps the bubble it backs. + */ + @RequiresApi(ConversationUtil.CONVERSATION_SUPPORT_VERSION) + private static void suppressNotification(@NonNull Context context, int notificationId, @NonNull Notification notification) { + Notification.BubbleMetadata bubbleMetadata = notification.getBubbleMetadata(); + + if (bubbleMetadata == null || bubbleMetadata.isNotificationSuppressed() || bubbleMetadata.getIntent() == null) { + return; + } + + Notification.BubbleMetadata suppressedMetadata = new Notification.BubbleMetadata.Builder(bubbleMetadata.getIntent(), bubbleMetadata.getIcon()) + .setDesiredHeight(bubbleMetadata.getDesiredHeight()) + .setSuppressNotification(true) + .build(); + + Notification suppressed = Notification.Builder.recoverBuilder(context, notification) + .setBubbleMetadata(suppressedMetadata) + .setOnlyAlertOnce(true) + .build(); + + try { + ServiceUtil.getNotificationManager(context).notify(notificationId, suppressed); + } catch (SecurityException e) { + Log.w(TAG, "Unable to suppress bubble notification", e); + } } } 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 0171ef80d8..b83f6ca0a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java @@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.LeakyBucketLimiter; import java.util.Optional; +import java.util.Set; /** * Uses a leaky-bucket strategy to limiting notification updates. @@ -65,13 +66,23 @@ public class OptimizedMessageNotifier implements MessageNotifier { } @Override - public @NonNull Optional getVisibleBubbleThread() { - return getNotifier().getVisibleBubbleThread(); + public void clearVisibleBubbleThread() { + getNotifier().clearVisibleBubbleThread(); } @Override - public void clearVisibleBubbleThread() { - getNotifier().clearVisibleBubbleThread(); + public void addActiveBubbleThread(@NonNull ConversationId conversationId) { + getNotifier().addActiveBubbleThread(conversationId); + } + + @Override + public void removeActiveBubbleThread(@NonNull ConversationId conversationId) { + getNotifier().removeActiveBubbleThread(conversationId); + } + + @Override + public @NonNull Set getActiveBubbleThreads() { + return getNotifier().getActiveBubbleThreads(); } @Override 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 bfd5442ad0..223dc32777 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 @@ -49,6 +49,8 @@ class DefaultMessageNotifier(context: Application) : MessageNotifier { @Volatile private var visibleBubbleThread: ConversationId? = null + private val activeBubbleThreads: MutableSet = ConcurrentHashMap.newKeySet() + @Volatile private var lastAudibleNotification: Long = -1 @Volatile private var lastScheduledReminder: Long = 0 @@ -89,14 +91,22 @@ class DefaultMessageNotifier(context: Application) : MessageNotifier { visibleBubbleThread = conversationId } - override fun getVisibleBubbleThread(): Optional { - return Optional.ofNullable(visibleBubbleThread) - } - override fun clearVisibleBubbleThread() { setVisibleBubbleThread(null) } + override fun addActiveBubbleThread(conversationId: ConversationId) { + activeBubbleThreads += conversationId + } + + override fun removeActiveBubbleThread(conversationId: ConversationId) { + activeBubbleThreads -= conversationId + } + + override fun getActiveBubbleThreads(): Set { + return activeBubbleThreads.toSet() + } + override fun notifyMessageDeliveryFailed(context: Context, recipient: Recipient, conversationId: ConversationId) { NotificationFactory.notifyMessageDeliveryFailed(context, recipient, conversationId, visibleThread.get(), visibleBubbleThread) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BubbleUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/BubbleUtil.java index 1e906667e6..3d1c08a466 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/BubbleUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/BubbleUtil.java @@ -116,6 +116,9 @@ public final class BubbleUtil { ConversationId conversationId = ConversationId.forConversation(threadId); SignalExecutors.BOUNDED.execute(() -> { if (canBubble(context, recipientId, threadId)) { + Recipient recipient = Recipient.resolved(recipientId); + ConversationUtil.ensureShortcutForRecipientSync(context, recipient); + NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); StatusBarNotification[] notifications = notificationManager.getActiveNotifications(); int threadNotificationId = NotificationIds.getNotificationIdForThread(conversationId); @@ -128,7 +131,6 @@ public final class BubbleUtil { if (activeThreadNotification != null && activeThreadNotification.deleteIntent != null) { AppDependencies.getMessageNotifier().forceBubbleNotification(context, conversationId); } else { - Recipient recipient = Recipient.resolved(recipientId); NotificationFactory.notifyToBubbleConversation(context, recipient, threadId); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ConversationUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ConversationUtil.java index 1e9ad95348..62b81744a5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ConversationUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ConversationUtil.java @@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.jobs.ConversationShortcutUpdateJob; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.signal.core.ui.permissions.Permissions; import org.thoughtcrime.securesms.recipients.Recipient; @@ -88,6 +89,24 @@ public final class ConversationUtil { return pushShortcutForRecipientInternal(context, recipient, 0, direction); } + /** + * Pushes a dynamic shortcut for the given recipient if one is not already published. The OS will + * not treat a notification as a conversation, or let it bubble, unless its shortcut exists. + */ + @WorkerThread + public static void ensureShortcutForRecipientSync(@NonNull Context context, @NonNull Recipient recipient) { + if (SignalStore.settings().getScreenLockEnabled()) { + return; + } + + String shortcutId = getShortcutId(recipient); + + if (ShortcutManagerCompat.getDynamicShortcuts(context).stream().noneMatch(shortcut -> shortcut.getId().equals(shortcutId))) { + Log.d(TAG, "Pushing missing shortcut for " + recipient.getId()); + pushShortcutForRecipientSync(context, recipient, Direction.NONE); + } + } + /** * Clears all currently set dynamic shortcuts */