package org.thoughtcrime.securesms.notifications; import android.app.Notification; import android.app.NotificationManager; import android.content.Context; import android.os.Build; import android.service.notification.StatusBarNotification; import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; import com.annimon.stream.Stream; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.BubbleUtil; import org.thoughtcrime.securesms.util.ConversationUtil; import org.thoughtcrime.securesms.util.ServiceUtil; import java.util.Objects; /** * Consolidates Notification Cancellation logic to one class. * * Because Bubbles are tied to Notifications, and disappear when those Notificaitons are cancelled, * we want to be very surgical about what notifications we dismiss and when. Behaviour on API levels * previous to {@link org.thoughtcrime.securesms.util.ConversationUtil#CONVERSATION_SUPPORT_VERSION} * is preserved. * */ public final class NotificationCancellationHelper { private static final String TAG = Log.tag(NotificationCancellationHelper.class); private NotificationCancellationHelper() {} /** * Cancels all Message-Based notifications. Specifically, this is any notification that is not the * summary notification assigned to the {@link DefaultMessageNotifier#NOTIFICATION_GROUP} group. * * We utilize our wrapped cancellation methods and a counter to make sure that we do not lose * bubble notifications that do not have unread messages in them. */ public static void cancelAllMessageNotifications(@NonNull Context context) { if (Build.VERSION.SDK_INT >= 23) { try { NotificationManager notifications = ServiceUtil.getNotificationManager(context); StatusBarNotification[] activeNotifications = notifications.getActiveNotifications(); int activeCount = 0; for (StatusBarNotification activeNotification : activeNotifications) { if (isSingleThreadNotification(activeNotification)) { activeCount++; if (cancel(context, activeNotification.getId())) { activeCount--; } } } if (activeCount == 0) { cancelLegacy(context, NotificationIds.MESSAGE_SUMMARY); } } catch (Throwable e) { // XXX Appears to be a ROM bug, see #6043 Log.w(TAG, "Canceling all notifications.", e); ServiceUtil.getNotificationManager(context).cancelAll(); } } else { cancelLegacy(context, NotificationIds.MESSAGE_SUMMARY); } } /** * @return whether this is a non-summary notification that is a member of the NOTIFICATION_GROUP group. */ @RequiresApi(23) private static boolean isSingleThreadNotification(@NonNull StatusBarNotification statusBarNotification) { return statusBarNotification.getId() != NotificationIds.MESSAGE_SUMMARY && Objects.equals(statusBarNotification.getNotification().getGroup(), DefaultMessageNotifier.NOTIFICATION_GROUP); } /** * Attempts to cancel the given notification. If the notification is allowed to be displayed as a * bubble, we do not cancel it. * * @return Whether or not the notification is considered cancelled. */ public static boolean cancel(@NonNull Context context, int notificationId) { Log.d(TAG, "cancel() called with: notificationId = [" + notificationId + "]"); if (Build.VERSION.SDK_INT >= ConversationUtil.CONVERSATION_SUPPORT_VERSION) { return cancelWithConversationSupport(context, notificationId); } else { cancelLegacy(context, notificationId); return true; } } /** * Bypasses bubble check. */ public static void cancelLegacy(@NonNull Context context, int notificationId) { Log.d(TAG, "cancelLegacy() called with: notificationId = [" + notificationId + "]"); ServiceUtil.getNotificationManager(context).cancel(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. */ @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) .filter(n -> n.getId() == notificationId) .findFirst() .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"); return true; } RecipientId recipientId = RecipientId.from(notification.getShortcutId()); Long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipientId); long focusedThreadId = ApplicationDependencies.getMessageNotifier().getVisibleThread(); if (Objects.equals(threadId, focusedThreadId)) { Log.d(TAG, "isCancellable: user entered full screen thread."); return true; } return !BubbleUtil.canBubble(context, recipientId, threadId); } }