diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 84b2a3c9deb..769e9caee74 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -259,6 +259,11 @@ android:value="org.thoughtcrime.securesms.MainActivity" /> + + { + MenuItem item = menu.findItem(R.id.menu_create_bubble); + + if (item != null) { + item.setVisible(canShowAsBubble && !isInBubble()); + } + }); + searchViewItem = menu.findItem(R.id.menu_search); SearchView searchView = (SearchView) searchViewItem.getActionView(); @@ -948,6 +967,7 @@ public boolean onOptionsItemSelected(MenuItem item) { case R.id.menu_conversation_settings: handleConversationSettings(); return true; case R.id.menu_expiring_messages_off: case R.id.menu_expiring_messages: handleSelectMessageExpiration(); return true; + case R.id.menu_create_bubble: handleCreateBubble(); return true; case android.R.id.home: onNavigateUp(); return true; } @@ -1220,6 +1240,13 @@ public void onLoadCleared(@Nullable Drawable placeholder) { } + private void handleCreateBubble() { + ConversationIntents.Args args = viewModel.getArgs(); + + BubbleUtil.displayAsBubble(this, args.getRecipientId(), args.getThreadId()); + finish(); + } + private static void addIconToHomeScreen(@NonNull Context context, @NonNull Bitmap bitmap, @NonNull Recipient recipient) @@ -1933,6 +1960,21 @@ protected void initializeActionBar() { supportActionBar.setDisplayHomeAsUpEnabled(true); supportActionBar.setDisplayShowTitleEnabled(false); + + if (isInBubble()) { + supportActionBar.setHomeAsUpIndicator(ContextCompat.getDrawable(this, R.drawable.ic_notification)); + toolbar.setNavigationOnClickListener(unused -> startActivity(new Intent(Intent.ACTION_MAIN).setClass(this, MainActivity.class))); + } + } + + private boolean isInBubble() { + if (Build.VERSION.SDK_INT >= ConversationUtil.CONVERSATION_SUPPORT_VERSION) { + Display display = getDisplay(); + + return display != null && display.getDisplayId() != Display.DEFAULT_DISPLAY; + } else { + return false; + } } private void initializeResources(@NonNull ConversationIntents.Args args) { @@ -2498,7 +2540,7 @@ protected void sendComplete(long threadId) { if (refreshFragment) { fragment.reload(recipient.get(), threadId); - ApplicationDependencies.getMessageNotifier().setVisibleThread(threadId); + setVisibleThread(threadId); } fragment.scrollToBottom(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index bd8dd8f6861..164fa4eed1c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -481,9 +481,9 @@ private void initializeResources() { int startingPosition = getStartPosition(); - this.recipient = Recipient.live(conversationViewModel.getArgs().getRecipientId()); - this.threadId = conversationViewModel.getArgs().getThreadId(); - this.markReadHelper = new MarkReadHelper(threadId, requireContext()); + this.recipient = Recipient.live(conversationViewModel.getArgs().getRecipientId()); + this.threadId = conversationViewModel.getArgs().getThreadId(); + this.markReadHelper = new MarkReadHelper(threadId, requireContext()); conversationViewModel.onConversationDataAvailable(threadId, startingPosition); messageCountsViewModel.setThreadId(threadId); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java index 00065191475..df11045820f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java @@ -19,6 +19,7 @@ public class ConversationIntents { + private static final String BUBBLE_AUTHORITY = "bubble"; private static final String EXTRA_RECIPIENT = "recipient_id"; private static final String EXTRA_THREAD_ID = "thread_id"; private static final String EXTRA_TEXT = "draft_text"; @@ -39,8 +40,20 @@ private ConversationIntents() { return new Builder(context, ConversationPopupActivity.class, recipientId, threadId); } + public static @NonNull Intent createBubbleIntent(@NonNull Context context, @NonNull RecipientId recipientId, long threadId) { + return new Builder(context, BubbleConversationActivity.class, recipientId, threadId).build(); + } + static boolean isInvalid(@NonNull Intent intent) { - return !intent.hasExtra(EXTRA_RECIPIENT); + if (isBubbleIntent(intent)) { + return intent.getData().getQueryParameter(EXTRA_RECIPIENT) == null; + } else { + return !intent.hasExtra(EXTRA_RECIPIENT); + } + } + + private static boolean isBubbleIntent(@NonNull Intent intent) { + return intent.getData() != null && Objects.equals(intent.getData().getAuthority(), BUBBLE_AUTHORITY); } final static class Args { @@ -54,6 +67,17 @@ final static class Args { private final int startingPosition; static Args from(@NonNull Intent intent) { + if (isBubbleIntent(intent)) { + return new Args(RecipientId.from(intent.getData().getQueryParameter(EXTRA_RECIPIENT)), + Long.parseLong(intent.getData().getQueryParameter(EXTRA_THREAD_ID)), + null, + null, + null, + false, + ThreadDatabase.DistributionTypes.DEFAULT, + -1); + } + return new Args(RecipientId.from(Objects.requireNonNull(intent.getStringExtra(EXTRA_RECIPIENT))), intent.getLongExtra(EXTRA_THREAD_ID, -1), intent.getStringExtra(EXTRA_TEXT), @@ -197,6 +221,16 @@ private Builder(@NonNull Context context, Intent intent = new Intent(context, conversationActivityClass); intent.setAction(Intent.ACTION_DEFAULT); + + if (Objects.equals(conversationActivityClass, BubbleConversationActivity.class)) { + intent.setData(new Uri.Builder().authority(BUBBLE_AUTHORITY) + .appendQueryParameter(EXTRA_RECIPIENT, recipientId.serialize()) + .appendQueryParameter(EXTRA_THREAD_ID, String.valueOf(threadId)) + .build()); + + return intent; + } + intent.putExtra(EXTRA_RECIPIENT, recipientId.serialize()); intent.putExtra(EXTRA_THREAD_ID, threadId); intent.putExtra(EXTRA_DISTRIBUTION_TYPE, distributionType); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java index 423129912c8..a7861a0f7f7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java @@ -1,15 +1,20 @@ package org.thoughtcrime.securesms.conversation; import android.content.Context; +import android.os.Build; import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientUtil; +import org.thoughtcrime.securesms.util.BubbleUtil; +import org.thoughtcrime.securesms.util.ConversationUtil; import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; import java.util.concurrent.Executor; @@ -34,6 +39,17 @@ LiveData getConversationData(long threadId, int jumpToPosition return liveData; } + @WorkerThread + boolean canShowAsBubble(long threadId) { + if (Build.VERSION.SDK_INT >= ConversationUtil.CONVERSATION_SUPPORT_VERSION) { + Recipient recipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId); + + return recipient != null && BubbleUtil.canBubble(context, recipient.getId(), threadId); + } else { + return false; + } + } + private @NonNull ConversationData getConversationDataInternal(long threadId, int jumpToPosition) { ThreadDatabase.ConversationMetadata metadata = DatabaseFactory.getThreadDatabase(context).getConversationMetadata(threadId); int threadSize = DatabaseFactory.getMmsSmsDatabase(context).getConversationCount(threadId); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java index 70b557b6d31..389c3437263 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java @@ -39,6 +39,7 @@ class ConversationViewModel extends ViewModel { private final Invalidator invalidator; private final MutableLiveData showScrollButtons; private final MutableLiveData hasUnreadMentions; + private final LiveData canShowAsBubble; private ConversationIntents.Args args; private int jumpToPosition; @@ -94,6 +95,8 @@ private ConversationViewModel() { (m, data) -> new DistinctConversationDataByThreadId(data)); conversationMetadata = Transformations.map(Transformations.distinctUntilChanged(distinctData), DistinctConversationDataByThreadId::getConversationData); + + canShowAsBubble = LiveDataUtil.mapAsync(threadId, conversationRepository::canShowAsBubble); } void onAttachmentKeyboardOpen() { @@ -113,6 +116,10 @@ void clearThreadId() { this.threadId.postValue(-1L); } + @NonNull LiveData canShowAsBubble() { + return canShowAsBubble; + } + @NonNull LiveData getShowScrollToBottom() { return Transformations.distinctUntilChanged(showScrollButtons); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJobApi29.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJobApi29.java index 741e48345a6..f44a7723dbd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJobApi29.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJobApi29.java @@ -1,17 +1,11 @@ package org.thoughtcrime.securesms.jobs; -import android.app.Notification; -import android.app.PendingIntent; -import android.content.Intent; import android.net.Uri; import androidx.annotation.NonNull; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; import androidx.documentfile.provider.DocumentFile; -import org.thoughtcrime.securesms.ApplicationPreferencesActivity; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.backup.BackupFileIOError; import org.thoughtcrime.securesms.backup.BackupPassphrase; diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java index a1b46c56cdd..7c0b978d76c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java @@ -22,11 +22,10 @@ import android.content.Context; import android.content.Intent; import android.os.AsyncTask; -import androidx.core.app.NotificationManagerCompat; import org.thoughtcrime.securesms.database.DatabaseFactory; -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.database.MessageDatabase.MarkedMessageInfo; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.logging.Log; import java.util.LinkedList; @@ -53,7 +52,7 @@ public void onReceive(final Context context, Intent intent) if (threadIds != null) { int notificationId = intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1); - NotificationManagerCompat.from(context).cancel(notificationId); + NotificationCancellationHelper.cancelLegacy(context, notificationId); new AsyncTask() { @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java index 799282e1467..22b7346f302 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java @@ -66,7 +66,7 @@ import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.service.KeyCachingService; -import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.BubbleUtil; import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.MessageRecordUtil; import org.thoughtcrime.securesms.util.ServiceUtil; @@ -100,9 +100,9 @@ public class DefaultMessageNotifier implements MessageNotifier { private static final String TAG = DefaultMessageNotifier.class.getSimpleName(); public static final String EXTRA_REMOTE_REPLY = "extra_remote_reply"; + public static final String NOTIFICATION_GROUP = "messages"; private static final String EMOJI_REPLACEMENT_STRING = "__EMOJI__"; - private static final String NOTIFICATION_GROUP = "messages"; private static final long MIN_AUDIBLE_PERIOD_MILLIS = TimeUnit.SECONDS.toMillis(2); private static final long DESKTOP_ACTIVITY_PERIOD = TimeUnit.MINUTES.toMillis(1); @@ -151,27 +151,6 @@ public void cancelDelayedNotifications() { executor.cancel(); } - private static void cancelActiveNotifications(@NonNull Context context) { - NotificationManager notifications = ServiceUtil.getNotificationManager(context); - notifications.cancel(NotificationIds.MESSAGE_SUMMARY); - - if (Build.VERSION.SDK_INT >= 23) { - try { - StatusBarNotification[] activeNotifications = notifications.getActiveNotifications(); - - for (StatusBarNotification activeNotification : activeNotifications) { - if (!CallNotificationBuilder.isWebRtcNotification(activeNotification.getId())) { - notifications.cancel(activeNotification.getId()); - } - } - } catch (Throwable e) { - // XXX Appears to be a ROM bug, see #6043 - Log.w(TAG, e); - notifications.cancelAll(); - } - } - } - private static boolean isDisplayingSummaryNotification(@NonNull Context context) { if (Build.VERSION.SDK_INT >= 23) { try { @@ -219,7 +198,7 @@ private static void cancelOrphanedNotifications(@NonNull Context context, Notifi } if (!validNotification) { - notifications.cancel(notification.getId()); + NotificationCancellationHelper.cancel(context, notification.getId()); } } } @@ -236,7 +215,7 @@ public void updateNotification(@NonNull Context context) { return; } - updateNotification(context, -1, false, 0); + updateNotification(context, -1, false, 0, BubbleUtil.BubbleState.HIDDEN); } @Override @@ -250,6 +229,11 @@ public void updateNotification(@NonNull Context context, long threadId) } } + @Override + public void updateNotification(@NonNull Context context, long threadId, @NonNull BubbleUtil.BubbleState defaultBubbleState) { + updateNotification(context, threadId, false, 0, defaultBubbleState); + } + @Override public void updateNotification(@NonNull Context context, long threadId, @@ -262,7 +246,7 @@ public void updateNotification(@NonNull Context context, if (isVisible) { sendInThreadNotification(context, recipient); } else { - updateNotification(context, threadId, signal, 0); + updateNotification(context, threadId, signal, 0, BubbleUtil.BubbleState.HIDDEN); } } } @@ -283,9 +267,10 @@ private boolean shouldNotify(@NonNull Context context, @Nullable Recipient recip @Override public void updateNotification(@NonNull Context context, - long targetThread, - boolean signal, - int reminderCount) + long targetThread, + boolean signal, + int reminderCount, + @NonNull BubbleUtil.BubbleState defaultBubbleState) { boolean isReminder = reminderCount > 0; Cursor telcoCursor = null; @@ -298,7 +283,7 @@ public void updateNotification(@NonNull Context context, if ((telcoCursor == null || telcoCursor.isAfterLast()) && (pushCursor == null || pushCursor.isAfterLast())) { - cancelActiveNotifications(context); + NotificationCancellationHelper.cancelAllMessageNotifications(context); updateBadge(context, 0); clearReminder(context); return; @@ -322,14 +307,18 @@ public void updateNotification(@NonNull Context context, new NotificationState(notificationState.getNotificationsForThread(threadId)), signal && (threadId == targetThread), true, - isReminder); + isReminder, + (threadId == targetThread) ? defaultBubbleState : BubbleUtil.BubbleState.HIDDEN); } } } sendMultipleThreadNotification(context, notificationState, signal && (Build.VERSION.SDK_INT < 23)); } else { - shouldScheduleReminder = sendSingleThreadNotification(context, notificationState, signal, false, isReminder); + long thread = notificationState.getNotifications().isEmpty() ? -1 : notificationState.getNotifications().get(0).getThreadId(); + BubbleUtil.BubbleState bubbleState = thread == targetThread ? defaultBubbleState : BubbleUtil.BubbleState.HIDDEN; + + shouldScheduleReminder = sendSingleThreadNotification(context, notificationState, signal, false, isReminder, bubbleState); if (isDisplayingSummaryNotification(context)) { sendMultipleThreadNotification(context, notificationState, false); @@ -363,12 +352,13 @@ private static boolean sendSingleThreadNotification(@NonNull Context context, @NonNull NotificationState notificationState, boolean signal, boolean bundled, - boolean isReminder) + boolean isReminder, + @NonNull BubbleUtil.BubbleState defaultBubbleState) { Log.i(TAG, "sendSingleThreadNotification() signal: " + signal + " bundled: " + bundled); if (notificationState.getNotifications().isEmpty()) { - if (!bundled) cancelActiveNotifications(context); + if (!bundled) NotificationCancellationHelper.cancelAllMessageNotifications(context); Log.i(TAG, "[sendSingleThreadNotification] Empty notification state. Skipping."); return false; } @@ -394,6 +384,7 @@ private static boolean sendSingleThreadNotification(@NonNull Context context, builder.setDeleteIntent(notificationState.getDeleteIntent(context)); builder.setOnlyAlertOnce(!shouldAlert); builder.setSortKey(String.valueOf(Long.MAX_VALUE - notifications.get(0).getTimestamp())); + builder.setDefaultBubbleState(defaultBubbleState); long timestamp = notifications.get(0).getTimestamp(); if (timestamp != 0) builder.setWhen(timestamp); @@ -424,7 +415,7 @@ private static boolean sendSingleThreadNotification(@NonNull Context context, while(iterator.hasPrevious()) { NotificationItem item = iterator.previous(); - builder.addMessageBody(item.getRecipient(), item.getIndividualRecipient(), item.getText(), item.getTimestamp()); + builder.addMessageBody(item.getRecipient(), item.getIndividualRecipient(), item.getText(), item.getTimestamp(), item.getSlideDeck()); } if (signal) { @@ -439,7 +430,6 @@ private static boolean sendSingleThreadNotification(@NonNull Context context, } Notification notification = builder.build(); - try { NotificationManagerCompat.from(context).notify(notificationId, notification); Log.i(TAG, "Posted notification."); 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 2007c519abe..3aa717470e7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.java @@ -7,7 +7,6 @@ import android.os.AsyncTask; import androidx.annotation.NonNull; -import androidx.core.app.NotificationManagerCompat; import com.annimon.stream.Collectors; import com.annimon.stream.Stream; @@ -44,7 +43,7 @@ public void onReceive(final Context context, Intent intent) { final long[] threadIds = intent.getLongArrayExtra(THREAD_IDS_EXTRA); if (threadIds != null) { - NotificationManagerCompat.from(context).cancel(intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1)); + NotificationCancellationHelper.cancelLegacy(context, intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1)); new AsyncTask() { @Override 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 fece5ba61f4..a5b97414065 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MessageNotifier.java @@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.BubbleUtil; import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; public interface MessageNotifier { @@ -19,8 +20,9 @@ public interface MessageNotifier { void cancelDelayedNotifications(); void updateNotification(@NonNull Context context); void updateNotification(@NonNull Context context, long threadId); + void updateNotification(@NonNull Context context, long threadId, @NonNull BubbleUtil.BubbleState defaultBubbleState); void updateNotification(@NonNull Context context, long threadId, boolean signal); - void updateNotification(@NonNull Context context, long threadId, boolean signal, int reminderCount); + void updateNotification(@NonNull Context context, long threadId, boolean signal, int reminderCount, @NonNull BubbleUtil.BubbleState defaultBubbleState); void clearReminder(@NonNull Context context); @@ -30,7 +32,7 @@ class ReminderReceiver extends BroadcastReceiver { public void onReceive(final Context context, final Intent intent) { SignalExecutors.BOUNDED.execute(() -> { int reminderCount = intent.getIntExtra("reminder_count", 0); - ApplicationDependencies.getMessageNotifier().updateNotification(context, -1, true, reminderCount + 1); + ApplicationDependencies.getMessageNotifier().updateNotification(context, -1, true, reminderCount + 1, BubbleUtil.BubbleState.HIDDEN); }); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationCancellationHelper.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationCancellationHelper.java new file mode 100644 index 00000000000..4b00a0b8bd8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationCancellationHelper.java @@ -0,0 +1,159 @@ +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.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.logging.Log; +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.Collection; +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. + */ + 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); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java index 9a966ac7a51..2488587e428 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java @@ -15,5 +15,4 @@ private NotificationIds() { } public static int getNotificationIdForThread(long threadId) { return THREAD + (int) threadId; } - } 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 e4631325013..e969d374970 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java @@ -7,6 +7,7 @@ import androidx.annotation.NonNull; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.BubbleUtil; import org.thoughtcrime.securesms.util.LeakyBucketLimiter; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; @@ -65,14 +66,19 @@ public void updateNotification(@NonNull Context context, long threadId) { runOnLimiter(() -> wrapped.updateNotification(context, threadId)); } + @Override + public void updateNotification(@NonNull Context context, long threadId, @NonNull BubbleUtil.BubbleState defaultBubbleState) { + runOnLimiter(() -> wrapped.updateNotification(context, threadId, defaultBubbleState)); + } + @Override public void updateNotification(@NonNull Context context, long threadId, boolean signal) { runOnLimiter(() -> wrapped.updateNotification(context, threadId, signal)); } @Override - public void updateNotification(@NonNull Context context, long threadId, boolean signal, int reminderCount) { - runOnLimiter(() -> wrapped.updateNotification(context, threadId, signal, reminderCount)); + public void updateNotification(@NonNull Context context, long threadId, boolean signal, int reminderCount, @NonNull BubbleUtil.BubbleState defaultBubbleState) { + runOnLimiter(() -> wrapped.updateNotification(context, threadId, signal, reminderCount, defaultBubbleState)); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java index f471a2f147b..043af66b83f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java @@ -27,6 +27,8 @@ import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto; +import org.thoughtcrime.securesms.conversation.ConversationIntents; +import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader; import org.thoughtcrime.securesms.mms.GlideApp; @@ -36,6 +38,7 @@ import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.AvatarUtil; import org.thoughtcrime.securesms.util.BitmapUtil; +import org.thoughtcrime.securesms.util.BubbleUtil; import org.thoughtcrime.securesms.util.ConversationUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; @@ -54,10 +57,11 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil private final List messages = new LinkedList<>(); - private SlideDeck slideDeck; - private CharSequence contentTitle; - private CharSequence contentText; - private Recipient threadRecipient; + private SlideDeck slideDeck; + private CharSequence contentTitle; + private CharSequence contentText; + private Recipient threadRecipient; + private BubbleUtil.BubbleState defaultBubbleState; public SingleRecipientNotificationBuilder(@NonNull Context context, @NonNull NotificationPrivacyPreference privacy) { @@ -229,7 +233,8 @@ private static int replyMethodLongDescription(@NonNull ReplyMethod replyMethod) public void addMessageBody(@NonNull Recipient threadRecipient, @NonNull Recipient individualRecipient, @Nullable CharSequence messageBody, - long timestamp) + long timestamp, + @Nullable SlideDeck slideDeck) { SpannableStringBuilder stringBuilder = new SpannableStringBuilder(); Person.Builder personBuilder = new Person.Builder() @@ -257,7 +262,21 @@ public void addMessageBody(@NonNull Recipient threadRecipient, text = stringBuilder.append(context.getString(R.string.SingleRecipientNotificationBuilder_new_message)); } - messages.add(new NotificationCompat.MessagingStyle.Message(text, timestamp, personBuilder.build())); + Uri dataUri = null; + String mimeType = null; + + if (slideDeck != null && slideDeck.getThumbnailSlide() != null) { + Slide thumbnail = slideDeck.getThumbnailSlide(); + + dataUri = thumbnail.getUri(); + mimeType = thumbnail.getContentType(); + } + + messages.add(new NotificationCompat.MessagingStyle.Message(text, timestamp, personBuilder.build()).setData(mimeType, dataUri)); + } + + public void setDefaultBubbleState(@NonNull BubbleUtil.BubbleState bubbleState) { + this.defaultBubbleState = bubbleState; } @Override @@ -270,13 +289,17 @@ public Notification build() { setLargeIcon(getNotificationPicture(largeIconUri.get(), LARGE_ICON_DIMEN)); } - if (messages.size() == 1 && bigPictureUri.isPresent()) { + if (messages.size() == 1 && bigPictureUri.isPresent() && Build.VERSION.SDK_INT < ConversationUtil.CONVERSATION_SUPPORT_VERSION) { setStyle(new NotificationCompat.BigPictureStyle() .bigPicture(getNotificationPicture(bigPictureUri.get(), BIG_PICTURE_DIMEN)) .setSummaryText(getBigText())); } else { if (Build.VERSION.SDK_INT >= 24) { applyMessageStyle(); + + if (Build.VERSION.SDK_INT >= ConversationUtil.CONVERSATION_SUPPORT_VERSION) { + applyBubbleMetadata(); + } } else { applyLegacy(); } @@ -304,6 +327,19 @@ private void applyMessageStyle() { setStyle(messagingStyle); } + private void applyBubbleMetadata() { + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(threadRecipient); + PendingIntent intent = PendingIntent.getActivity(context, 0, ConversationIntents.createBubbleIntent(context, threadRecipient.getId(), threadId), 0); + NotificationCompat.BubbleMetadata bubbleMetadata = new NotificationCompat.BubbleMetadata.Builder() + .setAutoExpandBubble(defaultBubbleState == BubbleUtil.BubbleState.SHOWN) + .setDesiredHeight(600) + .setIcon(AvatarUtil.getIconCompatForShortcut(context, threadRecipient)) + .setSuppressNotification(defaultBubbleState == BubbleUtil.BubbleState.SHOWN) + .setIntent(intent) + .build(); + setBubbleMetadata(bubbleMetadata); + } + private void applyLegacy() { setStyle(new NotificationCompat.BigTextStyle().bigText(getBigText())); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java index 2c425954a5e..63550dc7a50 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java @@ -120,6 +120,15 @@ public static Icon getIconForShortcut(@NonNull Context context, @NonNull Recipie } } + @WorkerThread + public static IconCompat getIconCompatForShortcut(@NonNull Context context, @NonNull Recipient recipient) { + try { + return IconCompat.createWithAdaptiveBitmap(getShortcutInfoBitmap(context, recipient)); + } catch (ExecutionException | InterruptedException e) { + return IconCompat.createWithAdaptiveBitmap(getFallbackForShortcut(context, recipient)); + } + } + @WorkerThread public static Bitmap getBitmapForNotification(@NonNull Context context, @NonNull Recipient recipient) { try { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BubbleUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/BubbleUtil.java new file mode 100644 index 00000000000..f3da9d1cd77 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/BubbleUtil.java @@ -0,0 +1,117 @@ +package org.thoughtcrime.securesms.util; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.service.notification.StatusBarNotification; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.conversation.BubbleConversationActivity; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier; +import org.thoughtcrime.securesms.notifications.NotificationIds; +import org.thoughtcrime.securesms.notifications.SingleRecipientNotificationBuilder; +import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; + +import java.util.Objects; + +import static org.thoughtcrime.securesms.util.ConversationUtil.CONVERSATION_SUPPORT_VERSION; + +/** + * Bubble-related utility methods. + */ +public final class BubbleUtil { + + private static final String TAG = Log.tag(BubbleUtil.class); + + private BubbleUtil() { + } + + /** + * Checks whether we are allowed to create a bubble for the given recipient. + * + * In order to Bubble, a recipient must have a thread, be unblocked, and the user must not have + * notification privacy settings enabled. Furthermore, we check the Notifications system to verify + * that bubbles are allowed in the first place. + */ + @RequiresApi(CONVERSATION_SUPPORT_VERSION) + @WorkerThread + public static boolean canBubble(@NonNull Context context, @NonNull RecipientId recipientId, @Nullable Long threadId) { + if (threadId == null) { + Log.i(TAG, "Cannot bubble recipient without thread"); + return false; + } + + NotificationPrivacyPreference privacyPreference = TextSecurePreferences.getNotificationPrivacy(context); + if (!privacyPreference.isDisplayMessage()) { + Log.i(TAG, "Bubbles are not available when notification privacy settings are enabled."); + return false; + } + + Recipient recipient = Recipient.resolved(recipientId); + if (recipient.isBlocked()) { + Log.i(TAG, "Cannot bubble blocked recipient"); + return false; + } + + NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); + NotificationChannel conversationChannel = notificationManager.getNotificationChannel(ConversationUtil.getChannelId(context, recipient), + ConversationUtil.getShortcutId(recipientId)); + + return notificationManager.areBubblesAllowed() || (conversationChannel != null && conversationChannel.canBubble()); + } + + /** + * Display a bubble for a given recipient's thread. + */ + public static void displayAsBubble(@NonNull Context context, @NonNull RecipientId recipientId, long threadId) { + if (Build.VERSION.SDK_INT >= CONVERSATION_SUPPORT_VERSION) { + SignalExecutors.BOUNDED.execute(() -> { + if (canBubble(context, recipientId, threadId)) { + NotificationManager notificationManager = ServiceUtil.getNotificationManager(context); + StatusBarNotification[] notifications = notificationManager.getActiveNotifications(); + int threadNotificationId = NotificationIds.getNotificationIdForThread(threadId); + Notification activeThreadNotification = Stream.of(notifications) + .filter(n -> n.getId() == threadNotificationId) + .findFirst() + .map(StatusBarNotification::getNotification) + .orElse(null); + + if (activeThreadNotification != null && activeThreadNotification.deleteIntent != null) { + ApplicationDependencies.getMessageNotifier().updateNotification(context, threadId, BubbleState.SHOWN); + } else { + Recipient recipient = Recipient.resolved(recipientId); + SingleRecipientNotificationBuilder builder = new SingleRecipientNotificationBuilder(context, TextSecurePreferences.getNotificationPrivacy(context)); + + builder.addMessageBody(recipient, recipient, "", System.currentTimeMillis(), null); + builder.setThread(recipient); + builder.setDefaultBubbleState(BubbleState.SHOWN); + builder.setGroup(DefaultMessageNotifier.NOTIFICATION_GROUP); + + Log.d(TAG, "Posting Notification for requested bubble"); + notificationManager.notify(NotificationIds.getNotificationIdForThread(threadId), builder.build()); + } + } + }); + } + } + + public enum BubbleState { + SHOWN, + HIDDEN + } +} 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 3f41d7dd7c1..7815676a599 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ConversationUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ConversationUtil.java @@ -1,11 +1,15 @@ package org.thoughtcrime.securesms.util; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; import android.app.Person; import android.content.ComponentName; import android.content.Context; import android.content.pm.ShortcutInfo; import android.content.pm.ShortcutManager; import android.os.Build; +import android.service.notification.StatusBarNotification; import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; @@ -18,7 +22,11 @@ import org.thoughtcrime.securesms.conversation.ConversationIntents; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.notifications.NotificationIds; +import org.thoughtcrime.securesms.notifications.SingleRecipientNotificationBuilder; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; @@ -33,10 +41,21 @@ */ public final class ConversationUtil { - public static final int CONVERSATION_SUPPORT_VERSION = 30; + public static final int CONVERSATION_SUPPORT_VERSION = 30; private ConversationUtil() {} + + /** + * @return The stringified channel id for a given Recipient + */ + @WorkerThread + public static @NonNull String getChannelId(@NonNull Context context, @NonNull Recipient recipient) { + Recipient resolved = recipient.resolve(); + + return resolved.getNotificationChannel() != null ? resolved.getNotificationChannel() : NotificationChannels.getMessagesChannel(context); + } + /** * Pushes a new dynamic shortcut for the given recipient and updates the ranks of all current * shortcuts. @@ -130,7 +149,7 @@ private static void pushShortcutAndUpdateRanks(@NonNull Context context, @NonNul ShortcutManager shortcutManager = ServiceUtil.getShortcutManager(context); List currentShortcuts = shortcutManager.getDynamicShortcuts(); - if (Util.isEmpty(currentShortcuts)) { + if (Util.hasItems(currentShortcuts)) { for (ShortcutInfo shortcutInfo : currentShortcuts) { RecipientId recipientId = RecipientId.from(shortcutInfo.getId()); Recipient resolved = Recipient.resolved(recipientId); diff --git a/app/src/main/res/menu/conversation.xml b/app/src/main/res/menu/conversation.xml index d836a0acc7f..e5325acefc3 100644 --- a/app/src/main/res/menu/conversation.xml +++ b/app/src/main/res/menu/conversation.xml @@ -17,4 +17,7 @@ + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d21d96e291a..87fea55bb5a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2532,6 +2532,7 @@ Conversation settings Add to home screen Pending members + Create bubble Expand popup