From 49f0c2502b9113a4f84f7a3ba781633099fb3bee Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 20 May 2026 10:44:40 -0300 Subject: [PATCH] Add IncomingMessageObserver integration test infrastructure. --- app/build.gradle.kts | 11 +- ...serverInstrumentationApplicationContext.kt | 36 ++++ .../DecryptionErrorTest.kt | 27 +++ .../IncomingGroupMessageTest.kt | 39 ++++ .../IncomingTextMessageTest.kt | 22 ++ .../IncomingMessageObserverAssertions.kt | 71 +++++++ ...comingMessageObserverDependencyProvider.kt | 63 ++++++ .../IncomingMessageObserverRule.kt | 201 ++++++++++++++++++ .../IncomingMessageObserverTestRunner.kt | 18 ++ .../securesms/BenchmarkApplicationContext.kt | 124 +---------- .../org/signal/benchmark/setup/NoOpJob.kt | 84 ++++++++ .../websocket/BenchmarkWebSocketConnection.kt | 12 ++ .../securesms/dependencies/AppDependencies.kt | 5 +- .../ApplicationDependencyProvider.java | 41 ++-- .../MockApplicationDependencyProvider.kt | 6 +- 15 files changed, 617 insertions(+), 143 deletions(-) create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/IncomingMessageObserverInstrumentationApplicationContext.kt create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/messages/incomingmessageobserver/DecryptionErrorTest.kt create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/messages/incomingmessageobserver/IncomingGroupMessageTest.kt create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/messages/incomingmessageobserver/IncomingTextMessageTest.kt create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/testing/incomingmessageobserver/IncomingMessageObserverAssertions.kt create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/testing/incomingmessageobserver/IncomingMessageObserverDependencyProvider.kt create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/testing/incomingmessageobserver/IncomingMessageObserverRule.kt create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/testing/incomingmessageobserver/IncomingMessageObserverTestRunner.kt create mode 100644 app/src/benchmarkShared/java/org/signal/benchmark/setup/NoOpJob.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4f00ac7120..06b8ff17ee 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -100,6 +100,9 @@ tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class.java).conf if (!isTestTask && (name.contains("Mocked") || name.contains("Benchmark"))) { source("$projectDir/src/benchmarkShared/java") } + if (isTestTask && name.contains("AndroidTest")) { + source("$projectDir/src/benchmarkShared/java") + } } wire { @@ -168,6 +171,7 @@ android { getByName("androidTest") { java.srcDir("$projectDir/src/testShared") + java.srcDir("$projectDir/src/benchmarkShared/java") } } @@ -287,7 +291,11 @@ android { } } - testInstrumentationRunner = "org.thoughtcrime.securesms.testing.SignalTestRunner" + testInstrumentationRunner = if (project.hasProperty("imoTests")) { + "org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverTestRunner" + } else { + "org.thoughtcrime.securesms.testing.SignalTestRunner" + } testInstrumentationRunnerArguments["clearPackageData"] = "true" } @@ -345,6 +353,7 @@ android { buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Instrumentation\"") buildConfigField("String", "STRIPE_BASE_URL", "\"http://127.0.0.1:8080/stripe\"") + buildConfigField("String[]", "UNIDENTIFIED_SENDER_TRUST_ROOTS", "new String[]{ \"BVT/2gHqbrG1xzuIypLIOjFgMtihrMld1/5TGADL6Dhv\"}") } create("spinner") { diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/IncomingMessageObserverInstrumentationApplicationContext.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/IncomingMessageObserverInstrumentationApplicationContext.kt new file mode 100644 index 0000000000..55833c1fb2 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/IncomingMessageObserverInstrumentationApplicationContext.kt @@ -0,0 +1,36 @@ +package org.thoughtcrime.securesms + +import org.signal.core.util.logging.AndroidLogger +import org.signal.core.util.logging.Log +import org.signal.libsignal.protocol.logging.SignalProtocolLoggerProvider +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider +import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger +import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverDependencyProvider +import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverTestRunner + +/** + * Application used when running `IncomingMessageObserver` instrumentation tests. Installs + * [IncomingMessageObserverDependencyProvider] so the websocket and job manager are replaced + * with test-friendly implementations. Selected by [IncomingMessageObserverTestRunner] when + * gradle is invoked with `-PimoTests`. + */ +class IncomingMessageObserverInstrumentationApplicationContext : ApplicationContext() { + + override fun initializeAppDependencies() { + val default = ApplicationDependencyProvider(this) + AppDependencies.init(this, IncomingMessageObserverDependencyProvider(this, default)) + AppDependencies.deadlockDetector.start() + } + + override fun initializeLogging() { + Log.initialize({ true }, AndroidLogger) + SignalProtocolLoggerProvider.setProvider(CustomSignalProtocolLogger()) + } + + override fun beginJobLoop() = Unit + + fun beginJobLoopForTests() { + super.beginJobLoop() + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/messages/incomingmessageobserver/DecryptionErrorTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/messages/incomingmessageobserver/DecryptionErrorTest.kt new file mode 100644 index 0000000000..45721080da --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/messages/incomingmessageobserver/DecryptionErrorTest.kt @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms.messages.incomingmessageobserver + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertMessageReceived +import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertNoMessageReceived +import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverRule + +@RunWith(AndroidJUnit4::class) +class DecryptionErrorTest { + + @get:Rule + val rule = IncomingMessageObserverRule(peerCount = 2) + + @Test + fun malformedEnvelope_dropsMessage_butPipelineRecovers() { + val peer = rule.peers[0] + + rule.deliver { malformedEnvelope() from peer } + assertNoMessageReceived(from = peer, body = "subsequent") + + rule.deliver { text("subsequent") from peer } + assertMessageReceived(from = peer, body = "subsequent") + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/messages/incomingmessageobserver/IncomingGroupMessageTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/messages/incomingmessageobserver/IncomingGroupMessageTest.kt new file mode 100644 index 0000000000..936f563650 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/messages/incomingmessageobserver/IncomingGroupMessageTest.kt @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.messages.incomingmessageobserver + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertGroupMessageReceived +import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverRule + +@RunWith(AndroidJUnit4::class) +class IncomingGroupMessageTest { + + @get:Rule + val rule = IncomingMessageObserverRule(peerCount = 5) + + @Test + fun deliveredGroupText_isPersistedInGroupThread() { + val group = rule.testGroup + + rule.deliver { groupText("hello group", group = group) from rule.peers[0] } + + assertGroupMessageReceived(from = rule.peers[0], group = group, body = "hello group") + } + + @Test + fun multipleGroupMembers_messagesPersistedFromEach() { + val group = rule.testGroup + + rule.deliver { + groupText("from peer 0", group = group) from rule.peers[0] + groupText("from peer 1", group = group) from rule.peers[1] + groupText("from peer 2", group = group) from rule.peers[2] + } + + assertGroupMessageReceived(from = rule.peers[0], group = group, body = "from peer 0") + assertGroupMessageReceived(from = rule.peers[1], group = group, body = "from peer 1") + assertGroupMessageReceived(from = rule.peers[2], group = group, body = "from peer 2") + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/messages/incomingmessageobserver/IncomingTextMessageTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/messages/incomingmessageobserver/IncomingTextMessageTest.kt new file mode 100644 index 0000000000..26c53c2e73 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/messages/incomingmessageobserver/IncomingTextMessageTest.kt @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.messages.incomingmessageobserver + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverAssertions.assertMessageReceived +import org.thoughtcrime.securesms.testing.incomingmessageobserver.IncomingMessageObserverRule + +@RunWith(AndroidJUnit4::class) +class IncomingTextMessageTest { + + @get:Rule + val rule = IncomingMessageObserverRule(peerCount = 2) + + @Test + fun deliveredOneToOneText_isPersisted() { + rule.deliver { text("hello world") from rule.peers[0] } + + assertMessageReceived(from = rule.peers[0], body = "hello world") + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/incomingmessageobserver/IncomingMessageObserverAssertions.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/incomingmessageobserver/IncomingMessageObserverAssertions.kt new file mode 100644 index 0000000000..bc52c67f95 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/incomingmessageobserver/IncomingMessageObserverAssertions.kt @@ -0,0 +1,71 @@ +package org.thoughtcrime.securesms.testing.incomingmessageobserver + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.isTrue +import org.signal.benchmark.setup.OtherClient +import org.thoughtcrime.securesms.database.MessageTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.whispersystems.signalservice.api.push.SignalServiceAddress + +/** + * Reads database state produced by [IncomingMessageObserverRule]-driven tests. Import members + * individually (e.g. `import …IncomingMessageObserverAssertions.assertMessageReceived`) so test + * bodies stay terse. + */ +object IncomingMessageObserverAssertions { + + fun OtherClient.recipientId(): RecipientId = Recipient.externalPush(SignalServiceAddress(serviceId, e164)).id + + fun findIncomingMessage(from: OtherClient, body: String): MessageRecord? { + val threadId = SignalDatabase.threads.getThreadIdFor(from.recipientId()) ?: return null + return SignalDatabase.messages.getConversation(threadId).use { cursor -> + MessageTable.MmsReader(cursor).use { reader -> reader.firstOrNull { it.body == body } } + } + } + + fun findIncomingGroupMessage(from: OtherClient, group: GroupHandle, body: String): MessageRecord? { + val threadId = SignalDatabase.threads.getThreadIdFor(group.recipientId) ?: return null + return SignalDatabase.messages.getConversation(threadId).use { cursor -> + MessageTable.MmsReader(cursor).use { reader -> + reader.firstOrNull { it.body == body && it.fromRecipient.id == from.recipientId() } + } + } + } + + fun assertMessageReceived(from: OtherClient, body: String) { + val record = findIncomingMessage(from, body) + assertThat(record, "incoming message with body \"$body\" from ${from.serviceId} not found").isNotNull() + assertThat(record!!.fromRecipient.id, "incoming message sender mismatch for body \"$body\"").isEqualTo(from.recipientId()) + } + + fun assertGroupMessageReceived(from: OtherClient, group: GroupHandle, body: String) { + val record = findIncomingGroupMessage(from, group, body) + assertThat(record, "group message \"$body\" from ${from.serviceId} in ${group.groupId} not found").isNotNull() + } + + fun assertNoMessageReceived(from: OtherClient, body: String) { + val record = findIncomingMessage(from, body) + assertThat(record == null, "expected no message with body \"$body\" from ${from.serviceId}, but found one").isTrue() + } + + fun assertNoMessagesInThread(recipientId: RecipientId) { + val threadId = SignalDatabase.threads.getThreadIdFor(recipientId) ?: return + val count = SignalDatabase.messages.getConversation(threadId).use { cursor -> cursor.count } + assertThat(count, "expected thread for $recipientId to be empty, but message count was").isEqualTo(0) + } + + fun assertDeliveryReceipt(outgoingMessageId: Long) { + val record = SignalDatabase.messages.getMessageRecord(outgoingMessageId) + assertThat(record.hasDeliveryReceipt(), "expected delivery receipt on outgoing message $outgoingMessageId, but none recorded").isTrue() + } + + fun assertReadReceipt(outgoingMessageId: Long) { + val record = SignalDatabase.messages.getMessageRecord(outgoingMessageId) + assertThat(record.hasReadReceipt(), "expected read receipt on outgoing message $outgoingMessageId, but none recorded").isTrue() + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/incomingmessageobserver/IncomingMessageObserverDependencyProvider.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/incomingmessageobserver/IncomingMessageObserverDependencyProvider.kt new file mode 100644 index 0000000000..45070e7e01 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/incomingmessageobserver/IncomingMessageObserverDependencyProvider.kt @@ -0,0 +1,63 @@ +package org.thoughtcrime.securesms.testing.incomingmessageobserver + +import android.app.Application +import org.signal.benchmark.setup.NoOpJob +import org.signal.libsignal.net.Network +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider +import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider +import org.thoughtcrime.securesms.jobmanager.JobManager +import org.thoughtcrime.securesms.jobs.JobManagerFactories +import org.whispersystems.signalservice.api.util.UptimeSleepTimer +import org.whispersystems.signalservice.api.websocket.SignalWebSocket +import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration +import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection +import java.util.function.Supplier +import kotlin.time.Duration.Companion.seconds + +/** + * Dependency provider used by [org.thoughtcrime.securesms.IncomingMessageObserverInstrumentationApplicationContext]. + * Composes [InstrumentationApplicationDependencyProvider] (so existing mocks for the account / + * archive / donations / billing APIs are reused) and overrides: + * + * - the auth and unauth websocket factories with [BenchmarkWebSocketConnection], so tests can + * inject encrypted envelopes through the real ingest pipeline; + * - the job manager, swapping the startup network jobs handled by [NoOpJob.replaceFactories] + * to no-ops so they can't fire against unstubbed mocks during a test. + */ +class IncomingMessageObserverDependencyProvider( + private val application: Application, + default: ApplicationDependencyProvider +) : AppDependencies.Provider by InstrumentationApplicationDependencyProvider(application, default) { + + override fun provideAuthWebSocket( + signalServiceConfigurationSupplier: Supplier, + libSignalNetworkSupplier: Supplier + ): SignalWebSocket.AuthenticatedWebSocket { + return SignalWebSocket.AuthenticatedWebSocket( + connectionFactory = { BenchmarkWebSocketConnection.createAuthInstance() }, + canConnect = { true }, + sleepTimer = UptimeSleepTimer(), + disconnectTimeoutMs = 15.seconds.inWholeMilliseconds + ) + } + + override fun provideUnauthWebSocket( + signalServiceConfigurationSupplier: Supplier, + libSignalNetworkSupplier: Supplier + ): SignalWebSocket.UnauthenticatedWebSocket { + return SignalWebSocket.UnauthenticatedWebSocket( + connectionFactory = { BenchmarkWebSocketConnection.createUnauthInstance() }, + canConnect = { true }, + sleepTimer = UptimeSleepTimer(), + disconnectTimeoutMs = 15.seconds.inWholeMilliseconds + ) + } + + override fun provideJobManager(configurationBuilder: JobManager.Configuration.Builder): JobManager { + val config = configurationBuilder + .setJobFactories(NoOpJob.replaceFactories(JobManagerFactories.getJobFactories(application))) + .build() + return JobManager(application, config) + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/incomingmessageobserver/IncomingMessageObserverRule.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/incomingmessageobserver/IncomingMessageObserverRule.kt new file mode 100644 index 0000000000..30470972e6 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/incomingmessageobserver/IncomingMessageObserverRule.kt @@ -0,0 +1,201 @@ +package org.thoughtcrime.securesms.testing.incomingmessageobserver + +import okio.ByteString.Companion.toByteString +import org.junit.Assume +import org.junit.rules.ExternalResource +import org.signal.benchmark.setup.Generator +import org.signal.benchmark.setup.Harness +import org.signal.benchmark.setup.OtherClient +import org.signal.benchmark.setup.TestUsers +import org.signal.core.util.logging.Log +import org.signal.network.websocket.WebSocketRequestMessage +import org.thoughtcrime.securesms.IncomingMessageObserverInstrumentationApplicationContext +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.JobTracker +import org.thoughtcrime.securesms.jobs.MarkerJob +import org.thoughtcrime.securesms.jobs.PushProcessMessageJob +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.whispersystems.signalservice.internal.push.Envelope +import org.whispersystems.signalservice.internal.websocket.BenchmarkWebSocketConnection +import java.util.concurrent.CopyOnWriteArraySet +import kotlin.random.Random +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * JUnit rule that drives [org.thoughtcrime.securesms.messages.IncomingMessageObserver] from + * instrumentation tests. Sets up self, registers [peerCount] simulated peers from + * [Harness.otherClients], establishes a Signal double-ratchet session with each, and exposes a + * small DSL for delivering encrypted envelopes through the real ingest pipeline: + * + * ``` + * @get:Rule val rule = IncomingMessageObserverRule(peerCount = 2) + * + * @Test fun example() { + * rule.deliver { text("hi") from rule.peers[0] } + * rule.deliver { groupText("hi all", group = rule.testGroup) from rule.peers[0] } + * } + * ``` + * + * Run with `-PimoTests`; tests are skipped under the default runner. Throws on drain timeout. + * Mutually exclusive with `SignalDatabaseRule` / `SignalActivityRule` — all three claim the + * local identity. + */ +class IncomingMessageObserverRule( + private val peerCount: Int = 2, + private val drainTimeout: Duration = 30.seconds +) : ExternalResource() { + + lateinit var self: Recipient + private set + + lateinit var peers: List + private set + + /** Lazily-created group. Touching this from a test triggers setup; tests that don't use groups pay nothing. */ + val testGroup: GroupHandle by lazy { + val gid = TestUsers.setupGroup(withLabels = false) + GroupHandle(gid, Recipient.externalGroupExact(gid).id) + } + + override fun before() { + Assume.assumeTrue( + "IncomingMessageObserverRule requires the IMO test runner — run with -PimoTests", + AppDependencies.application is IncomingMessageObserverInstrumentationApplicationContext + ) + + self = TestUsers.setupSelf() + TestUsers.setupTestClients(peerCount) + peers = Harness.otherClients.take(peerCount) + + val app = AppDependencies.application as IncomingMessageObserverInstrumentationApplicationContext + app.beginJobLoopForTests() + + // IncomingMessageObserver caches `canProcessMessages` from restoreDecisionState at thread + // construction. If it was built before setupSelf() flipped the state it will silently drop + // every message; reset network so a fresh observer is constructed. + AppDependencies.incomingMessageObserver.notifyRestoreDecisionMade() + AppDependencies.startNetwork() + forceObserverConstruction() + + val handshakeEnvelopes = peers.map { client -> + client.encrypt(Generator.encryptedTextMessage(System.currentTimeMillis())) + } + deliverEnvelopes(handshakeEnvelopes) + peers.forEach { it.completeSession() } + } + + fun deliver(builder: DeliveryBuilder.() -> Unit) { + val collected = DeliveryBuilder().apply(builder).specs + if (collected.isEmpty()) return + deliverEnvelopes(collected.map { it.materialize() }) + } + + private fun forceObserverConstruction() { + AppDependencies.incomingMessageObserver + } + + private fun deliverEnvelopes(envelopes: List) { + val jobManager = AppDependencies.jobManager + val seenQueues = CopyOnWriteArraySet() + val queueListener = object : JobTracker.JobListener { + override fun onStateChanged(job: Job, jobState: JobTracker.JobState) { + job.parameters.queue?.let { queue -> + if (queue.startsWith(PushProcessMessageJob.QUEUE_PREFIX)) { + seenQueues += queue + } + } + } + } + jobManager.addListener({ job: Job -> job.parameters.queue?.startsWith(PushProcessMessageJob.QUEUE_PREFIX) == true }, queueListener) + + try { + BenchmarkWebSocketConnection.addPendingMessages(envelopes.map { it.toWebSocketPayload() }) + BenchmarkWebSocketConnection.addQueueEmptyMessage() + BenchmarkWebSocketConnection.releaseMessages() + + val consumed = BenchmarkWebSocketConnection.awaitAllMessagesConsumed(drainTimeout.inWholeMilliseconds) + check(consumed) { "Timed out waiting for benchmark websocket to consume ${envelopes.size} envelope(s)" } + + // PushProcessMessageJob enqueue happens on a background thread after the websocket marks + // messages consumed; this tick lets that settle before we snapshot the queues to wait on. + Thread.sleep(100) + + val queuesToDrain = seenQueues.toSet() + Log.d(TAG, "Awaiting ${queuesToDrain.size} PushProcessMessageJob queue(s): $queuesToDrain") + for (queue in queuesToDrain) { + val state = jobManager.runSynchronously(MarkerJob(queue), drainTimeout.inWholeMilliseconds) + check(state.isPresent) { "Timed out waiting for queue $queue to drain" } + } + } finally { + jobManager.removeListener(queueListener) + } + } + + companion object { + private val TAG = Log.tag(IncomingMessageObserverRule::class) + + private fun Envelope.toWebSocketPayload(): WebSocketRequestMessage = WebSocketRequestMessage( + verb = "PUT", + path = "/api/v1/message", + id = Random.nextLong(), + headers = listOf("X-Signal-Timestamp: $serverTimestamp"), + body = encodeByteString() + ) + } +} + +/** Identifies the test group created by [IncomingMessageObserverRule]. Hold a reference to pass into the [DeliveryBuilder.groupText] DSL. */ +data class GroupHandle(val groupId: GroupId.V2, val recipientId: RecipientId) + +/** + * Receiver of the DSL passed to [IncomingMessageObserverRule.deliver]. Construct content with + * [text] / [groupText] / [deliveryReceipts] / [readReceipts] / [malformedEnvelope] and chain + * with the [from] infix to attach a sending peer. Each `from` adds the resulting envelope to + * the batch that will be delivered when the lambda returns. + */ +class DeliveryBuilder internal constructor() { + internal val specs = mutableListOf() + + fun text(body: String, timestamp: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.Text(body, timestamp, group = null) + + fun groupText(body: String, group: GroupHandle, timestamp: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.Text(body, timestamp, group) + + fun deliveryReceipts(targets: List, sentAt: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.DeliveryReceipt(targets, sentAt) + + fun readReceipts(targets: List, sentAt: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.ReadReceipt(targets, sentAt) + + fun malformedEnvelope(timestamp: Long = System.currentTimeMillis()): EnvelopeContentSpec = EnvelopeContentSpec.Malformed(timestamp) + + infix fun EnvelopeContentSpec.from(peer: OtherClient) { + specs += EnvelopeSpec(this, peer) + } +} + +/** Opaque envelope content returned by [DeliveryBuilder]. Tests never construct or inspect variants directly; the type only appears as a return / receiver of the DSL methods. */ +sealed class EnvelopeContentSpec { + internal data class Text(val body: String, val timestamp: Long, val group: GroupHandle?) : EnvelopeContentSpec() + internal data class DeliveryReceipt(val targets: List, val sentAt: Long) : EnvelopeContentSpec() + internal data class ReadReceipt(val targets: List, val sentAt: Long) : EnvelopeContentSpec() + internal data class Malformed(val timestamp: Long) : EnvelopeContentSpec() +} + +internal data class EnvelopeSpec(val content: EnvelopeContentSpec, val peer: OtherClient) { + fun materialize(): Envelope = when (val c = content) { + is EnvelopeContentSpec.Text -> + peer.encrypt(Generator.encryptedTextMessage(c.timestamp, c.body, c.group?.let { Harness.groupMasterKey })) + is EnvelopeContentSpec.DeliveryReceipt -> + peer.encrypt(Generator.encryptedDeliveryReceipt(c.sentAt, c.targets), c.sentAt) + is EnvelopeContentSpec.ReadReceipt -> + peer.encrypt(Generator.encryptedReadReceipt(c.sentAt, c.targets), c.sentAt) + is EnvelopeContentSpec.Malformed -> { + val valid = peer.encrypt(Generator.encryptedTextMessage(c.timestamp)) + val original = valid.content ?: error("Encrypted envelope unexpectedly had no content") + val corrupted = original.toByteArray().also { it[it.size / 2] = (it[it.size / 2].toInt() xor 0x01).toByte() } + valid.copy(content = corrupted.toByteString()) + } + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/incomingmessageobserver/IncomingMessageObserverTestRunner.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/incomingmessageobserver/IncomingMessageObserverTestRunner.kt new file mode 100644 index 0000000000..72dbca87fa --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/incomingmessageobserver/IncomingMessageObserverTestRunner.kt @@ -0,0 +1,18 @@ +package org.thoughtcrime.securesms.testing.incomingmessageobserver + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import org.thoughtcrime.securesms.IncomingMessageObserverInstrumentationApplicationContext + +/** + * Test runner that swaps in [IncomingMessageObserverInstrumentationApplicationContext] so the + * `IncomingMessageObserver` test harness can drive a faked websocket. Selected automatically by + * the build when `-PimoTests` is set. + */ +@Suppress("unused") +class IncomingMessageObserverTestRunner : AndroidJUnitRunner() { + override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application { + return super.newApplication(cl, IncomingMessageObserverInstrumentationApplicationContext::class.java.name, context) + } +} diff --git a/app/src/benchmark/java/org/thoughtcrime/securesms/BenchmarkApplicationContext.kt b/app/src/benchmark/java/org/thoughtcrime/securesms/BenchmarkApplicationContext.kt index b47c6aa32d..ddd3ed5895 100644 --- a/app/src/benchmark/java/org/thoughtcrime/securesms/BenchmarkApplicationContext.kt +++ b/app/src/benchmark/java/org/thoughtcrime/securesms/BenchmarkApplicationContext.kt @@ -6,55 +6,13 @@ package org.thoughtcrime.securesms import android.app.Application +import org.signal.benchmark.setup.NoOpJob import org.signal.libsignal.net.Network -import org.thoughtcrime.securesms.database.JobDatabase import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider -import org.thoughtcrime.securesms.jobmanager.Job import org.thoughtcrime.securesms.jobmanager.JobManager -import org.thoughtcrime.securesms.jobmanager.JobMigrator -import org.thoughtcrime.securesms.jobmanager.impl.FactoryJobPredicate -import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob -import org.thoughtcrime.securesms.jobs.ArchiveBackupIdReservationJob -import org.thoughtcrime.securesms.jobs.AttachmentCompressionJob -import org.thoughtcrime.securesms.jobs.AttachmentUploadJob -import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob -import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob -import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob -import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob -import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob -import org.thoughtcrime.securesms.jobs.FastJobStorage -import org.thoughtcrime.securesms.jobs.FontDownloaderJob -import org.thoughtcrime.securesms.jobs.GroupCallUpdateSendJob -import org.thoughtcrime.securesms.jobs.GroupRingCleanupJob -import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob -import org.thoughtcrime.securesms.jobs.IndividualSendJob import org.thoughtcrime.securesms.jobs.JobManagerFactories -import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob -import org.thoughtcrime.securesms.jobs.MarkerJob -import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob -import org.thoughtcrime.securesms.jobs.PostRegistrationBackupRedemptionJob -import org.thoughtcrime.securesms.jobs.PreKeysSyncJob -import org.thoughtcrime.securesms.jobs.ProfileUploadJob -import org.thoughtcrime.securesms.jobs.PushGroupSendJob -import org.thoughtcrime.securesms.jobs.PushProcessMessageJob -import org.thoughtcrime.securesms.jobs.ReactionSendJob -import org.thoughtcrime.securesms.jobs.RefreshAttributesJob -import org.thoughtcrime.securesms.jobs.RefreshSvrCredentialsJob -import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob -import org.thoughtcrime.securesms.jobs.ResetSvrGuessCountJob -import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob -import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob -import org.thoughtcrime.securesms.jobs.RetrieveProfileJob -import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob -import org.thoughtcrime.securesms.jobs.RotateCertificateJob -import org.thoughtcrime.securesms.jobs.SendDeliveryReceiptJob -import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob -import org.thoughtcrime.securesms.jobs.StorageSyncJob -import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob -import org.thoughtcrime.securesms.jobs.TypingSendJob import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor -import org.thoughtcrime.securesms.util.TextSecurePreferences import org.whispersystems.signalservice.api.util.UptimeSleepTimer import org.whispersystems.signalservice.api.websocket.SignalWebSocket import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration @@ -97,85 +55,11 @@ class BenchmarkApplicationContext : ApplicationContext() { ) } - override fun provideJobManager(): JobManager { - val config = JobManager.Configuration.Builder() - .setJobFactories(filterJobFactories(JobManagerFactories.getJobFactories(application))) - .setConstraintFactories(JobManagerFactories.getConstraintFactories(application)) - .setConstraintObservers(JobManagerFactories.getConstraintObservers(application)) - .setJobStorage(FastJobStorage(JobDatabase.getInstance(application))) - .setJobMigrator(JobMigrator(TextSecurePreferences.getJobManagerVersion(application), JobManager.CURRENT_VERSION, JobManagerFactories.getJobMigrations(application))) - .addReservedJobRunner(FactoryJobPredicate(PushProcessMessageJob.KEY, MarkerJob.KEY)) - .addReservedJobRunner(FactoryJobPredicate(AttachmentUploadJob.KEY, AttachmentCompressionJob.KEY)) - .addReservedJobRunner( - FactoryJobPredicate( - IndividualSendJob.KEY, - PushGroupSendJob.KEY, - ReactionSendJob.KEY, - TypingSendJob.KEY, - GroupCallUpdateSendJob.KEY, - SendDeliveryReceiptJob.KEY - ) - ) + override fun provideJobManager(configurationBuilder: JobManager.Configuration.Builder): JobManager { + val config = configurationBuilder + .setJobFactories(NoOpJob.replaceFactories(JobManagerFactories.getJobFactories(application))) .build() return JobManager(application, config) } - - private fun filterJobFactories(jobFactories: Map>): Map> { - val blockedJobs = setOf( - AccountConsistencyWorkerJob.KEY, - ArchiveBackupIdReservationJob.KEY, - AvatarGroupsV2DownloadJob.KEY, - CreateReleaseChannelJob.KEY, - DirectoryRefreshJob.KEY, - DownloadLatestEmojiDataJob.KEY, - EmojiSearchIndexDownloadJob.KEY, - FontDownloaderJob.KEY, - GroupRingCleanupJob.KEY, - GroupV2UpdateSelfProfileKeyJob.KEY, - LinkedDeviceInactiveCheckJob.KEY, - MultiDeviceProfileKeyUpdateJob.KEY, - PostRegistrationBackupRedemptionJob.KEY, - PreKeysSyncJob.KEY, - ProfileUploadJob.KEY, - RefreshAttributesJob.KEY, - RefreshSvrCredentialsJob.KEY, - RequestGroupV2InfoJob.KEY, - ResetSvrGuessCountJob.KEY, - RestoreOptimizedMediaJob.KEY, - RetrieveProfileAvatarJob.KEY, - RetrieveProfileJob.KEY, - RetrieveRemoteAnnouncementsJob.KEY, - RotateCertificateJob.KEY, - StickerPackDownloadJob.KEY, - StorageSyncJob.KEY, - StoryOnboardingDownloadJob.KEY - ) - - return jobFactories.mapValues { - if (it.key in blockedJobs) { - NoOpJob.Factory() - } else { - it.value - } - } - } - } - - private class NoOpJob(parameters: Parameters) : Job(parameters) { - - companion object { - const val KEY = "NoOpJob" - } - - override fun serialize(): ByteArray? = null - override fun getFactoryKey(): String = KEY - override fun run(): Result = Result.success() - override fun onFailure() = Unit - - class Factory : Job.Factory { - override fun create(parameters: Parameters, serializedData: ByteArray?): NoOpJob { - return NoOpJob(parameters) - } - } } } diff --git a/app/src/benchmarkShared/java/org/signal/benchmark/setup/NoOpJob.kt b/app/src/benchmarkShared/java/org/signal/benchmark/setup/NoOpJob.kt new file mode 100644 index 0000000000..707dbe11ff --- /dev/null +++ b/app/src/benchmarkShared/java/org/signal/benchmark/setup/NoOpJob.kt @@ -0,0 +1,84 @@ +package org.signal.benchmark.setup + +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob +import org.thoughtcrime.securesms.jobs.ArchiveBackupIdReservationJob +import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob +import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob +import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob +import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob +import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob +import org.thoughtcrime.securesms.jobs.FontDownloaderJob +import org.thoughtcrime.securesms.jobs.GroupRingCleanupJob +import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob +import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob +import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob +import org.thoughtcrime.securesms.jobs.PostRegistrationBackupRedemptionJob +import org.thoughtcrime.securesms.jobs.PreKeysSyncJob +import org.thoughtcrime.securesms.jobs.ProfileUploadJob +import org.thoughtcrime.securesms.jobs.RefreshAttributesJob +import org.thoughtcrime.securesms.jobs.RefreshSvrCredentialsJob +import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob +import org.thoughtcrime.securesms.jobs.ResetSvrGuessCountJob +import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob +import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob +import org.thoughtcrime.securesms.jobs.RetrieveProfileJob +import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob +import org.thoughtcrime.securesms.jobs.RotateCertificateJob +import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob +import org.thoughtcrime.securesms.jobs.StorageSyncJob +import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob + +/** + * A [Job] that does nothing and always succeeds. Test setups substitute this for jobs whose + * real implementations would hit the network at startup (and so would either generate noise + * against the [DeviceTransferBlockingInterceptor][org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor] + * or fail against unstubbed mocks). Use [replaceFactories] to apply the swap. + */ +class NoOpJob(parameters: Parameters) : Job(parameters) { + override fun serialize(): ByteArray? = null + override fun getFactoryKey(): String = KEY + override fun run(): Result = Result.success() + override fun onFailure() = Unit + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): NoOpJob = NoOpJob(parameters) + } + + companion object { + const val KEY = "NoOpJob" + + private val STARTUP_NETWORK_JOB_KEYS: Set = setOf( + AccountConsistencyWorkerJob.KEY, + ArchiveBackupIdReservationJob.KEY, + AvatarGroupsV2DownloadJob.KEY, + CreateReleaseChannelJob.KEY, + DirectoryRefreshJob.KEY, + DownloadLatestEmojiDataJob.KEY, + EmojiSearchIndexDownloadJob.KEY, + FontDownloaderJob.KEY, + GroupRingCleanupJob.KEY, + GroupV2UpdateSelfProfileKeyJob.KEY, + LinkedDeviceInactiveCheckJob.KEY, + MultiDeviceProfileKeyUpdateJob.KEY, + PostRegistrationBackupRedemptionJob.KEY, + PreKeysSyncJob.KEY, + ProfileUploadJob.KEY, + RefreshAttributesJob.KEY, + RefreshSvrCredentialsJob.KEY, + RequestGroupV2InfoJob.KEY, + ResetSvrGuessCountJob.KEY, + RestoreOptimizedMediaJob.KEY, + RetrieveProfileAvatarJob.KEY, + RetrieveProfileJob.KEY, + RetrieveRemoteAnnouncementsJob.KEY, + RotateCertificateJob.KEY, + StickerPackDownloadJob.KEY, + StorageSyncJob.KEY, + StoryOnboardingDownloadJob.KEY + ) + + fun replaceFactories(factories: Map>): Map> = + factories.mapValues { if (it.key in STARTUP_NETWORK_JOB_KEYS) Factory() else it.value } + } +} diff --git a/app/src/benchmarkShared/java/org/whispersystems/signalservice/internal/websocket/BenchmarkWebSocketConnection.kt b/app/src/benchmarkShared/java/org/whispersystems/signalservice/internal/websocket/BenchmarkWebSocketConnection.kt index 1a118a08c3..3767c28795 100644 --- a/app/src/benchmarkShared/java/org/whispersystems/signalservice/internal/websocket/BenchmarkWebSocketConnection.kt +++ b/app/src/benchmarkShared/java/org/whispersystems/signalservice/internal/websocket/BenchmarkWebSocketConnection.kt @@ -68,6 +68,18 @@ class BenchmarkWebSocketConnection : WebSocketConnection { fun addQueueEmptyMessage() { authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).forEach { it.addQueueEmptyMessage() } } + + fun awaitAllMessagesConsumed(timeoutMs: Long): Boolean { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + val activeInstances = synchronized(this) { authInstances.filterNot(BenchmarkWebSocketConnection::isShutdown).toList() } + if (activeInstances.isNotEmpty() && activeInstances.all { it.incomingRequests.isEmpty() && it.incomingSemaphore.availablePermits() == 0 }) { + return true + } + Thread.sleep(25) + } + return false + } } override val name: String = "bench-${System.identityHashCode(this)}" diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt index 6a0b0c60e3..658de2fc07 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt @@ -132,7 +132,7 @@ object AppDependencies { @JvmStatic val jobManager: JobManager by lazy { - provider.provideJobManager() + provider.provideJobManager(provider.provideJobManagerConfigurationBuilder()) } @JvmStatic @@ -445,7 +445,8 @@ object AppDependencies { fun provideSignalServiceMessageReceiver(pushServiceSocket: PushServiceSocket): SignalServiceMessageReceiver fun provideSignalServiceNetworkAccess(): SignalServiceNetworkAccess fun provideRecipientCache(): LiveRecipientCache - fun provideJobManager(): JobManager + fun provideJobManager(configurationBuilder: JobManager.Configuration.Builder): JobManager + fun provideJobManagerConfigurationBuilder(): JobManager.Configuration.Builder fun provideFrameRateTracker(): FrameRateTracker fun provideMegaphoneRepository(): MegaphoneRepository fun provideEarlyMessageCache(): EarlyMessageCache diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index 4d2795c650..a8aad0a918 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -216,25 +216,28 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider { } @Override - public @NonNull JobManager provideJobManager() { - JobManager.Configuration config = new JobManager.Configuration.Builder() - .setJobFactories(JobManagerFactories.getJobFactories(context)) - .setConstraintFactories(JobManagerFactories.getConstraintFactories(context)) - .setConstraintObservers(JobManagerFactories.getConstraintObservers(context)) - .setJobStorage(new FastJobStorage(JobDatabase.getInstance(context))) - .setJobMigrator(new JobMigrator(TextSecurePreferences.getJobManagerVersion(context), JobManager.CURRENT_VERSION, JobManagerFactories.getJobMigrations(context))) - .addReservedJobRunner(new FactoryJobPredicate(PushProcessMessageJob.KEY, MarkerJob.KEY)) - .addReservedJobRunner(new FactoryJobPredicate(AttachmentUploadJob.KEY, AttachmentCompressionJob.KEY)) - .addReservedJobRunner(new FactoryJobPredicate( - IndividualSendJob.KEY, - PushGroupSendJob.KEY, - ReactionSendJob.KEY, - TypingSendJob.KEY, - GroupCallUpdateSendJob.KEY, - SendDeliveryReceiptJob.KEY - )) - .build(); - return new JobManager(context, config); + public @NonNull JobManager provideJobManager(@NonNull JobManager.Configuration.Builder configurationBuilder) { + return new JobManager(context, configurationBuilder.build()); + } + + @Override + public @NonNull JobManager.Configuration.Builder provideJobManagerConfigurationBuilder() { + return new JobManager.Configuration.Builder() + .setJobFactories(JobManagerFactories.getJobFactories(context)) + .setConstraintFactories(JobManagerFactories.getConstraintFactories(context)) + .setConstraintObservers(JobManagerFactories.getConstraintObservers(context)) + .setJobStorage(new FastJobStorage(JobDatabase.getInstance(context))) + .setJobMigrator(new JobMigrator(TextSecurePreferences.getJobManagerVersion(context), JobManager.CURRENT_VERSION, JobManagerFactories.getJobMigrations(context))) + .addReservedJobRunner(new FactoryJobPredicate(PushProcessMessageJob.KEY, MarkerJob.KEY)) + .addReservedJobRunner(new FactoryJobPredicate(AttachmentUploadJob.KEY, AttachmentCompressionJob.KEY)) + .addReservedJobRunner(new FactoryJobPredicate( + IndividualSendJob.KEY, + PushGroupSendJob.KEY, + ReactionSendJob.KEY, + TypingSendJob.KEY, + GroupCallUpdateSendJob.KEY, + SendDeliveryReceiptJob.KEY + )); } @Override diff --git a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt index dea15aca1e..1a6566cd87 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt @@ -104,7 +104,11 @@ class MockApplicationDependencyProvider : AppDependencies.Provider { return mockk(relaxed = true) } - override fun provideJobManager(): JobManager { + override fun provideJobManager(configurationBuilder: JobManager.Configuration.Builder): JobManager { + return mockk(relaxed = true) + } + + override fun provideJobManagerConfigurationBuilder(): JobManager.Configuration.Builder { return mockk(relaxed = true) }