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