diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..9e408524b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +root = true + +[*.kt] +indent_size = 2 diff --git a/app/build.gradle b/app/build.gradle index 06a43680e..2ea59749e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -3,9 +3,12 @@ import org.signal.signing.ApkSignerUtil import java.security.MessageDigest apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-kapt' apply plugin: 'com.google.protobuf' apply plugin: 'androidx.navigation.safeargs' apply plugin: 'witness' +apply plugin: 'org.jlleitschuh.gradle.ktlint' apply from: 'translations.gradle' apply from: 'witness-verifications.gradle' @@ -388,8 +391,8 @@ dependencies { implementation 'org.apache.httpcomponents:httpclient-android:4.3.5' implementation 'com.github.chrisbanes:PhotoView:2.1.3' implementation 'com.github.bumptech.glide:glide:4.11.0' - annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0' - annotationProcessor 'androidx.annotation:annotation:1.1.0' + kapt 'com.github.bumptech.glide:compiler:4.11.0' + kapt 'androidx.annotation:annotation:1.1.0' implementation 'com.makeramen:roundedimageview:2.1.0' implementation 'com.pnikosis:materialish-progress:1.5' implementation 'org.greenrobot:eventbus:3.0.0' @@ -449,6 +452,8 @@ dependencies { androidTestImplementation 'androidx.test.ext:junit:1.1.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" } dependencyVerification { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index c64e9616a..8b7cfdcdc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -40,7 +40,6 @@ import java.io.Closeable; import java.util.Collection; import java.util.Collections; import java.util.HashSet; -import java.util.LinkedList; import java.util.List; import java.util.Set; @@ -744,11 +743,11 @@ public class MmsSmsDatabase extends Database { return db.rawQuery(query, null); } - public Reader readerFor(@NonNull Cursor cursor) { + public static Reader readerFor(@NonNull Cursor cursor) { return new Reader(cursor); } - public class Reader implements Closeable { + public static class Reader implements Closeable { private final Cursor cursor; private SmsDatabase.Reader smsReader; diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index 7ba508c86..d0cafbc77 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -36,6 +36,7 @@ import org.thoughtcrime.securesms.messages.IncomingMessageProcessor; import org.thoughtcrime.securesms.net.PipeConnectivityListener; import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier; import org.thoughtcrime.securesms.notifications.MessageNotifier; +import org.thoughtcrime.securesms.notifications.v2.MessageNotifierV2; import org.thoughtcrime.securesms.notifications.OptimizedMessageNotifier; import org.thoughtcrime.securesms.payments.MobileCoinConfig; import org.thoughtcrime.securesms.payments.Payments; @@ -185,7 +186,14 @@ public class ApplicationDependencyProvider implements ApplicationDependencies.Pr @Override public @NonNull MessageNotifier provideMessageNotifier() { - return new OptimizedMessageNotifier(new DefaultMessageNotifier()); + MessageNotifier inner; + if (FeatureFlags.useNewNotificationSystem()) { + inner = new MessageNotifierV2(); + } else { + inner = new DefaultMessageNotifier(); + } + + return new OptimizedMessageNotifier(inner); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.java index 55a839641..7cdc34cd3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.java @@ -19,6 +19,7 @@ package org.thoughtcrime.securesms.mms; import android.content.Context; import android.content.res.Resources.Theme; import android.net.Uri; +import android.os.Build; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; @@ -56,7 +57,11 @@ public abstract class Slide { } public @Nullable Uri getPublicUri() { - return attachment.getPublicUri(); + if (Build.VERSION.SDK_INT >= 28) { + return attachment.getPublicUri(); + } else { + return attachment.getUri(); + } } @NonNull diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AbstractNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/AbstractNotificationBuilder.java index a44199bf3..cfa893a0a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/AbstractNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AbstractNotificationBuilder.java @@ -24,7 +24,7 @@ public abstract class AbstractNotificationBuilder extends NotificationCompat.Bui @SuppressWarnings("unused") private static final String TAG = Log.tag(AbstractNotificationBuilder.class); - private static final int MAX_DISPLAY_LENGTH = 500; + public static final int MAX_DISPLAY_LENGTH = 500; protected Context context; protected NotificationPrivacyPreference privacy; 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 049636127..5392ae3e4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java @@ -102,8 +102,8 @@ public class DefaultMessageNotifier implements MessageNotifier { public static final String NOTIFICATION_GROUP = "messages"; private static final String EMOJI_REPLACEMENT_STRING = "__EMOJI__"; - private static final long MIN_AUDIBLE_PERIOD_MILLIS = TimeUnit.SECONDS.toMillis(2); - private static final long DESKTOP_ACTIVITY_PERIOD = TimeUnit.MINUTES.toMillis(1); + public static final long MIN_AUDIBLE_PERIOD_MILLIS = TimeUnit.SECONDS.toMillis(2); + public static final long DESKTOP_ACTIVITY_PERIOD = TimeUnit.MINUTES.toMillis(1); private volatile long visibleThread = -1; private volatile long lastDesktopActivityTimestamp = -1; diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationCancellationHelper.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationCancellationHelper.java index c324eef71..9575eb54b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationCancellationHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationCancellationHelper.java @@ -43,7 +43,7 @@ public final class NotificationCancellationHelper { * 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) { + public static void cancelAllMessageNotifications(@NonNull Context context) { if (Build.VERSION.SDK_INT >= 23) { try { NotificationManager notifications = ServiceUtil.getNotificationManager(context); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PendingMessageNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/PendingMessageNotificationBuilder.java deleted file mode 100644 index c07230d8f..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PendingMessageNotificationBuilder.java +++ /dev/null @@ -1,40 +0,0 @@ -package org.thoughtcrime.securesms.notifications; - - -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; - -import androidx.core.app.NotificationCompat; - -import org.thoughtcrime.securesms.MainActivity; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.database.RecipientDatabase; -import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference; -import org.thoughtcrime.securesms.util.TextSecurePreferences; - -public class PendingMessageNotificationBuilder extends AbstractNotificationBuilder { - - public PendingMessageNotificationBuilder(Context context, NotificationPrivacyPreference privacy) { - super(context, privacy); - - setSmallIcon(R.drawable.ic_notification); - setColor(context.getResources().getColor(R.color.core_ultramarine)); - setCategory(NotificationCompat.CATEGORY_MESSAGE); - - setContentTitle(context.getString(R.string.MessageNotifier_you_may_have_new_messages)); - setContentText(context.getString(R.string.MessageNotifier_open_signal_to_check_for_recent_notifications)); - setTicker(context.getString(R.string.MessageNotifier_open_signal_to_check_for_recent_notifications)); - - // TODO [greyson] Navigation - setContentIntent(PendingIntent.getActivity(context, 0, MainActivity.clearTop(context), 0)); - setAutoCancel(true); - setAlarms(null, RecipientDatabase.VibrateState.DEFAULT); - - setOnlyAlertOnce(true); - - if (!NotificationChannels.supported()) { - setPriority(TextSecurePreferences.getNotificationPriority(context)); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/MessageNotifierV2.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/MessageNotifierV2.kt new file mode 100644 index 000000000..27ddba024 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/MessageNotifierV2.kt @@ -0,0 +1,308 @@ +package org.thoughtcrime.securesms.notifications.v2 + +import android.app.AlarmManager +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.service.notification.StatusBarNotification +import androidx.appcompat.view.ContextThemeWrapper +import androidx.core.content.ContextCompat +import me.leolin.shortcutbadger.ShortcutBadger +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.database.DatabaseFactory +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.messages.IncomingMessageObserver +import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier +import org.thoughtcrime.securesms.notifications.MessageNotifier +import org.thoughtcrime.securesms.notifications.MessageNotifier.ReminderReceiver +import org.thoughtcrime.securesms.notifications.NotificationCancellationHelper +import org.thoughtcrime.securesms.notifications.NotificationIds +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.service.KeyCachingService +import org.thoughtcrime.securesms.util.BubbleUtil.BubbleState +import org.thoughtcrime.securesms.util.FeatureFlags +import org.thoughtcrime.securesms.util.ServiceUtil +import org.thoughtcrime.securesms.util.TextSecurePreferences +import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder +import org.whispersystems.signalservice.internal.util.Util +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.collections.MutableMap.MutableEntry +import kotlin.math.max + +/** + * MessageNotifier implementation using the new system for creating and showing notifications. + */ +class MessageNotifierV2 : MessageNotifier { + @Volatile private var visibleThread: Long = -1 + @Volatile private var lastDesktopActivityTimestamp: Long = -1 + @Volatile private var lastAudibleNotification: Long = -1 + @Volatile private var lastScheduledReminder: Long = 0 + + private val threadReminders: MutableMap = ConcurrentHashMap() + + private val executor = CancelableExecutor() + + override fun setVisibleThread(threadId: Long) { + visibleThread = threadId + } + + override fun getVisibleThread(): Long { + return visibleThread + } + + override fun clearVisibleThread() { + setVisibleThread(-1) + } + + override fun setLastDesktopActivityTimestamp(timestamp: Long) { + lastDesktopActivityTimestamp = timestamp + } + + override fun notifyMessageDeliveryFailed(context: Context, recipient: Recipient, threadId: Long) { + NotificationFactory.notifyMessageDeliveryFailed(context, recipient, threadId, visibleThread) + } + + override fun cancelDelayedNotifications() { + executor.cancel() + } + + override fun updateNotification(context: Context) { + updateNotification(context, -1, false, 0, BubbleState.HIDDEN) + } + + override fun updateNotification(context: Context, threadId: Long) { + if (System.currentTimeMillis() - lastDesktopActivityTimestamp < DefaultMessageNotifier.DESKTOP_ACTIVITY_PERIOD) { + Log.i(TAG, "Scheduling delayed notification...") + executor.enqueue(context, threadId) + } else { + updateNotification(context, threadId, true) + } + } + + override fun updateNotification(context: Context, threadId: Long, defaultBubbleState: BubbleState) { + updateNotification(context, threadId, false, 0, defaultBubbleState) + } + + override fun updateNotification(context: Context, threadId: Long, signal: Boolean) { + updateNotification(context, threadId, signal, 0, BubbleState.HIDDEN) + } + + /** + * @param signal is no longer used + * @param reminderCount is not longer used + */ + override fun updateNotification( + context: Context, + threadId: Long, + signal: Boolean, + reminderCount: Int, + defaultBubbleState: BubbleState + ) { + if (!TextSecurePreferences.isNotificationsEnabled(context)) { + return + } + + val state: NotificationStateV2 = NotificationStateProvider.constructNotificationState(context) + + if (FeatureFlags.internalUser()) { + Log.i(TAG, state.toString()) + } + + if (state.isEmpty) { + Log.i(TAG, "State is empty, cancelling all notifications") + NotificationCancellationHelper.cancelAllMessageNotifications(context) + updateBadge(context, 0) + clearReminderInternal(context) + return + } + + val alertOverrides: Set = threadReminders.filter { (_, reminder) -> reminder.lastNotified < System.currentTimeMillis() - REMINDER_TIMEOUT }.keys + + val threadsThatAlerted: Set = NotificationFactory.notify( + context = ContextThemeWrapper(context, R.style.TextSecure_LightTheme), + state = state, + visibleThreadId = visibleThread, + targetThreadId = threadId, + defaultBubbleState = defaultBubbleState, + lastAudibleNotification = lastAudibleNotification, + alertOverrides = alertOverrides + ) + + lastAudibleNotification = System.currentTimeMillis() + + updateReminderTimestamps(context, alertOverrides, threadsThatAlerted) + + ServiceUtil.getNotificationManager(context).cancelOrphanedNotifications(context, state) + updateBadge(context, state.messageCount) + + val smsIds: MutableList = mutableListOf() + val mmsIds: MutableList = mutableListOf() + for (item: NotificationItemV2 in state.notificationItems) { + if (item.isMms) { + mmsIds.add(item.id) + } else { + smsIds.add(item.id) + } + } + DatabaseFactory.getMmsSmsDatabase(context).setNotifiedTimestamp(System.currentTimeMillis(), smsIds, mmsIds) + + Log.i(TAG, "threads: ${state.threadCount} messages: ${state.messageCount}") + } + + private fun updateReminderTimestamps(context: Context, alertOverrides: Set, threadsThatAlerted: Set) { + if (TextSecurePreferences.getRepeatAlertsCount(context) == 0) { + return + } + + val iterator: MutableIterator> = threadReminders.iterator() + while (iterator.hasNext()) { + val entry: MutableEntry = iterator.next() + val (id: Long, reminder: Reminder) = entry + if (alertOverrides.contains(id)) { + val notifyCount: Int = reminder.count + 1 + if (notifyCount >= TextSecurePreferences.getRepeatAlertsCount(context)) { + iterator.remove() + } else { + entry.setValue(Reminder(lastAudibleNotification, notifyCount)) + } + } + } + + for (alertedThreadId: Long in threadsThatAlerted) { + threadReminders[alertedThreadId] = Reminder(lastAudibleNotification) + } + + if (threadReminders.isNotEmpty()) { + scheduleReminder(context) + } else { + lastScheduledReminder = 0 + } + } + + private fun scheduleReminder(context: Context) { + val timeout: Long = if (lastScheduledReminder != 0L) { + max(TimeUnit.SECONDS.toMillis(5), REMINDER_TIMEOUT - (System.currentTimeMillis() - lastScheduledReminder)) + } else { + REMINDER_TIMEOUT + } + + val alarmManager: AlarmManager? = ContextCompat.getSystemService(context, AlarmManager::class.java) + val pendingIntent: PendingIntent = PendingIntent.getBroadcast(context, 0, Intent(context, ReminderReceiver::class.java), PendingIntent.FLAG_UPDATE_CURRENT) + alarmManager?.set(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + timeout, pendingIntent) + lastScheduledReminder = System.currentTimeMillis() + } + + private fun clearReminderInternal(context: Context) { + lastScheduledReminder = 0 + threadReminders.clear() + + val pendingIntent: PendingIntent = PendingIntent.getBroadcast(context, 0, Intent(context, ReminderReceiver::class.java), PendingIntent.FLAG_CANCEL_CURRENT) + val alarmManager: AlarmManager? = ContextCompat.getSystemService(context, AlarmManager::class.java) + alarmManager?.cancel(pendingIntent) + } + + override fun clearReminder(context: Context) { + // Intentionally left blank + } + + companion object { + private val TAG = Log.tag(MessageNotifierV2::class.java) + private val REMINDER_TIMEOUT = TimeUnit.MINUTES.toMillis(2) + + private fun updateBadge(context: Context, count: Int) { + try { + if (count == 0) ShortcutBadger.removeCount(context) else ShortcutBadger.applyCount(context, count) + } catch (t: Throwable) { + Log.w(TAG, t) + } + } + } + + private fun NotificationManager.cancelOrphanedNotifications(context: Context, state: NotificationStateV2) { + if (Build.VERSION.SDK_INT < 23) { + return + } + + try { + for (notification: StatusBarNotification in activeNotifications) { + if (notification.id != NotificationIds.MESSAGE_SUMMARY && + notification.id != KeyCachingService.SERVICE_RUNNING_ID && + notification.id != IncomingMessageObserver.FOREGROUND_ID && + notification.id != NotificationIds.PENDING_MESSAGES && + !CallNotificationBuilder.isWebRtcNotification(notification.id) + ) { + if (!state.notificationIds.contains(notification.id)) { + Log.d(TAG, "Cancelling orphaned notification: ${notification.id}") + NotificationCancellationHelper.cancel(context, notification.id) + } + } + } + } catch (e: Throwable) { + Log.w(TAG, e) + } + } + + private data class Reminder(val lastNotified: Long, val count: Int = 0) +} + +private class CancelableExecutor { + private val executor: Executor = Executors.newSingleThreadExecutor() + private val tasks: MutableSet = mutableSetOf() + + fun enqueue(context: Context, threadId: Long) { + execute(DelayedNotification(context, threadId)) + } + + private fun execute(runnable: DelayedNotification) { + synchronized(tasks) { tasks.add(runnable) } + val wrapper = Runnable { + runnable.run() + synchronized(tasks) { tasks.remove(runnable) } + } + executor.execute(wrapper) + } + + fun cancel() { + synchronized(tasks) { + for (task in tasks) { + task.cancel() + } + } + } + + private class DelayedNotification constructor(private val context: Context, private val threadId: Long) : Runnable { + private val canceled = AtomicBoolean(false) + private val delayUntil: Long = System.currentTimeMillis() + DELAY + + override fun run() { + val delayMillis = delayUntil - System.currentTimeMillis() + Log.i(TAG, "Waiting to notify: $delayMillis") + if (delayMillis > 0) { + Util.sleep(delayMillis) + } + if (!canceled.get()) { + Log.i(TAG, "Not canceled, notifying...") + ApplicationDependencies.getMessageNotifier().updateNotification(context, threadId, true) + ApplicationDependencies.getMessageNotifier().cancelDelayedNotifications() + } else { + Log.w(TAG, "Canceled, not notifying...") + } + } + + fun cancel() { + canceled.set(true) + } + + companion object { + private val DELAY = TimeUnit.SECONDS.toMillis(5) + private val TAG = Log.tag(DelayedNotification::class.java) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationBuilder.kt new file mode 100644 index 000000000..4e1d4df8e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationBuilder.kt @@ -0,0 +1,649 @@ +package org.thoughtcrime.securesms.notifications.v2 + +import android.annotation.TargetApi +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.drawable.Icon +import android.net.Uri +import android.os.Build +import android.text.TextUtils +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.annotation.RequiresApi +import androidx.annotation.StringRes +import androidx.core.app.NotificationCompat +import androidx.core.app.Person +import androidx.core.app.RemoteInput +import androidx.core.graphics.drawable.IconCompat +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.conversation.ConversationIntents +import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier +import org.thoughtcrime.securesms.notifications.NotificationChannels +import org.thoughtcrime.securesms.notifications.ReplyMethod +import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientUtil +import org.thoughtcrime.securesms.service.KeyCachingService +import org.thoughtcrime.securesms.util.AvatarUtil +import org.thoughtcrime.securesms.util.BubbleUtil +import org.thoughtcrime.securesms.util.ConversationUtil +import org.thoughtcrime.securesms.util.TextSecurePreferences + +private const val BIG_PICTURE_DIMEN = 500 + +/** + * Wraps the compat and OS versions of the Notification builders so we can more easily access native + * features in newer versions. Also provides some domain specific helpers. + * + * Note: All business logic should exist in the base builder or the models that drive the notifications + * like NotificationConversation and NotificationItemV2. + */ +sealed class NotificationBuilder(protected val context: Context) { + + private val privacy: NotificationPrivacyPreference = TextSecurePreferences.getNotificationPrivacy(context) + + abstract fun setSmallIcon(@DrawableRes drawable: Int) + abstract fun setColor(@ColorInt color: Int) + abstract fun setCategory(category: String) + abstract fun setGroup(group: String) + abstract fun setGroupAlertBehavior(behavior: Int) + abstract fun setChannelId(channelId: String) + abstract fun setContentTitle(contentTitle: CharSequence) + abstract fun setLargeIcon(largeIcon: Bitmap?) + abstract fun setShortcutId(shortcutId: String) + abstract fun setContentInfo(contentInfo: String) + abstract fun setNumber(number: Int) + abstract fun setContentText(contentText: CharSequence?) + abstract fun setContentIntent(pendingIntent: PendingIntent?) + abstract fun setDeleteIntent(deleteIntent: PendingIntent?) + abstract fun setSortKey(sortKey: String) + abstract fun setOnlyAlertOnce(onlyAlertOnce: Boolean) + abstract fun addMessages(conversation: NotificationConversation) + abstract fun setGroupSummary(isGroupSummary: Boolean) + abstract fun setSubText(subText: String) + abstract fun addMarkAsReadActionActual(state: NotificationStateV2) + abstract fun setPriority(priority: Int) + abstract fun setAlarms(recipient: Recipient?) + abstract fun setTicker(ticker: CharSequence) + abstract fun addTurnOffJoinedNotificationsAction(pendingIntent: PendingIntent) + abstract fun setBubbleMetadata(conversation: NotificationConversation, bubbleState: BubbleUtil.BubbleState) + abstract fun setAutoCancel(autoCancel: Boolean) + abstract fun build(): Notification + + protected abstract fun addPersonActual(recipient: Recipient) + protected abstract fun setWhen(timestamp: Long) + protected abstract fun addActions(replyMethod: ReplyMethod, conversation: NotificationConversation) + protected abstract fun addMessagesActual(state: NotificationStateV2) + protected abstract fun setLights(@ColorInt color: Int, onTime: Int, offTime: Int) + + fun addPerson(recipient: Recipient) { + if (privacy.isDisplayContact) { + addPersonActual(recipient) + } + } + + fun setWhen(conversation: NotificationConversation) { + if (conversation.getWhen() != 0L) { + setWhen(conversation.getWhen()) + } + } + + fun setWhen(notificationItem: NotificationItemV2) { + if (notificationItem.timestamp != 0L) { + setWhen(notificationItem.timestamp) + } + } + + fun addReplyActions(conversation: NotificationConversation) { + if (!privacy.isDisplayMessage || + KeyCachingService.isLocked(context) || + !RecipientUtil.isMessageRequestAccepted(context, conversation.recipient) + ) { + return + } + + addActions(ReplyMethod.forRecipient(context, conversation.recipient), conversation) + } + + fun addMarkAsReadAction(state: NotificationStateV2) { + if (privacy.isDisplayMessage) { + addMarkAsReadActionActual(state) + } + } + + fun addMessages(state: NotificationStateV2) { + if (!privacy.isDisplayContact && !privacy.isDisplayMessage) { + return + } + + addMessagesActual(state) + } + + fun setSummaryContentText(recipient: Recipient) { + if (privacy.isDisplayContact) { + setContentText(context.getString(R.string.MessageNotifier_most_recent_from_s, recipient.getDisplayName(context))) + } + + recipient.notificationChannel?.let { channel -> setChannelId(channel) } + } + + fun setLights() { + val ledColor: String = TextSecurePreferences.getNotificationLedColor(context) + + if (ledColor != "none") { + var blinkPattern = TextSecurePreferences.getNotificationLedPattern(context) + if (blinkPattern == "custom") { + blinkPattern = TextSecurePreferences.getNotificationLedPatternCustom(context) + } + val (onTime: Int, offTime: Int) = blinkPattern.parseBlinkPattern() + setLights(Color.parseColor(ledColor), onTime, offTime) + } + } + + private fun String.parseBlinkPattern(): Pair { + return split(",").let { parts -> parts[0].toInt() to parts[1].toInt() } + } + + companion object { + fun create(context: Context): NotificationBuilder { + return if (Build.VERSION.SDK_INT >= 28) { + NotificationBuilderOS(context) + } else { + NotificationBuilderCompat(context) + } + } + } + + /** + * Notification builder using solely androidx/compat libraries. + */ + class NotificationBuilderCompat(context: Context) : NotificationBuilder(context) { + val builder: NotificationCompat.Builder = NotificationCompat.Builder(context, NotificationChannels.getMessagesChannel(context)) + + override fun addActions(replyMethod: ReplyMethod, conversation: NotificationConversation) { + val markAsRead: PendingIntent = conversation.getMarkAsReadIntent(context) + val markAsReadAction: NotificationCompat.Action = NotificationCompat.Action.Builder(R.drawable.check, context.getString(R.string.MessageNotifier_mark_read), markAsRead) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ) + .build() + + val extender: NotificationCompat.WearableExtender = NotificationCompat.WearableExtender() + + builder.addAction(markAsReadAction) + extender.addAction(markAsReadAction) + + if (conversation.mostRecentNotification.canReply(context)) { + val quickReply: PendingIntent = conversation.getQuickReplyIntent(context) + val remoteReply: PendingIntent = conversation.getRemoteReplyIntent(context, replyMethod) + + val actionName: String = context.getString(R.string.MessageNotifier_reply) + val label: String = context.getString(replyMethod.toLongDescription()) + val replyAction: NotificationCompat.Action = if (Build.VERSION.SDK_INT >= 24) { + NotificationCompat.Action.Builder(R.drawable.ic_reply_white_36dp, actionName, remoteReply) + .addRemoteInput(RemoteInput.Builder(DefaultMessageNotifier.EXTRA_REMOTE_REPLY).setLabel(label).build()) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) + .build() + } else { + NotificationCompat.Action(R.drawable.ic_reply_white_36dp, actionName, quickReply) + } + + val wearableReplyAction = NotificationCompat.Action.Builder(R.drawable.ic_reply, actionName, remoteReply) + .addRemoteInput(RemoteInput.Builder(DefaultMessageNotifier.EXTRA_REMOTE_REPLY).setLabel(label).build()) + .build() + + builder.addAction(replyAction) + extender.addAction(wearableReplyAction) + } + + builder.extend(extender) + } + + override fun addMarkAsReadActionActual(state: NotificationStateV2) { + val markAllAsReadAction = NotificationCompat.Action(R.drawable.check, context.getString(R.string.MessageNotifier_mark_all_as_read), state.getMarkAsReadIntent(context)) + builder.addAction(markAllAsReadAction) + builder.extend(NotificationCompat.WearableExtender().addAction(markAllAsReadAction)) + } + + override fun addTurnOffJoinedNotificationsAction(pendingIntent: PendingIntent) { + val turnOffTheseNotifications = NotificationCompat.Action( + R.drawable.check, + context.getString(R.string.MessageNotifier_turn_off_these_notifications), + pendingIntent + ) + + builder.addAction(turnOffTheseNotifications) + } + + override fun addMessages(conversation: NotificationConversation) { + val bigPictureUri: Uri? = conversation.getSlideBigPictureUri(context) + if (bigPictureUri != null) { + builder.setStyle( + NotificationCompat.BigPictureStyle() + .bigPicture(bigPictureUri.toBitmap(context, BIG_PICTURE_DIMEN)) + .setSummaryText(conversation.getContentText(context)) + .bigLargeIcon(null) + ) + return + } + + val messagingStyle: NotificationCompat.MessagingStyle = NotificationCompat.MessagingStyle(ConversationUtil.buildPersonCompat(context, Recipient.self())) + messagingStyle.conversationTitle = conversation.getConversationTitle(context) + messagingStyle.isGroupConversation = conversation.isGroup + + conversation.notificationItems.forEach { notificationItem -> + val personBuilder: Person.Builder = Person.Builder() + .setKey(ConversationUtil.getShortcutId(notificationItem.individualRecipient)) + .setBot(false) + .setName(notificationItem.getPersonName(context)) + .setUri(notificationItem.getPersonUri(context)) + .setIcon(notificationItem.getPersonIcon(context).toIconCompat()) + + val (dataUri: Uri?, mimeType: String?) = notificationItem.getThumbnailInfo() + + messagingStyle.addMessage(NotificationCompat.MessagingStyle.Message(notificationItem.getPrimaryText(context), notificationItem.timestamp, personBuilder.build()).setData(mimeType, dataUri)) + } + + builder.setStyle(messagingStyle) + } + + override fun addMessagesActual(state: NotificationStateV2) { + if (Build.VERSION.SDK_INT >= 24) { + return + } + + val style: NotificationCompat.InboxStyle = NotificationCompat.InboxStyle() + + for (notificationItem: NotificationItemV2 in state.notificationItems) { + val line: CharSequence? = notificationItem.getInboxLine(context) + if (line != null) { + style.addLine(line) + } + addPerson(notificationItem.individualRecipient) + } + + builder.setStyle(style) + } + + override fun setAlarms(recipient: Recipient?) { + if (NotificationChannels.supported()) { + return + } + + val ringtone: Uri? = recipient?.messageRingtone + val vibrate = recipient?.messageVibrate + + val defaultRingtone: Uri = TextSecurePreferences.getNotificationRingtone(context) + val defaultVibrate: Boolean = TextSecurePreferences.isNotificationVibrateEnabled(context) + + if (ringtone == null && !TextUtils.isEmpty(defaultRingtone.toString())) { + builder.setSound(defaultRingtone) + } else if (ringtone != null && ringtone.toString().isNotEmpty()) { + builder.setSound(ringtone) + } + + if (vibrate == RecipientDatabase.VibrateState.ENABLED || vibrate == RecipientDatabase.VibrateState.DEFAULT && defaultVibrate) { + builder.setDefaults(Notification.DEFAULT_VIBRATE) + } + } + + override fun setBubbleMetadata(conversation: NotificationConversation, bubbleState: BubbleUtil.BubbleState) { + // Intentionally left blank + } + + override fun setLights(@ColorInt color: Int, onTime: Int, offTime: Int) { + builder.setLights(color, onTime, offTime) + } + + override fun setSmallIcon(drawable: Int) { + builder.setSmallIcon(drawable) + } + + override fun setColor(@ColorInt color: Int) { + builder.color = color + } + + override fun setCategory(category: String) { + builder.setCategory(category) + } + + override fun setGroup(group: String) { + builder.setGroup(group) + } + + override fun setGroupAlertBehavior(behavior: Int) { + builder.setGroupAlertBehavior(behavior) + } + + override fun setChannelId(channelId: String) { + builder.setChannelId(channelId) + } + + override fun setContentTitle(contentTitle: CharSequence) { + builder.setContentTitle(contentTitle) + } + + override fun setLargeIcon(largeIcon: Bitmap?) { + builder.setLargeIcon(largeIcon) + } + + override fun setShortcutId(shortcutId: String) { + builder.setShortcutId(shortcutId) + } + + override fun setContentInfo(contentInfo: String) { + builder.setContentInfo(contentInfo) + } + + override fun setNumber(number: Int) { + builder.setNumber(number) + } + + override fun setContentText(contentText: CharSequence?) { + builder.setContentText(contentText) + } + + override fun setTicker(ticker: CharSequence) { + builder.setTicker(ticker) + } + + override fun setContentIntent(pendingIntent: PendingIntent?) { + builder.setContentIntent(pendingIntent) + } + + override fun setDeleteIntent(deleteIntent: PendingIntent?) { + builder.setDeleteIntent(deleteIntent) + } + + override fun setSortKey(sortKey: String) { + builder.setSortKey(sortKey) + } + + override fun setOnlyAlertOnce(onlyAlertOnce: Boolean) { + builder.setOnlyAlertOnce(onlyAlertOnce) + } + + override fun setPriority(priority: Int) { + if (!NotificationChannels.supported()) { + builder.priority = priority + } + } + + override fun setAutoCancel(autoCancel: Boolean) { + builder.setAutoCancel(autoCancel) + } + + override fun build(): Notification { + return builder.build() + } + + override fun addPersonActual(recipient: Recipient) { + builder.addPerson(recipient.contactUri.toString()) + } + + override fun setWhen(timestamp: Long) { + builder.setWhen(timestamp) + } + + override fun setGroupSummary(isGroupSummary: Boolean) { + builder.setGroupSummary(isGroupSummary) + } + + override fun setSubText(subText: String) { + builder.setSubText(subText) + } + } + + /** + * Notification builder using solely on device OS libraries. + */ + @TargetApi(28) + class NotificationBuilderOS(context: Context) : NotificationBuilder(context) { + val builder: Notification.Builder = Notification.Builder(context, NotificationChannels.getMessagesChannel(context)) + + override fun addActions(replyMethod: ReplyMethod, conversation: NotificationConversation) { + val markAsRead: PendingIntent = conversation.getMarkAsReadIntent(context) + val markAsReadAction: Notification.Action = Notification.Action.Builder(context.getIcon(R.drawable.check), context.getString(R.string.MessageNotifier_mark_read), markAsRead) + .setSemanticAction(Notification.Action.SEMANTIC_ACTION_MARK_AS_READ) + .build() + val extender: Notification.WearableExtender = Notification.WearableExtender() + + builder.addAction(markAsReadAction) + extender.addAction(markAsReadAction) + + if (conversation.mostRecentNotification.canReply(context)) { + val remoteReply: PendingIntent = conversation.getRemoteReplyIntent(context, replyMethod) + + val actionName: String = context.getString(R.string.MessageNotifier_reply) + val label: String = context.getString(replyMethod.toLongDescription()) + val replyAction: Notification.Action = Notification.Action.Builder(context.getIcon(R.drawable.ic_reply_white_36dp), actionName, remoteReply) + .addRemoteInput(android.app.RemoteInput.Builder(DefaultMessageNotifier.EXTRA_REMOTE_REPLY).setLabel(label).build()) + .setSemanticAction(Notification.Action.SEMANTIC_ACTION_REPLY) + .build() + + val wearableReplyAction = Notification.Action.Builder(context.getIcon(R.drawable.ic_reply), actionName, remoteReply) + .addRemoteInput(android.app.RemoteInput.Builder(DefaultMessageNotifier.EXTRA_REMOTE_REPLY).setLabel(label).build()) + .build() + + builder.addAction(replyAction) + extender.addAction(wearableReplyAction) + } + + builder.extend(extender) + } + + override fun addMarkAsReadActionActual(state: NotificationStateV2) { + val markAllAsReadAction: Notification.Action = Notification.Action.Builder( + context.getIcon(R.drawable.check), + context.getString(R.string.MessageNotifier_mark_all_as_read), + state.getMarkAsReadIntent(context) + ).build() + + builder.addAction(markAllAsReadAction) + builder.extend(Notification.WearableExtender().addAction(markAllAsReadAction)) + } + + override fun addTurnOffJoinedNotificationsAction(pendingIntent: PendingIntent) { + val turnOffTheseNotifications: Notification.Action = Notification.Action.Builder( + context.getIcon(R.drawable.check), + context.getString(R.string.MessageNotifier_turn_off_these_notifications), + pendingIntent + ).build() + + builder.addAction(turnOffTheseNotifications) + } + + override fun addMessages(conversation: NotificationConversation) { + val bigPictureUri: Uri? = conversation.getSlideBigPictureUri(context) + if (bigPictureUri != null) { + builder.style = Notification.BigPictureStyle() + .bigPicture(bigPictureUri.toBitmap(context, BIG_PICTURE_DIMEN)) + .setSummaryText(conversation.getContentText(context)) + .bigLargeIcon(null as Bitmap?) + return + } + + val messagingStyle: Notification.MessagingStyle = Notification.MessagingStyle(ConversationUtil.buildPerson(context, Recipient.self())) + messagingStyle.conversationTitle = conversation.getConversationTitle(context) + messagingStyle.isGroupConversation = conversation.isGroup + + conversation.notificationItems.forEach { notificationItem -> + val personBuilder: android.app.Person.Builder = android.app.Person.Builder() + .setKey(ConversationUtil.getShortcutId(notificationItem.individualRecipient)) + .setBot(false) + .setName(notificationItem.getPersonName(context)) + .setUri(notificationItem.getPersonUri(context)) + .setIcon(notificationItem.getPersonIcon(context).toIcon()) + + val (dataUri: Uri?, mimeType: String?) = notificationItem.getThumbnailInfo() + + messagingStyle.addMessage(Notification.MessagingStyle.Message(notificationItem.getPrimaryText(context), notificationItem.timestamp, personBuilder.build()).setData(mimeType, dataUri)) + } + + builder.style = messagingStyle + } + + override fun setBubbleMetadata(conversation: NotificationConversation, bubbleState: BubbleUtil.BubbleState) { + if (Build.VERSION.SDK_INT < ConversationUtil.CONVERSATION_SUPPORT_VERSION) { + return + } + + val intent = PendingIntent.getActivity( + context, + 0, + ConversationIntents.createBubbleIntent(context, conversation.recipient.id, conversation.threadId), + 0 + ) + + val bubbleMetadata = Notification.BubbleMetadata.Builder(intent, AvatarUtil.getIconForShortcut(context, conversation.recipient)) + .setAutoExpandBubble(bubbleState === BubbleUtil.BubbleState.SHOWN) + .setDesiredHeight(600) + .setSuppressNotification(bubbleState === BubbleUtil.BubbleState.SHOWN) + .build() + + builder.setBubbleMetadata(bubbleMetadata) + } + + override fun addMessagesActual(state: NotificationStateV2) { + // Intentionally left blank + } + + override fun setLights(@ColorInt color: Int, onTime: Int, offTime: Int) { + // Intentionally left blank + } + + override fun setAlarms(recipient: Recipient?) { + // Intentionally left blank + } + + override fun setSmallIcon(drawable: Int) { + builder.setSmallIcon(drawable) + } + + override fun setColor(@ColorInt color: Int) { + builder.setColor(color) + } + + override fun setCategory(category: String) { + builder.setCategory(category) + } + + override fun setGroup(group: String) { + builder.setGroup(group) + } + + override fun setGroupAlertBehavior(behavior: Int) { + builder.setGroupAlertBehavior(behavior) + } + + override fun setChannelId(channelId: String) { + builder.setChannelId(channelId) + } + + override fun setContentTitle(contentTitle: CharSequence) { + builder.setContentTitle(contentTitle) + } + + override fun setLargeIcon(largeIcon: Bitmap?) { + builder.setLargeIcon(largeIcon) + } + + override fun setShortcutId(shortcutId: String) { + builder.setShortcutId(shortcutId) + } + + @Suppress("DEPRECATION") + override fun setContentInfo(contentInfo: String) { + builder.setContentInfo(contentInfo) + } + + override fun setNumber(number: Int) { + builder.setNumber(number) + } + + override fun setContentText(contentText: CharSequence?) { + builder.setContentText(contentText) + } + + override fun setTicker(ticker: CharSequence) { + builder.setTicker(ticker) + } + + override fun setContentIntent(pendingIntent: PendingIntent?) { + builder.setContentIntent(pendingIntent) + } + + override fun setDeleteIntent(deleteIntent: PendingIntent?) { + builder.setDeleteIntent(deleteIntent) + } + + override fun setSortKey(sortKey: String) { + builder.setSortKey(sortKey) + } + + override fun setOnlyAlertOnce(onlyAlertOnce: Boolean) { + builder.setOnlyAlertOnce(onlyAlertOnce) + } + + override fun setPriority(priority: Int) { + // Intentionally left blank + } + + override fun setAutoCancel(autoCancel: Boolean) { + builder.setAutoCancel(autoCancel) + } + + override fun build(): Notification { + return builder.build() + } + + override fun addPersonActual(recipient: Recipient) { + builder.addPerson(ConversationUtil.buildPerson(context, recipient)) + } + + override fun setWhen(timestamp: Long) { + builder.setWhen(timestamp) + } + + override fun setGroupSummary(isGroupSummary: Boolean) { + builder.setGroupSummary(isGroupSummary) + } + + override fun setSubText(subText: String) { + builder.setSubText(subText) + } + } +} + +private fun Bitmap?.toIconCompat(): IconCompat? { + return if (this != null) { + IconCompat.createWithBitmap(this) + } else { + null + } +} + +@RequiresApi(23) +private fun Bitmap?.toIcon(): Icon? { + return if (this != null) { + Icon.createWithBitmap(this) + } else { + null + } +} + +@RequiresApi(23) +private fun Context.getIcon(@DrawableRes drawableRes: Int): Icon { + return Icon.createWithResource(this, drawableRes) +} + +@StringRes +private fun ReplyMethod.toLongDescription(): Int { + return when (this) { + ReplyMethod.GroupMessage -> R.string.MessageNotifier_reply + ReplyMethod.SecureMessage -> R.string.MessageNotifier_signal_message + ReplyMethod.UnsecuredSmsMessage -> R.string.MessageNotifier_unsecured_sms + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationConversation.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationConversation.kt new file mode 100644 index 000000000..d128a9ee8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationConversation.kt @@ -0,0 +1,191 @@ +package org.thoughtcrime.securesms.notifications.v2 + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.net.Uri +import android.text.SpannableStringBuilder +import androidx.core.app.TaskStackBuilder +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.contacts.TurnOffContactJoinedNotificationsActivity +import org.thoughtcrime.securesms.contacts.avatars.ContactColors +import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto +import org.thoughtcrime.securesms.conversation.ConversationIntents +import org.thoughtcrime.securesms.notifications.DeleteNotificationReceiver +import org.thoughtcrime.securesms.notifications.MarkReadReceiver +import org.thoughtcrime.securesms.notifications.NotificationIds +import org.thoughtcrime.securesms.notifications.RemoteReplyReceiver +import org.thoughtcrime.securesms.notifications.ReplyMethod +import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.TextSecurePreferences +import org.thoughtcrime.securesms.util.Util + +private const val LARGE_ICON_DIMEN = 250 + +/** + * Encapsulate all the notifications for a given conversation (thread) and the top + * level information about said conversation. + */ +class NotificationConversation( + val recipient: Recipient, + val threadId: Long, + unsortedNotificationItems: List +) { + + val notificationItems: List = unsortedNotificationItems.sorted() + val mostRecentNotification: NotificationItemV2 = notificationItems.last() + val notificationId: Int = NotificationIds.getNotificationIdForThread(threadId) + val sortKey: Long = Long.MAX_VALUE - mostRecentNotification.timestamp + val messageCount: Int = notificationItems.size + val isGroup: Boolean = recipient.isGroup + + fun getContentTitle(context: Context): CharSequence { + return if (TextSecurePreferences.getNotificationPrivacy(context).isDisplayContact) { + recipient.getDisplayName(context) + } else { + context.getString(R.string.SingleRecipientNotificationBuilder_signal) + } + } + + fun getLargeIcon(context: Context): Bitmap? { + if (TextSecurePreferences.getNotificationPrivacy(context).isDisplayMessage) { + val largeIconUri: Uri? = getSlideLargeIcon() + if (largeIconUri != null) { + return largeIconUri.toBitmap(context, LARGE_ICON_DIMEN) + } + } + + return getContactLargeIcon(context).toLargeBitmap(context) + } + + private fun getContactLargeIcon(context: Context): Drawable? { + return if (TextSecurePreferences.getNotificationPrivacy(context).isDisplayContact) { + recipient.getContactDrawable(context) + } else { + GeneratedContactPhoto("Unknown", R.drawable.ic_profile_outline_40).asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context)) + } + } + + fun getContactUri(context: Context): String? { + return if (TextSecurePreferences.getNotificationPrivacy(context).isDisplayContact) { + recipient.contactUri?.toString() + } else { + null + } + } + + private fun getSlideLargeIcon(): Uri? { + return if (notificationItems.size == 1) mostRecentNotification.getLargeIconUri() else null + } + + fun getSlideBigPictureUri(context: Context): Uri? { + return if (notificationItems.size == 1 && TextSecurePreferences.getNotificationPrivacy(context).isDisplayMessage) mostRecentNotification.getBigPictureUri() else null + } + + fun getContentText(context: Context): CharSequence? { + val privacy: NotificationPrivacyPreference = TextSecurePreferences.getNotificationPrivacy(context) + val stringBuilder = SpannableStringBuilder() + + if (privacy.isDisplayContact && recipient.isGroup) { + stringBuilder.append(Util.getBoldedString(mostRecentNotification.individualRecipient.getDisplayName(context) + ": ")) + } + + return if (privacy.isDisplayMessage) { + stringBuilder.append(mostRecentNotification.getPrimaryText(context)) + } else { + stringBuilder.append(context.getString(R.string.SingleRecipientNotificationBuilder_new_message)) + } + } + + fun getConversationTitle(context: Context): CharSequence? { + if (isGroup) { + return if (TextSecurePreferences.getNotificationPrivacy(context).isDisplayContact) { + recipient.getDisplayName(context) + } else { + context.getString(R.string.SingleRecipientNotificationBuilder_signal) + } + } + return null + } + + fun getWhen(): Long { + return mostRecentNotification.timestamp + } + + fun hasNewNotifications(): Boolean { + return notificationItems.any { it.isNewNotification } + } + + fun getPendingIntent(context: Context): PendingIntent { + val intent: Intent = ConversationIntents.createBuilder(context, recipient.id, threadId) + .withStartingPosition(mostRecentNotification.getStartingPosition(context)) + .build() + .makeUniqueToPreventMerging() + + return TaskStackBuilder.create(context) + .addNextIntentWithParentStack(intent) + .getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT)!! + } + + fun getDeleteIntent(context: Context): PendingIntent? { + var index = 0 + val ids = LongArray(notificationItems.size) + val mms = BooleanArray(ids.size) + notificationItems.forEach { notificationItem -> + ids[index] = notificationItem.id + mms[index++] = notificationItem.isMms + } + + val intent = Intent(context, DeleteNotificationReceiver::class.java) + .setAction(DeleteNotificationReceiver.DELETE_NOTIFICATION_ACTION) + .putExtra(DeleteNotificationReceiver.EXTRA_IDS, ids) + .putExtra(DeleteNotificationReceiver.EXTRA_MMS, mms) + .makeUniqueToPreventMerging() + + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + fun getMarkAsReadIntent(context: Context): PendingIntent { + val intent = Intent(context, MarkReadReceiver::class.java).setAction(MarkReadReceiver.CLEAR_ACTION) + .putExtra(MarkReadReceiver.THREAD_IDS_EXTRA, longArrayOf(mostRecentNotification.threadId)) + .putExtra(MarkReadReceiver.NOTIFICATION_ID_EXTRA, notificationId) + .makeUniqueToPreventMerging() + + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + fun getQuickReplyIntent(context: Context): PendingIntent { + val intent: Intent = ConversationIntents.createPopUpBuilder(context, recipient.id, mostRecentNotification.threadId) + .build() + .makeUniqueToPreventMerging() + + return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + fun getRemoteReplyIntent(context: Context, replyMethod: ReplyMethod): PendingIntent { + val intent = Intent(context, RemoteReplyReceiver::class.java) + .setAction(RemoteReplyReceiver.REPLY_ACTION) + .putExtra(RemoteReplyReceiver.RECIPIENT_EXTRA, recipient.id) + .putExtra(RemoteReplyReceiver.REPLY_METHOD, replyMethod) + .setPackage(context.packageName) + .makeUniqueToPreventMerging() + + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + fun getTurnOffJoinedNotificationsIntent(context: Context): PendingIntent { + return PendingIntent.getActivity( + context, + 0, + TurnOffContactJoinedNotificationsActivity.newIntent(context, threadId), + PendingIntent.FLAG_UPDATE_CURRENT + ) + } + + override fun toString(): String { + return "NotificationConversation(threadId=$threadId, notificationItems=$notificationItems, messageCount=$messageCount, hasNewNotifications=${hasNewNotifications()})" + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationExtensions.kt new file mode 100644 index 000000000..52d391833 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationExtensions.kt @@ -0,0 +1,69 @@ +package org.thoughtcrime.securesms.notifications.v2 + +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import android.net.Uri +import com.bumptech.glide.load.engine.DiskCacheStrategy +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto +import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri +import org.thoughtcrime.securesms.mms.GlideApp +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.BitmapUtil +import java.util.concurrent.ExecutionException + +fun Drawable?.toLargeBitmap(context: Context): Bitmap? { + if (this == null) { + return null + } + + val largeIconTargetSize: Int = context.resources.getDimensionPixelSize(R.dimen.contact_photo_target_size) + + return BitmapUtil.createFromDrawable(this, largeIconTargetSize, largeIconTargetSize) +} + +fun Recipient.getContactDrawable(context: Context): Drawable? { + val contactPhoto: ContactPhoto? = contactPhoto + val fallbackContactPhoto: FallbackContactPhoto = fallbackContactPhoto + return if (contactPhoto != null) { + try { + GlideApp.with(context.applicationContext) + .load(contactPhoto) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .circleCrop() + .submit( + context.resources.getDimensionPixelSize(android.R.dimen.notification_large_icon_width), + context.resources.getDimensionPixelSize(android.R.dimen.notification_large_icon_height) + ) + .get() + } catch (e: InterruptedException) { + fallbackContactPhoto.asDrawable(context, color.toConversationColor(context)) + } catch (e: ExecutionException) { + fallbackContactPhoto.asDrawable(context, color.toConversationColor(context)) + } + } else { + fallbackContactPhoto.asDrawable(context, color.toConversationColor(context)) + } +} + +fun Uri.toBitmap(context: Context, dimension: Int): Bitmap { + return try { + GlideApp.with(context.applicationContext) + .asBitmap() + .load(DecryptableUri(this)) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .submit(dimension, dimension) + .get() + } catch (e: InterruptedException) { + Bitmap.createBitmap(dimension, dimension, Bitmap.Config.RGB_565) + } catch (e: ExecutionException) { + Bitmap.createBitmap(dimension, dimension, Bitmap.Config.RGB_565) + } +} + +fun Intent.makeUniqueToPreventMerging(): Intent { + return setData((Uri.parse("custom://" + System.currentTimeMillis()))) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationFactory.kt new file mode 100644 index 000000000..a71ddaec4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationFactory.kt @@ -0,0 +1,254 @@ +package org.thoughtcrime.securesms.notifications.v2 + +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.BitmapFactory +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.RingtoneManager +import android.net.Uri +import android.os.Build +import android.os.TransactionTooLargeException +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import org.signal.core.util.concurrent.SignalExecutors +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.MainActivity +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.conversation.ConversationIntents +import org.thoughtcrime.securesms.database.DatabaseFactory +import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier +import org.thoughtcrime.securesms.notifications.NotificationChannels +import org.thoughtcrime.securesms.notifications.NotificationIds +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.BubbleUtil +import org.thoughtcrime.securesms.util.ConversationUtil +import org.thoughtcrime.securesms.util.FeatureFlags +import org.thoughtcrime.securesms.util.ServiceUtil +import org.thoughtcrime.securesms.util.TextSecurePreferences + +private val TAG = Log.tag(NotificationFactory::class.java) + +/** + * Given a notification state consisting of conversations of messages, show appropriate system notifications. + */ +object NotificationFactory { + + fun notify( + context: Context, + state: NotificationStateV2, + visibleThreadId: Long, + targetThreadId: Long, + defaultBubbleState: BubbleUtil.BubbleState, + lastAudibleNotification: Long, + alertOverrides: Set + ): Set { + if (state.isEmpty) { + Log.d(TAG, "State is empty, bailing") + return emptySet() + } + val threadsThatNewlyAlerted: MutableSet = mutableSetOf() + + if (Build.VERSION.SDK_INT >= 23 || state.conversations.size == 1) { + state.conversations.forEach { conversation -> + if (conversation.threadId == visibleThreadId && conversation.hasNewNotifications()) { + notifyInThread(context, conversation.recipient, lastAudibleNotification) + } else if (conversation.hasNewNotifications() || alertOverrides.contains(conversation.threadId)) { + + if (conversation.hasNewNotifications()) { + threadsThatNewlyAlerted += conversation.threadId + } + + notifyForConversation( + context = context, + conversation = conversation, + recipient = conversation.recipient, + targetThreadId = targetThreadId, + defaultBubbleState = defaultBubbleState + ) + } + } + } + + if (state.conversations.size > 1 || ServiceUtil.getNotificationManager(context).isDisplayingSummaryNotification()) { + val builder: NotificationBuilder = NotificationBuilder.create(context) + + builder.apply { + setSmallIcon(R.drawable.ic_notification) + setColor(ContextCompat.getColor(context, R.color.core_ultramarine)) + setCategory(NotificationCompat.CATEGORY_MESSAGE) + setGroup(DefaultMessageNotifier.NOTIFICATION_GROUP) + setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) + setChannelId(NotificationChannels.getMessagesChannel(context)) + setContentTitle(context.getString(R.string.app_name)) + setContentIntent(PendingIntent.getActivity(context, 0, MainActivity.clearTop(context), 0)) + setGroupSummary(true) + setSubText(context.getString(R.string.MessageNotifier_d_new_messages_in_d_conversations, state.messageCount, state.threadCount)) + setContentInfo(state.messageCount.toString()) + setNumber(state.messageCount) + setSummaryContentText(state.mostRecentSender) + setDeleteIntent(state.getDeleteIntent(context)) + setWhen(state.mostRecentNotification) + addMarkAsReadAction(state) + addMessages(state) + setOnlyAlertOnce(!state.notificationItems.any { it.isNewNotification }) + setPriority(TextSecurePreferences.getNotificationPriority(context)) + setLights() + setAlarms(state.mostRecentSender) + setTicker(state.mostRecentNotification.getStyledPrimaryText(context, true)) + } + + Log.d(TAG, "showing summary notification") + NotificationManagerCompat.from(context).safelyNotify(context, null, NotificationIds.MESSAGE_SUMMARY, builder.build()) + } + + return threadsThatNewlyAlerted + } + + private fun notifyForConversation( + context: Context, + conversation: NotificationConversation, + recipient: Recipient, + targetThreadId: Long, + defaultBubbleState: BubbleUtil.BubbleState + ) { + val builder: NotificationBuilder = NotificationBuilder.create(context) + + builder.apply { + setSmallIcon(R.drawable.ic_notification) + setColor(ContextCompat.getColor(context, R.color.core_ultramarine)) + setCategory(NotificationCompat.CATEGORY_MESSAGE) + setGroup(DefaultMessageNotifier.NOTIFICATION_GROUP) + setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN) + setChannelId(recipient.notificationChannel ?: NotificationChannels.getMessagesChannel(context)) + setContentTitle(conversation.getContentTitle(context)) + setLargeIcon(conversation.getLargeIcon(context)) + addPerson(recipient) + setShortcutId(ConversationUtil.getShortcutId(recipient)) + setContentInfo(conversation.messageCount.toString()) + setNumber(conversation.messageCount) + setContentText(conversation.getContentText(context)) + setContentIntent(conversation.getPendingIntent(context)) + setDeleteIntent(conversation.getDeleteIntent(context)) + setSortKey(conversation.sortKey.toString()) + setWhen(conversation) + addReplyActions(conversation) + setOnlyAlertOnce(false) + addMessages(conversation) + setPriority(TextSecurePreferences.getNotificationPriority(context)) + setLights() + setAlarms(conversation.recipient) + setTicker(conversation.mostRecentNotification.getStyledPrimaryText(context, true)) + setBubbleMetadata(conversation, if (targetThreadId == conversation.threadId) defaultBubbleState else BubbleUtil.BubbleState.HIDDEN) + } + + if (conversation.messageCount == 1 && conversation.mostRecentNotification.isJoined) { + builder.addTurnOffJoinedNotificationsAction(conversation.getTurnOffJoinedNotificationsIntent(context)) + } + + NotificationManagerCompat.from(context).safelyNotify(context, conversation.recipient, conversation.notificationId, builder.build()) + } + + private fun notifyInThread(context: Context, recipient: Recipient, lastAudibleNotification: Long) { + if (!TextSecurePreferences.isInThreadNotifications(context) || + ServiceUtil.getAudioManager(context).ringerMode != AudioManager.RINGER_MODE_NORMAL || + (System.currentTimeMillis() - lastAudibleNotification) < DefaultMessageNotifier.MIN_AUDIBLE_PERIOD_MILLIS + ) { + return + } + + val uri: Uri = if (NotificationChannels.supported()) { + NotificationChannels.getMessageRingtone(context, recipient) ?: NotificationChannels.getMessageRingtone(context) + } else { + recipient.messageRingtone ?: TextSecurePreferences.getNotificationRingtone(context) + } + + if (uri.toString().isEmpty()) { + Log.d(TAG, "ringtone uri is empty") + return + } + + val ringtone = RingtoneManager.getRingtone(context, uri) + + if (ringtone == null) { + Log.w(TAG, "ringtone is null") + return + } + + if (Build.VERSION.SDK_INT >= 21) { + ringtone.audioAttributes = AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_UNKNOWN) + .setUsage(AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_INSTANT) + .build() + } else { + @Suppress("DEPRECATION") + ringtone.streamType = AudioManager.STREAM_NOTIFICATION + } + + ringtone.play() + } + + fun notifyMessageDeliveryFailed(context: Context, recipient: Recipient, threadId: Long, visibleThread: Long) { + if (threadId == visibleThread) { + notifyInThread(context, recipient, 0) + return + } + + val intent: Intent = ConversationIntents.createBuilder(context, recipient.id, threadId) + .build() + .makeUniqueToPreventMerging() + + val builder: NotificationBuilder = NotificationBuilder.create(context) + + builder.apply { + setSmallIcon(R.drawable.ic_notification) + setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.ic_action_warning_red)) + setContentTitle(context.getString(R.string.MessageNotifier_message_delivery_failed)) + setContentText(context.getString(R.string.MessageNotifier_failed_to_deliver_message)) + setTicker(context.getString(R.string.MessageNotifier_error_delivering_message)) + setContentIntent(PendingIntent.getActivity(context, 0, intent, 0)) + setAutoCancel(true) + setAlarms(recipient) + setChannelId(NotificationChannels.FAILURES) + } + + NotificationManagerCompat.from(context).safelyNotify(context, recipient, threadId.toInt(), builder.build()) + } + + private fun NotificationManager.isDisplayingSummaryNotification(): Boolean { + if (Build.VERSION.SDK_INT >= 23) { + try { + return activeNotifications.any { notification -> notification.id == NotificationIds.MESSAGE_SUMMARY } + } catch (e: Throwable) { + } + } + return false + } + + private fun NotificationManagerCompat.safelyNotify(context: Context, threadRecipient: Recipient?, notificationId: Int, notification: Notification) { + try { + notify(notificationId, notification) + if (FeatureFlags.internalUser()) { + Log.i(TAG, "Posted notification: $notification") + } + } catch (e: SecurityException) { + Log.i(TAG, "Security exception when posting notification, clearing ringtone") + if (threadRecipient != null) { + SignalExecutors.BOUNDED.execute { + DatabaseFactory.getRecipientDatabase(context).setMessageRingtone(threadRecipient.id, null) + NotificationChannels.updateMessageRingtone(context, threadRecipient, null) + } + } + } catch (runtimeException: RuntimeException) { + if (runtimeException.cause is TransactionTooLargeException) { + Log.e(TAG, "Transaction too large", runtimeException) + } else { + throw runtimeException + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationItemV2.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationItemV2.kt new file mode 100644 index 000000000..5e79561e9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationItemV2.kt @@ -0,0 +1,287 @@ +package org.thoughtcrime.securesms.notifications.v2 + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.text.SpannableStringBuilder +import android.text.TextUtils +import androidx.annotation.StringRes +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.contactshare.Contact +import org.thoughtcrime.securesms.contactshare.ContactUtil +import org.thoughtcrime.securesms.database.DatabaseFactory +import org.thoughtcrime.securesms.database.MentionUtil +import org.thoughtcrime.securesms.database.ThreadBodyUtil +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.database.model.ReactionRecord +import org.thoughtcrime.securesms.mms.Slide +import org.thoughtcrime.securesms.mms.SlideDeck +import org.thoughtcrime.securesms.notifications.AbstractNotificationBuilder +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.service.KeyCachingService +import org.thoughtcrime.securesms.util.MediaUtil +import org.thoughtcrime.securesms.util.MessageRecordUtil +import org.thoughtcrime.securesms.util.SpanUtil +import org.thoughtcrime.securesms.util.TextSecurePreferences +import org.thoughtcrime.securesms.util.Util + +private val TAG: String = Log.tag(NotificationItemV2::class.java) +private const val EMOJI_REPLACEMENT_STRING = "__EMOJI__" + +/** + * Base for messaged-based notifications. Represents a single notification. + */ +sealed class NotificationItemV2(val threadRecipient: Recipient, protected val record: MessageRecord) : Comparable { + + val id: Long = record.id + val threadId: Long = record.threadId + val isMms: Boolean = record.isMms + val slideDeck: SlideDeck? = (record as? MmsMessageRecord)?.slideDeck + val isJoined: Boolean = record.isJoined + + protected val notifiedTimestamp: Long = record.notifiedTimestamp + + abstract val timestamp: Long + abstract val individualRecipient: Recipient + abstract val isNewNotification: Boolean + + protected abstract fun getPrimaryTextActual(context: Context): CharSequence + abstract fun getStartingPosition(context: Context): Int + abstract fun getLargeIconUri(): Uri? + abstract fun getBigPictureUri(): Uri? + abstract fun canReply(context: Context): Boolean + + protected fun getMessageContentType(messageRecord: MmsMessageRecord): String { + val thumbnailSlide: Slide? = messageRecord.slideDeck.thumbnailSlide + + return if (thumbnailSlide == null) { + val slideContentType: String? = messageRecord.slideDeck.firstSlideContentType + if (slideContentType != null) { + slideContentType + } else { + Log.w(TAG, "Could not distinguish view-once content type from message record, defaulting to JPEG") + MediaUtil.IMAGE_JPEG + } + } else { + thumbnailSlide.contentType + } + } + + fun getStyledPrimaryText(context: Context, trimmed: Boolean = false): CharSequence { + return if (TextSecurePreferences.getNotificationPrivacy(context).isDisplayNothing) { + context.getString(R.string.SingleRecipientNotificationBuilder_new_message) + } else { + SpannableStringBuilder().apply { + append(Util.getBoldedString(individualRecipient.getShortDisplayNameIncludingUsername(context))) + if (threadRecipient != individualRecipient) { + append(Util.getBoldedString("@${threadRecipient.getDisplayName(context)}")) + } + append(": ") + append(getPrimaryText(context).apply { if (trimmed) trimToDisplayLength() }) + } + } + } + + fun getPersonName(context: Context): CharSequence { + return if (TextSecurePreferences.getNotificationPrivacy(context).isDisplayContact) { + individualRecipient.getDisplayName(context) + } else { + "" + } + } + + override fun compareTo(other: NotificationItemV2): Int { + return timestamp.compareTo(other.timestamp) + } + + fun getPersonUri(context: Context): String? { + return if (TextSecurePreferences.getNotificationPrivacy(context).isDisplayContact && individualRecipient.isSystemContact) { + individualRecipient.contactUri.toString() + } else { + null + } + } + + fun getPersonIcon(context: Context): Bitmap? { + return if (TextSecurePreferences.getNotificationPrivacy(context).isDisplayContact) { + individualRecipient.getContactDrawable(context).toLargeBitmap(context) + } else { + null + } + } + + fun getPrimaryText(context: Context): CharSequence { + return if (TextSecurePreferences.getNotificationPrivacy(context).isDisplayMessage) { + getPrimaryTextActual(context) + } else { + context.getString(R.string.SingleRecipientNotificationBuilder_new_message) + } + } + + fun getThumbnailInfo(): ThumbnailInfo { + val thumbnailSlide: Slide? = slideDeck?.thumbnailSlide + + return ThumbnailInfo(thumbnailSlide?.publicUri, thumbnailSlide?.contentType) + } + + fun getInboxLine(context: Context): CharSequence? { + return when { + TextSecurePreferences.getNotificationPrivacy(context).isDisplayNothing -> null + else -> getStyledPrimaryText(context, true) + } + } + + private fun CharSequence?.trimToDisplayLength(): CharSequence { + val text: CharSequence = this ?: "" + return if (text.length <= AbstractNotificationBuilder.MAX_DISPLAY_LENGTH) { + text + } else { + text.subSequence(0, AbstractNotificationBuilder.MAX_DISPLAY_LENGTH) + } + } + + data class ThumbnailInfo(val uri: Uri?, val contentType: String?) +} + +/** + * Represents a notification associated with a new message. + */ +class MessageNotification(threadRecipient: Recipient, record: MessageRecord) : NotificationItemV2(threadRecipient, record) { + override val timestamp: Long = record.timestamp + override val individualRecipient: Recipient = record.individualRecipient.resolve() + override val isNewNotification: Boolean = notifiedTimestamp == 0L + + override fun getPrimaryTextActual(context: Context): CharSequence { + return if (KeyCachingService.isLocked(context)) { + SpanUtil.italic(context.getString(R.string.MessageNotifier_locked_message)) + } else if (record.isMms && (record as MmsMessageRecord).sharedContacts.isNotEmpty()) { + val contact = record.sharedContacts[0] + ContactUtil.getStringSummary(context, contact) + } else if (record.isMms && record.isViewOnce) { + SpanUtil.italic(context.getString(getViewOnceDescription(record as MmsMessageRecord))) + } else if (record.isRemoteDelete) { + SpanUtil.italic(context.getString(R.string.MessageNotifier_this_message_was_deleted)) + } else if (record.isMms && !record.isMmsNotification && (record as MmsMessageRecord).slideDeck.slides.isNotEmpty()) { + ThreadBodyUtil.getFormattedBodyFor(context, record) + } else if (record.isGroupCall) { + MessageRecord.getGroupCallUpdateDescription(context, record.body, false).string + } else { + MentionUtil.updateBodyWithDisplayNames(context, record) + } + } + + @StringRes + private fun getViewOnceDescription(messageRecord: MmsMessageRecord): Int { + val contentType = getMessageContentType(messageRecord) + return if (MediaUtil.isImageType(contentType)) R.string.MessageNotifier_view_once_photo else R.string.MessageNotifier_view_once_video + } + + override fun getStartingPosition(context: Context): Int { + return -1 + } + + override fun getLargeIconUri(): Uri? { + val slide: Slide? = slideDeck?.thumbnailSlide ?: slideDeck?.stickerSlide + + return if (slide?.isInProgress == false) slide.uri else null + } + + override fun getBigPictureUri(): Uri? { + val slide: Slide? = slideDeck?.thumbnailSlide + + return if (slide?.isInProgress == false) slide.uri else null + } + + override fun canReply(context: Context): Boolean { + if (KeyCachingService.isLocked(context) || + record.isRemoteDelete || + record.isGroupCall || + record.isViewOnce || + record.isJoined + ) { + return false + } + + if (record is MmsMessageRecord) { + return (record.isMmsNotification || record.slideDeck.slides.isEmpty()) && record.sharedContacts.isEmpty() + } + + return true + } + + override fun toString(): String { + return "MessageNotification(timestamp=$timestamp, isNewNotification=$isNewNotification)" + } +} + +/** + * Represents a notification associated with a new reaction. + */ +class ReactionNotification(threadRecipient: Recipient, record: MessageRecord, val reaction: ReactionRecord) : NotificationItemV2(threadRecipient, record) { + override val timestamp: Long = reaction.dateReceived + override val individualRecipient: Recipient = Recipient.resolved(reaction.author) + override val isNewNotification: Boolean = timestamp > notifiedTimestamp + + override fun getPrimaryTextActual(context: Context): CharSequence { + return if (KeyCachingService.isLocked(context)) { + SpanUtil.italic(context.getString(R.string.MessageNotifier_locked_message)) + } else { + val text: String = SpanUtil.italic(getReactionMessageBody(context)).toString() + val parts: Array = text.split(EMOJI_REPLACEMENT_STRING).toTypedArray() + val builder = SpannableStringBuilder() + + parts.forEachIndexed { i, part -> + builder.append(SpanUtil.italic(part)) + if (i != parts.size - 1) { + builder.append(reaction.emoji) + } + } + + if (text.endsWith(EMOJI_REPLACEMENT_STRING)) { + builder.append(reaction.emoji) + } + builder + } + } + + private fun getReactionMessageBody(context: Context): CharSequence { + val body: CharSequence = MentionUtil.updateBodyWithDisplayNames(context, record) + val bodyIsEmpty: Boolean = TextUtils.isEmpty(body) + + return if (MessageRecordUtil.hasSharedContact(record)) { + val contact: Contact = (record as MmsMessageRecord).sharedContacts[0] + val summary: CharSequence = ContactUtil.getStringSummary(context, contact) + context.getString(R.string.MessageNotifier_reacted_s_to_s, EMOJI_REPLACEMENT_STRING, summary) + } else if (MessageRecordUtil.hasSticker(record)) { + context.getString(R.string.MessageNotifier_reacted_s_to_your_sticker, EMOJI_REPLACEMENT_STRING) + } else if (record.isMms && record.isViewOnce) { + context.getString(R.string.MessageNotifier_reacted_s_to_your_view_once_media, EMOJI_REPLACEMENT_STRING) + } else if (!bodyIsEmpty) { + context.getString(R.string.MessageNotifier_reacted_s_to_s, EMOJI_REPLACEMENT_STRING, body) + } else if (MessageRecordUtil.isMediaMessage(record) && MediaUtil.isVideoType(getMessageContentType((record as MmsMessageRecord)))) { + context.getString(R.string.MessageNotifier_reacted_s_to_your_video, EMOJI_REPLACEMENT_STRING) + } else if (MessageRecordUtil.isMediaMessage(record) && MediaUtil.isImageType(getMessageContentType((record as MmsMessageRecord)))) { + context.getString(R.string.MessageNotifier_reacted_s_to_your_image, EMOJI_REPLACEMENT_STRING) + } else if (MessageRecordUtil.isMediaMessage(record) && MediaUtil.isAudioType(getMessageContentType((record as MmsMessageRecord)))) { + context.getString(R.string.MessageNotifier_reacted_s_to_your_audio, EMOJI_REPLACEMENT_STRING) + } else if (MessageRecordUtil.isMediaMessage(record)) { + context.getString(R.string.MessageNotifier_reacted_s_to_your_file, EMOJI_REPLACEMENT_STRING) + } else { + context.getString(R.string.MessageNotifier_reacted_s_to_s, EMOJI_REPLACEMENT_STRING, body) + } + } + + override fun getStartingPosition(context: Context): Int { + return DatabaseFactory.getMmsSmsDatabase(context).getMessagePositionInConversation(threadId, record.dateReceived) + } + + override fun getLargeIconUri(): Uri? = null + override fun getBigPictureUri(): Uri? = null + override fun canReply(context: Context): Boolean = false + + override fun toString(): String { + return "ReactionNotification(timestamp=$timestamp, isNewNotification=$isNewNotification)" + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationStateProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationStateProvider.kt new file mode 100644 index 000000000..3f9d4e7d2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationStateProvider.kt @@ -0,0 +1,92 @@ +package org.thoughtcrime.securesms.notifications.v2 + +import android.content.Context +import androidx.annotation.WorkerThread +import org.thoughtcrime.securesms.database.DatabaseFactory +import org.thoughtcrime.securesms.database.MmsSmsColumns +import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.RecipientDatabase +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.ReactionRecord +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.CursorUtil + +/** + * Queries the message databases to determine messages that should be in notifications. + */ +object NotificationStateProvider { + + @WorkerThread + fun constructNotificationState(context: Context): NotificationStateV2 { + val messages: MutableList = mutableListOf() + + DatabaseFactory.getMmsSmsDatabase(context).unread.use { unreadMessages -> + if (unreadMessages.count == 0) { + return NotificationStateV2.EMPTY + } + + MmsSmsDatabase.readerFor(unreadMessages).use { reader -> + var record: MessageRecord? = reader.next + while (record != null) { + messages += NotificationMessage( + messageRecord = record, + threadRecipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(record.threadId)?.resolve() ?: Recipient.UNKNOWN, + threadId = record.threadId, + isUnreadMessage = CursorUtil.requireInt(unreadMessages, MmsSmsColumns.READ) == 0, + hasUnreadReactions = CursorUtil.requireInt(unreadMessages, MmsSmsColumns.REACTIONS_UNREAD) == 1, + lastReactionRead = CursorUtil.requireLong(unreadMessages, MmsSmsColumns.REACTIONS_LAST_SEEN) + ) + record = reader.next + } + } + } + + val conversations: MutableList = mutableListOf() + messages.groupBy { it.threadId } + .forEach { (threadId, threadMessages) -> + val notificationItems: MutableList = mutableListOf() + for (notification: NotificationMessage in threadMessages) { + + if (notification.includeMessage()) { + notificationItems += MessageNotification(notification.threadRecipient, notification.messageRecord) + } + + if (notification.hasUnreadReactions) { + notification.messageRecord.reactions.filter { notification.includeReaction(it) } + .forEach { notificationItems += ReactionNotification(notification.threadRecipient, notification.messageRecord, it) } + } + } + + if (notificationItems.isNotEmpty()) { + conversations += NotificationConversation(notificationItems[0].threadRecipient, threadId, notificationItems) + } + } + + return NotificationStateV2(conversations) + } + + private data class NotificationMessage( + val messageRecord: MessageRecord, + val threadRecipient: Recipient, + val threadId: Long, + val isUnreadMessage: Boolean, + val hasUnreadReactions: Boolean, + val lastReactionRead: Long + ) { + private val unknownOrNotMutedThread: Boolean = threadRecipient == Recipient.UNKNOWN || threadRecipient.isNotMuted + + fun includeMessage(): Boolean { + return isUnreadMessage && (unknownOrNotMutedThread || (threadRecipient.isAlwaysNotifyMentions && messageRecord.hasSelfMention())) + } + + fun includeReaction(reaction: ReactionRecord): Boolean { + return reaction.author != Recipient.self().id && messageRecord.isOutgoing && reaction.dateReceived > lastReactionRead && unknownOrNotMutedThread + } + + private val Recipient.isNotMuted: Boolean + get() = !isMuted + + private val Recipient.isAlwaysNotifyMentions: Boolean + get() = mentionSetting == RecipientDatabase.MentionSetting.ALWAYS_NOTIFY + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationStateV2.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationStateV2.kt new file mode 100644 index 000000000..a38210128 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationStateV2.kt @@ -0,0 +1,80 @@ +package org.thoughtcrime.securesms.notifications.v2 + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import org.thoughtcrime.securesms.notifications.DeleteNotificationReceiver +import org.thoughtcrime.securesms.notifications.MarkReadReceiver +import org.thoughtcrime.securesms.notifications.NotificationIds +import org.thoughtcrime.securesms.recipients.Recipient + +/** + * Hold all state for notifications for all conversations. + */ +data class NotificationStateV2(val conversations: List) { + + val threadCount: Int = conversations.size + val isEmpty: Boolean = conversations.isEmpty() + + val messageCount: Int by lazy { + conversations.fold(0) { messageCount, conversation -> + messageCount + conversation.messageCount + } + } + + val notificationItems: List by lazy { + conversations.map { it.notificationItems } + .flatten() + .sorted() + } + + val notificationIds: Set by lazy { + conversations.map { it.notificationId } + .toSet() + } + + val mostRecentNotification: NotificationItemV2 + get() = notificationItems.last() + + val mostRecentSender: Recipient + get() = mostRecentNotification.individualRecipient + + fun getDeleteIntent(context: Context): PendingIntent? { + val ids = LongArray(messageCount) + val mms = BooleanArray(ids.size) + + conversations.forEach { conversation -> + conversation.notificationItems.forEachIndexed { index, notificationItem -> + ids[index] = notificationItem.id + mms[index] = notificationItem.isMms + } + } + + val intent = Intent(context, DeleteNotificationReceiver::class.java) + .setAction(DeleteNotificationReceiver.DELETE_NOTIFICATION_ACTION) + .putExtra(DeleteNotificationReceiver.EXTRA_IDS, ids) + .putExtra(DeleteNotificationReceiver.EXTRA_MMS, mms) + .makeUniqueToPreventMerging() + + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + fun getMarkAsReadIntent(context: Context): PendingIntent? { + val threadArray = LongArray(conversations.size) + + conversations.forEachIndexed { index, conversation -> + threadArray[index] = conversation.threadId + } + + val intent = Intent(context, MarkReadReceiver::class.java).setAction(MarkReadReceiver.CLEAR_ACTION) + .putExtra(MarkReadReceiver.THREAD_IDS_EXTRA, threadArray) + .putExtra(MarkReadReceiver.NOTIFICATION_ID_EXTRA, NotificationIds.MESSAGE_SUMMARY) + .makeUniqueToPreventMerging() + + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + companion object { + val EMPTY = NotificationStateV2(emptyList()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/NotificationPrivacyPreference.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/NotificationPrivacyPreference.java index c5ccbedd4..0212b7a07 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/NotificationPrivacyPreference.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/widgets/NotificationPrivacyPreference.java @@ -18,6 +18,10 @@ public class NotificationPrivacyPreference { return "all".equals(preference); } + public boolean isDisplayNothing() { + return !isDisplayContact(); + } + @Override public @NonNull String toString() { return preference; 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 e74efe180..96c852848 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ConversationUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ConversationUtil.java @@ -244,9 +244,9 @@ public final class ConversationUtil { /** * @return A Person object representing the given Recipient */ - @RequiresApi(CONVERSATION_SUPPORT_VERSION) + @RequiresApi(28) @WorkerThread - private static @NonNull Person buildPerson(@NonNull Context context, @NonNull Recipient recipient) { + public static @NonNull Person buildPerson(@NonNull Context context, @NonNull Recipient recipient) { return new Person.Builder() .setKey(getShortcutId(recipient.getId())) .setName(recipient.getDisplayName(context)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 05408416a..dd19cfa14 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.util; +import android.os.Build; import android.text.TextUtils; import androidx.annotation.NonNull; @@ -75,6 +76,7 @@ public final class FeatureFlags { private static final String MESSAGE_PROCESSOR_ALARM_INTERVAL = "android.messageProcessor.alarmIntervalMins"; private static final String MESSAGE_PROCESSOR_DELAY = "android.messageProcessor.foregroundDelayMs"; private static final String STORAGE_SYNC_V2 = "android.storageSyncV2.2"; + private static final String NOTIFICATION_REWRITE = "android.notificationRewrite"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -106,7 +108,8 @@ public final class FeatureFlags { ANIMATED_STICKER_MIN_TOTAL_MEMORY, MESSAGE_PROCESSOR_ALARM_INTERVAL, MESSAGE_PROCESSOR_DELAY, - STORAGE_SYNC_V2 + STORAGE_SYNC_V2, + NOTIFICATION_REWRITE ); @VisibleForTesting @@ -150,7 +153,8 @@ public final class FeatureFlags { MESSAGE_PROCESSOR_ALARM_INTERVAL, MESSAGE_PROCESSOR_DELAY, GV1_FORCED_MIGRATE, - STORAGE_SYNC_V2 + STORAGE_SYNC_V2, + NOTIFICATION_REWRITE ); /** @@ -342,6 +346,11 @@ public final class FeatureFlags { return getBoolean(STORAGE_SYNC_V2, false); } + /** Whether or not to use the new notification system. */ + public static boolean useNewNotificationSystem() { + return getBoolean(NOTIFICATION_REWRITE, false) && Build.VERSION.SDK_INT >= 26; + } + /** Only for rendering debug info. */ public static synchronized @NonNull Map getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/build.gradle b/build.gradle index 0293fbcc1..5ea67ceab 100644 --- a/build.gradle +++ b/build.gradle @@ -1,4 +1,5 @@ buildscript { + ext.kotlin_version = '1.4.32' repositories { google() mavenCentral() @@ -8,11 +9,19 @@ buildscript { includeGroupByRegex "com\\.archinamon.*" } } + maven { + url "https://plugins.gradle.org/m2/" + content { + includeGroupByRegex "org\\.jlleitschuh\\.gradle.*" + } + } } dependencies { classpath 'com.android.tools.build:gradle:4.1.1' classpath 'androidx.navigation:navigation-safe-args-gradle-plugin:2.1.0' classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.10' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "org.jlleitschuh.gradle:ktlint-gradle:10.0.0" } } @@ -64,6 +73,7 @@ task qa { description 'Quality Assurance. Run before pushing.' dependsOn ':Signal-Android:testPlayProdReleaseUnitTest', ':Signal-Android:lintPlayProdRelease', + 'Signal-Android:ktlintCheck', ':libsignal-service:test', ':Signal-Android:assemblePlayProdDebug' } diff --git a/lintchecks/src/main/java/org/signal/lint/SignalLogDetector.java b/lintchecks/src/main/java/org/signal/lint/SignalLogDetector.java index 415fb21f6..8c6273367 100644 --- a/lintchecks/src/main/java/org/signal/lint/SignalLogDetector.java +++ b/lintchecks/src/main/java/org/signal/lint/SignalLogDetector.java @@ -15,6 +15,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.uast.UCallExpression; import org.jetbrains.uast.UExpression; import org.jetbrains.uast.java.JavaUSimpleNameReferenceExpression; +import org.jetbrains.uast.kotlin.KotlinUSimpleReferenceExpression; import java.util.Arrays; import java.util.List; @@ -73,7 +74,7 @@ public final class SignalLogDetector extends Detector implements Detector.UastSc if (evaluator.isMemberInClass(method, "org.signal.core.util.logging.Log")) { List arguments = call.getValueArguments(); UExpression tag = arguments.get(0); - if (!(tag instanceof JavaUSimpleNameReferenceExpression)) { + if (!(tag instanceof JavaUSimpleNameReferenceExpression || tag instanceof KotlinUSimpleReferenceExpression)) { context.report(INLINE_TAG, call, context.getLocation(call), "Not using a tag constant"); } } diff --git a/lintchecks/src/test/java/org/signal/lint/LogDetectorTest.java b/lintchecks/src/test/java/org/signal/lint/LogDetectorTest.java index e8705b3fe..0a6b9c87c 100644 --- a/lintchecks/src/test/java/org/signal/lint/LogDetectorTest.java +++ b/lintchecks/src/test/java/org/signal/lint/LogDetectorTest.java @@ -8,6 +8,7 @@ import java.io.InputStream; import java.util.Scanner; import static com.android.tools.lint.checks.infrastructure.TestFiles.java; +import static com.android.tools.lint.checks.infrastructure.TestFiles.kotlin; import static com.android.tools.lint.checks.infrastructure.TestLintTask.lint; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; @@ -133,6 +134,24 @@ public final class LogDetectorTest { .expectClean(); } + @Test + public void log_uses_tag_constant_kotlin() { + lint() + .files(appLogStub, + kotlin("package foo\n" + + "import org.signal.core.util.logging.Log\n" + + "class Example {\n" + + " const val TAG: String = Log.tag(Example::class.java)\n" + + " fun log() {\n" + + " Log.d(TAG, \"msg\")\n" + + " }\n" + + "}") + ) + .issues(SignalLogDetector.INLINE_TAG) + .run() + .expectClean(); + } + @Test public void log_uses_inline_tag() { lint() @@ -154,6 +173,26 @@ public final class LogDetectorTest { .expectFixDiffs(""); } + @Test + public void log_uses_inline_tag_kotlin() { + lint() + .files(appLogStub, + kotlin("package foo\n" + + "import org.signal.core.util.logging.Log\n" + + "class Example {\n" + + " fun log() {\n" + + " Log.d(\"TAG\", \"msg\")\n" + + " }\n" + + "}")) + .issues(SignalLogDetector.INLINE_TAG) + .run() + .expect("src/foo/Example.kt:5: Error: Not using a tag constant [LogTagInlined]\n" + + " Log.d(\"TAG\", \"msg\")\n" + + " ~~~~~~~~~~~~~~~~~~~\n" + + "1 errors, 0 warnings") + .expectFixDiffs(""); + } + @Test public void glideLogUsed_LogNotSignal_2_args() { lint()