package org.thoughtcrime.securesms.notifications.v2 import android.app.AlarmManager import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Build import android.service.notification.StatusBarNotification import androidx.appcompat.view.ContextThemeWrapper import androidx.core.content.ContextCompat import me.leolin.shortcutbadger.ShortcutBadger import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.database.DatabaseFactory import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.messages.IncomingMessageObserver import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier import org.thoughtcrime.securesms.notifications.MessageNotifier import org.thoughtcrime.securesms.notifications.MessageNotifier.ReminderReceiver import org.thoughtcrime.securesms.notifications.NotificationCancellationHelper import org.thoughtcrime.securesms.notifications.NotificationIds import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.service.KeyCachingService import org.thoughtcrime.securesms.util.BubbleUtil.BubbleState import org.thoughtcrime.securesms.util.FeatureFlags import org.thoughtcrime.securesms.util.ServiceUtil import org.thoughtcrime.securesms.util.TextSecurePreferences import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder import org.whispersystems.signalservice.internal.util.Util import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executor import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import kotlin.collections.MutableMap.MutableEntry import kotlin.math.max /** * MessageNotifier implementation using the new system for creating and showing notifications. */ class MessageNotifierV2 : MessageNotifier { @Volatile private var visibleThread: Long = -1 @Volatile private var lastDesktopActivityTimestamp: Long = -1 @Volatile private var lastAudibleNotification: Long = -1 @Volatile private var lastScheduledReminder: Long = 0 private val threadReminders: MutableMap = ConcurrentHashMap() private val executor = CancelableExecutor() override fun setVisibleThread(threadId: Long) { visibleThread = threadId } override fun getVisibleThread(): Long { return visibleThread } override fun clearVisibleThread() { setVisibleThread(-1) } override fun setLastDesktopActivityTimestamp(timestamp: Long) { lastDesktopActivityTimestamp = timestamp } override fun notifyMessageDeliveryFailed(context: Context, recipient: Recipient, threadId: Long) { NotificationFactory.notifyMessageDeliveryFailed(context, recipient, threadId, visibleThread) } override fun cancelDelayedNotifications() { executor.cancel() } override fun updateNotification(context: Context) { updateNotification(context, -1, false, 0, BubbleState.HIDDEN) } override fun updateNotification(context: Context, threadId: Long) { if (System.currentTimeMillis() - lastDesktopActivityTimestamp < DefaultMessageNotifier.DESKTOP_ACTIVITY_PERIOD) { Log.i(TAG, "Scheduling delayed notification...") executor.enqueue(context, threadId) } else { updateNotification(context, threadId, true) } } override fun updateNotification(context: Context, threadId: Long, defaultBubbleState: BubbleState) { updateNotification(context, threadId, false, 0, defaultBubbleState) } override fun updateNotification(context: Context, threadId: Long, signal: Boolean) { updateNotification(context, threadId, signal, 0, BubbleState.HIDDEN) } /** * @param signal is no longer used * @param reminderCount is not longer used */ override fun updateNotification( context: Context, threadId: Long, signal: Boolean, reminderCount: Int, defaultBubbleState: BubbleState ) { if (!TextSecurePreferences.isNotificationsEnabled(context)) { return } val state: NotificationStateV2 = NotificationStateProvider.constructNotificationState(context) if (FeatureFlags.internalUser()) { Log.i(TAG, state.toString()) } if (state.isEmpty) { Log.i(TAG, "State is empty, cancelling all notifications") NotificationCancellationHelper.cancelAllMessageNotifications(context) updateBadge(context, 0) clearReminderInternal(context) return } val alertOverrides: Set = threadReminders.filter { (_, reminder) -> reminder.lastNotified < System.currentTimeMillis() - REMINDER_TIMEOUT }.keys val threadsThatAlerted: Set = NotificationFactory.notify( context = ContextThemeWrapper(context, R.style.TextSecure_LightTheme), state = state, visibleThreadId = visibleThread, targetThreadId = threadId, defaultBubbleState = defaultBubbleState, lastAudibleNotification = lastAudibleNotification, alertOverrides = alertOverrides ) lastAudibleNotification = System.currentTimeMillis() updateReminderTimestamps(context, alertOverrides, threadsThatAlerted) ServiceUtil.getNotificationManager(context).cancelOrphanedNotifications(context, state) updateBadge(context, state.messageCount) val smsIds: MutableList = mutableListOf() val mmsIds: MutableList = mutableListOf() for (item: NotificationItemV2 in state.notificationItems) { if (item.isMms) { mmsIds.add(item.id) } else { smsIds.add(item.id) } } DatabaseFactory.getMmsSmsDatabase(context).setNotifiedTimestamp(System.currentTimeMillis(), smsIds, mmsIds) Log.i(TAG, "threads: ${state.threadCount} messages: ${state.messageCount}") } private fun updateReminderTimestamps(context: Context, alertOverrides: Set, threadsThatAlerted: Set) { if (TextSecurePreferences.getRepeatAlertsCount(context) == 0) { return } val iterator: MutableIterator> = threadReminders.iterator() while (iterator.hasNext()) { val entry: MutableEntry = iterator.next() val (id: Long, reminder: Reminder) = entry if (alertOverrides.contains(id)) { val notifyCount: Int = reminder.count + 1 if (notifyCount >= TextSecurePreferences.getRepeatAlertsCount(context)) { iterator.remove() } else { entry.setValue(Reminder(lastAudibleNotification, notifyCount)) } } } for (alertedThreadId: Long in threadsThatAlerted) { threadReminders[alertedThreadId] = Reminder(lastAudibleNotification) } if (threadReminders.isNotEmpty()) { scheduleReminder(context) } else { lastScheduledReminder = 0 } } private fun scheduleReminder(context: Context) { val timeout: Long = if (lastScheduledReminder != 0L) { max(TimeUnit.SECONDS.toMillis(5), REMINDER_TIMEOUT - (System.currentTimeMillis() - lastScheduledReminder)) } else { REMINDER_TIMEOUT } val alarmManager: AlarmManager? = ContextCompat.getSystemService(context, AlarmManager::class.java) val pendingIntent: PendingIntent = PendingIntent.getBroadcast(context, 0, Intent(context, ReminderReceiver::class.java), PendingIntent.FLAG_UPDATE_CURRENT) alarmManager?.set(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + timeout, pendingIntent) lastScheduledReminder = System.currentTimeMillis() } private fun clearReminderInternal(context: Context) { lastScheduledReminder = 0 threadReminders.clear() val pendingIntent: PendingIntent = PendingIntent.getBroadcast(context, 0, Intent(context, ReminderReceiver::class.java), PendingIntent.FLAG_CANCEL_CURRENT) val alarmManager: AlarmManager? = ContextCompat.getSystemService(context, AlarmManager::class.java) alarmManager?.cancel(pendingIntent) } override fun clearReminder(context: Context) { // Intentionally left blank } companion object { private val TAG = Log.tag(MessageNotifierV2::class.java) private val REMINDER_TIMEOUT = TimeUnit.MINUTES.toMillis(2) private fun updateBadge(context: Context, count: Int) { try { if (count == 0) ShortcutBadger.removeCount(context) else ShortcutBadger.applyCount(context, count) } catch (t: Throwable) { Log.w(TAG, t) } } } private fun NotificationManager.cancelOrphanedNotifications(context: Context, state: NotificationStateV2) { if (Build.VERSION.SDK_INT < 23) { return } try { for (notification: StatusBarNotification in activeNotifications) { if (notification.id != NotificationIds.MESSAGE_SUMMARY && notification.id != KeyCachingService.SERVICE_RUNNING_ID && notification.id != IncomingMessageObserver.FOREGROUND_ID && notification.id != NotificationIds.PENDING_MESSAGES && !CallNotificationBuilder.isWebRtcNotification(notification.id) ) { if (!state.notificationIds.contains(notification.id)) { Log.d(TAG, "Cancelling orphaned notification: ${notification.id}") NotificationCancellationHelper.cancel(context, notification.id) } } } } catch (e: Throwable) { Log.w(TAG, e) } } private data class Reminder(val lastNotified: Long, val count: Int = 0) } private class CancelableExecutor { private val executor: Executor = Executors.newSingleThreadExecutor() private val tasks: MutableSet = mutableSetOf() fun enqueue(context: Context, threadId: Long) { execute(DelayedNotification(context, threadId)) } private fun execute(runnable: DelayedNotification) { synchronized(tasks) { tasks.add(runnable) } val wrapper = Runnable { runnable.run() synchronized(tasks) { tasks.remove(runnable) } } executor.execute(wrapper) } fun cancel() { synchronized(tasks) { for (task in tasks) { task.cancel() } } } private class DelayedNotification constructor(private val context: Context, private val threadId: Long) : Runnable { private val canceled = AtomicBoolean(false) private val delayUntil: Long = System.currentTimeMillis() + DELAY override fun run() { val delayMillis = delayUntil - System.currentTimeMillis() Log.i(TAG, "Waiting to notify: $delayMillis") if (delayMillis > 0) { Util.sleep(delayMillis) } if (!canceled.get()) { Log.i(TAG, "Not canceled, notifying...") ApplicationDependencies.getMessageNotifier().updateNotification(context, threadId, true) ApplicationDependencies.getMessageNotifier().cancelDelayedNotifications() } else { Log.w(TAG, "Canceled, not notifying...") } } fun cancel() { canceled.set(true) } companion object { private val DELAY = TimeUnit.SECONDS.toMillis(5) private val TAG = Log.tag(DelayedNotification::class.java) } } }