Add Notification profiles.

This commit is contained in:
Cody Henthorne
2021-12-08 13:22:36 -05:00
parent 31e0696395
commit 6c608e955e
106 changed files with 5692 additions and 238 deletions

View File

@@ -0,0 +1,25 @@
package org.thoughtcrime.securesms.notifications.profiles
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.recipients.RecipientId
data class NotificationProfile(
val id: Long,
val name: String,
val emoji: String,
val color: AvatarColor = AvatarColor.A210,
val createdAt: Long,
val allowAllCalls: Boolean = false,
val allowAllMentions: Boolean = false,
val schedule: NotificationProfileSchedule,
val allowedMembers: Set<RecipientId> = emptySet()
) : Comparable<NotificationProfile> {
fun isRecipientAllowed(id: RecipientId): Boolean {
return allowedMembers.contains(id)
}
override fun compareTo(other: NotificationProfile): Int {
return createdAt.compareTo(other.createdAt)
}
}

View File

@@ -0,0 +1,73 @@
package org.thoughtcrime.securesms.notifications.profiles
import org.thoughtcrime.securesms.util.isBetween
import org.thoughtcrime.securesms.util.toLocalDateTime
import java.time.DayOfWeek
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
/**
* Encapsulate when a notification should be active based on days of the week, start time,
* and end times.
*
* Examples:
*
* start: 9am end: 5pm daysEnabled: Monday would return true for times between Monday 9am and Monday 5pm
* start: 9pm end: 5am daysEnabled: Monday would return true for times between Monday 9pm and Tuesday 5am
* start: 12am end: 12am daysEnabled: Monday would return true for times between Monday 12am and Monday 11:59:59pm
*/
data class NotificationProfileSchedule(
val id: Long,
val enabled: Boolean = false,
val start: Int = 900,
val end: Int = 1700,
val daysEnabled: Set<DayOfWeek> = emptySet()
) {
@JvmOverloads
fun isCurrentlyActive(now: Long, zoneId: ZoneId = ZoneId.systemDefault()): Boolean {
if (!enabled) {
return false
}
return coversTime(now, zoneId)
}
@JvmOverloads
fun coversTime(time: Long, zoneId: ZoneId = ZoneId.systemDefault()): Boolean {
val localNow: LocalDateTime = time.toLocalDateTime(zoneId)
val localStart: LocalDateTime = start.toLocalDateTime(localNow)
val localEnd: LocalDateTime = end.toLocalDateTime(localNow)
return if (end < start) {
(daysEnabled.contains(localStart.dayOfWeek.minus(1)) && localNow.isBetween(localStart.minusDays(1), localEnd)) ||
(daysEnabled.contains(localStart.dayOfWeek) && localNow.isBetween(localStart, localEnd.plusDays(1)))
} else {
daysEnabled.contains(localStart.dayOfWeek) && localNow.isBetween(localStart, localEnd)
}
}
fun startTime(): LocalTime {
return LocalTime.of(start / 100, start % 100)
}
fun startDateTime(now: LocalDateTime): LocalDateTime {
return start.toLocalDateTime(now)
}
fun endTime(): LocalTime {
return LocalTime.of(end / 100, end % 100)
}
fun endDateTime(now: LocalDateTime): LocalDateTime {
return end.toLocalDateTime(now)
}
}
fun Int.toLocalDateTime(now: LocalDateTime): LocalDateTime {
if (this == 2400) {
return now.plusDays(1).withHour(0)
}
return now.withHour(this / 100).withMinute(this % 100)
}

View File

