diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index f706929155..d482026211 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -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"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index 03a3807063..7883f5be70 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -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) } } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt index 67178716e1..6902cb5cc8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/CallTable.kt @@ -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): Pair> { + fun getUnreadCallsForReminderNotification(threadId: Long, now: Long = System.currentTimeMillis()): Pair> { 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): Pair { + 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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index de343f7df6..3b30b5616b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -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, type: ReminderType): Pair> { + fun getUnreadContentForReminderNotification(threadId: Long, type: ReminderType, now: Long = System.currentTimeMillis()): Pair> { 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): Pair { + 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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt index 86a59a9172..1a0628c4a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt @@ -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 { - val isGV2Clause = "${RecipientTable.TABLE_NAME}.${RecipientTable.TYPE} = ${RecipientTable.RecipientType.GV2.id}" + fun getMutedThreadIds(reminderType: ReminderType, reminderThreshold: Long, now: Long = System.currentTimeMillis()): List { 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}" diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index e6f1f45adc..3c5feb298c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V327_AddLastUnreadReminderColumn.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V327_AddLastUnreadReminderColumn.kt new file mode 100644 index 0000000000..1e410775d8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V327_AddLastUnreadReminderColumn.kt @@ -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") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt index e9f65cd002..1635ec523f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index 867b0e9241..005f48f00f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/UnreadReminderJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/UnreadReminderJob.kt index da4f416894..9a293239d3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/UnreadReminderJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/UnreadReminderJob.kt @@ -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, reminderType: ReminderType): Pair> { - return if (threadIds.isEmpty()) { + private fun getUnreadForReminder(reminderType: ReminderType, isEligible: Boolean): Pair> { + 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 { 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) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationChannels.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationChannels.java index 791b035c51..869b2237ba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationChannels.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationChannels.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java index dc1b6de0eb..bcea3ccda4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java @@ -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; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/UnreadReminderManager.kt b/app/src/main/java/org/thoughtcrime/securesms/service/UnreadReminderManager.kt new file mode 100644 index 0000000000..4e82ecdd7e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/UnreadReminderManager.kt @@ -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(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() + } + } +} diff --git a/app/src/main/protowire/JobData.proto b/app/src/main/protowire/JobData.proto index 454accb8e5..1f237c110f 100644 --- a/app/src/main/protowire/JobData.proto +++ b/app/src/main/protowire/JobData.proto @@ -296,3 +296,8 @@ message MultiDeviceAttachmentBackfillRequestJobData { message BackupTierDowngradeCheckJobData { optional uint64 remoteBackupTier = 1; } + +message UnreadReminderJobData { + uint64 threadId = 1; + uint64 lastReminderTime = 2; +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9562b7b9da..30d76242c2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3391,6 +3391,8 @@ Additional message notifications New linked device + + Unread reminders Quick response unavailable when Signal is locked! diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/CallTableTest_unreadForReminder.kt b/app/src/test/java/org/thoughtcrime/securesms/database/CallTableTest_unreadForReminder.kt index 27d94ae577..1dd6aec4ef 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/CallTableTest_unreadForReminder.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/CallTableTest_unreadForReminder.kt @@ -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)) diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/MessageTableTest_unreadForReminder.kt b/app/src/test/java/org/thoughtcrime/securesms/database/MessageTableTest_unreadForReminder.kt index d9a55d9908..2c8499387d 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/MessageTableTest_unreadForReminder.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/MessageTableTest_unreadForReminder.kt @@ -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 = emptyList()): Long { val message = IncomingMessage( type = MessageType.NORMAL, diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/ThreadTableTest_mutedThreadIds.kt b/app/src/test/java/org/thoughtcrime/securesms/database/ThreadTableTest_mutedThreadIds.kt index aecfd2499f..725e9211c1 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/ThreadTableTest_mutedThreadIds.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/ThreadTableTest_mutedThreadIds.kt @@ -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() - } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt index 10ffa80397..6ccf00e724 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt @@ -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) }