diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dfee7486fa..4a23f51850 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -360,6 +360,9 @@ android { matchingFallbacks += "debug" buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Benchmark\"") buildConfigField("boolean", "TRACING_ENABLED", "true") + buildConfigField("String[]", "UNIDENTIFIED_SENDER_TRUST_ROOTS", "new String[]{ \"BVT/2gHqbrG1xzuIypLIOjFgMtihrMld1/5TGADL6Dhv\"}") + + manifestPlaceholders["applicationClass"] = "org.thoughtcrime.securesms.BenchmarkApplicationContext" } create("mocked") { @@ -370,6 +373,8 @@ android { matchingFallbacks += "debug" buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Benchmark\"") buildConfigField("boolean", "TRACING_ENABLED", "true") + + manifestPlaceholders["applicationClass"] = "org.thoughtcrime.securesms.ApplicationContext" } create("canary") { diff --git a/app/src/benchmark/java/org/thoughtcrime/securesms/BenchmarkApplicationContext.kt b/app/src/benchmark/java/org/thoughtcrime/securesms/BenchmarkApplicationContext.kt new file mode 100644 index 0000000000..953031c862 --- /dev/null +++ b/app/src/benchmark/java/org/thoughtcrime/securesms/BenchmarkApplicationContext.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms + +import android.app.Application +import org.signal.benchmark.network.BenchmarkWebSocketConnection +import org.signal.libsignal.net.Network +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider +import org.whispersystems.signalservice.api.util.UptimeSleepTimer +import org.whispersystems.signalservice.api.websocket.SignalWebSocket +import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration +import java.util.function.Supplier +import kotlin.time.Duration.Companion.seconds + +class BenchmarkApplicationContext : ApplicationContext() { + + override fun initializeAppDependencies() { + AppDependencies.init(this, BenchmarkDependencyProvider(this, ApplicationDependencyProvider(this))) + } + + override fun beginJobLoop() = Unit + + fun beginJobLoopForTests() { + super.beginJobLoop() + } + + override fun onForeground() = Unit + + class BenchmarkDependencyProvider(val application: Application, private val default: ApplicationDependencyProvider) : AppDependencies.Provider by default { + override fun provideAuthWebSocket( + signalServiceConfigurationSupplier: Supplier, + libSignalNetworkSupplier: Supplier + ): SignalWebSocket.AuthenticatedWebSocket { + return SignalWebSocket.AuthenticatedWebSocket( + connectionFactory = { BenchmarkWebSocketConnection.create() }, + canConnect = { true }, + sleepTimer = UptimeSleepTimer(), + disconnectTimeoutMs = 15.seconds.inWholeMilliseconds + ) + } + } +} diff --git a/app/src/benchmarkShared/AndroidManifest.xml b/app/src/benchmarkShared/AndroidManifest.xml index 023ae81555..e60aa43db1 100644 --- a/app/src/benchmarkShared/AndroidManifest.xml +++ b/app/src/benchmarkShared/AndroidManifest.xml @@ -1,16 +1,28 @@ - + - + - + android:windowSoftInputMode="stateHidden" /> + + + + + + - \ No newline at end of file + diff --git a/app/src/benchmarkShared/java/org/signal/benchmark/BenchmarkCommandReceiver.kt b/app/src/benchmarkShared/java/org/signal/benchmark/BenchmarkCommandReceiver.kt new file mode 100644 index 0000000000..68b7669adc --- /dev/null +++ b/app/src/benchmarkShared/java/org/signal/benchmark/BenchmarkCommandReceiver.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.benchmark + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.signal.benchmark.network.BenchmarkWebSocketConnection +import org.signal.benchmark.setup.Harness +import org.signal.core.util.ThreadUtil +import org.signal.core.util.logging.Log +import org.whispersystems.signalservice.internal.push.Envelope +import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage +import kotlin.random.Random + +/** + * A BroadcastReceiver that accepts commands sent from the benchmark app to perform + * background operations on the client. + */ +class BenchmarkCommandReceiver : BroadcastReceiver() { + + companion object { + private val TAG = Log.tag(BenchmarkCommandReceiver::class) + + const val ACTION_COMMAND = "org.signal.benchmark.action.COMMAND" + const val EXTRA_COMMAND = "command" + } + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != ACTION_COMMAND) { + Log.w(TAG, "Ignoring unknown action: ${intent.action}") + return + } + + val command = intent.getStringExtra(EXTRA_COMMAND) + Log.i(TAG, "Received command: $command") + + when (command) { + "individual-send" -> handlePrepareIndividualSend() + "release-messages" -> BenchmarkWebSocketConnection.instance.releaseMessages() + else -> Log.w(TAG, "Unknown command: $command") + } + } + + private fun handlePrepareIndividualSend() { + val bobClient = Harness.bobClient + + // Send message from Bob to Self + val firstPreKeyMessageTimestamp = System.currentTimeMillis() + val encryptedEnvelope = bobClient.encrypt(firstPreKeyMessageTimestamp) + + runBlocking { + launch(Dispatchers.IO) { + BenchmarkWebSocketConnection.instance.run { + Log.i(TAG, "Sending initial message form Bob to establish session.") + addPendingMessages(listOf(encryptedEnvelope.toWebSocketPayload())) + releaseMessages() + + // Sleep briefly to let the message be processed. + ThreadUtil.sleep(100) + } + } + } + + // Have Bob generate N messages that will be received by Alice + val messageCount = 100 + val envelopes = bobClient.generateInboundEnvelopes(messageCount) + + val messages = envelopes.map { e -> e.toWebSocketPayload() } + + BenchmarkWebSocketConnection.instance.addPendingMessages(messages) + } + + private fun Envelope.toWebSocketPayload(): WebSocketRequestMessage { + return WebSocketRequestMessage( + verb = "PUT", + path = "/api/v1/message", + id = Random.nextLong(), + headers = listOf("X-Signal-Timestamp: ${this.timestamp}"), + body = this.encodeByteString() + ) + } +} diff --git a/app/src/benchmarkShared/java/org/signal/benchmark/BenchmarkSetupActivity.kt b/app/src/benchmarkShared/java/org/signal/benchmark/BenchmarkSetupActivity.kt index 047c95130b..5c263b2f22 100644 --- a/app/src/benchmarkShared/java/org/signal/benchmark/BenchmarkSetupActivity.kt +++ b/app/src/benchmarkShared/java/org/signal/benchmark/BenchmarkSetupActivity.kt @@ -15,6 +15,7 @@ class BenchmarkSetupActivity : BaseActivity() { when (intent.extras!!.getString("setup-type")) { "cold-start" -> setupColdStart() "conversation-open" -> setupConversationOpen() + "message-send" -> setupMessageSend() } val textView: TextView = TextView(this).apply { @@ -56,4 +57,9 @@ class BenchmarkSetupActivity : BaseActivity() { SignalDatabase.threads.update(SignalDatabase.threads.getOrCreateThreadIdFor(recipient = recipient), true) } } + + private fun setupMessageSend() { + TestUsers.setupSelf() + TestUsers.setupBob() + } } diff --git a/app/src/benchmarkShared/java/org/signal/benchmark/network/BenchmarkWebSocketConnection.kt b/app/src/benchmarkShared/java/org/signal/benchmark/network/BenchmarkWebSocketConnection.kt new file mode 100644 index 0000000000..50afcb3d6b --- /dev/null +++ b/app/src/benchmarkShared/java/org/signal/benchmark/network/BenchmarkWebSocketConnection.kt @@ -0,0 +1,114 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.benchmark.network + +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.subjects.BehaviorSubject +import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState +import org.whispersystems.signalservice.internal.websocket.WebSocketConnection +import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage +import org.whispersystems.signalservice.internal.websocket.WebSocketResponseMessage +import org.whispersystems.signalservice.internal.websocket.WebsocketResponse +import java.net.SocketException +import java.util.LinkedList +import java.util.Optional +import java.util.concurrent.Semaphore +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +/** + * A [WebSocketConnection] that provides a way to add "incoming" WebSocket payloads + * and have client code pull them off the "wire" as they would a normal socket. + * + * Add messages with [addPendingMessages] and then can release them to the requestor via + * [releaseMessages]. + */ +class BenchmarkWebSocketConnection : WebSocketConnection { + + companion object { + lateinit var instance: BenchmarkWebSocketConnection + private set + + @Synchronized + fun create(): WebSocketConnection { + instance = BenchmarkWebSocketConnection() + return instance + } + } + + override val name: String = "bench-${System.identityHashCode(this)}" + + private val state = BehaviorSubject.create() + + private val incomingRequests = LinkedList() + private val incomingSemaphore = Semaphore(0) + + @Volatile + private var isShutdown = false + + override fun connect(): Observable { + state.onNext(WebSocketConnectionState.CONNECTED) + return state + } + + override fun isDead(): Boolean { + return false + } + + override fun disconnect() { + state.onNext(WebSocketConnectionState.DISCONNECTED) + + // Signal shutdown + isShutdown = true + + val queuedThreads = incomingSemaphore.queueLength + if (queuedThreads > 0) { + incomingSemaphore.release(queuedThreads) + } + } + + override fun readRequest(timeoutMillis: Long): WebSocketRequestMessage { + if (incomingSemaphore.tryAcquire(1, 10, TimeUnit.SECONDS)) { + // Check if we were woken up due to shutdown + if (isShutdown) { + throw SocketException("WebSocket connection closed") + } + return incomingRequests.removeFirst() + } + + throw TimeoutException("Timeout exceeded") + } + + override fun readRequestIfAvailable(): Optional { + return if (incomingSemaphore.tryAcquire()) { + Optional.of(incomingRequests.removeFirst()) + } else { + Optional.empty() + } + } + + override fun sendResponse(response: WebSocketResponseMessage) = Unit + + fun addPendingMessages(messages: List) { + incomingRequests.addAll(messages) + } + + fun releaseMessages() { + incomingSemaphore.release(incomingRequests.size) + } + + override fun sendRequest( + request: WebSocketRequestMessage, + timeoutSeconds: Long + ): Single { + error("Not yet implemented") + } + + override fun sendKeepAlive() { + error("Not yet implemented") + } +} diff --git a/app/src/benchmarkShared/java/org/signal/benchmark/setup/BobClient.kt b/app/src/benchmarkShared/java/org/signal/benchmark/setup/BobClient.kt new file mode 100644 index 0000000000..8cffb4ea3d --- /dev/null +++ b/app/src/benchmarkShared/java/org/signal/benchmark/setup/BobClient.kt @@ -0,0 +1,214 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +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 +import org.signal.libsignal.protocol.SignalProtocolAddress +import org.signal.libsignal.protocol.ecc.ECPublicKey +import org.signal.libsignal.protocol.groups.state.SenderKeyRecord +import org.signal.libsignal.protocol.state.IdentityKeyStore +import org.signal.libsignal.protocol.state.IdentityKeyStore.IdentityChange +import org.signal.libsignal.protocol.state.KyberPreKeyRecord +import org.signal.libsignal.protocol.state.PreKeyBundle +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.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.SealedSenderAccess +import org.whispersystems.signalservice.api.crypto.SignalServiceCipher +import org.whispersystems.signalservice.api.crypto.SignalSessionBuilder +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess +import org.whispersystems.signalservice.api.push.DistributionId +import org.whispersystems.signalservice.api.push.SignalServiceAddress +import org.whispersystems.signalservice.internal.push.Envelope +import java.util.UUID +import java.util.concurrent.locks.ReentrantLock +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 + * code. + */ +class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair: IdentityKeyPair, val profileKey: ProfileKey) { + + private val serviceAddress = SignalServiceAddress(serviceId, e164) + private val registrationId = KeyHelper.generateRegistrationId(false) + private val aciStore = BobSignalServiceAccountDataStore(registrationId, identityKeyPair) + private val senderCertificate = Harness.createCertificateFor(serviceId.rawUuid, e164, 1, identityKeyPair.publicKey.publicKey, System.currentTimeMillis().milliseconds + 30.days) + private val sessionLock = object : SignalSessionLock { + private val lock = ReentrantLock() + + override fun acquire(): SignalSessionLock.Lock { + lock.lock() + return SignalSessionLock.Lock { lock.unlock() } + } + } + + /** Inspired by SignalServiceMessageSender#getEncryptedMessage */ + fun encrypt(now: Long): Envelope { + val envelopeContent = Generator.encryptedTextMessage(now) + + val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, null) + + if (!aciStore.containsSession(getAliceProtocolAddress())) { + val sessionBuilder = SignalSessionBuilder(sessionLock, SessionBuilder(aciStore, getAliceProtocolAddress())) + sessionBuilder.process(getAlicePreKeyBundle()) + } + + return cipher.encrypt(getAliceProtocolAddress(), getAliceUnidentifiedAccess(), envelopeContent) + .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 { + val envelopes = ArrayList(count) + var now = System.currentTimeMillis() + for (i in 0 until count) { + envelopes += encrypt(now) + now += 3 + } + + return envelopes + } + + private fun getAliceServiceId(): ServiceId { + return SignalStore.account.requireAci() + } + + 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 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 + + return PreKeyBundle( + SignalStore.account.registrationId, + 1, + alicePreKeyId, + alicePreKeyRecord.keyPair.publicKey, + aliceSignedPreKeyId, + aliceSignedPreKeyRecord.keyPair.publicKey, + aliceSignedPreKeyRecord.signature, + getAlicePublicKey(), + aliceSignedKyberPreKeyId, + aliceSignedKyberPreKeyRecord.keyPair.publicKey, + aliceSignedKyberPreKeyRecord.signature + ) + } + + private fun getAliceProtocolAddress(): SignalProtocolAddress { + return SignalProtocolAddress(SignalStore.account.requireAci().toString(), 1) + } + + private fun getAlicePublicKey(): IdentityKey { + return SignalStore.account.aciIdentityKey.publicKey + } + + private fun getAliceProfileKey(): ProfileKey { + return ProfileKeyUtil.getSelfProfileKey() + } + + private fun getAliceUnidentifiedAccess(): SealedSenderAccess? { + val theirProfileKey = getAliceProfileKey() + val themUnidentifiedAccessKey = UnidentifiedAccess(UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey), senderCertificate.serialized, false) + + return SealedSenderAccess.forIndividual(themUnidentifiedAccessKey) + } + + private class BobSignalServiceAccountDataStore(private val registrationId: Int, private val identityKeyPair: IdentityKeyPair) : SignalServiceAccountDataStore { + private var aliceSessionRecord: SessionRecord? = null + + override fun getIdentityKeyPair(): IdentityKeyPair = identityKeyPair + + override fun getLocalRegistrationId(): Int = registrationId + override fun isTrustedIdentity(address: SignalProtocolAddress?, identityKey: IdentityKey?, direction: IdentityKeyStore.Direction?): Boolean = true + override fun loadSession(address: SignalProtocolAddress?): SessionRecord = aliceSessionRecord ?: SessionRecord() + override fun saveIdentity(address: SignalProtocolAddress?, identityKey: IdentityKey?): IdentityChange = IdentityChange.NEW_OR_UNCHANGED + override fun storeSession(address: SignalProtocolAddress?, record: SessionRecord?) { + aliceSessionRecord = record + } + override fun getSubDeviceSessions(name: String?): List = emptyList() + override fun containsSession(address: SignalProtocolAddress?): Boolean = aliceSessionRecord != null + override fun getIdentity(address: SignalProtocolAddress?): IdentityKey = SignalStore.account.aciIdentityKey.publicKey + override fun loadPreKey(preKeyId: Int): PreKeyRecord = throw UnsupportedOperationException() + override fun storePreKey(preKeyId: Int, record: PreKeyRecord?) = throw UnsupportedOperationException() + override fun containsPreKey(preKeyId: Int): Boolean = throw UnsupportedOperationException() + override fun removePreKey(preKeyId: Int) = throw UnsupportedOperationException() + override fun loadExistingSessions(addresses: MutableList?): MutableList = throw UnsupportedOperationException() + override fun deleteSession(address: SignalProtocolAddress?) = throw UnsupportedOperationException() + override fun deleteAllSessions(name: String?) = throw UnsupportedOperationException() + override fun loadSignedPreKey(signedPreKeyId: Int): SignedPreKeyRecord = throw UnsupportedOperationException() + override fun loadSignedPreKeys(): MutableList = throw UnsupportedOperationException() + override fun storeSignedPreKey(signedPreKeyId: Int, record: SignedPreKeyRecord?) = throw UnsupportedOperationException() + override fun containsSignedPreKey(signedPreKeyId: Int): Boolean = throw UnsupportedOperationException() + override fun removeSignedPreKey(signedPreKeyId: Int) = throw UnsupportedOperationException() + override fun loadKyberPreKey(kyberPreKeyId: Int): KyberPreKeyRecord = throw UnsupportedOperationException() + override fun loadKyberPreKeys(): MutableList = throw UnsupportedOperationException() + override fun storeKyberPreKey(kyberPreKeyId: Int, record: KyberPreKeyRecord?) = throw UnsupportedOperationException() + override fun containsKyberPreKey(kyberPreKeyId: Int): Boolean = throw UnsupportedOperationException() + override fun markKyberPreKeyUsed(kyberPreKeyId: Int, signedPreKeyId: Int, baseKey: ECPublicKey) = throw UnsupportedOperationException() + override fun deleteAllStaleOneTimeEcPreKeys(threshold: Long, minCount: Int) = throw UnsupportedOperationException() + override fun markAllOneTimeEcPreKeysStaleIfNecessary(staleTime: Long) = throw UnsupportedOperationException() + override fun storeSenderKey(sender: SignalProtocolAddress?, distributionId: UUID?, record: SenderKeyRecord?) = throw UnsupportedOperationException() + override fun loadSenderKey(sender: SignalProtocolAddress?, distributionId: UUID?): SenderKeyRecord = throw UnsupportedOperationException() + override fun archiveSession(address: SignalProtocolAddress?) = throw UnsupportedOperationException() + override fun getAllAddressesWithActiveSessions(addressNames: MutableList?): MutableMap = throw UnsupportedOperationException() + override fun getSenderKeySharedWith(distributionId: DistributionId?): MutableSet = throw UnsupportedOperationException() + override fun markSenderKeySharedWith(distributionId: DistributionId?, addresses: MutableCollection?) = throw UnsupportedOperationException() + override fun clearSenderKeySharedWith(addresses: MutableCollection?) = throw UnsupportedOperationException() + override fun storeLastResortKyberPreKey(kyberPreKeyId: Int, kyberPreKeyRecord: KyberPreKeyRecord) = throw UnsupportedOperationException() + override fun removeKyberPreKey(kyberPreKeyId: Int) = throw UnsupportedOperationException() + override fun markAllOneTimeKyberPreKeysStaleIfNecessary(staleTime: Long) = throw UnsupportedOperationException() + override fun deleteAllStaleOneTimeKyberPreKeys(threshold: Long, minCount: Int) = throw UnsupportedOperationException() + override fun loadLastResortKyberPreKeys(): List = throw UnsupportedOperationException() + override fun isMultiDevice(): Boolean = throw UnsupportedOperationException() + } +} diff --git a/app/src/benchmarkShared/java/org/signal/benchmark/setup/Generator.kt b/app/src/benchmarkShared/java/org/signal/benchmark/setup/Generator.kt new file mode 100644 index 0000000000..747888969b --- /dev/null +++ b/app/src/benchmarkShared/java/org/signal/benchmark/setup/Generator.kt @@ -0,0 +1,47 @@ +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.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.OutgoingPushMessage +import java.util.Optional +import java.util.UUID + +object Generator { + + fun encryptedTextMessage(now: Long, message: String = "Test body message"): EnvelopeContent { + val content = Content.Builder().apply { + dataMessage( + DataMessage.Builder().buildWith { + body = message + timestamp = now + } + ) + } + return EnvelopeContent.encrypted(content.build(), ContentHint.RESENDABLE, Optional.empty()) + } + + fun OutgoingPushMessage.toEnvelope(timestamp: Long, destination: ServiceId): Envelope { + val serverGuid = UUID.randomUUID() + return Envelope.Builder() + .type(Envelope.Type.fromValue(this.type)) + .sourceDevice(1) + .timestamp(timestamp) + .serverTimestamp(timestamp + 1) + .destinationServiceId(destination.toString()) + .destinationServiceIdBinary(destination.toByteString()) + .serverGuid(serverGuid.toString()) + .serverGuidBinary(serverGuid.toByteArray().toByteString()) + .content(Base64.decode(this.content).toByteString()) + .urgent(true) + .story(false) + .build() + } +} diff --git a/app/src/benchmarkShared/java/org/signal/benchmark/setup/Harness.kt b/app/src/benchmarkShared/java/org/signal/benchmark/setup/Harness.kt new file mode 100644 index 0000000000..0064189870 --- /dev/null +++ b/app/src/benchmarkShared/java/org/signal/benchmark/setup/Harness.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.benchmark.setup + +import org.signal.core.models.ServiceId.ACI +import org.signal.core.util.Base64 +import org.signal.core.util.UuidUtil +import org.signal.libsignal.metadata.certificate.SenderCertificate +import org.signal.libsignal.metadata.certificate.ServerCertificate +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.profiles.ProfileKey +import java.util.Optional +import java.util.UUID +import kotlin.time.Duration + +object Harness { + val SELF_E164 = "+15555550101" + 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")) + + 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 + ) + } + + fun createCertificateFor(uuid: UUID, e164: String?, deviceId: Int, identityKey: ECPublicKey, expires: Duration): SenderCertificate { + val serverKey: ECKeyPair = ECKeyPair.generate() + val serverCertificate = ServerCertificate(trustRoot.privateKey, 1, serverKey.publicKey) + return serverCertificate.issue(serverKey.privateKey, uuid.toString(), Optional.ofNullable(e164), deviceId, identityKey, expires.inWholeMilliseconds) + } +} diff --git a/app/src/benchmarkShared/java/org/signal/benchmark/setup/TestUsers.kt b/app/src/benchmarkShared/java/org/signal/benchmark/setup/TestUsers.kt index d7acf63dea..e035f40f43 100644 --- a/app/src/benchmarkShared/java/org/signal/benchmark/setup/TestUsers.kt +++ b/app/src/benchmarkShared/java/org/signal/benchmark/setup/TestUsers.kt @@ -9,10 +9,12 @@ import org.signal.core.util.Util import org.signal.libsignal.protocol.IdentityKeyPair import org.signal.libsignal.protocol.SignalProtocolAddress import org.thoughtcrime.securesms.crypto.MasterSecretUtil +import org.thoughtcrime.securesms.crypto.PreKeyUtil import org.thoughtcrime.securesms.crypto.ProfileKeyUtil import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.keyvalue.CertificateType import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.Skipped import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor @@ -28,11 +30,12 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences import org.whispersystems.signalservice.api.profiles.SignalServiceProfile import org.whispersystems.signalservice.api.push.SignalServiceAddress import java.util.UUID +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.milliseconds object TestUsers { - private var generatedOthers: Int = 0 - private val TEST_E164 = "+15555550101" + private var generatedOthers: Int = 1 fun setupSelf(): Recipient { val application: Application = AppDependencies.application @@ -50,19 +53,19 @@ object TestUsers { runBlocking { val registrationData = RegistrationData( code = "123123", - e164 = TEST_E164, + e164 = Harness.SELF_E164, password = Util.getSecret(18), registrationId = RegistrationRepository.getRegistrationId(), - profileKey = RegistrationRepository.getProfileKey(TEST_E164), + profileKey = RegistrationRepository.getProfileKey(Harness.SELF_E164), fcmToken = null, pniRegistrationId = RegistrationRepository.getPniRegistrationId(), recoveryPassword = "asdfasdfasdfasdf" ) val remoteResult = AccountRegistrationResult( - uuid = UUID.randomUUID().toString(), + uuid = Harness.SELF_ACI.toString(), pni = UUID.randomUUID().toString(), storageCapable = false, - number = TEST_E164, + number = Harness.SELF_E164, masterKey = null, pin = null, aciPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account.aciIdentityKey, SignalStore.account.aciPreKeys), @@ -78,6 +81,31 @@ object TestUsers { RegistrationUtil.maybeMarkRegistrationComplete() SignalDatabase.recipients.setProfileName(Recipient.self().id, ProfileName.fromParts("Tester", "McTesterson")) TextSecurePreferences.setPromptedOptimizeDoze(application, true) + TextSecurePreferences.setRatingEnabled(application, false) + + PreKeyUtil.generateAndStoreSignedPreKey(AppDependencies.protocolStore.aci(), SignalStore.account.aciPreKeys) + PreKeyUtil.generateAndStoreOneTimeEcPreKeys(AppDependencies.protocolStore.aci(), SignalStore.account.aciPreKeys) + PreKeyUtil.generateAndStoreOneTimeKyberPreKeys(AppDependencies.protocolStore.aci(), SignalStore.account.aciPreKeys) + + val aliceSenderCertificate = Harness.createCertificateFor( + uuid = Harness.SELF_ACI.rawUuid, + e164 = Harness.SELF_E164, + deviceId = 1, + identityKey = SignalStore.account.aciIdentityKey.publicKey.publicKey, + expires = System.currentTimeMillis().milliseconds + 30.days + ) + + val aliceSenderCertificate2 = Harness.createCertificateFor( + uuid = Harness.SELF_ACI.rawUuid, + e164 = null, + deviceId = 1, + identityKey = SignalStore.account.aciIdentityKey.publicKey.publicKey, + expires = System.currentTimeMillis().milliseconds + 30.days + ) + + SignalStore.certificate.setUnidentifiedAccessCertificate(CertificateType.ACI_AND_E164, aliceSenderCertificate.serialized) + SignalStore.certificate.setUnidentifiedAccessCertificate(CertificateType.ACI_ONLY, aliceSenderCertificate2.serialized) + return Recipient.self() } @@ -111,4 +139,18 @@ 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")) + + 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) + + AppDependencies.protocolStore.aci().saveIdentity(SignalProtocolAddress(Harness.BOB_ACI.toString(), 1), Harness.BOB_IDENTITY_KEY.publicKey) + + return Recipient.resolved(recipientId) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageObserver.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageObserver.kt index 18228aa25c..fb4be9ca00 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageObserver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/IncomingMessageObserver.kt @@ -35,6 +35,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.util.AlarmSleepTimer import org.thoughtcrime.securesms.util.AppForegroundObserver import org.thoughtcrime.securesms.util.SignalLocalMetrics +import org.thoughtcrime.securesms.util.SignalTrace import org.thoughtcrime.securesms.util.asChain import org.whispersystems.signalservice.api.util.SleepTimer import org.whispersystems.signalservice.api.util.UptimeSleepTimer @@ -117,7 +118,7 @@ class IncomingMessageObserver( } ) - private val messageContentProcessor = MessageContentProcessor(context) + private val messageContentProcessor = MessageContentProcessor.create(context) private var appVisible = false private var lastInteractionTime: Long = System.currentTimeMillis() @@ -278,7 +279,9 @@ class IncomingMessageObserver( fun processEnvelope(bufferedProtocolStore: BufferedProtocolStore, envelope: Envelope, serverDeliveredTimestamp: Long): List? { return when (envelope.type) { Envelope.Type.SERVER_DELIVERY_RECEIPT -> { + SignalTrace.beginSection("IncomingMessageObserver#processReceipt") processReceipt(envelope) + SignalTrace.endSection() null } @@ -286,7 +289,10 @@ class IncomingMessageObserver( Envelope.Type.CIPHERTEXT, Envelope.Type.UNIDENTIFIED_SENDER, Envelope.Type.PLAINTEXT_CONTENT -> { - processMessage(bufferedProtocolStore, envelope, serverDeliveredTimestamp) + SignalTrace.beginSection("IncomingMessageObserver#processMessage") + val followUps = processMessage(bufferedProtocolStore, envelope, serverDeliveredTimestamp) + SignalTrace.endSection() + followUps } else -> { @@ -298,7 +304,9 @@ class IncomingMessageObserver( private fun processMessage(bufferedProtocolStore: BufferedProtocolStore, envelope: Envelope, serverDeliveredTimestamp: Long): List { val localReceiveMetric = SignalLocalMetrics.MessageReceive.start() + SignalTrace.beginSection("IncomingMessageObserver#decryptMessage") val result = MessageDecryptor.decrypt(context, bufferedProtocolStore, envelope, serverDeliveredTimestamp) + SignalTrace.endSection() localReceiveMetric.onEnvelopeDecrypted() SignalLocalMetrics.MessageLatency.onMessageReceived(envelope.serverTimestamp!!, serverDeliveredTimestamp, envelope.urgent!!) @@ -425,15 +433,13 @@ class IncomingMessageObserver( GroupsV2ProcessingLock.acquireGroupProcessingLock().use { ReentrantSessionLock.INSTANCE.acquire().use { batch.forEach { response -> - Log.d(TAG, "Beginning database transaction...") val followUpOperations = SignalDatabase.runInTransaction { db -> val followUps: List? = processEnvelope(bufferedStore, response.envelope, response.serverDeliveredTimestamp) bufferedStore.flushToDisk() followUps } - Log.d(TAG, "Ended database transaction.") - if (followUpOperations != null) { + if (followUpOperations?.isNotEmpty() == true) { Log.d(TAG, "Running ${followUpOperations.size} follow-up operations...") val jobs = followUpOperations.mapNotNull { it.run() } AppDependencies.jobManager.addAllChains(jobs) diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.kt index 257d40a50b..e5c18b6c64 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.kt @@ -46,6 +46,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.util.EarlyMessageCacheEntry import org.thoughtcrime.securesms.util.RemoteConfig import org.thoughtcrime.securesms.util.SignalLocalMetrics +import org.thoughtcrime.securesms.util.SignalTrace import org.thoughtcrime.securesms.util.TextSecurePreferences import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata import org.whispersystems.signalservice.api.push.DistributionId @@ -326,7 +327,9 @@ open class MessageContentProcessor(private val context: Context) { open fun process(envelope: Envelope, content: Content, metadata: EnvelopeMetadata, serverDeliveredTimestamp: Long, processingEarlyContent: Boolean = false, localMetric: SignalLocalMetrics.MessageReceive? = null) { val senderRecipient = Recipient.externalPush(SignalServiceAddress(metadata.sourceServiceId, metadata.sourceE164)) + SignalTrace.beginSection("MessageContentProcessor#handleMessage") handleMessage(senderRecipient, envelope, content, metadata, serverDeliveredTimestamp, processingEarlyContent, localMetric) + SignalTrace.endSection() val earlyCacheEntries: List? = AppDependencies .earlyMessageCache diff --git a/benchmark/src/main/java/org/thoughtcrime/benchmark/BenchmarkSetup.kt b/benchmark/src/main/java/org/thoughtcrime/benchmark/BenchmarkSetup.kt index 5e8a7d85a9..7673f42f99 100644 --- a/benchmark/src/main/java/org/thoughtcrime/benchmark/BenchmarkSetup.kt +++ b/benchmark/src/main/java/org/thoughtcrime/benchmark/BenchmarkSetup.kt @@ -5,8 +5,23 @@ import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.Until object BenchmarkSetup { + private const val TARGET_PACKAGE = "org.thoughtcrime.securesms" + private const val RECEIVER = "org.signal.benchmark.BenchmarkCommandReceiver" + fun setup(type: String, device: UiDevice) { - device.executeShellCommand("am start -W -n org.thoughtcrime.securesms/org.signal.benchmark.BenchmarkSetupActivity --es setup-type $type") + 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) } + + fun setupIndividualSend(device: UiDevice) { + device.benchmarkCommandBroadcast("individual-send") + } + + fun releaseMessages(device: UiDevice) { + device.benchmarkCommandBroadcast("release-messages") + } + + private fun UiDevice.benchmarkCommandBroadcast(command: String) { + executeShellCommand("am broadcast -a org.signal.benchmark.action.COMMAND -e command $command -n $TARGET_PACKAGE/$RECEIVER") + } } diff --git a/benchmark/src/main/java/org/thoughtcrime/benchmark/MessageProcessingBenchmarks.kt b/benchmark/src/main/java/org/thoughtcrime/benchmark/MessageProcessingBenchmarks.kt new file mode 100644 index 0000000000..9e08efa62e --- /dev/null +++ b/benchmark/src/main/java/org/thoughtcrime/benchmark/MessageProcessingBenchmarks.kt @@ -0,0 +1,113 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.benchmark + +import android.os.SystemClock +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.BySelector +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Macrobenchmark benchmarks for message processing performance. + * + * WARNING! THIS WILL WIPE YOUR SIGNAL INSTALL + */ +@OptIn(ExperimentalMetricApi::class) +@RunWith(AndroidJUnit4::class) +@RequiresApi(31) +class MessageProcessingBenchmarks { + @get:Rule + val benchmarkRule = MacrobenchmarkRule() + + @Test + fun individualMessageReceiveOnConversationList() { + run(withConversationOpen = false) + } + + @Test + fun individualMessageReceiveOnConversation() { + run(withConversationOpen = true) + } + + private fun run(withConversationOpen: Boolean) { + var setup = false + benchmarkRule.measureRepeated( + packageName = "org.thoughtcrime.securesms", + metrics = listOf( + TraceSectionMetric( + sectionName = "IncomingMessageObserver#decryptMessage", + mode = Mode.Average + ), + TraceSectionMetric( + sectionName = "MessageContentProcessor#handleMessage", + mode = Mode.Average + ), + TraceSectionMetric( + sectionName = "IncomingMessageObserver#processMessage", + mode = Mode.Average + ) + ), + iterations = 5, + compilationMode = CompilationMode.Partial(), + setupBlock = { + if (!setup) { + BenchmarkSetup.setup("message-send", device) + } + + killProcess() + startActivityAndWait() + device.waitForIdle() + + BenchmarkSetup.setupIndividualSend(device) + + val uiObject = device.wait(Until.findObject(By.textContains("Bob")), 5_000) + if (withConversationOpen) { + uiObject.click() + } + } + ) { + + BenchmarkSetup.releaseMessages(device) + + device.waitForAny( + 10_000L, + By.textContains("101"), + By.textContains("202"), + By.textContains("303"), + By.textContains("404"), + By.textContains("505"), + ) + } + } +} + +/** + * 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 +}