Add incoming group message benchmark tests.

This commit is contained in:
Cody Henthorne
2026-02-13 14:58:22 -05:00
committed by Alex Hart
parent e67307a961
commit 08254edae6
16 changed files with 344 additions and 128 deletions

View File

@@ -373,6 +373,8 @@ android {
isDebuggable = false
isMinifyEnabled = true
matchingFallbacks += "debug"
applicationIdSuffix = ".benchmark"
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Benchmark\"")
buildConfigField("boolean", "TRACING_ENABLED", "true")
buildConfigField("String[]", "UNIDENTIFIED_SENDER_TRUST_ROOTS", "new String[]{ \"BVT/2gHqbrG1xzuIypLIOjFgMtihrMld1/5TGADL6Dhv\"}")

View File

@@ -12,6 +12,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.signal.benchmark.network.BenchmarkWebSocketConnection
import org.signal.benchmark.setup.Generator
import org.signal.benchmark.setup.Harness
import org.signal.core.util.ThreadUtil
import org.signal.core.util.logging.Log
@@ -43,17 +44,20 @@ class BenchmarkCommandReceiver : BroadcastReceiver() {
when (command) {
"individual-send" -> handlePrepareIndividualSend()
"release-messages" -> BenchmarkWebSocketConnection.instance.releaseMessages()
"group-send" -> handlePrepareGroupSend()
"release-messages" -> {
BenchmarkWebSocketConnection.instance.startWholeBatchTrace = true
BenchmarkWebSocketConnection.instance.releaseMessages()
}
else -> Log.w(TAG, "Unknown command: $command")
}
}
private fun handlePrepareIndividualSend() {
val bobClient = Harness.bobClient
val client = Harness.otherClients[0]
// Send message from Bob to Self
val firstPreKeyMessageTimestamp = System.currentTimeMillis()
val encryptedEnvelope = bobClient.encrypt(firstPreKeyMessageTimestamp)
val encryptedEnvelope = client.encrypt(Generator.encryptedTextMessage(System.currentTimeMillis()))
runBlocking {
launch(Dispatchers.IO) {
@@ -70,11 +74,43 @@ class BenchmarkCommandReceiver : BroadcastReceiver() {
// Have Bob generate N messages that will be received by Alice
val messageCount = 100
val envelopes = bobClient.generateInboundEnvelopes(messageCount)
val envelopes = client.generateInboundEnvelopes(messageCount)
val messages = envelopes.map { e -> e.toWebSocketPayload() }
BenchmarkWebSocketConnection.instance.addPendingMessages(messages)
BenchmarkWebSocketConnection.instance.addQueueEmptyMessage()
}
private fun handlePrepareGroupSend() {
val clients = Harness.otherClients.take(5)
// Send message from others to Self in the group
val encryptedEnvelopes = clients.map { it.encrypt(Generator.encryptedTextMessage(System.currentTimeMillis(), groupMasterKey = Harness.groupMasterKey)) }
runBlocking {
launch(Dispatchers.IO) {
BenchmarkWebSocketConnection.instance.run {
Log.i(TAG, "Sending initial group messages from client to establish sessions.")
addPendingMessages(encryptedEnvelopes.map { it.toWebSocketPayload() })
releaseMessages()
// Sleep briefly to let the messages be processed.
ThreadUtil.sleep(1000)
}
}
}
// Have clients generate N group messages that will be received by Alice
clients.forEach { client ->
val messageCount = 100
val envelopes = client.generateInboundGroupEnvelopes(messageCount, Harness.groupMasterKey)
val messages = envelopes.map { e -> e.toWebSocketPayload() }
BenchmarkWebSocketConnection.instance.addPendingMessages(messages)
}
BenchmarkWebSocketConnection.instance.addQueueEmptyMessage()
}
private fun Envelope.toWebSocketPayload(): WebSocketRequestMessage {

View File

@@ -16,6 +16,7 @@ class BenchmarkSetupActivity : BaseActivity() {
"cold-start" -> setupColdStart()
"conversation-open" -> setupConversationOpen()
"message-send" -> setupMessageSend()
"group-message-send" -> setupGroupMessageSend()
}
val textView: TextView = TextView(this).apply {
@@ -60,6 +61,11 @@ class BenchmarkSetupActivity : BaseActivity() {
private fun setupMessageSend() {
TestUsers.setupSelf()
TestUsers.setupBob()
TestUsers.setupTestClients(1)
}
private fun setupGroupMessageSend() {
TestUsers.setupSelf()
TestUsers.setupGroup()
}
}

View File

@@ -8,6 +8,8 @@ package org.signal.benchmark.network
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.subjects.BehaviorSubject
import okio.IOException
import org.thoughtcrime.securesms.util.SignalTrace
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
import org.whispersystems.signalservice.internal.websocket.WebSocketConnection
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
@@ -47,6 +49,8 @@ class BenchmarkWebSocketConnection : WebSocketConnection {
private val incomingRequests = LinkedList<WebSocketRequestMessage>()
private val incomingSemaphore = Semaphore(0)
var startWholeBatchTrace = false
@Volatile
private var isShutdown = false
@@ -77,7 +81,7 @@ class BenchmarkWebSocketConnection : WebSocketConnection {
if (isShutdown) {
throw SocketException("WebSocket connection closed")
}
return incomingRequests.removeFirst()
return getNextRequest()
}
throw TimeoutException("Timeout exceeded")
@@ -85,12 +89,21 @@ class BenchmarkWebSocketConnection : WebSocketConnection {
override fun readRequestIfAvailable(): Optional<WebSocketRequestMessage> {
return if (incomingSemaphore.tryAcquire()) {
Optional.of(incomingRequests.removeFirst())
Optional.of(getNextRequest())
} else {
Optional.empty()
}
}
private fun getNextRequest(): WebSocketRequestMessage {
if (startWholeBatchTrace) {
startWholeBatchTrace = false
SignalTrace.beginSection("IncomingMessageObserver#totalProcessing")
}
return incomingRequests.removeFirst()
}
override fun sendResponse(response: WebSocketResponseMessage) = Unit
fun addPendingMessages(messages: List<WebSocketRequestMessage>) {
@@ -105,10 +118,21 @@ class BenchmarkWebSocketConnection : WebSocketConnection {
request: WebSocketRequestMessage,
timeoutSeconds: Long
): Single<WebsocketResponse> {
error("Not yet implemented")
return Single.error(IOException("fake timeout"))
}
override fun sendKeepAlive() {
error("Not yet implemented")
}
fun addQueueEmptyMessage() {
addPendingMessages(
listOf(
WebSocketRequestMessage(
verb = "PUT",
path = "/api/v1/queue/empty"
)
)
)
}
}

View File

@@ -1,27 +1,44 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.benchmark.setup
import okio.ByteString.Companion.toByteString
import org.signal.core.models.ServiceId
import org.signal.core.util.Base64
import org.signal.core.util.toByteArray
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.buildWith
import org.whispersystems.signalservice.api.crypto.ContentHint
import org.whispersystems.signalservice.api.crypto.EnvelopeContent
import org.whispersystems.signalservice.internal.push.Content
import org.whispersystems.signalservice.internal.push.DataMessage
import org.whispersystems.signalservice.internal.push.Envelope
import org.whispersystems.signalservice.internal.push.GroupContextV2
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage
import java.util.Optional
import java.util.UUID
object Generator {
fun encryptedTextMessage(now: Long, message: String = "Test body message"): EnvelopeContent {
fun encryptedTextMessage(
now: Long,
message: String = "Test message",
groupMasterKey: GroupMasterKey? = null
): EnvelopeContent {
val content = Content.Builder().apply {
dataMessage(
DataMessage.Builder().buildWith {
body = message
timestamp = now
if (groupMasterKey != null) {
groupV2 = GroupContextV2.Builder().buildWith {
masterKey = groupMasterKey.serialize().toByteString()
revision = 1
}
}
}
)
}

View File

@@ -7,6 +7,7 @@ package org.signal.benchmark.setup
import org.signal.core.models.ServiceId.ACI
import org.signal.core.util.Base64
import org.signal.core.util.Hex
import org.signal.core.util.UuidUtil
import org.signal.libsignal.metadata.certificate.SenderCertificate
import org.signal.libsignal.metadata.certificate.ServerCertificate
@@ -14,32 +15,39 @@ import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.protocol.ecc.ECPrivateKey
import org.signal.libsignal.protocol.ecc.ECPublicKey
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import java.util.Optional
import java.util.UUID
import kotlin.random.Random
import kotlin.time.Duration
object Harness {
val SELF_E164 = "+15555550101"
const val SELF_E164 = "+15555559999"
val SELF_ACI = ACI.from(UuidUtil.parseOrThrow("d81b9a54-0ec9-43aa-a73f-7e99280ad53e"))
val BOB_E164 = "+15555551001"
val BOB_ACI = ACI.from(UuidUtil.parseOrThrow("752eb667-75f6-4ed6-ade5-ca4bfd050d3d"))
val BOB_IDENTITY_KEY = IdentityKeyPair(Base64.decode("CiEFbAw403SCGPB+tjqfk+jrH7r9ma1P2hcujqydHRYVzzISIGiWYdWYBBdBzDdF06wgEm+HKcc6ETuWB7Jnvk7Wjw1u"))
val BOB_PROFILE_KEY = ProfileKey(Base64.decode("aJJ/A7GBCSnU9HJ1DdMWcKMMeXQKRUguTlAbtlfo/ik"))
private val OTHERS_IDENTITY_KEY = IdentityKeyPair(Base64.decode("CiEFbAw403SCGPB+tjqfk+jrH7r9ma1P2hcujqydHRYVzzISIGiWYdWYBBdBzDdF06wgEm+HKcc6ETuWB7Jnvk7Wjw1u"))
private val OTHERS_PROFILE_KEY = ProfileKey(Base64.decode("aJJ/A7GBCSnU9HJ1DdMWcKMMeXQKRUguTlAbtlfo/ik"))
val groupMasterKey = GroupMasterKey(Hex.fromStringCondensed("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"))
val trustRoot = ECKeyPair(
ECPublicKey(Base64.decode("BVT/2gHqbrG1xzuIypLIOjFgMtihrMld1/5TGADL6Dhv")),
ECPrivateKey(Base64.decode("2B1zU7JQdPol/XWiom4pQXrSrHFeO8jzZ1u7wfrtY3o"))
)
val bobClient: BobClient by lazy {
BobClient(
serviceId = BOB_ACI,
e164 = BOB_E164,
identityKeyPair = BOB_IDENTITY_KEY,
profileKey = BOB_PROFILE_KEY
)
val otherClients: List<OtherClient> by lazy {
val random = Random(4242)
buildList {
(0 until 1000).forEach { i ->
val aci = ACI.from(UUID(random.nextLong(), random.nextLong()))
val e164 = "+1555555%04d".format(i)
val identityKey = OTHERS_IDENTITY_KEY
val profileKey = OTHERS_PROFILE_KEY
add(OtherClient(aci, e164, identityKey, profileKey))
}
}
}
fun createCertificateFor(uuid: UUID, e164: String?, deviceId: Int, identityKey: ECPublicKey, expires: Duration): SenderCertificate {

View File

@@ -7,8 +7,6 @@ package org.signal.benchmark.setup
import org.signal.benchmark.setup.Generator.toEnvelope
import org.signal.core.models.ServiceId
import org.signal.core.util.readToSingleInt
import org.signal.core.util.select
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.SessionBuilder
@@ -23,16 +21,14 @@ import org.signal.libsignal.protocol.state.PreKeyRecord
import org.signal.libsignal.protocol.state.SessionRecord
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
import org.signal.libsignal.protocol.util.KeyHelper
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil
import org.thoughtcrime.securesms.database.KyberPreKeyTable
import org.thoughtcrime.securesms.database.OneTimePreKeyTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.SignedPreKeyTable
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.signalservice.api.SignalServiceAccountDataStore
import org.whispersystems.signalservice.api.SignalSessionLock
import org.whispersystems.signalservice.api.crypto.EnvelopeContent
import org.whispersystems.signalservice.api.crypto.SealedSenderAccess
import org.whispersystems.signalservice.api.crypto.SignalServiceCipher
import org.whispersystems.signalservice.api.crypto.SignalSessionBuilder
@@ -46,12 +42,10 @@ import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
/**
* Welcome to Bob's Client.
*
* Bob is a "fake" client that can start a session with the running app's user, referred to as Alice in this
* This is a "fake" client that can start a session with the running app's user, referred to as Alice in this
* code.
*/
class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair: IdentityKeyPair, val profileKey: ProfileKey) {
class OtherClient(val serviceId: ServiceId, val e164: String, val identityKeyPair: IdentityKeyPair, val profileKey: ProfileKey) {
private val serviceAddress = SignalServiceAddress(serviceId, e164)
private val registrationId = KeyHelper.generateRegistrationId(false)
@@ -67,9 +61,7 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
}
/** Inspired by SignalServiceMessageSender#getEncryptedMessage */
fun encrypt(now: Long): Envelope {
val envelopeContent = Generator.encryptedTextMessage(now)
fun encrypt(envelopeContent: EnvelopeContent): Envelope {
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, null)
if (!aciStore.containsSession(getAliceProtocolAddress())) {
@@ -81,16 +73,22 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
.toEnvelope(envelopeContent.content.get().dataMessage!!.timestamp!!, getAliceServiceId())
}
fun decrypt(envelope: Envelope, serverDeliveredTimestamp: Long) {
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, SealedSenderAccessUtil.getCertificateValidator())
cipher.decrypt(envelope, serverDeliveredTimestamp)
}
fun generateInboundEnvelopes(count: Int): List<Envelope> {
val envelopes = ArrayList<Envelope>(count)
var now = System.currentTimeMillis()
for (i in 0 until count) {
envelopes += encrypt(now)
envelopes += encrypt(Generator.encryptedTextMessage(now))
now += 3
}
return envelopes
}
fun generateInboundGroupEnvelopes(count: Int, groupMasterKey: GroupMasterKey): List<Envelope> {
val envelopes = ArrayList<Envelope>(count)
var now = System.currentTimeMillis()
for (i in 0 until count) {
envelopes += encrypt(Generator.encryptedTextMessage(now, groupMasterKey = groupMasterKey))
now += 3
}
@@ -102,45 +100,22 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
}
private fun getAlicePreKeyBundle(): PreKeyBundle {
val alicePreKeyId = SignalDatabase.rawDatabase
.select(OneTimePreKeyTable.KEY_ID)
.from(OneTimePreKeyTable.TABLE_NAME)
.where("${OneTimePreKeyTable.ACCOUNT_ID} = ?", getAliceServiceId().toString())
.run()
.readToSingleInt(-1)
val aliceSignedPreKeyRecord = SignalDatabase.signedPreKeys.getAll(getAliceServiceId()).first()
val alicePreKeyRecord = SignalDatabase.oneTimePreKeys.get(getAliceServiceId(), alicePreKeyId)!!
val aliceSignedPreKeyId = SignalDatabase.rawDatabase
.select(SignedPreKeyTable.KEY_ID)
.from(SignedPreKeyTable.TABLE_NAME)
.where("${SignedPreKeyTable.ACCOUNT_ID} = ?", getAliceServiceId().toString())
.run()
.readToSingleInt(-1)
val aliceSignedPreKeyRecord = SignalDatabase.signedPreKeys.get(getAliceServiceId(), aliceSignedPreKeyId)!!
val aliceSignedKyberPreKeyId = SignalDatabase.rawDatabase
.select(KyberPreKeyTable.KEY_ID)
.from(KyberPreKeyTable.TABLE_NAME)
.where("${KyberPreKeyTable.ACCOUNT_ID} = ?", getAliceServiceId().toString())
.run()
.readToSingleInt(-1)
val aliceSignedKyberPreKeyRecord = SignalDatabase.kyberPreKeys.get(getAliceServiceId(), aliceSignedKyberPreKeyId)!!.record
val aliceSignedKyberPreKeyRecord = SignalDatabase.kyberPreKeys.getAllLastResort(getAliceServiceId()).first().record
return PreKeyBundle(
SignalStore.account.registrationId,
1,
alicePreKeyId,
alicePreKeyRecord.keyPair.publicKey,
aliceSignedPreKeyId,
aliceSignedPreKeyRecord.keyPair.publicKey,
aliceSignedPreKeyRecord.signature,
getAlicePublicKey(),
aliceSignedKyberPreKeyId,
aliceSignedKyberPreKeyRecord.keyPair.publicKey,
aliceSignedKyberPreKeyRecord.signature
registrationId = SignalStore.account.registrationId,
deviceId = 1,
preKeyId = PreKeyBundle.NULL_PRE_KEY_ID,
preKeyPublic = null,
signedPreKeyId = aliceSignedPreKeyRecord.id,
signedPreKeyPublic = aliceSignedPreKeyRecord.keyPair.publicKey,
signedPreKeySignature = aliceSignedPreKeyRecord.signature,
identityKey = getAlicePublicKey(),
kyberPreKeyId = aliceSignedKyberPreKeyRecord.id,
kyberPreKeyPublic = aliceSignedKyberPreKeyRecord.keyPair.publicKey,
kyberPreKeySignature = aliceSignedKyberPreKeyRecord.signature
)
}

View File

@@ -4,10 +4,17 @@ import android.app.Application
import android.content.SharedPreferences
import android.preference.PreferenceManager
import kotlinx.coroutines.runBlocking
import okio.ByteString
import org.signal.core.models.ServiceId.ACI
import org.signal.core.util.Util
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.SignalProtocolAddress
import org.signal.storageservice.storage.protos.groups.AccessControl
import org.signal.storageservice.storage.protos.groups.Member
import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup
import org.signal.storageservice.storage.protos.groups.local.DecryptedMember
import org.signal.storageservice.storage.protos.groups.local.DecryptedTimer
import org.signal.storageservice.storage.protos.groups.local.EnabledState
import org.thoughtcrime.securesms.crypto.MasterSecretUtil
import org.thoughtcrime.securesms.crypto.PreKeyUtil
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
@@ -140,17 +147,70 @@ object TestUsers {
return others
}
fun setupBob(): Recipient {
val recipientId = RecipientId.from(SignalServiceAddress(Harness.BOB_ACI, Harness.BOB_E164))
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Bob", "Boberson"))
fun setupTestClients(othersCount: Int): List<RecipientId> {
val others = mutableListOf<RecipientId>()
synchronized(this) {
for (i in 0 until othersCount) {
val otherClient = Harness.otherClients[i]
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, Harness.BOB_PROFILE_KEY)
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
SignalDatabase.recipients.markRegistered(recipientId, Harness.BOB_ACI)
val recipientId = RecipientId.from(SignalServiceAddress(otherClient.serviceId, otherClient.e164))
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, otherClient.profileKey)
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
SignalDatabase.recipients.markRegistered(recipientId, otherClient.serviceId)
AppDependencies.protocolStore.aci().saveIdentity(SignalProtocolAddress(otherClient.serviceId.toString(), 1), otherClient.identityKeyPair.publicKey)
AppDependencies.protocolStore.aci().saveIdentity(SignalProtocolAddress(Harness.BOB_ACI.toString(), 1), Harness.BOB_IDENTITY_KEY.publicKey)
others += recipientId
}
return Recipient.resolved(recipientId)
generatedOthers += othersCount
}
return others
}
fun setupGroup() {
val members = setupTestClients(5)
val self = Recipient.self()
val fullMembers = buildList {
add(member(aci = self.requireAci()))
addAll(members.map { member(aci = Recipient.resolved(it).requireAci()) })
}
val group = DecryptedGroup(
title = "Title",
avatar = "",
disappearingMessagesTimer = DecryptedTimer(),
accessControl = AccessControl(),
revision = 1,
members = fullMembers,
pendingMembers = emptyList(),
requestingMembers = emptyList(),
inviteLinkPassword = ByteString.EMPTY,
description = "Description",
isAnnouncementGroup = EnabledState.DISABLED,
bannedMembers = emptyList(),
isPlaceholderGroup = false
)
val groupId = SignalDatabase.groups.create(
groupMasterKey = Harness.groupMasterKey,
groupState = group,
groupSendEndorsements = null
)
SignalDatabase.recipients.setProfileSharing(Recipient.externalGroupExact(groupId!!).id, true)
}
private fun member(aci: ACI, role: Member.Role = Member.Role.DEFAULT, joinedAt: Int = 0, labelEmoji: String = "", labelString: String = ""): DecryptedMember {
return DecryptedMember(
role = role,
aciBytes = aci.toByteString(),
joinedAtRevision = joinedAt,
labelEmoji = labelEmoji,
labelString = labelString
)
}
}

View File

@@ -457,6 +457,7 @@ class IncomingMessageObserver(
SignalLocalMetrics.PushWebsocketFetch.onProcessedBatch()
if (!hasMore && !decryptionDrained) {
SignalTrace.endSection()
Log.i(TAG, "Decryptions newly-drained.")
decryptionDrained = true

View File

@@ -2,6 +2,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<queries>
<package android:name="org.thoughtcrime.securesms" />
<package android:name="org.thoughtcrime.securesms.benchmark" />
</queries>
</manifest>

View File

@@ -5,10 +5,11 @@ import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.Until
object BenchmarkSetup {
private const val TARGET_PACKAGE = "org.thoughtcrime.securesms"
private const val TARGET_PACKAGE = "org.thoughtcrime.securesms.benchmark"
private const val RECEIVER = "org.signal.benchmark.BenchmarkCommandReceiver"
fun setup(type: String, device: UiDevice) {
device.executeShellCommand("pm clear $TARGET_PACKAGE")
device.executeShellCommand("am start -W -n $TARGET_PACKAGE/org.signal.benchmark.BenchmarkSetupActivity --es setup-type $type")
device.wait(Until.hasObject(By.textContains("done")), 25_000L)
}
@@ -17,6 +18,10 @@ object BenchmarkSetup {
device.benchmarkCommandBroadcast("individual-send")
}
fun setupGroupSend(device: UiDevice) {
device.benchmarkCommandBroadcast("group-send")
}
fun releaseMessages(device: UiDevice) {
device.benchmarkCommandBroadcast("release-messages")
}

View File

@@ -25,7 +25,7 @@ class ConversationBenchmarks {
fun simpleConversationOpen() {
var setup = false
benchmarkRule.measureRepeated(
packageName = "org.thoughtcrime.securesms",
packageName = "org.thoughtcrime.securesms.benchmark",
metrics = listOf(
TraceSectionMetric("6-ConversationOpen"),
TraceSectionMetric("1-ConversationOpen-ViewModel-Init"),

View File

@@ -0,0 +1,85 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.benchmark
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 benchmarks for message processing performance.
*/
@OptIn(ExperimentalMetricApi::class)
@RunWith(AndroidJUnit4::class)
@RequiresApi(31)
class GroupMessageProcessingBenchmarks {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
@Test
fun groupMessageReceiveOnConversationList() {
run(withConversationOpen = false)
}
@Test
fun individualMessageReceiveOnConversation() {
run(withConversationOpen = true)
}
private fun run(withConversationOpen: Boolean) {
benchmarkRule.measureRepeated(
packageName = "org.thoughtcrime.securesms.benchmark",
metrics = listOf(
TraceSectionMetric(
sectionName = "IncomingMessageObserver#decryptMessage",
mode = Mode.Average
),
TraceSectionMetric(
sectionName = "MessageContentProcessor#handleMessage",
mode = Mode.Average
),
TraceSectionMetric(
sectionName = "IncomingMessageObserver#processMessage",
mode = Mode.Average
),
TraceSectionMetric(
sectionName = "IncomingMessageObserver#totalProcessing",
mode = Mode.Sum
)
),
iterations = 5,
compilationMode = CompilationMode.Partial(),
setupBlock = {
BenchmarkSetup.setup("group-message-send", device)
killProcess()
startActivityAndWait()
device.waitForIdle()
BenchmarkSetup.setupGroupSend(device)
val uiObject = device.wait(Until.findObject(By.textContains("Title")), 5_000)
if (withConversationOpen) {
uiObject.click()
}
}
) {
BenchmarkSetup.releaseMessages(device)
device.wait(Until.hasObject(By.textContains("505")),10_000L)
}
}
}

View File

@@ -5,7 +5,6 @@
package org.thoughtcrime.benchmark
import android.os.SystemClock
import androidx.annotation.RequiresApi
import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.ExperimentalMetricApi
@@ -14,8 +13,6 @@ 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.BySelector
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.Until
import org.junit.Rule
import org.junit.Test
@@ -23,8 +20,6 @@ import org.junit.runner.RunWith
/**
* Macrobenchmark benchmarks for message processing performance.
*
* WARNING! THIS WILL WIPE YOUR SIGNAL INSTALL
*/
@OptIn(ExperimentalMetricApi::class)
@RunWith(AndroidJUnit4::class)
@@ -44,9 +39,8 @@ class MessageProcessingBenchmarks {
}
private fun run(withConversationOpen: Boolean) {
var setup = false
benchmarkRule.measureRepeated(
packageName = "org.thoughtcrime.securesms",
packageName = "org.thoughtcrime.securesms.benchmark",
metrics = listOf(
TraceSectionMetric(
sectionName = "IncomingMessageObserver#decryptMessage",
@@ -59,14 +53,16 @@ class MessageProcessingBenchmarks {
TraceSectionMetric(
sectionName = "IncomingMessageObserver#processMessage",
mode = Mode.Average
),
TraceSectionMetric(
sectionName = "IncomingMessageObserver#totalProcessing",
mode = Mode.Sum
)
),
iterations = 5,
compilationMode = CompilationMode.Partial(),
setupBlock = {
if (!setup) {
BenchmarkSetup.setup("message-send", device)
}
BenchmarkSetup.setup("message-send", device)
killProcess()
startActivityAndWait()
@@ -74,7 +70,7 @@ class MessageProcessingBenchmarks {
BenchmarkSetup.setupIndividualSend(device)
val uiObject = device.wait(Until.findObject(By.textContains("Bob")), 5_000)
val uiObject = device.wait(Until.findObject(By.textContains("Buddy")), 5_000)
if (withConversationOpen) {
uiObject.click()
}
@@ -83,31 +79,7 @@ class MessageProcessingBenchmarks {
BenchmarkSetup.releaseMessages(device)
device.waitForAny(
10_000L,
By.textContains("101"),
By.textContains("202"),
By.textContains("303"),
By.textContains("404"),
By.textContains("505"),
)
device.wait(Until.hasObject(By.textContains("101")), 10_000L)
}
}
}
/**
* Inspired by [androidx.test.uiautomator.WaitMixin]
*/
fun UiDevice.waitForAny(timeout: Long, vararg conditions: BySelector): Boolean {
val startTime = SystemClock.uptimeMillis()
var result = conditions.any { this.hasObject(it) }
var elapsedTime = 0L
while (!result && elapsedTime < timeout) {
SystemClock.sleep(100)
result = conditions.any { this.hasObject(it) }
elapsedTime = SystemClock.uptimeMillis() - startTime
}
return result
}

View File

@@ -15,8 +15,6 @@ import org.junit.runner.RunWith
/**
* Macrobenchmark benchmarks for app startup performance.
*
* WARNING! THIS WILL WIPE YOUR SIGNAL INSTALL
*/
@RunWith(AndroidJUnit4::class)
@RequiresApi(31)
@@ -38,7 +36,7 @@ class StartupBenchmarks {
private fun measureStartup(iterations: Int, compilationMode: CompilationMode) {
var setup = false
benchmarkRule.measureRepeated(
packageName = "org.thoughtcrime.securesms",
packageName = "org.thoughtcrime.securesms.benchmark",
metrics = listOf(StartupTimingMetric(), TraceSectionMetric("ConversationListDataSource#load")),
iterations = iterations,
startupMode = StartupMode.COLD,

View File

@@ -0,0 +1,27 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.benchmark
import android.os.SystemClock
import androidx.test.uiautomator.BySelector
import androidx.test.uiautomator.UiDevice
/**
* Inspired by [androidx.test.uiautomator.WaitMixin]
*/
fun UiDevice.waitForAny(timeout: Long, vararg conditions: BySelector): Boolean {
val startTime = SystemClock.uptimeMillis()
var result = conditions.any { this.hasObject(it) }
var elapsedTime = 0L
while (!result && elapsedTime < timeout) {
SystemClock.sleep(100)
result = conditions.any { this.hasObject(it) }
elapsedTime = SystemClock.uptimeMillis() - startTime
}
return result
}