@@ -0,0 +1,64 @@
package org.thoughtcrime.securesms.notifications.profiles
import android.content.Context
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.NotificationProfileValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.formatHours
import org.thoughtcrime.securesms.util.toLocalDateTime
import org.thoughtcrime.securesms.util.toLocalTime
import org.thoughtcrime.securesms.util.toMillis
import java.time.LocalDateTime
import java.time.ZoneId
/**
* Helper for determining the single, currently active Notification Profile (if any) and also how to describe
* how long the active profile will be on for.
*/
object NotificationProfiles {
@JvmStatic
@JvmOverloads
fun getActiveProfile(profiles: List<NotificationProfile>, now: Long = System.currentTimeMillis(), zoneId: ZoneId = ZoneId.systemDefault()): NotificationProfile? {
val storeValues: NotificationProfileValues = SignalStore.notificationProfileValues()
val localNow: LocalDateTime = now.toLocalDateTime(zoneId)
val manualProfile: NotificationProfile? = profiles.firstOrNull { it.id == storeValues.manuallyEnabledProfile }
val scheduledProfile: NotificationProfile? = profiles.sortedDescending().filter { it.schedule.isCurrentlyActive(now, zoneId) }.firstOrNull { profile ->
profile.schedule.startDateTime(localNow).toMillis() > storeValues.manuallyDisabledAt
}
if (manualProfile == null || scheduledProfile == null) {
return (if (now < storeValues.manuallyEnabledUntil) manualProfile else null) ?: scheduledProfile
}
return if (manualProfile == scheduledProfile) {
if (storeValues.manuallyEnabledUntil == Long.MAX_VALUE || now < storeValues.manuallyEnabledUntil) {
manualProfile
} else {
null
}
} else {
scheduledProfile
}
}
fun getActiveProfileDescription(context: Context, profile: NotificationProfile, now: Long = System.currentTimeMillis()): String {
val storeValues: NotificationProfileValues = SignalStore.notificationProfileValues()
if (profile.id == storeValues.manuallyEnabledProfile) {
if (storeValues.manuallyEnabledUntil.isForever()) {
return context.getString(R.string.NotificationProfilesFragment__on)
} else if (now < storeValues.manuallyEnabledUntil) {
return context.getString(R.string.NotificationProfileSelection__on_until_s, storeValues.manuallyEnabledUntil.toLocalTime().formatHours())
}
}
return context.getString(R.string.NotificationProfileSelection__on_until_s, profile.schedule.endTime().formatHours())
}
private fun Long.isForever(): Boolean {
return this == Long.MAX_VALUE
}
}

View File

