Remove calls from unread reminders.

This commit is contained in:
Michelle Tang
2026-09-08 10:16:17 -04:00
parent 3dd270be31
commit 8d10cc4dce
11 changed files with 89 additions and 454 deletions
@@ -42,7 +42,6 @@ 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
@@ -440,8 +439,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
title = DSLSettingsText.from("Run unread reminder job"),
summary = DSLSettingsText.from("Generates an unread reminder notification based on unreads and muted preferences. Skips the three day cooldown."),
onClick = {
val threadIds = (SignalDatabase.threads.getMutedThreadIds(ReminderType.MESSAGES, 0) + SignalDatabase.threads.getMutedThreadIds(ReminderType.CALLS, 0)).distinct()
threadIds.forEach { threadId -> UnreadReminderJob.enqueue(threadId, 0) }
SignalDatabase.threads.getMutedThreadIds(0).forEach { threadId -> UnreadReminderJob.enqueue(threadId, 0) }
}
)
@@ -10,7 +10,6 @@ package org.thoughtcrime.securesms.components.settings.app.notifications
*/
enum class ReminderType {
MESSAGES,
CALLS,
MENTIONS,
REPLIES
}
@@ -37,7 +37,6 @@ 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,74 +193,6 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
.readToSingleLong()
}
/**
* Returns the number of unread calls and up to three distinct callers from [threadId].
*/
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} = $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 > ${now - UnreadReminderManager.MAX_UNREAD_MESSAGE_AGE.inWholeMilliseconds}
"""
)
.orderBy("$TABLE_NAME.$TIMESTAMP DESC")
.run()
.readToList { cursor -> RecipientId.from(cursor.requireLong(PEER)) }
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)
@@ -5403,7 +5403,6 @@ 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 [threadId]
* For missed calls, see [getUnreadContentForReminderNotification] in the calls table.
*/
fun getUnreadContentForReminderNotification(threadId: Long, type: ReminderType, now: Long = System.currentTimeMillis()): Pair<Int, List<RecipientId>> {
val categoryClause = when (type) {
@@ -41,7 +41,6 @@ import org.signal.core.util.withinTransaction
import org.signal.libsignal.zkgroup.InvalidInputException
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord
import org.thoughtcrime.securesms.components.settings.app.notifications.ReminderType
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
import org.thoughtcrime.securesms.database.MessageTable.MarkedMessageInfo
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.attachments
@@ -679,17 +678,13 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
}
/**
* Gets eligible threads that could quality for unread reminders depending on its while muted settings.
* 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.
* Gets eligible threads that could quality for unread reminders (opted in, has unread messages, elapsed time).
*/
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 -> "AND $unreadReminderClause AND $UNREAD_COUNT > 0"
ReminderType.CALLS -> "AND $unreadReminderClause AND (${getNotificationClause(RecipientTable.CALL_NOTIFICATION_SETTING, SignalStore.settings.allowCallsWhileMuted)})"
else -> ""
fun getMutedThreadIds(reminderThreshold: Long, now: Long = System.currentTimeMillis()): List<Long> {
val unreadReminderClause = if (SignalStore.settings.unreadReminderEnabled) {
"${RecipientTable.TABLE_NAME}.${RecipientTable.UNREAD_REMINDER} != ${RecipientTable.NotificationSetting.DO_NOT_NOTIFY.id}"
} else {
"${RecipientTable.TABLE_NAME}.${RecipientTable.UNREAD_REMINDER} = ${RecipientTable.NotificationSetting.ALWAYS_NOTIFY.id}"
}
return readableDatabase
@@ -699,9 +694,10 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
"""
$ACTIVE = 1 AND
$ARCHIVED = 0 AND
$UNREAD_COUNT > 0 AND
$LAST_UNREAD_REMINDER < ${now - reminderThreshold} AND
${RecipientTable.MUTE_UNTIL} >= $now
$reminderClause
${RecipientTable.MUTE_UNTIL} >= $now AND
$unreadReminderClause
""".trimIndent()
)
.run()
@@ -725,14 +721,6 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
.run()
}
private fun getNotificationClause(column: String, allowByDefault: Boolean): String {
return if (allowByDefault) {
"${RecipientTable.TABLE_NAME}.$column != ${RecipientTable.NotificationSetting.DO_NOT_NOTIFY.id}"
} else {
"${RecipientTable.TABLE_NAME}.$column = ${RecipientTable.NotificationSetting.ALWAYS_NOTIFY.id}"
}
}
/**
* Returns whether or not there are chats in a chat folder
*/
@@ -98,9 +98,8 @@ class UnreadReminderJob(private val threadId: Long, private val lastReminderTime
}
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.")
if (!hasNewMessages) {
Log.i(TAG, "No new unread messages for thread $threadId since last reminder. Skipping.")
return Result.success()
}
@@ -109,8 +108,6 @@ class UnreadReminderJob(private val threadId: Long, private val lastReminderTime
// Get the unread counts/authors
val (messages, unreadAuthorIds) = getUnreadForReminder(ReminderType.MESSAGES, isEligible = true)
stopwatch.split("fetch-messages")
val (calls, callsAuthorIds) = getUnreadForReminder(ReminderType.CALLS, isEligible = recipient.callNotificationSetting == RecipientTable.NotificationSetting.ALWAYS_NOTIFY)
stopwatch.split("fetch-calls")
val (mentions, mentionsAuthorIds) = getUnreadForReminder(ReminderType.MENTIONS, isEligible = !hideAuthors && recipient.isPushV2Group && recipient.mentionSetting == RecipientTable.NotificationSetting.ALWAYS_NOTIFY)
stopwatch.split("fetch-mentions")
val (replies, repliesAuthorIds) = getUnreadForReminder(ReminderType.REPLIES, isEligible = !hideAuthors && recipient.isPushV2Group && recipient.replyNotificationSetting == RecipientTable.NotificationSetting.ALWAYS_NOTIFY)
@@ -119,10 +116,9 @@ class UnreadReminderJob(private val threadId: Long, private val lastReminderTime
val summary = buildSummary(
context = context,
messages = messages,
calls = calls,
mentions = mentions,
replies = replies,
messageAndCallAuthors = (unreadAuthorIds + callsAuthorIds).distinct().map { Recipient.resolved(it).getShortDisplayName(context) },
messageAuthors = unreadAuthorIds.map { Recipient.resolved(it).getShortDisplayName(context) },
mentionAuthors = mentionsAuthorIds.map { Recipient.resolved(it).getShortDisplayName(context) },
replyAuthors = repliesAuthorIds.map { Recipient.resolved(it).getShortDisplayName(context) },
hideAuthors = hideAuthors
@@ -169,48 +165,31 @@ class UnreadReminderJob(private val threadId: Long, private val lastReminderTime
internal fun buildSummary(
context: Context,
messages: Int = 0,
calls: Int = 0,
mentions: Int = 0,
replies: Int = 0,
messageAndCallAuthors: List<String> = emptyList(),
messageAuthors: List<String> = emptyList(),
mentionAuthors: List<String> = emptyList(),
replyAuthors: List<String> = emptyList(),
hideAuthors: Boolean = false
): String {
val showUnread = messages > 0
val showCalls = calls > 0
val showAuthors = (showUnread || showCalls) && !hideAuthors
val showAuthors = showUnread && !hideAuthors
val showMentions = mentions > 0 && !hideAuthors
val showReplies = replies > 0 && !hideAuthors
val messagesString = if (showUnread) context.resources.getQuantityString(R.plurals.UnreadReminderJob__messages, messages, messages) else ""
val callsString = if (showCalls) context.resources.getQuantityString(R.plurals.UnreadReminderJob__calls, calls, calls) else ""
val authorsString = if (showAuthors) buildAuthorSummary(context, ReminderType.MESSAGES, messages + calls, messageAndCallAuthors) else ""
val authorsString = if (showAuthors) buildAuthorSummary(context, ReminderType.MESSAGES, messages, messageAuthors) else ""
val mentionsString = if (showMentions) buildAuthorSummary(context, ReminderType.MENTIONS, mentions, mentionAuthors) else ""
val repliesString = if (showReplies) buildAuthorSummary(context, ReminderType.REPLIES, replies, replyAuthors) else ""
return if (showUnread && showCalls && showMentions && showReplies) {
context.getString(R.string.UnreadReminderJob__calls_and_unread_full_summary, callsString, messagesString, mentionsString, repliesString)
} else if (showUnread && showCalls && showMentions) {
context.getString(R.string.UnreadReminderJob__calls_and_unread_summary, callsString, messagesString, mentionsString)
} else if (showUnread && showCalls && showReplies) {
context.getString(R.string.UnreadReminderJob__calls_and_unread_summary, callsString, messagesString, repliesString)
} else if (showUnread && showCalls && hideAuthors) {
context.getString(R.string.UnreadReminderJob__calls_and_unread, callsString, messagesString)
} else if (showUnread && showCalls) {
context.getString(R.string.UnreadReminderJob__calls_and_unread_author, callsString, messagesString, authorsString)
} else if (showUnread && showMentions && showReplies) {
return if (showUnread && showMentions && showReplies) {
context.getString(R.string.UnreadReminderJob__unread_both_summary, messagesString, mentionsString, repliesString)
} else if (showUnread && showMentions) {
context.getString(R.string.UnreadReminderJob__unread_one_summary, messagesString, mentionsString)
} else if (showUnread && showReplies) {
context.getString(R.string.UnreadReminderJob__unread_one_summary, messagesString, repliesString)
} else if (hideAuthors && showCalls) {
context.getString(R.string.UnreadReminderJob__calls_or_unread, callsString)
} else if (hideAuthors && showUnread) {
context.getString(R.string.UnreadReminderJob__calls_or_unread, messagesString)
} else if (showCalls) {
context.getString(R.string.UnreadReminderJob__calls_or_unread_author, callsString, authorsString)
} else if (showUnread) {
context.getString(R.string.UnreadReminderJob__calls_or_unread_author, messagesString, authorsString)
} else {
@@ -225,8 +204,7 @@ class UnreadReminderJob(private val threadId: Long, private val lastReminderTime
@VisibleForTesting
internal fun buildAuthorSummary(context: Context, reminderType: ReminderType, count: Int, authors: List<String>): String {
val (oneRes, twoRes, manyRes) = when (reminderType) {
ReminderType.MESSAGES,
ReminderType.CALLS -> Triple(R.string.UnreadReminderJob__authors_one, R.string.UnreadReminderJob__authors_two, R.string.UnreadReminderJob__authors_many)
ReminderType.MESSAGES -> Triple(R.string.UnreadReminderJob__authors_one, R.string.UnreadReminderJob__authors_two, R.string.UnreadReminderJob__authors_many)
ReminderType.MENTIONS -> Triple(R.string.UnreadReminderJob__mentions_one, R.plurals.UnreadReminderJob__mentions_two, R.plurals.UnreadReminderJob__mentions_many)
ReminderType.REPLIES -> Triple(R.string.UnreadReminderJob__replies_one, R.plurals.UnreadReminderJob__replies_two, R.plurals.UnreadReminderJob__replies_many)
}
@@ -250,8 +228,6 @@ class UnreadReminderJob(private val threadId: Long, private val lastReminderTime
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(threadId)
} else {
SignalDatabase.messages.getUnreadContentForReminderNotification(threadId, reminderType)
}
@@ -6,7 +6,6 @@ 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
@@ -35,24 +34,17 @@ class UnreadReminderManager(
@WorkerThread
override fun getNextClosestEvent(): Event? {
val messageThreadIds = SignalDatabase.threads.getMutedThreadIds(ReminderType.MESSAGES, reminderThreshold)
val callThreadIds = SignalDatabase.threads.getMutedThreadIds(ReminderType.CALLS, reminderThreshold)
val messageThreadIds = SignalDatabase.threads.getMutedThreadIds(reminderThreshold)
val (messageThreadId, messageTimestamp) = SignalDatabase.messages.getOldestUnreadMessage(messageThreadIds)
val (callThreadId, callTimestamp) = SignalDatabase.calls.getOldestUnreadCall(callThreadIds)
return if (messageThreadId == -1L && callThreadId == -1L) {
return if (messageThreadId == -1L) {
Log.i(TAG, "No existing unread message or calls from a qualifying thread.")
cancelAlarm(application, UnreadReminderAlarm::class.java)
null
} else if (messageTimestamp < callTimestamp) {
} else {
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)
}
}
+1 -14
View File
@@ -9959,27 +9959,14 @@
<!-- Button declining to paste the recovery key -->
<string name="RecoveryKeyWarningDialog__dont_share">Don\'t share</string>
<!-- Notification body for unread reminders. First two placeholders are the number of unread messages and calls, last two are the summaries for mentions and replies -->
<string name="UnreadReminderJob__calls_and_unread_full_summary">You have %1$s and %2$s including %3$s and %4$s.</string>
<!-- Notification body for unread reminders. First two placeholders are the number of unread messages and calls, third is a summary of mentions or replies -->
<string name="UnreadReminderJob__calls_and_unread_summary">You have %1$s and %2$s including %3$s.</string>
<!-- Notification body for unread reminders. First two placeholders are the number of unread messages and calls, third is a list of recipients who sent them -->
<string name="UnreadReminderJob__calls_and_unread_author">You have %1$s and %2$s from %3$s.</string>
<!-- Notification body for unread reminders. First placeholder is unread messages, second is a summary of unread mentions or replies -->
<string name="UnreadReminderJob__unread_one_summary">You have %1$s, including %2$s.</string>
<!-- Notification body for unread reminders. First placeholder is unread messages, second is a summary of unread mentions, third replies -->
<string name="UnreadReminderJob__unread_both_summary">You have %1$s, including %2$s and %3$s.</string>
<!-- Notification body for unread reminders. First placeholder is unread messages, second is a list of people who sent them -->
<string name="UnreadReminderJob__calls_or_unread_author">You have %1$s from %2$s.</string>
<!-- Notification body for unread reminders. First placeholder is unread messages or unread calls -->
<!-- Notification body for unread reminders. First placeholder is unread messages -->
<string name="UnreadReminderJob__calls_or_unread">You have %1$s.</string>
<!-- Notification body for unread reminders. First placeholder is unread messages, second is unread calls. -->
<string name="UnreadReminderJob__calls_and_unread">You have %1$s and %2$s.</string>
<!-- Notification placeholder for unread calls -->
<plurals name="UnreadReminderJob__calls">
<item quantity="one">%1$d missed call</item>
<item quantity="other">%1$d missed calls</item>
</plurals>
<!-- Notification placeholder for unread messages -->
<plurals name="UnreadReminderJob__messages">
<item quantity="one">%1$d unread message</item>
@@ -1,115 +0,0 @@
package org.thoughtcrime.securesms.database
import android.app.Application
import assertk.assertThat
import assertk.assertions.isEqualTo
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testutil.RecipientTestRule
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
class CallTableTest_unreadForReminder {
@get:Rule
val recipients = RecipientTestRule()
private val calls: CallTable
get() = SignalDatabase.calls
private var nextCallId = 1L
@Test
fun `counts unread missed calls from a single caller`() {
val caller = recipients.createRecipient("Alice Android")
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(caller))
insertMissedCall(caller, time = 1000)
insertMissedCall(caller, time = 1001)
val (count, authors) = calls.getUnreadCallsForReminderNotification(threadId, 1001)
assertThat(count).isEqualTo(2)
assertThat(authors).isEqualTo(listOf(caller))
}
@Test
fun `a missed-notification-profile call also counts as missed`() {
val caller = recipients.createRecipient("Bob Caller")
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(caller))
insertCall(caller, time = 1000, event = CallTable.Event.MISSED_NOTIFICATION_PROFILE)
val (count, authors) = calls.getUnreadCallsForReminderNotification(threadId, 1001)
assertThat(count).isEqualTo(1)
assertThat(authors).isEqualTo(listOf(caller))
}
@Test
fun `non-missed call events are excluded`() {
val caller = recipients.createRecipient("Carol Answered")
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(caller))
insertCall(caller, time = 1000, event = CallTable.Event.ACCEPTED)
insertMissedCall(caller, time = 1001)
val (count, authors) = calls.getUnreadCallsForReminderNotification(threadId, 1002)
assertThat(count).isEqualTo(1)
assertThat(authors).isEqualTo(listOf(caller))
}
@Test
fun `already-read missed calls are excluded`() {
val caller = recipients.createRecipient("Dave Rung")
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(caller))
insertMissedCall(caller, time = 1000)
calls.markAllCallEventsRead(timestamp = 1000)
insertMissedCall(caller, time = 1001)
val (count, authors) = calls.getUnreadCallsForReminderNotification(threadId, 1002)
assertThat(count).isEqualTo(1)
assertThat(authors).isEqualTo(listOf(caller))
}
@Test
fun `only the requested threads are counted`() {
val included = recipients.createRecipient("Erin Included")
val excluded = recipients.createRecipient("Frank Excluded")
val includedThreadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(included))
SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(excluded))
insertMissedCall(included, time = 1000)
insertMissedCall(excluded, time = 1000)
val (count, authors) = calls.getUnreadCallsForReminderNotification(includedThreadId, 1001)
assertThat(count).isEqualTo(1)
assertThat(authors).isEqualTo(listOf(included))
}
private fun insertMissedCall(caller: RecipientId, time: Long): Long {
return insertCall(caller, time, CallTable.Event.MISSED)
}
private fun insertCall(caller: RecipientId, time: Long, event: CallTable.Event): Long {
val callId = nextCallId++
calls.insertOneToOneCall(
callId = callId,
timestamp = time,
peer = caller,
type = CallTable.Type.AUDIO_CALL,
direction = CallTable.Direction.INCOMING,
event = event
)
return callId
}
}
@@ -12,7 +12,6 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.thoughtcrime.securesms.components.settings.app.notifications.ReminderType
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testutil.RecipientTestRule
@@ -36,15 +35,14 @@ class ThreadTableTest_mutedThreadIds {
SignalDatabase.recipients.setMuted(contactId, Long.MAX_VALUE)
}
private fun globalDefaults(unreadReminder: Boolean = true, calls: Boolean = false, mentions: Boolean = true, replies: Boolean = true) {
private fun globalDefaults(unreadReminder: Boolean = true, mentions: Boolean = true, replies: Boolean = true) {
every { recipients.signalStore.settings.unreadReminderEnabled } returns unreadReminder
every { recipients.signalStore.settings.allowCallsWhileMuted } returns calls
every { recipients.signalStore.settings.allowMentionsWhileMuted } returns mentions
every { recipients.signalStore.settings.allowRepliesWhileMuted } returns replies
}
private fun isMutedFor(reminderType: ReminderType, threshold: Long = 0): Boolean {
return threadId in SignalDatabase.threads.getMutedThreadIds(reminderType, threshold)
private fun isMutedFor(threshold: Long = 0): Boolean {
return threadId in SignalDatabase.threads.getMutedThreadIds(threshold)
}
@Test
@@ -52,14 +50,14 @@ class ThreadTableTest_mutedThreadIds {
globalDefaults(unreadReminder = true)
SignalDatabase.recipients.setUnreadReminder(contactId, RecipientTable.NotificationSetting.SYSTEM_DEFAULT)
SignalDatabase.threads.incrementUnread(threadId, 1, 1)
assertThat(isMutedFor(ReminderType.MESSAGES)).isTrue()
assertThat(isMutedFor()).isTrue()
}
@Test
fun `allow-by-default plus an explicit opt-out excludes the thread`() {
globalDefaults(unreadReminder = true)
SignalDatabase.recipients.setUnreadReminder(contactId, RecipientTable.NotificationSetting.DO_NOT_NOTIFY)
assertThat(isMutedFor(ReminderType.MESSAGES)).isFalse()
assertThat(isMutedFor()).isFalse()
}
@Test
@@ -67,21 +65,21 @@ class ThreadTableTest_mutedThreadIds {
globalDefaults(unreadReminder = true)
SignalDatabase.recipients.setUnreadReminder(contactId, RecipientTable.NotificationSetting.ALWAYS_NOTIFY)
SignalDatabase.threads.incrementUnread(threadId, 1, 1)
assertThat(isMutedFor(ReminderType.MESSAGES)).isTrue()
assertThat(isMutedFor()).isTrue()
}
@Test
fun `always-allow-required plus system-default recipient setting excludes the thread`() {
globalDefaults(unreadReminder = false)
SignalDatabase.recipients.setUnreadReminder(contactId, RecipientTable.NotificationSetting.SYSTEM_DEFAULT)
assertThat(isMutedFor(ReminderType.MESSAGES)).isFalse()
assertThat(isMutedFor()).isFalse()
}
@Test
fun `always-allow-required plus an explicit opt-out excludes the thread`() {
globalDefaults(unreadReminder = true)
SignalDatabase.recipients.setUnreadReminder(contactId, RecipientTable.NotificationSetting.DO_NOT_NOTIFY)
assertThat(isMutedFor(ReminderType.MESSAGES)).isFalse()
assertThat(isMutedFor()).isFalse()
}
@Test
@@ -89,7 +87,7 @@ class ThreadTableTest_mutedThreadIds {
globalDefaults(unreadReminder = false)
SignalDatabase.recipients.setUnreadReminder(contactId, RecipientTable.NotificationSetting.ALWAYS_NOTIFY)
SignalDatabase.threads.incrementUnread(threadId, 1, 1)
assertThat(isMutedFor(ReminderType.MESSAGES)).isTrue()
assertThat(isMutedFor()).isTrue()
}
@Test
@@ -97,7 +95,7 @@ class ThreadTableTest_mutedThreadIds {
globalDefaults(unreadReminder = true)
SignalDatabase.recipients.setUnreadReminder(contactId, RecipientTable.NotificationSetting.ALWAYS_NOTIFY)
SignalDatabase.recipients.setMuted(contactId, 0L)
assertThat(isMutedFor(ReminderType.MESSAGES)).isFalse()
assertThat(isMutedFor()).isFalse()
}
@Test
@@ -105,7 +103,7 @@ class ThreadTableTest_mutedThreadIds {
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()
assertThat(isMutedFor(3.days.inWholeMilliseconds)).isFalse()
}
@Test
@@ -114,14 +112,14 @@ class ThreadTableTest_mutedThreadIds {
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()
assertThat(isMutedFor(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()
assertThat(isMutedFor()).isFalse()
}
@Test
@@ -129,30 +127,6 @@ class ThreadTableTest_mutedThreadIds {
globalDefaults(unreadReminder = true)
SignalDatabase.recipients.setUnreadReminder(contactId, RecipientTable.NotificationSetting.ALWAYS_NOTIFY)
SignalDatabase.threads.setArchived(setOf(threadId), true)
assertThat(isMutedFor(ReminderType.MESSAGES)).isFalse()
}
@Test
fun `calls, allow-by-default false, require an explicit always-allow for calls specifically`() {
globalDefaults(unreadReminder = true, calls = false)
SignalDatabase.recipients.setUnreadReminder(contactId, RecipientTable.NotificationSetting.ALWAYS_NOTIFY)
SignalDatabase.recipients.setCallNotificationSetting(contactId, RecipientTable.NotificationSetting.SYSTEM_DEFAULT)
assertThat(isMutedFor(ReminderType.CALLS)).isFalse()
SignalDatabase.recipients.setCallNotificationSetting(contactId, RecipientTable.NotificationSetting.ALWAYS_NOTIFY)
assertThat(isMutedFor(ReminderType.CALLS)).isTrue()
}
@Test
fun `calls, allow-by-default true, an explicit opt-out for calls specifically still excludes it`() {
globalDefaults(unreadReminder = true, calls = true)
SignalDatabase.recipients.setUnreadReminder(contactId, RecipientTable.NotificationSetting.ALWAYS_NOTIFY)
SignalDatabase.recipients.setCallNotificationSetting(contactId, RecipientTable.NotificationSetting.DO_NOT_NOTIFY)
assertThat(isMutedFor(ReminderType.CALLS)).isFalse()
SignalDatabase.recipients.setCallNotificationSetting(contactId, RecipientTable.NotificationSetting.SYSTEM_DEFAULT)
assertThat(isMutedFor(ReminderType.CALLS)).isTrue()
assertThat(isMutedFor()).isFalse()
}
}
@@ -40,12 +40,6 @@ class UnreadReminderJobTest {
assertEquals("Alice and others", result)
}
@Test
fun `calls, two authors, shares the same wording as messages`() {
val result = UnreadReminderJob.create().buildAuthorSummary(context, ReminderType.CALLS, 5, listOf("Alice", "Bob"))
assertEquals("Alice and Bob", result)
}
@Test
fun `mentions, one author, omits the count`() {
val result = UnreadReminderJob.create().buildAuthorSummary(context, ReminderType.MENTIONS, 1, listOf("Dave"))
@@ -90,140 +84,48 @@ class UnreadReminderJobTest {
@Test
fun `messages only, singular message and singular author`() {
val result = UnreadReminderJob.create().buildSummary(context, messages = 1, messageAndCallAuthors = listOf("Alice"))
val result = UnreadReminderJob.create().buildSummary(context, messages = 1, messageAuthors = listOf("Alice"))
assertEquals("You have 1 unread message from Alice.", result)
}
@Test
fun `messages only, plural messages and two authors`() {
val result = UnreadReminderJob.create().buildSummary(context, messages = 5, messageAndCallAuthors = listOf("Alice", "Bob"))
val result = UnreadReminderJob.create().buildSummary(context, messages = 5, messageAuthors = listOf("Alice", "Bob"))
assertEquals("You have 5 unread messages from Alice and Bob.", result)
}
@Test
fun `calls only, singular call and singular author`() {
val result = UnreadReminderJob.create().buildSummary(context, calls = 1, messageAndCallAuthors = listOf("Bob"))
assertEquals("You have 1 missed call from Bob.", result)
}
@Test
fun `calls only, plural calls and three or more authors`() {
val result = UnreadReminderJob.create().buildSummary(context, calls = 4, messageAndCallAuthors = listOf("Carol", "Dave", "Erin"))
assertEquals("You have 4 missed calls from Carol and others.", result)
}
@Test
fun `messages and calls, no mentions or replies, combines counts and authors`() {
val result = UnreadReminderJob.create().buildSummary(context, messages = 2, calls = 1, messageAndCallAuthors = listOf("Alice", "Bob"))
assertEquals("You have 1 missed call and 2 unread messages from Alice and Bob.", result)
}
@Test
fun `messages, calls, and mentions uses the calls-and-unread summary with mentions`() {
fun `messages and mentions, combines counts and authors`() {
val result = UnreadReminderJob.create().buildSummary(
context,
messages = 1,
calls = 1,
mentions = 1,
messageAndCallAuthors = listOf("Alice"),
messageAuthors = listOf("Alice"),
mentionAuthors = listOf("Dave")
)
assertEquals("You have 1 missed call and 1 unread message including a mention of you by Dave.", result)
assertEquals("You have 1 unread message, including a mention of you by Dave.", result)
}
@Test
fun `messages, calls, and replies uses the calls-and-unread summary with replies`() {
fun `messages and replies, combines counts and authors`() {
val result = UnreadReminderJob.create().buildSummary(
context,
messages = 1,
calls = 1,
replies = 1,
messageAndCallAuthors = listOf("Alice"),
messageAuthors = listOf("Alice"),
replyAuthors = listOf("Grace")
)
assertEquals("You have 1 missed call and 1 unread message including a reply from Grace.", result)
}
@Test
fun `messages, calls, mentions, and replies all true uses the full summary`() {
val result = UnreadReminderJob.create().buildSummary(
context,
messages = 1,
calls = 1,
mentions = 1,
replies = 1,
messageAndCallAuthors = listOf("Alice"),
mentionAuthors = listOf("Dave"),
replyAuthors = listOf("Grace")
)
assertEquals("You have 1 missed call and 1 unread message including a mention of you by Dave and a reply from Grace.", result)
}
@Test
fun `messages, calls, mentions, and replies all true, plural messages and calls`() {
val result = UnreadReminderJob.create().buildSummary(
context,
messages = 3,
calls = 2,
mentions = 1,
replies = 1,
messageAndCallAuthors = listOf("Alice", "Bob"),
mentionAuthors = listOf("Dave"),
replyAuthors = listOf("Grace")
)
assertEquals("You have 2 missed calls and 3 unread messages including a mention of you by Dave and a reply from Grace.", result)
}
@Test
fun `messages, calls, mentions, and replies all true, two mention and reply authors`() {
val result = UnreadReminderJob.create().buildSummary(
context,
messages = 1,
calls = 1,
mentions = 2,
replies = 2,
messageAndCallAuthors = listOf("Alice"),
mentionAuthors = listOf("Dave", "Erin"),
replyAuthors = listOf("Grace", "Heidi")
)
assertEquals("You have 1 missed call and 1 unread message including 2 mentions of you by Dave and Erin and 2 replies from Grace and Heidi.", result)
}
@Test
fun `messages, calls, mentions, and replies all true, three or more mention and reply authors`() {
val result = UnreadReminderJob.create().buildSummary(
context,
messages = 1,
calls = 1,
mentions = 5,
replies = 4,
messageAndCallAuthors = listOf("Alice"),
mentionAuthors = listOf("Dave", "Erin", "Frank"),
replyAuthors = listOf("Grace", "Heidi", "Ivan")
)
assertEquals("You have 1 missed call and 1 unread message including 5 mentions of you by Dave and others and 4 replies from Grace and others.", result)
}
@Test
fun `messages and mentions, no calls`() {
val result = UnreadReminderJob.create().buildSummary(context, messages = 2, mentions = 2, messageAndCallAuthors = listOf("Alice"), mentionAuthors = listOf("Frank", "Erin"))
assertEquals("You have 2 unread messages, including 2 mentions of you by Frank and Erin.", result)
}
@Test
fun `messages and replies, no calls`() {
val result = UnreadReminderJob.create().buildSummary(context, messages = 1, replies = 3, messageAndCallAuthors = listOf("Alice"), replyAuthors = listOf("Grace"))
assertEquals("You have 1 unread message, including a reply from Grace.", result)
}
@Test
fun `messages, mentions, and replies all true, no calls - both are shown`() {
fun `messages, mentions, and replies all true uses the full summary`() {
val result = UnreadReminderJob.create().buildSummary(
context,
messages = 1,
mentions = 1,
replies = 1,
messageAndCallAuthors = listOf("Alice"),
messageAuthors = listOf("Alice"),
mentionAuthors = listOf("Dave"),
replyAuthors = listOf("Grace")
)
@@ -231,61 +133,65 @@ class UnreadReminderJobTest {
}
@Test
fun `calls and mentions, no unread messages - falls back to calls-or-unread-author with calls`() {
val result = UnreadReminderJob.create().buildSummary(context, calls = 1, mentions = 1, messageAndCallAuthors = listOf("Bob"), mentionAuthors = listOf("Dave"))
assertEquals("You have 1 missed call from Bob.", result)
fun `messages, mentions, and replies all true, plural messages`() {
val result = UnreadReminderJob.create().buildSummary(
context,
messages = 3,
mentions = 1,
replies = 1,
messageAuthors = listOf("Alice", "Bob"),
mentionAuthors = listOf("Dave"),
replyAuthors = listOf("Grace")
)
assertEquals("You have 3 unread messages, including a mention of you by Dave and a reply from Grace.", result)
}
@Test
fun `messages, mentions, and replies all true, two mention and reply authors`() {
val result = UnreadReminderJob.create().buildSummary(
context,
messages = 1,
mentions = 2,
replies = 2,
messageAuthors = listOf("Alice"),
mentionAuthors = listOf("Dave", "Erin"),
replyAuthors = listOf("Grace", "Heidi")
)
assertEquals("You have 1 unread message, including 2 mentions of you by Dave and Erin and 2 replies from Grace and Heidi.", result)
}
@Test
fun `messages, mentions, and replies all true, three or more mention and reply authors`() {
val result = UnreadReminderJob.create().buildSummary(
context,
messages = 1,
mentions = 5,
replies = 4,
messageAuthors = listOf("Alice"),
mentionAuthors = listOf("Dave", "Erin", "Frank"),
replyAuthors = listOf("Grace", "Heidi", "Ivan")
)
assertEquals("You have 1 unread message, including 5 mentions of you by Dave and others and 4 replies from Grace and others.", result)
}
@Test
fun `hideAuthors, messages only, omits author names`() {
val result = UnreadReminderJob.create().buildSummary(context, messages = 1, messageAndCallAuthors = listOf("Alice"), hideAuthors = true)
val result = UnreadReminderJob.create().buildSummary(context, messages = 1, messageAuthors = listOf("Alice"), hideAuthors = true)
assertEquals("You have 1 unread message.", result)
}
@Test
fun `hideAuthors, calls only, omits author names`() {
val result = UnreadReminderJob.create().buildSummary(context, calls = 1, messageAndCallAuthors = listOf("Bob"), hideAuthors = true)
assertEquals("You have 1 missed call.", result)
}
@Test
fun `hideAuthors, messages and calls, uses calls-and-unread without authors`() {
val result = UnreadReminderJob.create().buildSummary(context, messages = 2, calls = 1, messageAndCallAuthors = listOf("Alice", "Bob"), hideAuthors = true)
assertEquals("You have 1 missed call and 2 unread messages.", result)
}
@Test
fun `hideAuthors, messages, calls, and mentions, mentions are dropped entirely`() {
val result = UnreadReminderJob.create().buildSummary(
context,
messages = 1,
calls = 1,
mentions = 1,
messageAndCallAuthors = listOf("Alice"),
mentionAuthors = listOf("Dave"),
hideAuthors = true
)
assertEquals("You have 1 missed call and 1 unread message.", result)
}
@Test
fun `hideAuthors, messages and mentions, no calls, mentions are dropped entirely`() {
val result = UnreadReminderJob.create().buildSummary(context, messages = 2, mentions = 2, messageAndCallAuthors = listOf("Alice"), mentionAuthors = listOf("Frank", "Erin"), hideAuthors = true)
fun `hideAuthors, messages and mentions, mentions are dropped entirely`() {
val result = UnreadReminderJob.create().buildSummary(context, messages = 2, mentions = 2, messageAuthors = listOf("Alice"), mentionAuthors = listOf("Frank", "Erin"), hideAuthors = true)
assertEquals("You have 2 unread messages.", result)
}
@Test
fun `hideAuthors, messages and replies, no calls, replies are dropped entirely`() {
val result = UnreadReminderJob.create().buildSummary(context, messages = 1, replies = 3, messageAndCallAuthors = listOf("Alice"), replyAuthors = listOf("Grace"), hideAuthors = true)
fun `hideAuthors, messages and replies, replies are dropped entirely`() {
val result = UnreadReminderJob.create().buildSummary(context, messages = 1, replies = 3, messageAuthors = listOf("Alice"), replyAuthors = listOf("Grace"), hideAuthors = true)
assertEquals("You have 1 unread message.", result)
}
@Test
fun `hideAuthors, calls and mentions, no unread messages, mentions are dropped entirely`() {
val result = UnreadReminderJob.create().buildSummary(context, calls = 1, mentions = 1, messageAndCallAuthors = listOf("Bob"), mentionAuthors = listOf("Dave"), hideAuthors = true)
assertEquals("You have 1 missed call.", result)
}
@Test
fun `hideAuthors, nothing unread produces an empty summary`() {
val result = UnreadReminderJob.create().buildSummary(context, hideAuthors = true)