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 338013d874..de343f7df6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -135,6 +135,7 @@ import org.thoughtcrime.securesms.mms.MmsException import org.thoughtcrime.securesms.mms.OutgoingMessage import org.thoughtcrime.securesms.mms.QuoteModel import org.thoughtcrime.securesms.mms.SlideDeck +import org.thoughtcrime.securesms.notifications.v2.ConversationId import org.thoughtcrime.securesms.notifications.v2.DefaultMessageNotifier.StickyThread import org.thoughtcrime.securesms.polls.Poll import org.thoughtcrime.securesms.polls.PollRecord @@ -2572,18 +2573,77 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat } } - fun markAsNotified(id: Long) { + fun markAsNotified(ids: Collection) { + if (ids.isEmpty()) { + return + } + + val now = System.currentTimeMillis() + val idQuery = SqlUtil.buildFastCollectionQuery(ID, ids) + val originalQuery = SqlUtil.buildFastCollectionQuery(ORIGINAL_MESSAGE_ID, ids) + val revisionQuery = SqlUtil.buildFastCollectionQuery(LATEST_REVISION_ID, ids) + writableDatabase .update(TABLE_NAME) .values( NOTIFIED to 1, - REACTIONS_LAST_SEEN to System.currentTimeMillis(), - VOTES_LAST_SEEN to System.currentTimeMillis() + REACTIONS_LAST_SEEN to now, + VOTES_LAST_SEEN to now + ) + .where( + "(${idQuery.where}) OR (${originalQuery.where}) OR (${revisionQuery.where})", + idQuery.whereArgs + originalQuery.whereArgs + revisionQuery.whereArgs ) - .where("$ID = ? OR $ORIGINAL_MESSAGE_ID = ? OR $LATEST_REVISION_ID = ?", id, id, id) .run() } + /** + * Marks everything not yet notified in [conversationIds] as notified, up to and including [maxMessageId]. + */ + fun markConversationsAsNotified(conversationIds: Collection, maxMessageId: Long) { + if (conversationIds.isEmpty() || maxMessageId <= 0) { + return + } + + val now = System.currentTimeMillis() + val (storyReplies, chats) = conversationIds.partition { it.groupStoryId != null } + + writableDatabase.withinTransaction { db -> + if (chats.isNotEmpty()) { + val query = SqlUtil.buildFastCollectionQuery( + column = THREAD_ID, + values = chats.map { it.threadId }.distinct(), + prefix = "$NOTIFIED = 0 AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $ID <= $maxMessageId AND" + ) + + db.update(TABLE_NAME) + .values( + NOTIFIED to 1, + REACTIONS_LAST_SEEN to now, + VOTES_LAST_SEEN to now + ) + .where(query.where, query.whereArgs) + .run() + } + + storyReplies.forEach { conversationId -> + db.update(TABLE_NAME) + .values( + NOTIFIED to 1, + REACTIONS_LAST_SEEN to now, + VOTES_LAST_SEEN to now + ) + .where( + "$NOTIFIED = 0 AND $STORY_TYPE = 0 AND $PARENT_STORY_ID = ? AND $THREAD_ID = ? AND $ID <= ?", + conversationId.groupStoryId!!, + conversationId.threadId, + maxMessageId + ) + .run() + } + } + } + fun markAsNotNotified(id: Long) { writableDatabase .update(TABLE_NAME) diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.java index 5bebe8c00e..8e995fe8ac 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DeleteNotificationReceiver.java @@ -16,18 +16,16 @@ public class DeleteNotificationReceiver extends BroadcastReceiver { public static String DELETE_NOTIFICATION_ACTION = "org.thoughtcrime.securesms.DELETE_NOTIFICATION"; - public static final String EXTRA_IDS = "message_ids"; - public static final String EXTRA_MMS = "is_mms"; - public static final String EXTRA_THREADS = "threads"; + public static final String EXTRA_MAX_MESSAGE_ID = "max_message_id"; + public static final String EXTRA_THREADS = "threads"; @Override public void onReceive(final Context context, Intent intent) { if (DELETE_NOTIFICATION_ACTION.equals(intent.getAction())) { MessageNotifier notifier = AppDependencies.getMessageNotifier(); - final long[] ids = intent.getLongArrayExtra(EXTRA_IDS); - final boolean[] mms = intent.getBooleanArrayExtra(EXTRA_MMS); - final ArrayList threads = intent.getParcelableArrayListExtra(EXTRA_THREADS); + final long maxMessageId = intent.getLongExtra(EXTRA_MAX_MESSAGE_ID, 0); + final ArrayList threads = intent.getParcelableArrayListExtra(EXTRA_THREADS); if (threads != null) { for (ConversationId thread : threads) { @@ -35,18 +33,12 @@ public class DeleteNotificationReceiver extends BroadcastReceiver { } } - if (ids == null || mms == null || ids.length != mms.length) return; + if (threads == null || threads.isEmpty() || maxMessageId <= 0) return; PendingResult finisher = goAsync(); SignalExecutors.BOUNDED.execute(() -> { - for (int i = 0; i < ids.length; i++) { - if (!mms[i]) { - SignalDatabase.messages().markAsNotified(ids[i]); - } else { - SignalDatabase.messages().markAsNotified(ids[i]); - } - } + SignalDatabase.messages().markConversationsAsNotified(threads, maxMessageId); finisher.finish(); }); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java index c3b1577708..999de76c16 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/OptimizedMessageNotifier.java @@ -12,10 +12,10 @@ import org.signal.core.util.ExceptionUtil; import org.signal.core.util.ThreadUtil; import org.signal.core.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.dependencies.AppDependencies; import org.thoughtcrime.securesms.notifications.v2.DefaultMessageNotifier; import org.thoughtcrime.securesms.notifications.v2.ConversationId; import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.util.BubbleUtil; import org.thoughtcrime.securesms.util.LeakyBucketLimiter; import java.util.Optional; @@ -30,10 +30,12 @@ public class OptimizedMessageNotifier implements MessageNotifier { private static final String DEDUPE_KEY_GENERAL = "MESSAGE_NOTIFIER_DEFAULT"; private static final String DEDUPE_KEY_CHAT = "MESSAGE_NOTIFIER_CHAT_"; + private static final long DRIP_INTERVAL_DRAINED_MS = 1_000; + private static final long DRIP_INTERVAL_DRAINING_MS = 10_000; @MainThread public OptimizedMessageNotifier(@NonNull Application context) { - this.limiter = new LeakyBucketLimiter(3, 1000, new Handler(SignalExecutors.getAndStartHandlerThread("signal-notifier", ThreadUtil.PRIORITY_IMPORTANT_BACKGROUND_THREAD).getLooper())); + this.limiter = new LeakyBucketLimiter(3, OptimizedMessageNotifier::dripInterval, new Handler(SignalExecutors.getAndStartHandlerThread("signal-notifier", ThreadUtil.PRIORITY_IMPORTANT_BACKGROUND_THREAD).getLooper())); this.defaultMessageNotifier = new DefaultMessageNotifier(context); } @@ -110,7 +112,7 @@ public class OptimizedMessageNotifier implements MessageNotifier { @Override public void forceBubbleNotification(@NonNull Context context, @NonNull ConversationId conversationId) { SignalDatabase.runPostSuccessfulTransaction(() -> { - runOnLimiter(() -> getNotifier().forceBubbleNotification(context, conversationId)); + runWithoutLimit(() -> getNotifier().forceBubbleNotification(context, conversationId)); }); } @@ -129,14 +131,26 @@ public class OptimizedMessageNotifier implements MessageNotifier { } private void runOnLimiter(@NonNull Runnable runnable) { + limiter.run(withCallerStackTrace(runnable)); + } + + private void runWithoutLimit(@NonNull Runnable runnable) { + limiter.runWithoutLimit(withCallerStackTrace(runnable)); + } + + private static Runnable withCallerStackTrace(@NonNull Runnable runnable) { Throwable prettyException = new Throwable(); - limiter.run(() -> { + return () -> { try { runnable.run(); } catch (RuntimeException e) { throw ExceptionUtil.joinStackTrace(e, prettyException); } - }); + }; + } + + private static long dripInterval() { + return AppDependencies.getIncomingMessageObserver().getDecryptionDrained() ? DRIP_INTERVAL_DRAINED_MS : DRIP_INTERVAL_DRAINING_MS; } private MessageNotifier getNotifier() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/DefaultMessageNotifier.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/DefaultMessageNotifier.kt index 41275a26a0..285b664add 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/DefaultMessageNotifier.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/DefaultMessageNotifier.kt @@ -60,15 +60,15 @@ class DefaultMessageNotifier(context: Application) : MessageNotifier { @Volatile private var previousState: NotificationState = NotificationState.EMPTY private val threadReminders: MutableMap = ConcurrentHashMap() - private val stickyThreads: MutableMap = mutableMapOf() + private val stickyThreads: MutableMap = ConcurrentHashMap() private val lastThreadNotification: MutableMap = ConcurrentHashMap() private val executor = CancelableExecutor() override fun setVisibleThread(conversationId: ConversationId?) { visibleThread.set(conversationId) - stickyThreads.remove(conversationId) if (conversationId != null) { + stickyThreads.remove(conversationId) lastThreadNotification.remove(conversationId) } } @@ -155,37 +155,26 @@ class DefaultMessageNotifier(context: Application) : MessageNotifier { val notificationProfile: NotificationProfile? = NotificationProfiles.getActiveProfile(SignalDatabase.notificationProfiles.getProfiles()) Log.internal().i(TAG, "sticky thread: $stickyThreads active profile: ${notificationProfile?.id ?: "none" }") - var state: NotificationState = NotificationStateProvider.constructNotificationState(stickyThreads, notificationProfile) - Log.internal().i(TAG, "state: $state") + val state: NotificationState = NotificationStateProvider.constructNotificationState(stickyThreads, notificationProfile) if (state.muteFilteredMessages.isNotEmpty()) { Log.i(TAG, "Marking ${state.muteFilteredMessages.size} muted messages as notified to skip notification") - state.muteFilteredMessages.forEach { item -> - SignalDatabase.messages.markAsNotified(item.id) - } + SignalDatabase.messages.markAsNotified(state.muteFilteredMessages.map { it.id }) } if (state.profileFilteredMessages.isNotEmpty()) { Log.i(TAG, "Marking ${state.profileFilteredMessages.size} profile filtered messages as notified to skip notification") - state.profileFilteredMessages.forEach { item -> - SignalDatabase.messages.markAsNotified(item.id) - } + SignalDatabase.messages.markAsNotified(state.profileFilteredMessages.map { it.id }) } if (state.reactionsDisabledFilteredMessages.isNotEmpty()) { Log.i(TAG, "Marking ${state.reactionsDisabledFilteredMessages.size} reactions as notified to skip notification") - state.reactionsDisabledFilteredMessages.forEach { item -> - SignalDatabase.messages.markAsNotified(item.id) - } + SignalDatabase.messages.markAsNotified(state.reactionsDisabledFilteredMessages.map { it.id }) } if (!SignalStore.settings.isMessageNotificationsEnabled) { Log.i(TAG, "Marking ${state.conversations.size} conversations as notified to skip notification") - state.conversations.forEach { conversation -> - conversation.notificationItems.forEach { item -> - SignalDatabase.messages.markAsNotified(item.id) - } - } + SignalDatabase.messages.markConversationsAsNotified(state.conversations.map { it.thread }, state.notificationItems.maxOfOrNull { it.id } ?: 0L) return } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationBuilder.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationBuilder.kt index 92551b91f8..11a59d6b48 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationBuilder.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationBuilder.kt @@ -37,8 +37,6 @@ import java.util.Optional import androidx.core.app.Person as PersonCompat import org.signal.core.ui.R as CoreUiR -private const val BIG_PICTURE_DIMEN = 500 - /** * Wraps the compat and OS versions of the Notification builders so we can more easily access native * features in newer versions. Also provides some domain-specific helpers. @@ -199,6 +197,13 @@ sealed class NotificationBuilder(protected val context: Context) { * Notification builder using solely androidx/compat libraries. */ private class NotificationBuilderCompat(context: Context) : NotificationBuilder(context) { + companion object { + private const val BIG_PICTURE_DIMEN = 500 + + /** Cap on messages rendered into a single notification. */ + private const val MAX_DISPLAYED_MESSAGES = 25 + } + val builder: NotificationCompat.Builder = NotificationCompat.Builder(context, NotificationChannels.getInstance().messagesChannel) override fun addActions(replyMethod: ReplyMethod, conversation: NotificationConversation) { @@ -299,7 +304,7 @@ sealed class NotificationBuilder(protected val context: Context) { messagingStyle.conversationTitle = conversation.getConversationTitle(context) messagingStyle.isGroupConversation = conversation.isGroup - conversation.notificationItems.forEach { notificationItem -> + conversation.notificationItems.takeLast(MAX_DISPLAYED_MESSAGES).forEach { notificationItem -> var person: PersonCompat? = null val isNoteToSelf = notificationItem.isPersonSelf && conversation.recipient.isSelf @@ -332,7 +337,7 @@ sealed class NotificationBuilder(protected val context: Context) { val style: NotificationCompat.InboxStyle = NotificationCompat.InboxStyle() - for (notificationItem: NotificationItem in state.notificationItems) { + for (notificationItem: NotificationItem in state.notificationItems.takeLast(MAX_DISPLAYED_MESSAGES)) { val line: CharSequence? = notificationItem.getInboxLine(context) if (line != null) { style.addLine(line) diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationConversation.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationConversation.kt index 835e12cb78..9458e31fb9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationConversation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationConversation.kt @@ -152,17 +152,9 @@ data class NotificationConversation( } fun getDeleteIntent(context: Context): PendingIntent? { - val ids = LongArray(notificationItems.size) - val mms = BooleanArray(ids.size) - notificationItems.forEachIndexed { index, notificationItem -> - ids[index] = notificationItem.id - mms[index] = notificationItem.isMms - } - val intent = Intent(context, DeleteNotificationReceiver::class.java) .setAction(DeleteNotificationReceiver.DELETE_NOTIFICATION_ACTION) - .putExtra(DeleteNotificationReceiver.EXTRA_IDS, ids) - .putExtra(DeleteNotificationReceiver.EXTRA_MMS, mms) + .putExtra(DeleteNotificationReceiver.EXTRA_MAX_MESSAGE_ID, notificationItems.maxOfOrNull { it.id } ?: 0L) .putParcelableArrayListExtra(DeleteNotificationReceiver.EXTRA_THREADS, arrayListOf(thread)) .makeUniqueToPreventMerging() diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationFactory.kt index 1c90427078..80e449f11e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationFactory.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationFactory.kt @@ -48,8 +48,9 @@ object NotificationFactory { val TAG: String = Log.tag(NotificationFactory::class.java) - private val STILL_DECRYPTING_INDIVIDUAL_THROTTLE: Duration = 5.seconds + private val INDIVIDUAL_THROTTLE: Duration = 5.seconds private val GROUP_THROTTLE: Duration = 20.seconds + private val STILL_DECRYPTING_THROTTLE: Duration = 30.seconds @WorkerThread fun notify( @@ -208,9 +209,9 @@ object NotificationFactory { private fun shouldAlert(conversation: NotificationConversation, lastNotificationTimestamp: Long, alertOverride: Boolean): Boolean { val throttle: Duration = when { + !AppDependencies.incomingMessageObserver.decryptionDrained -> STILL_DECRYPTING_THROTTLE conversation.recipient.isGroup && (conversation.mostRecentNotification as? MessageNotification)?.hasSelfMention == false -> GROUP_THROTTLE - AppDependencies.incomingMessageObserver.decryptionDrained -> STILL_DECRYPTING_INDIVIDUAL_THROTTLE - else -> 0.seconds + else -> INDIVIDUAL_THROTTLE } val canAlertBasedOnTime: Boolean = lastNotificationTimestamp < System.currentTimeMillis() - throttle.inWholeMilliseconds || lastNotificationTimestamp > System.currentTimeMillis() val isUnreadNoteToSelf: Boolean = conversation.recipient.isSelf && (conversation.mostRecentNotification as? MessageNotification)?.isUnread == true diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationState.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationState.kt index c457bb1a2c..3e4963710e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationState.kt @@ -54,23 +54,10 @@ data class NotificationState( } fun getDeleteIntent(context: Context): PendingIntent? { - val ids = LongArray(messageCount) - val mms = BooleanArray(ids.size) - val threads: MutableList = mutableListOf() - - conversations.forEach { conversation -> - threads += conversation.thread - conversation.notificationItems.forEachIndexed { index, notificationItem -> - ids[index] = notificationItem.id - mms[index] = notificationItem.isMms - } - } - val intent = Intent(context, DeleteNotificationReceiver::class.java) .setAction(DeleteNotificationReceiver.DELETE_NOTIFICATION_ACTION) - .putExtra(DeleteNotificationReceiver.EXTRA_IDS, ids) - .putExtra(DeleteNotificationReceiver.EXTRA_MMS, mms) - .putParcelableArrayListExtra(DeleteNotificationReceiver.EXTRA_THREADS, ArrayList(threads)) + .putExtra(DeleteNotificationReceiver.EXTRA_MAX_MESSAGE_ID, notificationItems.maxOfOrNull { it.id } ?: 0L) + .putParcelableArrayListExtra(DeleteNotificationReceiver.EXTRA_THREADS, ArrayList(conversations.map { it.thread })) .makeUniqueToPreventMerging() return NotificationPendingIntentHelper.getBroadcast(context, 0, intent, PendingIntentFlags.updateCurrent()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java index 774a80194c..210449f5f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java @@ -243,28 +243,14 @@ public final class AvatarUtil { } } - @Override - public void onDestroy() { - Log.d(TAG, "AvatarTarget: onDestroy"); - super.onDestroy(); - } - - @Override - public void onLoadStarted(@Nullable Drawable placeholder) { - Log.d(TAG, "AvatarTarget: onLoadStarted"); - super.onLoadStarted(placeholder); - } - @Override public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition transition) { - Log.d(TAG, "AvatarTarget: onResourceReady"); bitmap.set(resource); countDownLatch.countDown(); } @Override public void onLoadFailed(@Nullable Drawable errorDrawable) { - Log.d(TAG, "AvatarTarget: onLoadFailed"); if (errorDrawable == null) { throw new AssertionError("Expected an error drawable."); } @@ -276,7 +262,6 @@ public final class AvatarUtil { @Override public void onLoadCleared(@Nullable Drawable placeholder) { - Log.d(TAG, "AvatarTarget: onLoadCleared"); bitmap.set(null); countDownLatch.countDown(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/LeakyBucketLimiter.java b/app/src/main/java/org/thoughtcrime/securesms/util/LeakyBucketLimiter.java index c6d0235609..62ae59574b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/LeakyBucketLimiter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/LeakyBucketLimiter.java @@ -8,6 +8,8 @@ import androidx.core.os.HandlerCompat; import org.signal.core.util.logging.Log; +import java.util.function.LongSupplier; + /** * Imagine a bucket. Now imagine your tasks as little droplets. As your tasks are thrown into the * bucket, the tasks are executed, and the bucket fills up. If the bucket is full, the tasks @@ -22,6 +24,9 @@ import org.signal.core.util.logging.Log; * waited 10 seconds, the bucket would be fully drained, and you'd be able to execute 10 tasks in * rapid succession again. * + * The drip interval is read each time a drip is scheduled, so a caller can vary the rate at runtime. + * A change applies to the next drip scheduled, not to one already posted. + * * This class also does something a little extra -- it keeps track of the most-recently-overflowed * task, and will run it the next time it 'drips' instead of leaking. This lets you have a sort of * "throw tasks at the bucket and forget about it" attitude, because you know the task will @@ -37,16 +42,16 @@ public final class LeakyBucketLimiter { private static final String TAG = Log.tag(LeakyBucketLimiter.class); - private final int bucketCapacity; - private final long dripInterval; - private final Handler handler; + private final int bucketCapacity; + private final LongSupplier dripInterval; + private final Handler handler; private int bucketLevel; private Runnable lastOverflowedRunnable; private final Object RUNNABLE_TOKEN = new Object(); - public LeakyBucketLimiter(int bucketCapacity, long dripInterval, @NonNull Handler handler) { + public LeakyBucketLimiter(int bucketCapacity, @NonNull LongSupplier dripInterval, @NonNull Handler handler) { this.bucketCapacity = bucketCapacity; this.dripInterval = dripInterval; this.handler = handler; @@ -76,10 +81,15 @@ public final class LeakyBucketLimiter { } if (scheduleDrip) { - handler.postDelayed(this::drip, dripInterval); + handler.postDelayed(this::drip, dripInterval.getAsLong()); } } + @AnyThread + public void runWithoutLimit(@NonNull Runnable runnable) { + handler.post(runnable); + } + private void drip() { Runnable runnable = null; boolean needsDrip = false; @@ -101,7 +111,7 @@ public final class LeakyBucketLimiter { } if (needsDrip) { - handler.postDelayed(this::drip, dripInterval); + handler.postDelayed(this::drip, dripInterval.getAsLong()); } } } diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/ApiPlugin.kt b/app/src/spinner/java/org/thoughtcrime/securesms/ApiPlugin.kt index 74bd7bf5e2..05a5c677e3 100644 --- a/app/src/spinner/java/org/thoughtcrime/securesms/ApiPlugin.kt +++ b/app/src/spinner/java/org/thoughtcrime/securesms/ApiPlugin.kt @@ -34,6 +34,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.mms.IncomingMessage import org.thoughtcrime.securesms.mms.OutgoingMessage +import org.thoughtcrime.securesms.notifications.v2.ConversationId import org.thoughtcrime.securesms.profiles.ProfileName import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId @@ -100,6 +101,7 @@ class ApiPlugin : Plugin { ) ), "createThread" to ApiSpec(::createThread, listOf(Param("recipientId", "1"))), + "updateNotification" to ApiSpec(::updateNotification, listOf(Param("threadId", "", placeholder = "blank = all threads"))), "createMessage" to ApiSpec( ::createMessage, listOf( @@ -513,6 +515,30 @@ class ApiPlugin : Plugin { } } + /** + * Drives a notification pass on demand. + */ + private fun updateNotification(parameters: Map>): PluginResult { + val threadIdParam = parameters["threadId"]?.firstOrNull()?.takeIf { it.isNotBlank() } + val threadId = if (threadIdParam != null) { + threadIdParam.toLongOrNull() ?: return PluginResult.ErrorResult(message = "Invalid 'threadId' parameter") + } else { + null + } + + return try { + if (threadId != null) { + AppDependencies.messageNotifier.updateNotification(AppDependencies.application, ConversationId.forConversation(threadId)) + } else { + AppDependencies.messageNotifier.updateNotification(AppDependencies.application) + } + UpdateNotificationResponse(threadId ?: -1).toJsonResult() + } catch (e: Exception) { + Log.w(TAG, "Failed to update notification", e) + PluginResult.ErrorResult(message = "Failed: ${e.message}") + } + } + private fun createMessage(parameters: Map>): PluginResult { val threadId = parameters["threadId"]?.firstOrNull()?.toLongOrNull() ?: return PluginResult.ErrorResult(message = "Missing or invalid 'threadId' parameter") @@ -685,6 +711,10 @@ class ApiPlugin : Plugin { @field:JsonProperty val groupId: String ) + data class UpdateNotificationResponse @JsonCreator constructor( + @JsonProperty("threadId") val threadId: Long + ) + data class CreateThreadResponse @JsonCreator constructor( @field:JsonProperty val threadId: Long ) diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/MessageTableTest_markNotified.kt b/app/src/test/java/org/thoughtcrime/securesms/database/MessageTableTest_markNotified.kt new file mode 100644 index 0000000000..8468aa17d5 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/database/MessageTableTest_markNotified.kt @@ -0,0 +1,241 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.database + +import android.app.Application +import assertk.assertThat +import assertk.assertions.isFalse +import assertk.assertions.isTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.signal.core.util.readToSingleBoolean +import org.signal.core.util.select +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.database.model.ParentStoryId +import org.thoughtcrime.securesms.mms.IncomingMessage +import org.thoughtcrime.securesms.notifications.v2.ConversationId +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.testutil.RecipientTestRule + +@Suppress("ClassName") +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class MessageTableTest_markNotified { + + @get:Rule + val recipients = RecipientTestRule() + + private val messages: MessageTable + get() = SignalDatabase.messages + + @Test + fun `markAsNotified marks only the given messages`() { + val sender = recipients.createRecipient("Alice Bulk") + val threadId = threadFor(sender) + + val first = insertIncoming(threadId, sender, time = 1000) + val second = insertIncoming(threadId, sender, time = 1001) + val untouched = insertIncoming(threadId, sender, time = 1002) + + messages.markAsNotified(listOf(first, second)) + + assertThat(isNotified(first)).isTrue() + assertThat(isNotified(second)).isTrue() + assertThat(isNotified(untouched)).isFalse() + } + + @Test + fun `markAsNotified with no ids is a no-op`() { + val sender = recipients.createRecipient("Bob Empty") + val threadId = threadFor(sender) + val message = insertIncoming(threadId, sender, time = 1000) + + messages.markAsNotified(emptyList()) + + assertThat(isNotified(message)).isFalse() + } + + @Test + fun `markAsNotified marks every revision of an edited message`() { + val sender = recipients.createRecipient("Carol Editor") + val threadId = threadFor(sender) + + val original = insertIncoming(threadId, sender, time = 1000) + val edit = insertEdit(sender, originalSentTimestamp = 1000, editSentTimeMillis = 1001) + + messages.markAsNotified(listOf(original)) + + assertThat(isNotified(original)).isTrue() + assertThat(isNotified(edit)).isTrue() + } + + @Test + fun `markConversationsAsNotified marks unnotified messages up to the bound`() { + val sender = recipients.createRecipient("Dave Bound") + val threadId = threadFor(sender) + + val first = insertIncoming(threadId, sender, time = 1000) + val second = insertIncoming(threadId, sender, time = 1001) + + messages.markConversationsAsNotified(listOf(ConversationId.forConversation(threadId)), second) + + assertThat(isNotified(first)).isTrue() + assertThat(isNotified(second)).isTrue() + } + + @Test + fun `markConversationsAsNotified leaves messages above the bound alone`() { + val sender = recipients.createRecipient("Erin Snapshot") + val threadId = threadFor(sender) + + val inSnapshot = insertIncoming(threadId, sender, time = 1000) + val arrivedAfter = insertIncoming(threadId, sender, time = 1001) + + messages.markConversationsAsNotified(listOf(ConversationId.forConversation(threadId)), inSnapshot) + + assertThat(isNotified(inSnapshot)).isTrue() + assertThat(isNotified(arrivedAfter)).isFalse() + } + + @Test + fun `markConversationsAsNotified bounds by id and not by received time`() { + val sender = recipients.createRecipient("Frank Backdated") + val threadId = threadFor(sender) + + val inSnapshot = insertIncoming(threadId, sender, time = 5000) + val backdated = insertIncoming(threadId, sender, time = 1000) + + messages.markConversationsAsNotified(listOf(ConversationId.forConversation(threadId)), inSnapshot) + + assertThat(isNotified(inSnapshot)).isTrue() + assertThat(isNotified(backdated)).isFalse() + } + + @Test + fun `markConversationsAsNotified only touches the given threads`() { + val included = recipients.createRecipient("Grace Included") + val excluded = recipients.createRecipient("Heidi Excluded") + val includedThreadId = threadFor(included) + val excludedThreadId = threadFor(excluded) + + val includedMessage = insertIncoming(includedThreadId, included, time = 1000) + val excludedMessage = insertIncoming(excludedThreadId, excluded, time = 1001) + + messages.markConversationsAsNotified(listOf(ConversationId.forConversation(includedThreadId)), excludedMessage) + + assertThat(isNotified(includedMessage)).isTrue() + assertThat(isNotified(excludedMessage)).isFalse() + } + + @Test + fun `markConversationsAsNotified with no conversations is a no-op`() { + val sender = recipients.createRecipient("Ivan None") + val threadId = threadFor(sender) + val message = insertIncoming(threadId, sender, time = 1000) + + messages.markConversationsAsNotified(emptyList(), message) + + assertThat(isNotified(message)).isFalse() + } + + @Test + fun `markConversationsAsNotified with a non-positive bound is a no-op`() { + val sender = recipients.createRecipient("Judy Zero") + val threadId = threadFor(sender) + val message = insertIncoming(threadId, sender, time = 1000) + + messages.markConversationsAsNotified(listOf(ConversationId.forConversation(threadId)), 0) + + assertThat(isNotified(message)).isFalse() + } + + @Test + fun `markConversationsAsNotified for a group story reply leaves the main chat alone`() { + val sender = recipients.createRecipient("Karl Story") + val group = recipients.createGroup(sender) + val threadId = threadFor(group.recipientId) + + val storyId = insertIncoming(threadId, sender, time = 1000) + val chatMessage = insertIncoming(threadId, sender, time = 1001) + val reply = insertGroupReply(threadId, sender, time = 1002, parentStoryId = storyId) + + messages.markConversationsAsNotified(listOf(ConversationId(threadId, storyId)), reply) + + assertThat(isNotified(reply)).isTrue() + assertThat(isNotified(chatMessage)).isFalse() + } + + @Test + fun `markConversationsAsNotified for a chat leaves group story replies alone`() { + val sender = recipients.createRecipient("Lena Chat") + val group = recipients.createGroup(sender) + val threadId = threadFor(group.recipientId) + + val storyId = insertIncoming(threadId, sender, time = 1000) + val chatMessage = insertIncoming(threadId, sender, time = 1001) + val reply = insertGroupReply(threadId, sender, time = 1002, parentStoryId = storyId) + + messages.markConversationsAsNotified(listOf(ConversationId.forConversation(threadId)), reply) + + assertThat(isNotified(chatMessage)).isTrue() + assertThat(isNotified(reply)).isFalse() + } + + private fun threadFor(recipientId: RecipientId): Long { + return SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId)) + } + + private fun insertIncoming(threadId: Long, from: RecipientId, time: Long): Long { + val message = IncomingMessage( + type = MessageType.NORMAL, + from = from, + sentTimeMillis = time, + serverTimeMillis = time, + receivedTimeMillis = time, + body = "msg $time" + ) + return messages.insertMessageInbox(message, threadId).get().messageId + } + + private fun insertGroupReply(threadId: Long, from: RecipientId, time: Long, parentStoryId: Long): Long { + val message = IncomingMessage( + type = MessageType.NORMAL, + from = from, + sentTimeMillis = time, + serverTimeMillis = time, + receivedTimeMillis = time, + body = "reply $time", + parentStoryId = ParentStoryId.GroupReply(parentStoryId) + ) + return messages.insertMessageInbox(message, threadId).get().messageId + } + + private fun insertEdit(from: RecipientId, originalSentTimestamp: Long, editSentTimeMillis: Long): Long { + val target = messages.getMessageFor(originalSentTimestamp, from) as MmsMessageRecord + val edit = IncomingMessage( + type = MessageType.NORMAL, + from = from, + sentTimeMillis = editSentTimeMillis, + serverTimeMillis = editSentTimeMillis, + receivedTimeMillis = editSentTimeMillis, + body = "edited at $editSentTimeMillis" + ) + return messages.insertEditMessageInbox(edit, target).get().messageId + } + + private fun isNotified(messageId: Long): Boolean { + return SignalDatabase.writableDatabase + .select(MessageTable.NOTIFIED) + .from(MessageTable.TABLE_NAME) + .where("${MessageTable.ID} = ?", messageId) + .run() + .readToSingleBoolean() + } +}