@@ -10,9 +10,11 @@ import android.os.Build
import android.service.notification.StatusBarNotification
import androidx.appcompat.view.ContextThemeWrapper
import androidx.core.content.ContextCompat
import io.reactivex.rxjava3.kotlin.subscribeBy
import me.leolin.shortcutbadger.ShortcutBadger
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository
import org.thoughtcrime.securesms.database.MessageDatabase
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
@@ -22,6 +24,8 @@ 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.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles
import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.KeyCachingService
@@ -48,12 +52,24 @@ class MessageNotifierV2(context: Application) : MessageNotifier {
@Volatile private var previousLockedStatus: Boolean = KeyCachingService.isLocked(context)
@Volatile private var previousPrivacyPreference: NotificationPrivacyPreference = SignalStore.settings().messageNotificationsPrivacy
@Volatile private var previousState: NotificationStateV2 = NotificationStateV2.EMPTY
@Volatile private var notificationProfile: NotificationProfile? = null
@Volatile private var notificationProfileInitialized: Boolean = false
private val threadReminders: MutableMap<Long, Reminder> = ConcurrentHashMap()
private val stickyThreads: MutableMap<Long, StickyThread> = mutableMapOf()
private val executor = CancelableExecutor()
init {
NotificationProfilesRepository().getProfiles()
.subscribeBy(
onNext = {
notificationProfile = NotificationProfiles.getActiveProfile(it)
notificationProfileInitialized = true
}
)
}
override fun setVisibleThread(threadId: Long) {
visibleThread = threadId
stickyThreads.remove(threadId)
@@ -115,10 +131,6 @@ class MessageNotifierV2(context: Application) : MessageNotifier {
reminderCount: Int,
defaultBubbleState: BubbleState
) {
if (!SignalStore.settings().isMessageNotificationsEnabled) {
return
}
val currentLockStatus: Boolean = KeyCachingService.isLocked(context)
val currentPrivacyPreference: NotificationPrivacyPreference = SignalStore.settings().messageNotificationsPrivacy
val notificationConfigurationChanged: Boolean = currentLockStatus != previousLockedStatus || currentPrivacyPreference != previousPrivacyPreference
@@ -129,10 +141,42 @@ class MessageNotifierV2(context: Application) : MessageNotifier {
stickyThreads.clear()
}
if (!notificationProfileInitialized) {
notificationProfile = NotificationProfiles.getActiveProfile(SignalDatabase.notificationProfiles.getProfiles())
notificationProfileInitialized = true
}
Log.internal().i(TAG, "sticky thread: $stickyThreads")
var state: NotificationStateV2 = NotificationStateProvider.constructNotificationState(context, stickyThreads)
var state: NotificationStateV2 = NotificationStateProvider.constructNotificationState(stickyThreads, notificationProfile)
Log.internal().i(TAG, "state: $state")
if (state.muteFilteredMessages.isNotEmpty()) {
Log.i(TAG, "Marking ${state.muteFilteredMessages.size} muted messages as notified to skip notification")
state.muteFilteredMessages.forEach { item ->
val messageDatabase: MessageDatabase = if (item.isMms) SignalDatabase.mms else SignalDatabase.sms
messageDatabase.markAsNotified(item.id)
}
}
if (state.profileFilteredMessages.isNotEmpty()) {
Log.i(TAG, "Marking ${state.profileFilteredMessages.size} profile filtered messages as notified to skip notification")
state.profileFilteredMessages.forEach { item ->
val messageDatabase: MessageDatabase = if (item.isMms) SignalDatabase.mms else SignalDatabase.sms
messageDatabase.markAsNotified(item.id)
}
}
if (!SignalStore.settings().isMessageNotificationsEnabled) {
Log.i(TAG, "Marking ${state.conversations.size} conversations as notified to skip notification")
state.conversations.forEach { conversation ->
conversation.notificationItems.forEach { item ->
val messageDatabase: MessageDatabase = if (item.isMms) SignalDatabase.mms else SignalDatabase.sms
messageDatabase.markAsNotified(item.id)
}
}
return
}
val displayedNotifications: Set<Int>? = ServiceUtil.getNotificationManager(context).getDisplayedNotificationIds().getOrNull()
if (displayedNotifications != null) {
val cleanedUpThreadIds: MutableSet<Long> = mutableSetOf()
@@ -146,7 +190,7 @@ class MessageNotifierV2(context: Application) : MessageNotifier {
}
if (cleanedUpThreadIds.isNotEmpty()) {
Log.i(TAG, "Cleaned up ${cleanedUpThreadIds.size} thread(s) with dangling notifications")
state = NotificationStateV2(state.conversations.filterNot { cleanedUpThreadIds.contains(it.threadId) })
state = state.copy(conversations = state.conversations.filterNot { cleanedUpThreadIds.contains(it.threadId) })
}
}

View File

@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.notifications.v2
import android.content.Context
import androidx.annotation.WorkerThread
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.MmsSmsColumns
@@ -10,9 +9,9 @@ import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.CursorUtil
import java.lang.IllegalStateException
/**
* Queries the message databases to determine messages that should be in notifications.
@@ -22,7 +21,7 @@ object NotificationStateProvider {
private val TAG = Log.tag(NotificationStateProvider::class.java)
@WorkerThread
fun constructNotificationState(context: Context, stickyThreads: Map<Long, MessageNotifierV2.StickyThread>): NotificationStateV2 {
fun constructNotificationState(stickyThreads: Map<Long, MessageNotifierV2.StickyThread>, notificationProfile: NotificationProfile?): NotificationStateV2 {
val messages: MutableList<NotificationMessage> = mutableListOf()
SignalDatabase.mmsSms.getMessagesForNotificationState(stickyThreads.values).use { unreadMessages ->
@@ -60,18 +59,30 @@ object NotificationStateProvider {
}
val conversations: MutableList<NotificationConversation> = mutableListOf()
val muteFilteredMessages: MutableList<NotificationStateV2.FilteredMessage> = mutableListOf()
val profileFilteredMessages: MutableList<NotificationStateV2.FilteredMessage> = mutableListOf()
messages.groupBy { it.threadId }
.forEach { (threadId, threadMessages) ->
var notificationItems: MutableList<NotificationItemV2> = mutableListOf()
for (notification: NotificationMessage in threadMessages) {
if (notification.includeMessage()) {
notificationItems.add(MessageNotification(notification.threadRecipient, notification.messageRecord))
when (notification.includeMessage(notificationProfile)) {
MessageInclusion.INCLUDE -> notificationItems.add(MessageNotification(notification.threadRecipient, notification.messageRecord))
MessageInclusion.EXCLUDE -> Unit
MessageInclusion.MUTE_FILTERED -> muteFilteredMessages += NotificationStateV2.FilteredMessage(notification.messageRecord.id, notification.messageRecord.isMms)
MessageInclusion.PROFILE_FILTERED -> profileFilteredMessages += NotificationStateV2.FilteredMessage(notification.messageRecord.id, notification.messageRecord.isMms)
}
if (notification.hasUnreadReactions) {
notification.reactions.filter { notification.includeReaction(it) }
.forEach { notificationItems.add(ReactionNotification(notification.threadRecipient, notification.messageRecord, it)) }
notification.reactions.forEach {
when (notification.includeReaction(it, notificationProfile)) {
MessageInclusion.INCLUDE -> notificationItems.add(ReactionNotification(notification.threadRecipient, notification.messageRecord, it))
MessageInclusion.EXCLUDE -> Unit
MessageInclusion.MUTE_FILTERED -> muteFilteredMessages += NotificationStateV2.FilteredMessage(notification.messageRecord.id, notification.messageRecord.isMms)
MessageInclusion.PROFILE_FILTERED -> profileFilteredMessages += NotificationStateV2.FilteredMessage(notification.messageRecord.id, notification.messageRecord.isMms)
}
}
}
}
@@ -86,7 +97,7 @@ object NotificationStateProvider {
}
}
return NotificationStateV2(conversations)
return NotificationStateV2(conversations, muteFilteredMessages, profileFilteredMessages)
}
private data class NotificationMessage(
@@ -101,18 +112,40 @@ object NotificationStateProvider {
) {
private val isUnreadIncoming: Boolean = isUnreadMessage && !messageRecord.isOutgoing
fun includeMessage(): Boolean {
return (isUnreadIncoming || stickyThread) && (threadRecipient.isNotMuted || (threadRecipient.isAlwaysNotifyMentions && messageRecord.hasSelfMention()))
fun includeMessage(notificationProfile: NotificationProfile?): MessageInclusion {
return if (isUnreadIncoming || stickyThread) {
if (threadRecipient.isMuted && (threadRecipient.isDoNotNotifyMentions || !messageRecord.hasSelfMention())) {
MessageInclusion.MUTE_FILTERED
} else if (notificationProfile != null && !notificationProfile.isRecipientAllowed(threadRecipient.id) && !(notificationProfile.allowAllMentions && messageRecord.hasSelfMention())) {
MessageInclusion.PROFILE_FILTERED
} else {
MessageInclusion.INCLUDE
}
} else {
MessageInclusion.EXCLUDE
}
}
fun includeReaction(reaction: ReactionRecord): Boolean {
return reaction.author != Recipient.self().id && messageRecord.isOutgoing && reaction.dateReceived > lastReactionRead && threadRecipient.isNotMuted
fun includeReaction(reaction: ReactionRecord, notificationProfile: NotificationProfile?): MessageInclusion {
return if (threadRecipient.isMuted) {
MessageInclusion.MUTE_FILTERED
} else if (notificationProfile != null && !notificationProfile.isRecipientAllowed(threadRecipient.id)) {
MessageInclusion.PROFILE_FILTERED
} else if (reaction.author != Recipient.self().id && messageRecord.isOutgoing && reaction.dateReceived > lastReactionRead) {
MessageInclusion.INCLUDE
} else {
MessageInclusion.EXCLUDE
}
}
private val Recipient.isNotMuted: Boolean
get() = !isMuted
private val Recipient.isDoNotNotifyMentions: Boolean
get() = mentionSetting == RecipientDatabase.MentionSetting.DO_NOT_NOTIFY
}
private val Recipient.isAlwaysNotifyMentions: Boolean
get() = mentionSetting == RecipientDatabase.MentionSetting.ALWAYS_NOTIFY
private enum class MessageInclusion {
INCLUDE,
EXCLUDE,
MUTE_FILTERED,
PROFILE_FILTERED
}
}

View File

@@ -11,7 +11,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
/**
* Hold all state for notifications for all conversations.
*/
data class NotificationStateV2(val conversations: List<NotificationConversation>) {
data class NotificationStateV2(val conversations: List<NotificationConversation>, val muteFilteredMessages: List<FilteredMessage>, val profileFilteredMessages: List<FilteredMessage>) {
val threadCount: Int = conversations.size
val isEmpty: Boolean = conversations.isEmpty()
@@ -91,7 +91,9 @@ data class NotificationStateV2(val conversations: List<NotificationConversation>
.toSet()
}
data class FilteredMessage(val id: Long, val isMms: Boolean)
companion object {
val EMPTY = NotificationStateV2(emptyList())
val EMPTY = NotificationStateV2(emptyList(), emptyList(), emptyList())
}
}