From 1ef93d72dbd941020e3f585347032b85c3619443 Mon Sep 17 00:00:00 2001 From: Konstantin Ivanov <54908981+konstantiniiv@users.noreply.github.com> Date: Tue, 6 May 2025 11:28:59 +0200 Subject: [PATCH] DROID-3634 Notifications | Handle pushKeyUpdates and store (#2379) --- .../anytype/di/main/NotificationsModule.kt | 47 ++++ .../core_models/chats/PushKeyUpdate.kt | 13 + .../data/auth/event/PushKeyRemoteChannel.kt | 20 ++ .../anytype/domain/chats/PushKeyChannel.kt | 8 + .../events/PushKeyMiddlewareChannel.kt | 58 +++++ .../PushKeyMiddlewareChannelTest.kt | 227 ++++++++++++++++++ .../notifications/PushKeyProviderImpl.kt | 65 +++++ .../notifications/PushKeyProviderImplTest.kt | 181 ++++++++++++++ 8 files changed, 619 insertions(+) create mode 100644 core-models/src/main/java/com/anytypeio/anytype/core_models/chats/PushKeyUpdate.kt create mode 100644 data/src/main/java/com/anytypeio/anytype/data/auth/event/PushKeyRemoteChannel.kt create mode 100644 domain/src/main/java/com/anytypeio/anytype/domain/chats/PushKeyChannel.kt create mode 100644 middleware/src/main/java/com/anytypeio/anytype/middleware/interactor/events/PushKeyMiddlewareChannel.kt create mode 100644 middleware/src/test/java/com/anytypeio/anytype/middleware/interactor/PushKeyMiddlewareChannelTest.kt create mode 100644 presentation/src/main/java/com/anytypeio/anytype/presentation/notifications/PushKeyProviderImpl.kt create mode 100644 presentation/src/test/java/com/anytypeio/anytype/presentation/notifications/PushKeyProviderImplTest.kt diff --git a/app/src/main/java/com/anytypeio/anytype/di/main/NotificationsModule.kt b/app/src/main/java/com/anytypeio/anytype/di/main/NotificationsModule.kt index 763f5f542c..4d5c480bd4 100644 --- a/app/src/main/java/com/anytypeio/anytype/di/main/NotificationsModule.kt +++ b/app/src/main/java/com/anytypeio/anytype/di/main/NotificationsModule.kt @@ -1,16 +1,24 @@ package com.anytypeio.anytype.di.main import android.content.Context +import android.content.SharedPreferences import com.anytypeio.anytype.app.AnytypeNotificationService import com.anytypeio.anytype.data.auth.event.NotificationsDateChannel import com.anytypeio.anytype.data.auth.event.NotificationsRemoteChannel +import com.anytypeio.anytype.data.auth.event.PushKeyDataChannel +import com.anytypeio.anytype.data.auth.event.PushKeyRemoteChannel import com.anytypeio.anytype.domain.account.AwaitAccountStartManager import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers +import com.anytypeio.anytype.domain.chats.PushKeyChannel import com.anytypeio.anytype.domain.notifications.SystemNotificationService import com.anytypeio.anytype.domain.workspace.NotificationsChannel import com.anytypeio.anytype.middleware.EventProxy +import com.anytypeio.anytype.middleware.interactor.EventHandlerChannel import com.anytypeio.anytype.middleware.interactor.NotificationsMiddlewareChannel +import com.anytypeio.anytype.middleware.interactor.events.PushKeyMiddlewareChannel import com.anytypeio.anytype.presentation.notifications.NotificationsProvider +import com.anytypeio.anytype.presentation.notifications.PushKeyProvider +import com.anytypeio.anytype.presentation.notifications.PushKeyProviderImpl import dagger.Module import dagger.Provides import javax.inject.Named @@ -59,4 +67,43 @@ object NotificationsModule { notificationsChannel = notificationsChannel, awaitAccountStartManager = awaitAccountStartManager, ) + + @JvmStatic + @Provides + @Singleton + fun providePushKeyProvider( + @Named("encrypted") sharedPreferences: SharedPreferences, + dispatchers: AppCoroutineDispatchers, + @Named(ConfigModule.DEFAULT_APP_COROUTINE_SCOPE) scope: CoroutineScope, + channel: PushKeyChannel + ): PushKeyProvider { + return PushKeyProviderImpl( + sharedPreferences = sharedPreferences, + dispatchers = dispatchers, + scope = scope, + channel = channel + ) + } + + @JvmStatic + @Provides + @Singleton + fun providePushKeyRemoteChannel( + channel: EventHandlerChannel, + @Named(ConfigModule.DEFAULT_APP_COROUTINE_SCOPE) scope: CoroutineScope, + dispatchers: AppCoroutineDispatchers + ): PushKeyRemoteChannel = PushKeyMiddlewareChannel( + channel = channel, + scope = scope, + dispatcher = dispatchers.io + ) + + @JvmStatic + @Provides + @Singleton + fun providePushKeyChannel( + channel: PushKeyRemoteChannel + ): PushKeyChannel = PushKeyDataChannel( + channel = channel + ) } \ No newline at end of file diff --git a/core-models/src/main/java/com/anytypeio/anytype/core_models/chats/PushKeyUpdate.kt b/core-models/src/main/java/com/anytypeio/anytype/core_models/chats/PushKeyUpdate.kt new file mode 100644 index 0000000000..8ae1077f3d --- /dev/null +++ b/core-models/src/main/java/com/anytypeio/anytype/core_models/chats/PushKeyUpdate.kt @@ -0,0 +1,13 @@ +package com.anytypeio.anytype.core_models.chats + +data class PushKeyUpdate( + val encryptionKeyId: String, + val encryptionKey: String +) { + companion object { + val EMPTY = PushKeyUpdate( + encryptionKeyId = "", + encryptionKey = "" + ) + } +} \ No newline at end of file diff --git a/data/src/main/java/com/anytypeio/anytype/data/auth/event/PushKeyRemoteChannel.kt b/data/src/main/java/com/anytypeio/anytype/data/auth/event/PushKeyRemoteChannel.kt new file mode 100644 index 0000000000..d29f275ca6 --- /dev/null +++ b/data/src/main/java/com/anytypeio/anytype/data/auth/event/PushKeyRemoteChannel.kt @@ -0,0 +1,20 @@ +package com.anytypeio.anytype.data.auth.event + +import com.anytypeio.anytype.core_models.chats.PushKeyUpdate +import com.anytypeio.anytype.domain.chats.PushKeyChannel +import kotlinx.coroutines.flow.Flow + +interface PushKeyRemoteChannel { + fun start() + fun stop() + fun observe(): Flow +} + +class PushKeyDataChannel( + private val channel: PushKeyRemoteChannel +) : PushKeyChannel { + + override fun observe(): Flow { + return channel.observe() + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/anytypeio/anytype/domain/chats/PushKeyChannel.kt b/domain/src/main/java/com/anytypeio/anytype/domain/chats/PushKeyChannel.kt new file mode 100644 index 0000000000..30d0983e4e --- /dev/null +++ b/domain/src/main/java/com/anytypeio/anytype/domain/chats/PushKeyChannel.kt @@ -0,0 +1,8 @@ +package com.anytypeio.anytype.domain.chats + +import com.anytypeio.anytype.core_models.chats.PushKeyUpdate +import kotlinx.coroutines.flow.Flow + +interface PushKeyChannel { + fun observe(): Flow +} \ No newline at end of file diff --git a/middleware/src/main/java/com/anytypeio/anytype/middleware/interactor/events/PushKeyMiddlewareChannel.kt b/middleware/src/main/java/com/anytypeio/anytype/middleware/interactor/events/PushKeyMiddlewareChannel.kt new file mode 100644 index 0000000000..1195d03168 --- /dev/null +++ b/middleware/src/main/java/com/anytypeio/anytype/middleware/interactor/events/PushKeyMiddlewareChannel.kt @@ -0,0 +1,58 @@ +package com.anytypeio.anytype.middleware.interactor.events + +import com.anytypeio.anytype.core_models.chats.PushKeyUpdate +import com.anytypeio.anytype.core_utils.ext.cancel +import com.anytypeio.anytype.data.auth.event.PushKeyRemoteChannel +import com.anytypeio.anytype.middleware.interactor.EventHandlerChannel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.launch +import timber.log.Timber + +class PushKeyMiddlewareChannel( + private val scope: CoroutineScope, + private val channel: EventHandlerChannel, + private val dispatcher: CoroutineDispatcher +) : PushKeyRemoteChannel { + + private val jobs = mutableListOf() + + private val _pushKeyStatus = MutableStateFlow(PushKeyUpdate.EMPTY) + val pushKeyStatus: Flow = _pushKeyStatus.asStateFlow() + + override fun start() { + Timber.i("PushKeyMiddlewareChannel start") + jobs.cancel() + jobs += scope.launch(dispatcher) { + channel.flow() + .catch { + Timber.w(it, "Error collecting push key updates") + } + .collect { emission -> + emission.messages.forEach { message -> + message.pushEncryptionKeyUpdate?.let { + val pushKeyUpdate = PushKeyUpdate( + encryptionKeyId = it.encryptionKeyId, + encryptionKey = it.encryptionKey + ) + _pushKeyStatus.value = pushKeyUpdate + } + } + } + } + } + + override fun stop() { + Timber.i("PushKeyMiddlewareChannel stop") + jobs.cancel() + } + + override fun observe(): Flow { + return pushKeyStatus + } +} \ No newline at end of file diff --git a/middleware/src/test/java/com/anytypeio/anytype/middleware/interactor/PushKeyMiddlewareChannelTest.kt b/middleware/src/test/java/com/anytypeio/anytype/middleware/interactor/PushKeyMiddlewareChannelTest.kt new file mode 100644 index 0000000000..486891767e --- /dev/null +++ b/middleware/src/test/java/com/anytypeio/anytype/middleware/interactor/PushKeyMiddlewareChannelTest.kt @@ -0,0 +1,227 @@ +package com.anytypeio.anytype.middleware.interactor + +import app.cash.turbine.test +import app.cash.turbine.turbineScope +import com.anytypeio.anytype.core_models.chats.PushKeyUpdate +import com.anytypeio.anytype.middleware.interactor.events.PushKeyMiddlewareChannel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import net.bytebuddy.utility.RandomString +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class PushKeyMiddlewareChannelTest { + + private val dispatcher = StandardTestDispatcher(name = "Default test dispatcher") + private val testScope = TestScope(dispatcher) + lateinit var eventHandlerChannel: EventHandlerChannel + private lateinit var channel: PushKeyMiddlewareChannel + + @Before + fun setup() { + eventHandlerChannel = EventHandlerChannelImpl() + channel = PushKeyMiddlewareChannel( + scope = testScope, + channel = eventHandlerChannel, + dispatcher = dispatcher + ) + } + + @Test + fun `should emit empty initially`() = runTest { + // Given + val initialValue = channel.observe().first() + + // Then + assertEquals(PushKeyUpdate.EMPTY, initialValue) + } + + @Test + fun `should emit push key update when receiving two valid events`() = runTest(dispatcher) { + + turbineScope { + // Given + val expectedUpdate1 = PushKeyUpdate( + encryptionKeyId = RandomString.make(), + encryptionKey = RandomString.make(), + ) + + val event1 = anytype.Event( + messages = listOf( + anytype.Event.Message( + pushEncryptionKeyUpdate = anytype.Event.PushEncryptionKey.Update( + encryptionKeyId = expectedUpdate1.encryptionKeyId, + encryptionKey = expectedUpdate1.encryptionKey + ) + ) + ) + ) + + val expectedUpdate2 = PushKeyUpdate( + encryptionKeyId = RandomString.make(), + encryptionKey = RandomString.make() + ) + + val event2 = anytype.Event( + messages = listOf( + anytype.Event.Message( + pushEncryptionKeyUpdate = anytype.Event.PushEncryptionKey.Update( + encryptionKeyId = expectedUpdate2.encryptionKeyId, + encryptionKey = expectedUpdate2.encryptionKey + ) + ) + ) + ) + + // When + channel.start() + dispatcher.scheduler.advanceUntilIdle() + + // Then + channel.observe().test { + + val emittedValue = awaitItem() + assertEquals(PushKeyUpdate.EMPTY, emittedValue) + + eventHandlerChannel.emit(event1) + dispatcher.scheduler.advanceUntilIdle() + + val update1 = awaitItem() + assertEquals(expectedUpdate1, update1) + + eventHandlerChannel.emit(event2) + dispatcher.scheduler.advanceUntilIdle() + + val update2 = awaitItem() + assertEquals(expectedUpdate2, update2) + + ensureAllEventsConsumed() + } + } + } + + @Test + fun `should not emit update when receiving invalid event`() = runTest(dispatcher) { + turbineScope { + // Given + + val event = anytype.Event( + messages = listOf( + anytype.Event.Message( + p2pStatusUpdate = anytype.Event.P2PStatus.Update( + spaceId = RandomString.make() + ) + ) + ) + ) + + // When + channel.start() + dispatcher.scheduler.advanceUntilIdle() + + // Then + channel.observe().test { + + val emittedValue = awaitItem() + assertEquals(PushKeyUpdate.EMPTY, emittedValue) + + eventHandlerChannel.emit(event) + dispatcher.scheduler.advanceUntilIdle() + + ensureAllEventsConsumed() + } + } + } + + @Test + fun `should stop processing events after stop is called`() = runTest(dispatcher) { + + turbineScope { + // Given + + val expectedUpdate = PushKeyUpdate( + encryptionKeyId = RandomString.make(), + encryptionKey = RandomString.make(), + ) + + val event = anytype.Event( + messages = listOf( + anytype.Event.Message( + pushEncryptionKeyUpdate = anytype.Event.PushEncryptionKey.Update( + encryptionKeyId = expectedUpdate.encryptionKeyId, + encryptionKey = expectedUpdate.encryptionKey + ) + ) + ) + ) + + // When + channel.start() + channel.stop() + dispatcher.scheduler.advanceUntilIdle() + + // Then + channel.observe().test { + + val emittedValue = awaitItem() + assertEquals(PushKeyUpdate.EMPTY, emittedValue) + + eventHandlerChannel.emit(event) + dispatcher.scheduler.advanceUntilIdle() + + ensureAllEventsConsumed() + } + } + } + + @Test + fun `should handle multiple messages in single emission`() = runTest { + + turbineScope { + turbineScope { + // Given + val expectedUpdate1 = PushKeyUpdate( + encryptionKeyId = RandomString.make(), + encryptionKey = RandomString.make(), + ) + + val event1 = anytype.Event( + messages = listOf( + anytype.Event.Message(pushEncryptionKeyUpdate = null), + anytype.Event.Message( + pushEncryptionKeyUpdate = anytype.Event.PushEncryptionKey.Update( + encryptionKeyId = expectedUpdate1.encryptionKeyId, + encryptionKey = expectedUpdate1.encryptionKey + ) + ), + anytype.Event.Message(pushEncryptionKeyUpdate = null), + ) + ) + + // When + channel.start() + dispatcher.scheduler.advanceUntilIdle() + + // Then + channel.observe().test { + + val emittedValue = awaitItem() + assertEquals(PushKeyUpdate.EMPTY, emittedValue) + + eventHandlerChannel.emit(event1) + dispatcher.scheduler.advanceUntilIdle() + + val update1 = awaitItem() + assertEquals(expectedUpdate1, update1) + + ensureAllEventsConsumed() + } + } + } + } +} \ No newline at end of file diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/notifications/PushKeyProviderImpl.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/notifications/PushKeyProviderImpl.kt new file mode 100644 index 0000000000..fa7f4ae310 --- /dev/null +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/notifications/PushKeyProviderImpl.kt @@ -0,0 +1,65 @@ +package com.anytypeio.anytype.presentation.notifications + +import android.content.SharedPreferences +import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers +import com.anytypeio.anytype.domain.chats.PushKeyChannel +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber + +interface PushKeyProvider { + fun getPushKey(): PushKey +} + +data class PushKey( + val key: String, + val id: String +) { + companion object { + val EMPTY = PushKey(key = "", id = "") + } +} + +class PushKeyProviderImpl @Inject constructor( + private val sharedPreferences: SharedPreferences, + private val channel: PushKeyChannel, + dispatchers: AppCoroutineDispatchers, + scope: CoroutineScope +) : PushKeyProvider { + + init { + Timber.d("PushKeyProvider initialized") + scope.launch(dispatchers.io) { + channel + .observe() + .collect { event -> + Timber.d("New push key updates: $event") + savePushKey( + pushKey = event.encryptionKey, + pushKeyId = event.encryptionKeyId + ) + } + } + } + + private fun savePushKey(pushKey: String?, pushKeyId: String?) { + sharedPreferences.edit().apply { + putString(PREF_PUSH_KEY, pushKey) + putString(PREF_PUSH_KEY_ID, pushKeyId) + apply() + } + } + + override fun getPushKey(): PushKey { + val pushKey = sharedPreferences.getString(PREF_PUSH_KEY, "") ?: "" + val pushKeyId = sharedPreferences.getString(PREF_PUSH_KEY_ID, "") ?: "" + Timber.d("PushKeyProvider getPushKey: $pushKey, $pushKeyId") + return PushKey(key = pushKey, id = pushKeyId) + } + + companion object { + const val PREF_PUSH_KEY = "pref.push_key" + const val PREF_PUSH_KEY_ID = "pref.push_key_id" + } +} diff --git a/presentation/src/test/java/com/anytypeio/anytype/presentation/notifications/PushKeyProviderImplTest.kt b/presentation/src/test/java/com/anytypeio/anytype/presentation/notifications/PushKeyProviderImplTest.kt new file mode 100644 index 0000000000..09aed908b1 --- /dev/null +++ b/presentation/src/test/java/com/anytypeio/anytype/presentation/notifications/PushKeyProviderImplTest.kt @@ -0,0 +1,181 @@ +package com.anytypeio.anytype.presentation.notifications + +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import com.anytypeio.anytype.core_models.chats.PushKeyUpdate +import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers +import com.anytypeio.anytype.domain.chats.PushKeyChannel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.stub +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@ExperimentalCoroutinesApi +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P]) +class PushKeyProviderImplTest { + + @Mock + private lateinit var mockChannel: PushKeyChannel + + private lateinit var sharedPreferences: SharedPreferences + private val dispatcher = StandardTestDispatcher(name = "Default test dispatcher") + private val testScope = TestScope(dispatcher) + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + // Use a real SharedPreferences from Robolectric + val context = ApplicationProvider.getApplicationContext() + sharedPreferences = context.getSharedPreferences("test_prefs", Context.MODE_PRIVATE) + + // Clear shared preferences before each test + sharedPreferences.edit().clear().apply() + } + + @After + fun tearDown() { + // Clean up coroutines + testScope.cancel() + // Clear shared preferences after each test + sharedPreferences.edit().clear().apply() + } + + @Test + fun `getPushKey should return empty PushKey when no key is set`() = runTest { + val pushKeyProvider = createPushKeyProvider() + val pushKey = pushKeyProvider.getPushKey() + assertTrue(pushKey == PushKey.EMPTY) + } + + @Test + fun `savePushKey should store the key and keyId in shared preferences when channel emits`() = + runTest(dispatcher) { + val pushKey = "test_push_key" + val pushKeyId = "test_push_key_id" + + // Simulate the event emission from the channel + val channelFlow = MutableSharedFlow(replay = 0) + mockChannel.stub { + on { observe() } doReturn channelFlow + } + + createPushKeyProvider() // This will start observing the channel + dispatcher.scheduler.advanceUntilIdle() // Allow the observation coroutine to run + + // Emit the event + channelFlow.emit(PushKeyUpdate(encryptionKey = pushKey, encryptionKeyId = pushKeyId)) + dispatcher.scheduler.advanceUntilIdle() // Allow the processing coroutine to run + + // Verify the stored values in SharedPreferences + val storedKey = sharedPreferences.getString(PushKeyProviderImpl.PREF_PUSH_KEY, null) + val storedKeyId = + sharedPreferences.getString(PushKeyProviderImpl.PREF_PUSH_KEY_ID, null) + + assertEquals(pushKey, storedKey) + assertEquals(pushKeyId, storedKeyId) + } + + @Test + fun `getPushKey should return the stored key when a key is set`() = runTest { + val pushKey = PushKey(key = "test_push_key", id = "test_push_key_id") + sharedPreferences.edit() + .putString(PushKeyProviderImpl.PREF_PUSH_KEY_ID, pushKey.id) + .apply() + sharedPreferences.edit() + .putString(PushKeyProviderImpl.PREF_PUSH_KEY, pushKey.key) + .apply() + + val pushKeyProvider = createPushKeyProvider() + val retrievedKey = pushKeyProvider.getPushKey() + + assertEquals(pushKey, retrievedKey) + } + + @Test + fun `observation should update shared preferences on subsequent channel emissions`() = + runTest(dispatcher) { + val initialKey = "initial_key" + val initialKeyId = "initial_key_id" + val updatedKey = "updated_key" + val updatedKeyId = "updated_key_id" + + // Manually control the flow emission + val channelFlow = MutableSharedFlow(replay = 0) + mockChannel.stub { + on { observe() } doReturn channelFlow + } + + createPushKeyProvider() // Start observing + dispatcher.scheduler.advanceUntilIdle() // Ensure observation is set up + + // Emit initial event + channelFlow.emit( + PushKeyUpdate( + encryptionKey = initialKey, + encryptionKeyId = initialKeyId + ) + ) + dispatcher.scheduler.advanceUntilIdle() // Process emission + + // Verify initial storage + assertEquals( + initialKey, + sharedPreferences.getString(PushKeyProviderImpl.PREF_PUSH_KEY, null) + ) + assertEquals( + initialKeyId, + sharedPreferences.getString(PushKeyProviderImpl.PREF_PUSH_KEY_ID, null) + ) + + // Emit updated event + channelFlow.emit( + PushKeyUpdate( + encryptionKey = updatedKey, + encryptionKeyId = updatedKeyId + ) + ) + dispatcher.scheduler.advanceUntilIdle() // Process emission + + // Verify updated storage + assertEquals( + updatedKey, + sharedPreferences.getString(PushKeyProviderImpl.PREF_PUSH_KEY, null) + ) + assertEquals( + updatedKeyId, + sharedPreferences.getString(PushKeyProviderImpl.PREF_PUSH_KEY_ID, null) + ) + } + + private fun createPushKeyProvider(): PushKeyProviderImpl { + return PushKeyProviderImpl( + sharedPreferences = sharedPreferences, + channel = mockChannel, + dispatchers = AppCoroutineDispatchers( + io = dispatcher, + main = dispatcher, + computation = dispatcher + ), + scope = testScope + ) + } + +} \ No newline at end of file