diff --git a/app/src/benchmarkShared/java/org/signal/benchmark/BenchmarkSetupActivity.kt b/app/src/benchmarkShared/java/org/signal/benchmark/BenchmarkSetupActivity.kt index 88f401e946..b46f8d021f 100644 --- a/app/src/benchmarkShared/java/org/signal/benchmark/BenchmarkSetupActivity.kt +++ b/app/src/benchmarkShared/java/org/signal/benchmark/BenchmarkSetupActivity.kt @@ -32,6 +32,14 @@ class BenchmarkSetupActivity : BaseActivity() { companion object { private val TAG = Log.tag(BenchmarkSetupActivity::class) + + const val SEARCH_KEYWORD = "lighthouse" + + private val SEARCH_VOCABULARY = listOf( + "hello", "world", "signal", "android", "kotlin", "database", "benchmark", "conversation", + "morning", "evening", "weekend", "project", "meeting", "dinner", "coffee", "garden", + "mountain", "river", "forest", "harbor", "market", "library", "concert", "holiday" + ) } override fun onCreate(savedInstanceState: Bundle?) { @@ -51,6 +59,7 @@ class BenchmarkSetupActivity : BaseActivity() { when (intent.extras!!.getString("setup-type")) { "cold-start" -> setupColdStart() "conversation-open" -> setupConversationOpen() + "conversation-list-search" -> setupConversationListSearch() "message-send" -> setupMessageSend() "group-message-send" -> setupGroupMessageSend() "group-delivery-receipt" -> setupGroupReceipt(includeMsl = true) @@ -97,6 +106,39 @@ class BenchmarkSetupActivity : BaseActivity() { } } + private fun setupConversationListSearch() { + TestUsers.setupSelf() + + val recipientCount = 50 + val messagesPerRecipient = 2000 + val totalMessages = recipientCount * messagesPerRecipient + val generator = TestMessages.TimestampGenerator(System.currentTimeMillis() - (totalMessages * 2000L) - 60_000L) + + TestUsers.setupTestRecipients(recipientCount).forEachIndexed { recipientIndex, recipientId -> + val recipient: Recipient = Recipient.resolved(recipientId) + + for (i in 0 until messagesPerRecipient) { + val body = searchableMessageBody(recipientIndex, i) + if (i % 2 == 0) { + TestMessages.insertIncomingTextMessage(other = recipient, body = body, timestamp = generator.nextTimestamp()) + } else { + TestMessages.insertOutgoingTextMessage(other = recipient, body = body, timestamp = generator.nextTimestamp()) + } + } + + SignalDatabase.messages.setAllMessagesRead() + SignalDatabase.threads.update(SignalDatabase.threads.getOrCreateThreadIdFor(recipient = recipient), true) + } + } + + private fun searchableMessageBody(recipientIndex: Int, messageIndex: Int): String { + val words = SEARCH_VOCABULARY + val w1 = words[(recipientIndex + messageIndex) % words.size] + val w2 = words[(recipientIndex * 7 + messageIndex * 3) % words.size] + val w3 = words[(recipientIndex * 13 + messageIndex * 5) % words.size] + return "$w1 $w2 $SEARCH_KEYWORD $w3 message $messageIndex" + } + private fun setupMessageSend() { TestUsers.setupSelf() TestUsers.setupTestClients(1) diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java index 7f12c38222..ab1906a138 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java @@ -34,6 +34,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList; import org.thoughtcrime.securesms.dependencies.AppDependencies; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.SignalTrace; import org.signal.core.util.Util; import org.signal.core.util.concurrent.SerialExecutor; @@ -82,26 +83,36 @@ public class SearchRepository { @WorkerThread public @NonNull ThreadSearchResult queryThreadsSync(@NonNull String query, boolean unreadOnly) { - long start = System.currentTimeMillis(); - List result = queryConversations(query, unreadOnly); + SignalTrace.beginSection("ConversationListSearch-Threads"); + try { + long start = System.currentTimeMillis(); + List result = queryConversations(query, unreadOnly); - Log.d(TAG, "[threads] Search took " + (System.currentTimeMillis() - start) + " ms"); + Log.d(TAG, "[threads] Search took " + (System.currentTimeMillis() - start) + " ms"); - return new ThreadSearchResult(result, query); + return new ThreadSearchResult(result, query); + } finally { + SignalTrace.endSection(); + } } @WorkerThread public @NonNull MessageSearchResult queryMessagesSync(@NonNull String query, @NonNull SearchFilter filter) { - long start = System.currentTimeMillis(); + SignalTrace.beginSection("ConversationListSearch-Messages"); + try { + long start = System.currentTimeMillis(); - List messages = queryMessages(query, filter); - List mentionMessages = queryMentions(convertMentionsQueryToTokens(query)); - List filteredMentions = filterMentionResults(mentionMessages, filter); - List combined = mergeMessagesAndMentions(messages, filteredMentions); + List messages = queryMessages(query, filter); + List mentionMessages = queryMentions(convertMentionsQueryToTokens(query)); + List filteredMentions = filterMentionResults(mentionMessages, filter); + List combined = mergeMessagesAndMentions(messages, filteredMentions); - Log.d(TAG, "[messages] Search took " + (System.currentTimeMillis() - start) + " ms"); + Log.d(TAG, "[messages] Search took " + (System.currentTimeMillis() - start) + " ms"); - return new MessageSearchResult(combined, query); + return new MessageSearchResult(combined, query); + } finally { + SignalTrace.endSection(); + } } public void query(@NonNull String query, long threadId, @NonNull Callback> callback) { diff --git a/benchmark/src/main/java/org/thoughtcrime/benchmark/SearchBenchmarks.kt b/benchmark/src/main/java/org/thoughtcrime/benchmark/SearchBenchmarks.kt new file mode 100644 index 0000000000..8b64120930 --- /dev/null +++ b/benchmark/src/main/java/org/thoughtcrime/benchmark/SearchBenchmarks.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.benchmark + +import android.Manifest +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.benchmark.macro.CompilationMode +import androidx.benchmark.macro.ExperimentalMetricApi +import androidx.benchmark.macro.TraceSectionMetric +import androidx.benchmark.macro.TraceSectionMetric.Mode +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Macrobenchmark for searching from the conversation list. + * + * Seeds 50 conversations with 2,000 messages each (100,000 messages total), then performs the same + * operations the app runs when a user searches from the conversation list: opening the search + * toolbar, typing a query, and waiting for results. Measures the full-text search against the + * search table via the [SearchRepository] trace sections. + */ +@RunWith(AndroidJUnit4::class) +@RequiresApi(31) +class SearchBenchmarks { + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + @OptIn(ExperimentalMetricApi::class) + @Test + fun conversationListSearch() { + var setup = false + benchmarkRule.measureRepeated( + packageName = "org.thoughtcrime.securesms.benchmark", + metrics = listOf( + TraceSectionMetric("ConversationListSearch-Messages", Mode.Sum), + TraceSectionMetric("ConversationListSearch-Threads", Mode.Sum) + ), + iterations = 3, + compilationMode = CompilationMode.Partial(), + setupBlock = { + if (!setup) { + BenchmarkSetup.setup("conversation-list-search", device, timeout = 600_000L) + setup = true + } + killProcess() + if (Build.VERSION.SDK_INT >= 33) { + device.executeShellCommand("pm grant $packageName ${Manifest.permission.POST_NOTIFICATIONS}") + } + startActivityAndWait() + device.waitForIdle() + } + ) { + device.findObject(By.desc("Search")).click() + + val searchField = device.wait(Until.findObject(By.clazz("android.widget.EditText")), 10_000L) + searchField.text = SEARCH_QUERY + + device.wait(Until.hasObject(By.textContains("Buddy")), 10_000L) + } + } + + companion object { + private const val SEARCH_QUERY = "lighthouse" + } +}