Update unread reminder system.

This commit is contained in:
Michelle Tang
2026-09-02 16:11:24 -03:00
committed by Alex Hart
parent fe5fc7dc78
commit b84bb29b53
19 changed files with 424 additions and 112 deletions
@@ -257,6 +257,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
.addPostRender(() -> ActiveCallManager.clearNotifications(this))
.addPostRender(RestoreOptimizedMediaJob::enqueueIfNecessary)
.addPostRender(() -> AppDependencies.getPinnedMessageManager().scheduleIfNecessary())
.addPostRender(() -> AppDependencies.getUnreadReminderManager().scheduleIfNecessary())
.execute();
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
@@ -42,6 +42,7 @@ import org.thoughtcrime.securesms.calls.quality.CallQualityBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.notifications.ReminderType
import org.thoughtcrime.securesms.components.settings.app.privacy.advanced.AdvancedPrivacySettingsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.configure
@@ -454,9 +455,10 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
clickPref(
title = DSLSettingsText.from("Run unread reminder job"),
summary = DSLSettingsText.from("Generates an unread reminder notification based on unreads and muted preferences."),
summary = DSLSettingsText.from("Generates an unread reminder notification based on unreads and muted preferences. Skips the three day cooldown."),
onClick = {
UnreadReminderJob.enqueue()
val threadIds = (SignalDatabase.threads.getMutedThreadIds(ReminderType.MESSAGES, 0) + SignalDatabase.threads.getMutedThreadIds(ReminderType.CALLS, 0)).distinct()
threadIds.forEach { threadId -> UnreadReminderJob.enqueue(threadId, 0) }
}
)
@@ -37,6 +37,7 @@ import org.thoughtcrime.securesms.jobs.CallLinkUpdateSendJob
import org.thoughtcrime.securesms.jobs.CallSyncEventJob
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.UnreadReminderManager
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.whispersystems.signalservice.internal.push.SyncMessage.CallEvent
import java.util.UUID
@@ -194,17 +195,18 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
}
/**
* Returns the number of unread calls and up to three distinct callers from eligible [threadIds].
* Returns the number of unread calls and up to three distinct callers from [threadId].
*/
fun getUnreadCallsForReminderNotification(threadIds: List<Long>): Pair<Int, List<RecipientId>> {
fun getUnreadCallsForReminderNotification(threadId: Long, now: Long = System.currentTimeMillis()): Pair<Int, List<RecipientId>> {
val peerIds = readableDatabase
.select("$TABLE_NAME.$PEER")
.from("$TABLE_NAME INNER JOIN ${MessageTable.TABLE_NAME} ON $TABLE_NAME.$MESSAGE_ID = ${MessageTable.TABLE_NAME}.${MessageTable.ID}")
.where(
"""
${MessageTable.TABLE_NAME}.${MessageTable.THREAD_ID} IN (${threadIds.joinToString(",")}) AND
${MessageTable.TABLE_NAME}.${MessageTable.THREAD_ID} = $threadId AND
$TABLE_NAME.$READ = ${ReadState.serialize(ReadState.UNREAD)} AND
($TABLE_NAME.$EVENT = ${Event.serialize(Event.MISSED)} OR $TABLE_NAME.$EVENT = ${Event.serialize(Event.MISSED_NOTIFICATION_PROFILE)})
($TABLE_NAME.$EVENT = ${Event.serialize(Event.MISSED)} OR $TABLE_NAME.$EVENT = ${Event.serialize(Event.MISSED_NOTIFICATION_PROFILE)}) AND
$TABLE_NAME.$TIMESTAMP > ${now - UnreadReminderManager.MAX_UNREAD_MESSAGE_AGE.inWholeMilliseconds}
"""
)
.orderBy("$TABLE_NAME.$TIMESTAMP DESC")
@@ -214,6 +216,52 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
return peerIds.size to peerIds.distinct().take(3)
}
/**
* Returns true if [threadId] has an unread missed call that occurred after [since].
*/
fun hasUnreadCallsSince(threadId: Long, since: Long): Boolean {
return readableDatabase
.exists("$TABLE_NAME INNER JOIN ${MessageTable.TABLE_NAME} ON $TABLE_NAME.$MESSAGE_ID = ${MessageTable.TABLE_NAME}.${MessageTable.ID}")
.where(
"""
${MessageTable.TABLE_NAME}.${MessageTable.THREAD_ID} = $threadId AND
$TABLE_NAME.$READ = ${ReadState.serialize(ReadState.UNREAD)} AND
($TABLE_NAME.$EVENT = ${Event.serialize(Event.MISSED)} OR $TABLE_NAME.$EVENT = ${Event.serialize(Event.MISSED_NOTIFICATION_PROFILE)}) AND
$TABLE_NAME.$TIMESTAMP > $since
"""
)
.run()
}
/**
* Returns the thread id and timestamp of the oldest unread missed call across [threadIds].
* If there is none, it returns -1 for thread id and Long.MAX_VALUE for timestamp.
*/
fun getOldestUnreadCall(threadIds: List<Long>): Pair<Long, Long> {
if (threadIds.isEmpty()) {
return Pair(-1, Long.MAX_VALUE)
}
val query = SqlUtil.buildFastCollectionQuery("${MessageTable.TABLE_NAME}.${MessageTable.THREAD_ID}", threadIds)
return readableDatabase
.select("${MessageTable.TABLE_NAME}.${MessageTable.THREAD_ID}", "$TABLE_NAME.$TIMESTAMP")
.from("$TABLE_NAME INNER JOIN ${MessageTable.TABLE_NAME} ON $TABLE_NAME.$MESSAGE_ID = ${MessageTable.TABLE_NAME}.${MessageTable.ID}")
.where(
"""
${query.where} AND
$TABLE_NAME.$READ = ${ReadState.serialize(ReadState.UNREAD)} AND
($TABLE_NAME.$EVENT = ${Event.serialize(Event.MISSED)} OR $TABLE_NAME.$EVENT = ${Event.serialize(Event.MISSED_NOTIFICATION_PROFILE)}) AND
$TABLE_NAME.$TIMESTAMP > ${System.currentTimeMillis() - UnreadReminderManager.MAX_UNREAD_MESSAGE_AGE.inWholeMilliseconds}
""",
query.whereArgs
)
.orderBy("$TABLE_NAME.$TIMESTAMP ASC")
.limit(1)
.run()
.readToSingleObject { cursor -> cursor.requireLong(MessageTable.THREAD_ID) to cursor.requireLong(TIMESTAMP) } ?: Pair(-1, Long.MAX_VALUE)
}
fun insertOneToOneCall(callId: Long, timestamp: Long, peer: RecipientId, type: Type, direction: Direction, event: Event, fromSync: Boolean = false) {
val messageType: Long = Call.getMessageType(type, direction, event)
@@ -142,6 +142,7 @@ import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.revealable.ViewOnceExpirationInfo
import org.thoughtcrime.securesms.service.UnreadReminderManager
import org.thoughtcrime.securesms.sms.GroupV2UpdateMessageUtil
import org.thoughtcrime.securesms.stories.Stories.isFeatureEnabled
import org.thoughtcrime.securesms.util.DateUtils
@@ -5400,10 +5401,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
/**
* Returns the number of unread messages (based on the [ReminderType]) and up to three distinct authors from eligible [threadIds].
* Returns the number of unread messages (based on the [ReminderType]) and up to three distinct authors from [threadId]
* For missed calls, see [getUnreadContentForReminderNotification] in the calls table.
*/
fun getUnreadContentForReminderNotification(threadIds: List<Long>, type: ReminderType): Pair<Int, List<RecipientId>> {
fun getUnreadContentForReminderNotification(threadId: Long, type: ReminderType, now: Long = System.currentTimeMillis()): Pair<Int, List<RecipientId>> {
val categoryClause = when (type) {
ReminderType.MENTIONS -> "AND $MENTIONS_SELF = 1"
ReminderType.REPLIES -> "AND $QUOTE_AUTHOR = ${Recipient.self().id.serialize()}"
@@ -5415,13 +5416,14 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
.from("$TABLE_NAME INDEXED BY $INDEX_THREAD_UNREAD_COUNT")
.where(
"""
$THREAD_ID IN (${threadIds.joinToString(",")}) AND
$READ = 0 AND
$THREAD_ID = $threadId AND
$STORY_TYPE = 0 AND
$PARENT_STORY_ID <= 0 AND
$ORIGINAL_MESSAGE_ID IS NULL AND
$SCHEDULED_DATE = -1 AND
($TYPE & ${MessageTypes.SPECIAL_TYPES_MASK}) != ${MessageTypes.SPECIAL_TYPE_PINNED_MESSAGE}
$ORIGINAL_MESSAGE_ID IS NULL AND
$READ = 0 AND
($TYPE & ${MessageTypes.SPECIAL_TYPES_MASK}) != ${MessageTypes.SPECIAL_TYPE_PINNED_MESSAGE} AND
$DATE_RECEIVED > ${now - UnreadReminderManager.MAX_UNREAD_MESSAGE_AGE.inWholeMilliseconds}
$categoryClause
"""
)
@@ -5432,6 +5434,60 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return authorIds.size to authorIds.distinct().take(3)
}
/**
* Returns true if [threadId] has an unread message that arrived after [since].
*/
fun hasUnreadMessagesSince(threadId: Long, since: Long): Boolean {
return readableDatabase
.exists(TABLE_NAME)
.where(
"""
$THREAD_ID = $threadId AND
$STORY_TYPE = 0 AND
$PARENT_STORY_ID <= 0 AND
$SCHEDULED_DATE = -1 AND
$ORIGINAL_MESSAGE_ID IS NULL AND
$READ = 0 AND
($TYPE & ${MessageTypes.SPECIAL_TYPES_MASK}) != ${MessageTypes.SPECIAL_TYPE_PINNED_MESSAGE} AND
$DATE_RECEIVED > $since
"""
)
.run()
}
/**
* Returns the thread id and timestamp of the oldest unread message across [threadIds].
* If there is none, it returns -1 for thread id and Long.MAX_VALUE for timestamp.
*/
fun getOldestUnreadMessage(threadIds: List<Long>): Pair<Long, Long> {
if (threadIds.isEmpty()) {
return Pair(-1, Long.MAX_VALUE)
}
val query = SqlUtil.buildFastCollectionQuery(THREAD_ID, threadIds)
return readableDatabase
.select(THREAD_ID, DATE_RECEIVED)
.from("$TABLE_NAME INDEXED BY $INDEX_THREAD_UNREAD_COUNT")
.where(
"""
${query.where} AND
$STORY_TYPE = 0 AND
$PARENT_STORY_ID <= 0 AND
$SCHEDULED_DATE = -1 AND
$ORIGINAL_MESSAGE_ID IS NULL AND
$READ = 0 AND
($TYPE & ${MessageTypes.SPECIAL_TYPES_MASK}) != ${MessageTypes.SPECIAL_TYPE_PINNED_MESSAGE} AND
$DATE_RECEIVED > ${System.currentTimeMillis() - UnreadReminderManager.MAX_UNREAD_MESSAGE_AGE.inWholeMilliseconds}
""",
query.whereArgs
)
.orderBy("$DATE_RECEIVED ASC")
.limit(1)
.run()
.readToSingleObject { cursor -> cursor.requireLong(THREAD_ID) to cursor.requireLong(DATE_RECEIVED) } ?: Pair(-1, Long.MAX_VALUE)
}
fun messageExists(messageRecord: MessageRecord): Boolean {
return readableDatabase
.exists(TABLE_NAME)
@@ -127,6 +127,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
const val PINNED_ORDER = "pinned_order"
const val UNREAD_SELF_MENTION_COUNT = "unread_self_mention_count"
const val ACTIVE = "active"
const val LAST_UNREAD_REMINDER = "last_unread_reminder"
const val MAX_CACHE_SIZE = 1000
@@ -158,7 +159,8 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
$UNREAD_SELF_MENTION_COUNT INTEGER DEFAULT 0,
$ACTIVE INTEGER DEFAULT 0,
$SNIPPET_MESSAGE_EXTRAS BLOB DEFAULT NULL,
$SNIPPET_MESSAGE_ID INTEGER DEFAULT 0
$SNIPPET_MESSAGE_ID INTEGER DEFAULT 0,
$LAST_UNREAD_REMINDER INTEGER DEFAULT 0
)
"""
@@ -681,25 +683,48 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
* e.g. when getting missed calls, we get the muted threads that have opted into unread reminders
* and also have notify for calls while muted on.
*/
fun getMutedThreadIds(reminderType: ReminderType): List<Long> {
val isGV2Clause = "${RecipientTable.TABLE_NAME}.${RecipientTable.TYPE} = ${RecipientTable.RecipientType.GV2.id}"
fun getMutedThreadIds(reminderType: ReminderType, reminderThreshold: Long, now: Long = System.currentTimeMillis()): List<Long> {
val unreadReminderClause = getNotificationClause(RecipientTable.UNREAD_REMINDER, SignalStore.settings.unreadReminderEnabled)
val reminderClause = when (reminderType) {
ReminderType.MESSAGES -> unreadReminderClause
ReminderType.CALLS -> "$unreadReminderClause AND (${getNotificationClause(RecipientTable.CALL_NOTIFICATION_SETTING, SignalStore.settings.allowCallsWhileMuted)})"
ReminderType.MENTIONS -> "$unreadReminderClause AND $isGV2Clause AND (${getNotificationClause(RecipientTable.MENTION_SETTING, SignalStore.settings.allowMentionsWhileMuted)})"
ReminderType.REPLIES -> "$unreadReminderClause AND $isGV2Clause AND (${getNotificationClause(RecipientTable.REPLY_NOTIFICATION_SETTING, SignalStore.settings.allowRepliesWhileMuted)})"
ReminderType.MESSAGES -> "AND $unreadReminderClause AND $UNREAD_COUNT > 0"
ReminderType.CALLS -> "AND $unreadReminderClause AND (${getNotificationClause(RecipientTable.CALL_NOTIFICATION_SETTING, SignalStore.settings.allowCallsWhileMuted)})"
else -> ""
}
return readableDatabase
.select("$TABLE_NAME.$ID")
.from("$TABLE_NAME INNER JOIN ${RecipientTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${RecipientTable.TABLE_NAME}.${RecipientTable.ID}")
.where("$ARCHIVED = 0 AND ${RecipientTable.MUTE_UNTIL} >= ${System.currentTimeMillis()} AND $reminderClause")
.where(
"""
$ACTIVE = 1 AND
$ARCHIVED = 0 AND
$LAST_UNREAD_REMINDER < ${now - reminderThreshold} AND
${RecipientTable.MUTE_UNTIL} >= $now
$reminderClause
""".trimIndent()
)
.run()
.readToList { it.requireLong(ID) }
}
fun getUnreadReminderTime(threadId: Long): Long {
return readableDatabase
.select(LAST_UNREAD_REMINDER)
.from(TABLE_NAME)
.where("$ID = ?", threadId)
.run()
.readToSingleLong(0)
}
fun setUnreadReminderTime(threadId: Long, timestamp: Long) {
writableDatabase
.update(TABLE_NAME)
.values(LAST_UNREAD_REMINDER to timestamp)
.where("$ID = ?", threadId)
.run()
}
private fun getNotificationClause(column: String, allowByDefault: Boolean): String {
return if (allowByDefault) {
"${RecipientTable.TABLE_NAME}.$column != ${RecipientTable.NotificationSetting.DO_NOT_NOTIFY.id}"
@@ -179,6 +179,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V323_AddStickerPack
import org.thoughtcrime.securesms.database.helpers.migration.V324_MoveGroupV1StorageIdsToUnknownIds
import org.thoughtcrime.securesms.database.helpers.migration.V325_AddBlockedAtToRecipientTable
import org.thoughtcrime.securesms.database.helpers.migration.V326_AddUnreadReminderColumn
import org.thoughtcrime.securesms.database.helpers.migration.V327_AddLastUnreadReminderColumn
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase
/**
@@ -365,10 +366,11 @@ object SignalDatabaseMigrations {
323 to V323_AddStickerPackStorageSync,
324 to V324_MoveGroupV1StorageIdsToUnknownIds,
325 to V325_AddBlockedAtToRecipientTable,
326 to V326_AddUnreadReminderColumn
326 to V326_AddUnreadReminderColumn,
327 to V327_AddLastUnreadReminderColumn
)
const val DATABASE_VERSION = 326
const val DATABASE_VERSION = 327
@JvmStatic
fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) {
@@ -0,0 +1,16 @@
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import org.thoughtcrime.securesms.database.SQLiteDatabase
/**
* Adds a column to track when a thread last had an unread reminder sent.
*/
@Suppress("ClassName")
object V327_AddLastUnreadReminderColumn : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("ALTER TABLE thread ADD COLUMN last_unread_reminder INTEGER DEFAULT 0")
db.execSQL("UPDATE thread SET last_unread_reminder = last_seen")
}
}
@@ -69,6 +69,7 @@ import org.thoughtcrime.securesms.service.PendingRetryReceiptManager
import org.thoughtcrime.securesms.service.PinnedMessageManager
import org.thoughtcrime.securesms.service.ScheduledMessageManager
import org.thoughtcrime.securesms.service.TrimThreadsByDateManager
import org.thoughtcrime.securesms.service.UnreadReminderManager
import org.thoughtcrime.securesms.service.webrtc.SignalCallManager
import org.thoughtcrime.securesms.shakereport.ShakeToReport
import org.thoughtcrime.securesms.util.EarlyMessageCache
@@ -265,6 +266,11 @@ object AppDependencies {
provider.provideAndroidCallAudioManager()
}
@JvmStatic
val unreadReminderManager: UnreadReminderManager by lazy {
provider.provideUnreadReminderManager()
}
@JvmStatic
val billingApi: BillingApi by lazy {
provider.provideBillingApi()
@@ -540,6 +546,7 @@ object AppDependencies {
fun provideOkHttpClient(): OkHttpClient
fun provideScheduledMessageManager(): ScheduledMessageManager
fun providePinnedMessageManager(): PinnedMessageManager
fun provideUnreadReminderManager(): UnreadReminderManager
fun provideLibsignalNetwork(config: SignalServiceConfiguration): Network
fun provideBillingApi(): BillingApi
fun provideArchiveApi(pushServiceSocket: PushServiceSocket): ArchiveApi
@@ -109,6 +109,7 @@ import org.thoughtcrime.securesms.service.PendingRetryReceiptManager;
import org.thoughtcrime.securesms.service.PinnedMessageManager;
import org.thoughtcrime.securesms.service.ScheduledMessageManager;
import org.thoughtcrime.securesms.service.TrimThreadsByDateManager;
import org.thoughtcrime.securesms.service.UnreadReminderManager;
import org.thoughtcrime.securesms.service.webrtc.SignalCallManager;
import org.thoughtcrime.securesms.shakereport.ShakeToReport;
import org.thoughtcrime.securesms.stories.Stories;
@@ -338,6 +339,11 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
return new PinnedMessageManager(context);
}
@Override
public @NonNull UnreadReminderManager provideUnreadReminderManager() {
return new UnreadReminderManager(context);
}
@Override
public @NonNull Network provideLibsignalNetwork(@NonNull SignalServiceConfiguration config) {
Network network = new Network(BuildConfig.LIBSIGNAL_NET_ENV, StandardUserAgentInterceptor.USER_AGENT, RemoteConfig.getLibsignalConfigs(), Network.BuildVariant.PRODUCTION);
@@ -5,44 +5,59 @@ import android.app.PendingIntent
import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.content.ContextCompat
import androidx.core.content.LocusIdCompat
import org.signal.core.util.PendingIntentFlags
import org.signal.core.util.ServiceUtil
import org.signal.core.util.Stopwatch
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatar
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatarDrawable
import org.thoughtcrime.securesms.components.settings.app.notifications.ReminderType
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobs.protos.UnreadReminderJobData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.notifications.NotificationIds
import org.thoughtcrime.securesms.notifications.v2.getContactDrawable
import org.thoughtcrime.securesms.notifications.v2.makeUniqueToPreventMerging
import org.thoughtcrime.securesms.notifications.v2.toLargeBitmap
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.AvatarUtil
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.RemoteConfig
/**
* Job that periodically runs and sends an unread reminder for muted chats.
* Job that sends an unread reminder notification for a single muted thread.
*/
class UnreadReminderJob(parameters: Parameters) : Job(parameters) {
class UnreadReminderJob(private val threadId: Long, private val lastReminderTime: Long, parameters: Parameters) : Job(parameters) {
companion object {
private val TAG = Log.tag(UnreadReminderJob::class.java)
const val KEY = "UnreadReminderJob"
@JvmStatic
fun enqueue() {
fun enqueue(threadId: Long, lastReminderTime: Long) {
if (!RemoteConfig.internalUser || !NotificationChannels.getInstance().areNotificationsEnabled()) {
return
}
AppDependencies.jobManager.add(
UnreadReminderJob(
threadId = threadId,
lastReminderTime = lastReminderTime,
parameters = Parameters.Builder()
.setGlobalPriority(Parameters.PRIORITY_LOWER)
.setMaxInstancesForFactory(1)
.setQueue("UnreadReminderJob_$threadId")
.setMaxInstancesForQueue(1)
.build()
)
)
@@ -51,7 +66,9 @@ class UnreadReminderJob(parameters: Parameters) : Job(parameters) {
@VisibleForTesting
fun create(): UnreadReminderJob {
return UnreadReminderJob(
Parameters.Builder()
threadId = 1,
lastReminderTime = 0,
parameters = Parameters.Builder()
.setGlobalPriority(Parameters.PRIORITY_LOWER)
.setMaxInstancesForFactory(1)
.build()
@@ -59,7 +76,7 @@ class UnreadReminderJob(parameters: Parameters) : Job(parameters) {
}
}
override fun serialize(): ByteArray? = null
override fun serialize(): ByteArray = UnreadReminderJobData(threadId = threadId, lastReminderTime = lastReminderTime).encode()
override fun getFactoryKey(): String = KEY
@@ -70,23 +87,34 @@ class UnreadReminderJob(parameters: Parameters) : Job(parameters) {
return Result.success()
}
val recipient = SignalDatabase.threads.getRecipientForThreadId(threadId)
if (recipient == null) {
Log.w(TAG, "Missing recipient for thread $threadId.")
return Result.success()
}
if (recipient.unreadReminderSetting == RecipientTable.NotificationSetting.DO_NOT_NOTIFY || !recipient.isMuted) {
Log.w(TAG, "Recipient ${recipient.id} no longer qualifies for unread reminders")
return Result.success()
}
val hasNewMessages = SignalDatabase.messages.hasUnreadMessagesSince(threadId, lastReminderTime)
val hasNewCalls = SignalDatabase.calls.hasUnreadCallsSince(threadId, lastReminderTime)
if (!hasNewMessages && !hasNewCalls) {
Log.i(TAG, "No new unread messages or calls for thread $threadId since last reminder. Skipping.")
return Result.success()
}
val hideAuthors = SignalStore.settings.messageNotificationsPrivacy.isDisplayNothing
// Get all muted threads that have opted for reminders. If notification privacy is on, ignore mentions and replies.
val messageThreadIds = SignalDatabase.threads.getMutedThreadIds(ReminderType.MESSAGES)
val callThreadIds = SignalDatabase.threads.getMutedThreadIds(ReminderType.CALLS)
val mentionThreadIds = if (hideAuthors) emptyList() else SignalDatabase.threads.getMutedThreadIds(ReminderType.MENTIONS)
val replyThreadIds = if (hideAuthors) emptyList() else SignalDatabase.threads.getMutedThreadIds(ReminderType.REPLIES)
stopwatch.split("fetch-threads")
// Get the unread counts/authors
val (messages, unreadAuthorIds) = getUnreadForReminder(messageThreadIds, ReminderType.MESSAGES)
val (messages, unreadAuthorIds) = getUnreadForReminder(ReminderType.MESSAGES, isEligible = true)
stopwatch.split("fetch-messages")
val (calls, callsAuthorIds) = getUnreadForReminder(callThreadIds, ReminderType.CALLS)
val (calls, callsAuthorIds) = getUnreadForReminder(ReminderType.CALLS, isEligible = recipient.callNotificationSetting == RecipientTable.NotificationSetting.ALWAYS_NOTIFY)
stopwatch.split("fetch-calls")
val (mentions, mentionsAuthorIds) = getUnreadForReminder(mentionThreadIds, ReminderType.MENTIONS)
val (mentions, mentionsAuthorIds) = getUnreadForReminder(ReminderType.MENTIONS, isEligible = !hideAuthors && recipient.isPushV2Group && recipient.mentionSetting == RecipientTable.NotificationSetting.ALWAYS_NOTIFY)
stopwatch.split("fetch-mentions")
val (replies, repliesAuthorIds) = getUnreadForReminder(replyThreadIds, ReminderType.REPLIES)
val (replies, repliesAuthorIds) = getUnreadForReminder(ReminderType.REPLIES, isEligible = !hideAuthors && recipient.isPushV2Group && recipient.replyNotificationSetting == RecipientTable.NotificationSetting.ALWAYS_NOTIFY)
stopwatch.split("fetch-replies")
val summary = buildSummary(
@@ -101,19 +129,36 @@ class UnreadReminderJob(parameters: Parameters) : Job(parameters) {
hideAuthors = hideAuthors
)
val notificationId = NotificationIds.getNotificationIdForUnreadReminder(threadId)
if (summary.isEmpty()) {
ServiceUtil.getNotificationManager(context).cancel(NotificationIds.UNREAD_REMINDER)
ServiceUtil.getNotificationManager(context).cancel(notificationId)
} else if (NotificationChannels.getInstance().areNotificationsEnabled()) {
val builder = NotificationCompat.Builder(context, NotificationChannels.getInstance().ADDITIONAL_MESSAGE_NOTIFICATIONS)
val contentIntent = ConversationIntents.createBuilderSync(context, recipient.id, threadId).build().makeUniqueToPreventMerging()
val avatar = if (!hideAuthors) recipient.getContactDrawable(context) else FallbackAvatarDrawable(context, FallbackAvatar.forTextOrDefault("Unknown", AvatarColor.UNKNOWN)).circleCrop()
val person = Person.Builder()
.setName(recipient.getDisplayName(context))
.setIcon(AvatarUtil.getIconCompat(context, recipient))
.build()
val messagingStyle: NotificationCompat.MessagingStyle = NotificationCompat.MessagingStyle(Person.Builder().setName(context.getString(R.string.SingleRecipientNotificationBuilder_you)).build())
messagingStyle.addMessage(NotificationCompat.MessagingStyle.Message(summary, System.currentTimeMillis(), person))
val builder = NotificationCompat.Builder(context, NotificationChannels.getInstance().UNREAD_REMINDERS)
.setSmallIcon(R.drawable.ic_notification)
.setLargeIcon(avatar.toLargeBitmap(context))
.setContentText(summary)
.setStyle(NotificationCompat.BigTextStyle().bigText(summary))
.setContentIntent(PendingIntent.getActivity(context, 0, MainActivity.clearTop(context), PendingIntentFlags.mutable()))
.setStyle(messagingStyle.takeIf { !hideAuthors })
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setShortcutId(ConversationUtil.getShortcutId(recipient))
.setLocusId(LocusIdCompat(ConversationUtil.getShortcutId(recipient)))
.setContentIntent(PendingIntent.getActivity(context, 0, contentIntent, PendingIntentFlags.updateCurrent()))
.setAutoCancel(true)
ContextCompat.getSystemService(context, NotificationManager::class.java)!!.notify(NotificationIds.UNREAD_REMINDER, builder.build())
ContextCompat.getSystemService(context, NotificationManager::class.java)!!.notify(notificationId, builder.build())
}
SignalDatabase.threads.setUnreadReminderTime(threadId, System.currentTimeMillis())
stopwatch.stop(TAG)
return Result.success()
}
@@ -203,23 +248,24 @@ class UnreadReminderJob(parameters: Parameters) : Job(parameters) {
}
}
private fun getUnreadForReminder(threadIds: List<Long>, reminderType: ReminderType): Pair<Int, List<RecipientId>> {
return if (threadIds.isEmpty()) {
private fun getUnreadForReminder(reminderType: ReminderType, isEligible: Boolean): Pair<Int, List<RecipientId>> {
return if (!isEligible) {
0 to emptyList()
} else if (reminderType == ReminderType.CALLS) {
SignalDatabase.calls.getUnreadCallsForReminderNotification(threadIds)
SignalDatabase.calls.getUnreadCallsForReminderNotification(threadId)
} else {
SignalDatabase.messages.getUnreadContentForReminderNotification(threadIds, reminderType)
SignalDatabase.messages.getUnreadContentForReminderNotification(threadId, reminderType)
}
}
override fun onFailure() {
Log.w(TAG, "Failed to create unread reminder notification")
Log.w(TAG, "Failed to create unread reminder notification for thread $threadId")
}
class Factory : Job.Factory<UnreadReminderJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): UnreadReminderJob {
return UnreadReminderJob(parameters)
val data = UnreadReminderJobData.ADAPTER.decode(serializedData!!)
return UnreadReminderJob(threadId = data.threadId, lastReminderTime = data.lastReminderTime, parameters = parameters)
}
}
}
@@ -82,6 +82,7 @@ public class NotificationChannels {
public final String ADDITIONAL_MESSAGE_NOTIFICATIONS = "additional_message_notifications";
public final String NEW_LINKED_DEVICE = "new_linked_device";
public final String INTERNAL_ISSUES = "internal_issues";
public final String UNREAD_REMINDERS = "unread_reminders";
private static volatile NotificationChannels instance;
@@ -638,6 +639,7 @@ public class NotificationChannels {
NotificationChannel appAlerts = new NotificationChannel(APP_ALERTS, context.getString(R.string.NotificationChannel_critical_app_alerts), NotificationManager.IMPORTANCE_HIGH);
NotificationChannel additionalMessageNotifications = new NotificationChannel(ADDITIONAL_MESSAGE_NOTIFICATIONS, context.getString(R.string.NotificationChannel_additional_message_notifications), NotificationManager.IMPORTANCE_HIGH);
NotificationChannel newLinkedDevice = new NotificationChannel(NEW_LINKED_DEVICE, context.getString(R.string.NotificationChannel_new_linked_device), NotificationManager.IMPORTANCE_HIGH);
NotificationChannel unreadReminders = new NotificationChannel(UNREAD_REMINDERS, context.getString(R.string.NotificationChannel_unread_reminders), NotificationManager.IMPORTANCE_DEFAULT);
messages.setGroup(CATEGORY_MESSAGES);
setVibrationEnabled(messages, SignalStore.settings().isMessageVibrateEnabled());
@@ -655,7 +657,7 @@ public class NotificationChannels {
callStatus.setShowBadge(false);
appAlerts.setShowBadge(false);
notificationManager.createNotificationChannels(Arrays.asList(messages, calls, failures, backups, lockedStatus, other, voiceNotes, joinEvents, background, callStatus, appAlerts, additionalMessageNotifications, newLinkedDevice));
notificationManager.createNotificationChannels(Arrays.asList(messages, calls, failures, backups, lockedStatus, other, voiceNotes, joinEvents, background, callStatus, appAlerts, additionalMessageNotifications, newLinkedDevice, unreadReminders));
if (BuildConfig.MANAGES_APP_UPDATES) {
NotificationChannel appUpdates = new NotificationChannel(APP_UPDATES, context.getString(R.string.NotificationChannel_app_updates), NotificationManager.IMPORTANCE_DEFAULT);
@@ -38,7 +38,8 @@ public final class NotificationIds {
public static final int OUT_OF_REMOTE_STORAGE = 1205000;
public static final int INITIAL_BACKUP_FAILED = 1205010;
public static final int MANUAL_BACKUP_NOT_CREATED = 1205020;
public static final int UNREAD_REMINDER = 1205030;
public static final int UNREAD_REMINDER = 1206000;
public static final int MAX_UNREAD_REMINDER = UNREAD_REMINDER + 100_000;
private NotificationIds() { }
@@ -61,4 +62,8 @@ public final class NotificationIds {
public static boolean isMessageNotificationId(int id) {
return (id >= THREAD && id < (MAX_THREAD)) || (id >= STORY_THREAD && id < MAX_STORY_THREAD);
}
public static int getNotificationIdForUnreadReminder(long threadId) {
return UNREAD_REMINDER + (int) threadId;
}
}
@@ -0,0 +1,88 @@
package org.thoughtcrime.securesms.service
import android.app.Application
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.annotation.WorkerThread
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.settings.app.notifications.ReminderType
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.UnreadReminderJob
import org.thoughtcrime.securesms.util.RemoteConfig
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.seconds
/**
* Manages enqueueing [UnreadReminderJob] when a thread has an unread message/call that exceeds the reminder threshold.
*/
class UnreadReminderManager(
val application: Application
) : TimedEventManager<UnreadReminderManager.Event>(application, "UnreadReminderManager") {
companion object {
private val TAG = Log.tag(UnreadReminderManager::class.java)
private val reminderThreshold: Long
get() = RemoteConfig.unreadReminderIntervalSeconds.seconds.inWholeMilliseconds
val MAX_UNREAD_MESSAGE_AGE = 14.days
}
init {
scheduleIfNecessary()
}
@WorkerThread
override fun getNextClosestEvent(): Event? {
val messageThreadIds = SignalDatabase.threads.getMutedThreadIds(ReminderType.MESSAGES, reminderThreshold)
val callThreadIds = SignalDatabase.threads.getMutedThreadIds(ReminderType.CALLS, reminderThreshold)
val (messageThreadId, messageTimestamp) = SignalDatabase.messages.getOldestUnreadMessage(messageThreadIds)
val (callThreadId, callTimestamp) = SignalDatabase.calls.getOldestUnreadCall(callThreadIds)
return if (messageThreadId == -1L && callThreadId == -1L) {
Log.i(TAG, "No existing unread message or calls from a qualifying thread.")
cancelAlarm(application, UnreadReminderAlarm::class.java)
null
} else if (messageTimestamp < callTimestamp) {
val delay = (messageTimestamp + reminderThreshold - System.currentTimeMillis()).coerceAtLeast(0)
Log.i(TAG, "The next unread reminder needs to fire in $delay ms for a message in thread $messageThreadId.")
Event(delay, messageThreadId)
} else {
val delay = (callTimestamp + reminderThreshold - System.currentTimeMillis()).coerceAtLeast(0)
Log.i(TAG, "The next unread reminder needs to fire in $delay ms for a call in thread $callThreadId.")
Event(delay, callThreadId)
}
}
@WorkerThread
override fun executeEvent(event: Event) {
Log.i(TAG, "Executing event $event")
val lastReminderTime = SignalDatabase.threads.getUnreadReminderTime(event.threadId)
SignalDatabase.threads.setUnreadReminderTime(event.threadId, System.currentTimeMillis())
UnreadReminderJob.enqueue(event.threadId, lastReminderTime)
}
@WorkerThread
override fun getDelayForEvent(event: Event): Long = event.delay
@WorkerThread
override fun scheduleAlarm(application: Application, event: Event, delay: Long) {
setAlarm(application, delay, UnreadReminderAlarm::class.java)
}
data class Event(val delay: Long, val threadId: Long)
class UnreadReminderAlarm : BroadcastReceiver() {
companion object {
private val TAG = Log.tag(UnreadReminderAlarm::class.java)
}
override fun onReceive(context: Context?, intent: Intent?) {
Log.d(TAG, "onReceive()")
AppDependencies.unreadReminderManager.scheduleIfNecessary()
}
}
}
+5
View File
@@ -296,3 +296,8 @@ message MultiDeviceAttachmentBackfillRequestJobData {
message BackupTierDowngradeCheckJobData {
optional uint64 remoteBackupTier = 1;
}
message UnreadReminderJobData {
uint64 threadId = 1;
uint64 lastReminderTime = 2;
}
+2
View File
@@ -3391,6 +3391,8 @@
<string name="NotificationChannel_additional_message_notifications">Additional message notifications</string>
<!-- Notification channel name for notifications sent when a device has been linked -->
<string name="NotificationChannel_new_linked_device">New linked device</string>
<!-- Notification channel name for unread reminder notifications -->
<string name="NotificationChannel_unread_reminders">Unread reminders</string>
<!-- QuickResponseService -->
<string name="QuickResponseService_quick_response_unavailable_when_Signal_is_locked">Quick response unavailable when Signal is locked!</string>
@@ -32,7 +32,7 @@ class CallTableTest_unreadForReminder {
insertMissedCall(caller, time = 1000)
insertMissedCall(caller, time = 1001)
val (count, authors) = calls.getUnreadCallsForReminderNotification(listOf(threadId))
val (count, authors) = calls.getUnreadCallsForReminderNotification(threadId, 1001)
assertThat(count).isEqualTo(2)
assertThat(authors).isEqualTo(listOf(caller))
@@ -45,7 +45,7 @@ class CallTableTest_unreadForReminder {
insertCall(caller, time = 1000, event = CallTable.Event.MISSED_NOTIFICATION_PROFILE)
val (count, authors) = calls.getUnreadCallsForReminderNotification(listOf(threadId))
val (count, authors) = calls.getUnreadCallsForReminderNotification(threadId, 1001)
assertThat(count).isEqualTo(1)
assertThat(authors).isEqualTo(listOf(caller))
@@ -59,7 +59,7 @@ class CallTableTest_unreadForReminder {
insertCall(caller, time = 1000, event = CallTable.Event.ACCEPTED)
insertMissedCall(caller, time = 1001)
val (count, authors) = calls.getUnreadCallsForReminderNotification(listOf(threadId))
val (count, authors) = calls.getUnreadCallsForReminderNotification(threadId, 1002)
assertThat(count).isEqualTo(1)
assertThat(authors).isEqualTo(listOf(caller))
@@ -74,7 +74,7 @@ class CallTableTest_unreadForReminder {
calls.markAllCallEventsRead(timestamp = 1000)
insertMissedCall(caller, time = 1001)
val (count, authors) = calls.getUnreadCallsForReminderNotification(listOf(threadId))
val (count, authors) = calls.getUnreadCallsForReminderNotification(threadId, 1002)
assertThat(count).isEqualTo(1)
assertThat(authors).isEqualTo(listOf(caller))
@@ -90,7 +90,7 @@ class CallTableTest_unreadForReminder {
insertMissedCall(included, time = 1000)
insertMissedCall(excluded, time = 1000)
val (count, authors) = calls.getUnreadCallsForReminderNotification(listOf(includedThreadId))
val (count, authors) = calls.getUnreadCallsForReminderNotification(includedThreadId, 1001)
assertThat(count).isEqualTo(1)
assertThat(authors).isEqualTo(listOf(included))
@@ -15,6 +15,7 @@ import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testutil.RecipientTestRule
import kotlin.time.Duration.Companion.days
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
@@ -34,7 +35,7 @@ class MessageTableTest_unreadForReminder {
insertIncoming(threadId, sender, time = 1000)
insertIncoming(threadId, sender, time = 1001)
val (count, authors) = messages.getUnreadContentForReminderNotification(listOf(threadId), ReminderType.MESSAGES)
val (count, authors) = messages.getUnreadContentForReminderNotification(threadId, ReminderType.MESSAGES, 1002)
assertThat(count).isEqualTo(2)
assertThat(authors).isEqualTo(listOf(sender))
@@ -48,7 +49,7 @@ class MessageTableTest_unreadForReminder {
insertIncoming(threadId, sender, time = 1000)
insertIncoming(threadId, sender, time = 1001, mentions = listOf(Mention(recipients.self, 0, 1)))
val (count, authors) = messages.getUnreadContentForReminderNotification(listOf(threadId), ReminderType.MENTIONS)
val (count, authors) = messages.getUnreadContentForReminderNotification(threadId, ReminderType.MENTIONS, 1002)
assertThat(count).isEqualTo(1)
assertThat(authors).isEqualTo(listOf(sender))
@@ -72,7 +73,7 @@ class MessageTableTest_unreadForReminder {
insertIncoming(threadId, sender, time = 1000)
insertIncoming(threadId, sender, time = 1001, quote = quote)
val (count, authors) = messages.getUnreadContentForReminderNotification(listOf(threadId), ReminderType.REPLIES)
val (count, authors) = messages.getUnreadContentForReminderNotification(threadId, ReminderType.REPLIES, 1002)
assertThat(count).isEqualTo(1)
assertThat(authors).isEqualTo(listOf(sender))
@@ -87,7 +88,7 @@ class MessageTableTest_unreadForReminder {
insertIncoming(threadId, sender, time = 1001)
markRead(read)
val (count, _) = messages.getUnreadContentForReminderNotification(listOf(threadId), ReminderType.MESSAGES)
val (count, _) = messages.getUnreadContentForReminderNotification(threadId, ReminderType.MESSAGES, 1002)
assertThat(count).isEqualTo(1)
}
@@ -101,7 +102,7 @@ class MessageTableTest_unreadForReminder {
insertIncoming(includedThreadId, included, time = 1000)
insertIncoming(excludedThreadId, excluded, time = 1000)
val (count, authors) = messages.getUnreadContentForReminderNotification(listOf(includedThreadId), ReminderType.MESSAGES)
val (count, authors) = messages.getUnreadContentForReminderNotification(includedThreadId, ReminderType.MESSAGES, 1002)
assertThat(count).isEqualTo(1)
assertThat(authors).isEqualTo(listOf(included))
@@ -122,12 +123,24 @@ class MessageTableTest_unreadForReminder {
insertIncoming(threadId, c, time = 1003)
insertIncoming(threadId, d, time = 1004)
val (count, authors) = messages.getUnreadContentForReminderNotification(listOf(threadId), ReminderType.MESSAGES)
val (count, authors) = messages.getUnreadContentForReminderNotification(threadId, ReminderType.MESSAGES, 1005)
assertThat(count).isEqualTo(5)
assertThat(authors).isEqualTo(listOf(d, c, b))
}
@Test
fun `messages more than two weeks old are excluded`() {
val sender = recipients.createRecipient("Erin Reader")
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(sender))
insertIncoming(threadId, sender, time = 1000)
insertIncoming(threadId, sender, time = 1001)
val (count, _) = messages.getUnreadContentForReminderNotification(threadId, ReminderType.MESSAGES, 1000 + 14.days.inWholeMilliseconds)
assertThat(count).isEqualTo(1)
}
private fun insertIncoming(threadId: Long, from: RecipientId, time: Long, quote: QuoteModel? = null, mentions: List<Mention> = emptyList()): Long {
val message = IncomingMessage(
type = MessageType.NORMAL,
@@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.components.settings.app.notifications.Reminder
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testutil.RecipientTestRule
import kotlin.time.Duration.Companion.days
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
@@ -31,6 +32,7 @@ class ThreadTableTest_mutedThreadIds {
fun setUp() {
contactId = recipients.createRecipient("Alice Android")
threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(contactId))
SignalDatabase.threads.markAsActiveEarly(threadId)
SignalDatabase.recipients.setMuted(contactId, Long.MAX_VALUE)
}
@@ -41,14 +43,15 @@ class ThreadTableTest_mutedThreadIds {
every { recipients.signalStore.settings.allowRepliesWhileMuted } returns replies
}
private fun isMutedFor(reminderType: ReminderType): Boolean {
return threadId in SignalDatabase.threads.getMutedThreadIds(reminderType)
private fun isMutedFor(reminderType: ReminderType, threshold: Long = 0): Boolean {
return threadId in SignalDatabase.threads.getMutedThreadIds(reminderType, threshold)
}
@Test
fun `allow-by-default plus system-default recipient setting includes the thread`() {
globalDefaults(unreadReminder = true)
SignalDatabase.recipients.setUnreadReminder(contactId, RecipientTable.NotificationSetting.SYSTEM_DEFAULT)
SignalDatabase.threads.incrementUnread(threadId, 1, 1)
assertThat(isMutedFor(ReminderType.MESSAGES)).isTrue()
}
@@ -63,6 +66,7 @@ class ThreadTableTest_mutedThreadIds {
fun `allow-by-default plus an explicit always-allow includes the thread`() {
globalDefaults(unreadReminder = true)
SignalDatabase.recipients.setUnreadReminder(contactId, RecipientTable.NotificationSetting.ALWAYS_NOTIFY)
SignalDatabase.threads.incrementUnread(threadId, 1, 1)
assertThat(isMutedFor(ReminderType.MESSAGES)).isTrue()
}
@@ -84,6 +88,7 @@ class ThreadTableTest_mutedThreadIds {
fun `always-allow-required plus an explicit always-allow includes the thread`() {
globalDefaults(unreadReminder = false)
SignalDatabase.recipients.setUnreadReminder(contactId, RecipientTable.NotificationSetting.ALWAYS_NOTIFY)
SignalDatabase.threads.incrementUnread(threadId, 1, 1)
assertThat(isMutedFor(ReminderType.MESSAGES)).isTrue()
}
@@ -95,6 +100,30 @@ class ThreadTableTest_mutedThreadIds {
assertThat(isMutedFor(ReminderType.MESSAGES)).isFalse()
}
@Test
fun `a thread reminded within the last three days is excluded`() {
globalDefaults(unreadReminder = true)
SignalDatabase.recipients.setUnreadReminder(contactId, RecipientTable.NotificationSetting.ALWAYS_NOTIFY)
SignalDatabase.threads.setUnreadReminderTime(threadId, System.currentTimeMillis())
assertThat(isMutedFor(ReminderType.MESSAGES, 3.days.inWholeMilliseconds)).isFalse()
}
@Test
fun `a thread reminded more than three days ago is included again`() {
globalDefaults(unreadReminder = true)
SignalDatabase.recipients.setUnreadReminder(contactId, RecipientTable.NotificationSetting.ALWAYS_NOTIFY)
SignalDatabase.threads.setUnreadReminderTime(threadId, System.currentTimeMillis() - 4.days.inWholeMilliseconds)
SignalDatabase.threads.incrementUnread(threadId, 1, 1)
assertThat(isMutedFor(ReminderType.MESSAGES, 3.days.inWholeMilliseconds)).isTrue()
}
@Test
fun `a thread that does not have any unread is not included`() {
globalDefaults(unreadReminder = true)
SignalDatabase.recipients.setUnreadReminder(contactId, RecipientTable.NotificationSetting.ALWAYS_NOTIFY)
assertThat(isMutedFor(ReminderType.MESSAGES)).isFalse()
}
@Test
fun `an archived thread is excluded even when muted and always-allowed`() {
globalDefaults(unreadReminder = true)
@@ -126,50 +155,4 @@ class ThreadTableTest_mutedThreadIds {
SignalDatabase.recipients.setCallNotificationSetting(contactId, RecipientTable.NotificationSetting.SYSTEM_DEFAULT)
assertThat(isMutedFor(ReminderType.CALLS)).isTrue()
}
@Test
fun `mentions are excluded for a 1-to-1 thread no matter the notification settings`() {
globalDefaults(unreadReminder = true, mentions = true)
SignalDatabase.recipients.setUnreadReminder(contactId, RecipientTable.NotificationSetting.ALWAYS_NOTIFY)
SignalDatabase.recipients.setMentionSetting(contactId, RecipientTable.NotificationSetting.ALWAYS_NOTIFY)
assertThat(isMutedFor(ReminderType.MENTIONS)).isFalse()
}
@Test
fun `mentions in a muted GV2 group thread follow the same allow-by-default vs always-allow rule`() {
val group = recipients.createGroup()
val groupThreadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(group.recipientId))
SignalDatabase.recipients.setMuted(group.recipientId, Long.MAX_VALUE)
SignalDatabase.recipients.setUnreadReminder(group.recipientId, RecipientTable.NotificationSetting.ALWAYS_NOTIFY)
globalDefaults(unreadReminder = true, mentions = false)
SignalDatabase.recipients.setMentionSetting(group.recipientId, RecipientTable.NotificationSetting.SYSTEM_DEFAULT)
assertThat(groupThreadId in SignalDatabase.threads.getMutedThreadIds(ReminderType.MENTIONS)).isFalse()
SignalDatabase.recipients.setMentionSetting(group.recipientId, RecipientTable.NotificationSetting.ALWAYS_NOTIFY)
assertThat(groupThreadId in SignalDatabase.threads.getMutedThreadIds(ReminderType.MENTIONS)).isTrue()
}
@Test
fun `replies are excluded for a 1-to-1 thread no matter the notification settings`() {
globalDefaults(unreadReminder = true, replies = true)
SignalDatabase.recipients.setUnreadReminder(contactId, RecipientTable.NotificationSetting.ALWAYS_NOTIFY)
SignalDatabase.recipients.setReplyNotificationSetting(contactId, RecipientTable.NotificationSetting.ALWAYS_NOTIFY)
assertThat(isMutedFor(ReminderType.REPLIES)).isFalse()
}
@Test
fun `replies in a muted GV2 group thread follow the same allow-by-default vs always-allow rule`() {
val group = recipients.createGroup()
val groupThreadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(group.recipientId))
SignalDatabase.recipients.setMuted(group.recipientId, Long.MAX_VALUE)
SignalDatabase.recipients.setUnreadReminder(group.recipientId, RecipientTable.NotificationSetting.ALWAYS_NOTIFY)
globalDefaults(unreadReminder = true, replies = true)
SignalDatabase.recipients.setReplyNotificationSetting(group.recipientId, RecipientTable.NotificationSetting.DO_NOT_NOTIFY)
assertThat(groupThreadId in SignalDatabase.threads.getMutedThreadIds(ReminderType.REPLIES)).isFalse()
SignalDatabase.recipients.setReplyNotificationSetting(group.recipientId, RecipientTable.NotificationSetting.SYSTEM_DEFAULT)
assertThat(groupThreadId in SignalDatabase.threads.getMutedThreadIds(ReminderType.REPLIES)).isTrue()
}
}
@@ -51,6 +51,7 @@ import org.thoughtcrime.securesms.service.PendingRetryReceiptManager
import org.thoughtcrime.securesms.service.PinnedMessageManager
import org.thoughtcrime.securesms.service.ScheduledMessageManager
import org.thoughtcrime.securesms.service.TrimThreadsByDateManager
import org.thoughtcrime.securesms.service.UnreadReminderManager
import org.thoughtcrime.securesms.service.webrtc.SignalCallManager
import org.thoughtcrime.securesms.shakereport.ShakeToReport
import org.thoughtcrime.securesms.util.EarlyMessageCache
@@ -271,6 +272,10 @@ class MockApplicationDependencyProvider : AppDependencies.Provider {
return mockk(relaxed = true)
}
override fun provideUnreadReminderManager(): UnreadReminderManager {
return mockk(relaxed = true)
}
override fun provideLibsignalNetwork(config: SignalServiceConfiguration): Network {
return mockk(relaxed = true)
}