Fix conversation bubbles disappearing.

This commit is contained in:
Cody Henthorne
2026-09-25 12:50:39 -04:00
committed by Michelle Tang
parent b8185e715d
commit 7051d10cef
8 changed files with 111 additions and 27 deletions
@@ -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 {
@@ -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<MarkedMessageInfo> messageIdsCollection = new LinkedList<>();
for (ConversationId thread : threads) {
@@ -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<ConversationId> getVisibleBubbleThread();
void clearVisibleBubbleThread();
void addActiveBubbleThread(@NonNull ConversationId conversationId);
void removeActiveBubbleThread(@NonNull ConversationId conversationId);
@NonNull Set<ConversationId> 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);
@@ -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<ConversationId> 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);
}
}
}
@@ -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<ConversationId> 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<ConversationId> getActiveBubbleThreads() {
return getNotifier().getActiveBubbleThreads();
}
@Override
@@ -49,6 +49,8 @@ class DefaultMessageNotifier(context: Application) : MessageNotifier {
@Volatile private var visibleBubbleThread: ConversationId? = null
private val activeBubbleThreads: MutableSet<ConversationId> = 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<ConversationId> {
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<ConversationId> {
return activeBubbleThreads.toSet()
}
override fun notifyMessageDeliveryFailed(context: Context, recipient: Recipient, conversationId: ConversationId) {
NotificationFactory.notifyMessageDeliveryFailed(context, recipient, conversationId, visibleThread.get(), visibleBubbleThread)
}
@@ -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);
}
}
@@ -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
*/