Add new notification system.
This commit is contained in:
parent
c8f17e2ab0
commit
e796968d19
23 changed files with 2030 additions and 56 deletions
4
.editorconfig
Normal file
4
.editorconfig
Normal file
|
@ -0,0 +1,4 @@
|
|||
root = true
|
||||
|
||||
[*.kt]
|
||||
indent_size = 2
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<Long, Reminder> = 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<Long> = threadReminders.filter { (_, reminder) -> reminder.lastNotified < System.currentTimeMillis() - REMINDER_TIMEOUT }.keys
|
||||
|
||||
val threadsThatAlerted: Set<Long> = 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<Long> = mutableListOf()
|
||||
val mmsIds: MutableList<Long> = 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<Long>, threadsThatAlerted: Set<Long>) {
|
||||
if (TextSecurePreferences.getRepeatAlertsCount(context) == 0) {
|
||||
return
|
||||
}
|
||||
|
||||
val iterator: MutableIterator<MutableEntry<Long, Reminder>> = threadReminders.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val entry: MutableEntry<Long, Reminder> = 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<DelayedNotification> = 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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<Int, Int> {
|
||||
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
|
||||
}
|
||||
}
|
|
@ -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<NotificationItemV2>
|
||||
) {
|
||||
|
||||
val notificationItems: List<NotificationItemV2> = 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()})"
|
||||
}
|
||||
}
|
|
@ -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())))
|
||||
}
|
|
@ -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<Long>
|
||||
): Set<Long> {
|
||||
if (state.isEmpty) {
|
||||
Log.d(TAG, "State is empty, bailing")
|
||||
return emptySet()
|
||||
}
|
||||
val threadsThatNewlyAlerted: MutableSet<Long> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<NotificationItemV2> {
|
||||
|
||||
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<String> = 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)"
|
||||
}
|
||||
}
|
|
@ -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<NotificationMessage> = 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<NotificationConversation> = mutableListOf()
|
||||
messages.groupBy { it.threadId }
|
||||
.forEach { (threadId, threadMessages) ->
|
||||
val notificationItems: MutableList<NotificationItemV2> = 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
|
||||
}
|
||||
}
|
|
@ -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<NotificationConversation>) {
|
||||
|
||||
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<NotificationItemV2> by lazy {
|
||||
conversations.map { it.notificationItems }
|
||||
.flatten()
|
||||
.sorted()
|
||||
}
|
||||
|
||||
val notificationIds: Set<Int> 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())
|
||||
}
|
||||
}
|
|
@ -18,6 +18,10 @@ public class NotificationPrivacyPreference {
|
|||
return "all".equals(preference);
|
||||
}
|
||||
|
||||
public boolean isDisplayNothing() {
|
||||
return !isDisplayContact();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String toString() {
|
||||
return preference;
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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<String, Object> getMemoryValues() {
|
||||
return new TreeMap<>(REMOTE_VALUES);
|
||||
|
|
10
build.gradle
10
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'
|
||||
}
|
||||
|
|
|
@ -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<UExpression> 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");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Add table
Reference in a new issue