mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-22 09:49:30 +01:00
Add Notification profiles.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user