From 3b9c2698966db8899372428b0d7f63ffe610ee74 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 26 Jan 2026 16:42:44 +0000 Subject: [PATCH 01/22] MS-1299 Sync revamp: SyncUseCase input/output signature finalized --- .../feature/dashboard/debug/DebugFragment.kt | 4 +- .../dashboard/logout/LogoutSyncViewModel.kt | 4 +- .../settings/syncinfo/SyncInfoViewModel.kt | 4 +- .../usecase/ObserveSyncInfoUseCase.kt | 4 +- .../logout/LogoutSyncViewModelTest.kt | 8 +- .../syncinfo/SyncInfoViewModelTest.kt | 8 +- .../usecase/ObserveSyncInfoUseCaseTest.kt | 7 +- .../usecase/RunBlockingEventSyncUseCase.kt | 6 +- .../usecase/ShouldSuggestSyncUseCase.kt | 4 +- .../RunBlockingEventSyncUseCaseTest.kt | 26 ++-- .../usecase/ShouldSuggestSyncUseCaseTest.kt | 8 +- .../com/simprints/infra/sync/SyncCommand.kt | 3 - .../com/simprints/infra/sync/SyncCommands.kt | 127 ++++++++++++++++++ .../com/simprints/infra/sync/SyncResponse.kt | 11 ++ .../infra/sync/usecase/SyncUseCase.kt | 51 ++++++- .../infra/sync/usecase/SyncUseCaseTest.kt | 18 +-- 16 files changed, 250 insertions(+), 43 deletions(-) delete mode 100644 infra/sync/src/main/java/com/simprints/infra/sync/SyncCommand.kt create mode 100644 infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt create mode 100644 infra/sync/src/main/java/com/simprints/infra/sync/SyncResponse.kt diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt index 4621022cd6..77e4d79c85 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt @@ -19,7 +19,7 @@ import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepositor import com.simprints.infra.events.EventRepository import com.simprints.infra.eventsync.EventSyncManager import com.simprints.infra.eventsync.status.models.EventSyncWorkerState -import com.simprints.infra.sync.SyncCommand +import com.simprints.infra.sync.SyncCommands import com.simprints.infra.sync.SyncOrchestrator import com.simprints.infra.sync.usecase.SyncUseCase import com.simprints.infra.uibase.view.applySystemBarInsets @@ -67,7 +67,7 @@ internal class DebugFragment : Fragment(R.layout.fragment_debug) { super.onViewCreated(view, savedInstanceState) applySystemBarInsets(view) - sync(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) + sync(SyncCommands.ObserveOnly).syncStatusFlow .map { it.eventSyncState }.asLiveData() diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModel.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModel.kt index ee2c674b95..2c16a4c299 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModel.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModel.kt @@ -10,7 +10,7 @@ import com.simprints.feature.dashboard.logout.usecase.LogoutUseCase import com.simprints.infra.authstore.AuthStore import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.SettingsPasswordConfig -import com.simprints.infra.sync.SyncCommand +import com.simprints.infra.sync.SyncCommands import com.simprints.infra.sync.usecase.SyncUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.debounce @@ -36,7 +36,7 @@ internal class LogoutSyncViewModel @Inject constructor( .asLiveData(viewModelScope.coroutineContext) val isLogoutWithoutSyncVisibleLiveData: LiveData = - sync(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) + sync(SyncCommands.ObserveOnly).syncStatusFlow .map { syncStatus -> !syncStatus.eventSyncState.isSyncCompleted() || syncStatus.imageSyncStatus.isSyncing }.debounce(timeoutMillis = ANTI_JITTER_DELAY_MILLIS) diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt index f083d2ffdd..ae44bbb06d 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt @@ -16,7 +16,7 @@ import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.ProjectState import com.simprints.infra.config.store.models.isModuleSelectionAvailable import com.simprints.infra.recent.user.activity.RecentUserActivityManager -import com.simprints.infra.sync.SyncCommand +import com.simprints.infra.sync.SyncCommands import com.simprints.infra.sync.SyncOrchestrator import com.simprints.infra.sync.usecase.SyncUseCase import dagger.hilt.android.lifecycle.HiltViewModel @@ -57,7 +57,7 @@ internal class SyncInfoViewModel @Inject constructor( get() = _loginNavigationEventLiveData private val _loginNavigationEventLiveData = MutableLiveData() - private val syncStatusFlow = sync(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) + private val syncStatusFlow = sync(SyncCommands.ObserveOnly).syncStatusFlow private val eventSyncStateFlow = syncStatusFlow.map { it.eventSyncState } private val imageSyncStatusFlow = diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCase.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCase.kt index 9cde07fe60..8dfe22858a 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCase.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCase.kt @@ -23,7 +23,7 @@ import com.simprints.infra.config.store.models.isSimprintsEventDownSyncAllowed import com.simprints.infra.eventsync.permission.CommCarePermissionChecker import com.simprints.infra.eventsync.status.models.DownSyncCounts import com.simprints.infra.network.ConnectivityTracker -import com.simprints.infra.sync.SyncCommand +import com.simprints.infra.sync.SyncCommands import com.simprints.infra.sync.usecase.ObserveSyncableCountsUseCase import com.simprints.infra.sync.usecase.SyncUseCase import kotlinx.coroutines.CoroutineDispatcher @@ -58,7 +58,7 @@ internal class ObserveSyncInfoUseCase @Inject constructor( operator fun invoke(isPreLogoutUpSync: Boolean = false): Flow = combine( combinedRefreshSignals(), authStore.observeSignedInProjectId(), - sync(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly), + sync(SyncCommands.ObserveOnly).syncStatusFlow, observeSyncableCounts(), observeConfigurationFlow(), ) { isOnline, projectId, (eventSyncState, imageSyncStatus), counts, (isRefreshing, isProjectRunning, moduleCounts, projectConfig) -> diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModelTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModelTest.kt index 731a976c6c..5804938bd1 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModelTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModelTest.kt @@ -8,6 +8,8 @@ import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.SettingsPasswordConfig import com.simprints.infra.eventsync.status.models.EventSyncState import com.simprints.infra.sync.ImageSyncStatus +import com.simprints.infra.sync.SyncCommands +import com.simprints.infra.sync.SyncResponse import com.simprints.infra.sync.SyncStatus import com.simprints.infra.sync.usecase.SyncUseCase import com.simprints.testtools.common.coroutines.TestCoroutineRule @@ -15,6 +17,7 @@ import com.simprints.testtools.common.livedata.getOrAwaitValue import io.mockk.* import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -140,7 +143,10 @@ internal class LogoutSyncViewModelTest { imageSyncStatus: ImageSyncStatus, ) { val statusFlow = MutableStateFlow(SyncStatus(eventSyncState = eventSyncState, imageSyncStatus = imageSyncStatus)) - every { sync.invoke(any(), any()) } returns statusFlow + every { sync.invoke(SyncCommands.ObserveOnly) } returns SyncResponse( + syncCommandJob = Job().apply { complete() }, + syncStatusFlow = statusFlow, + ) } private fun createViewModel() = LogoutSyncViewModel( diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModelTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModelTest.kt index 47b9e64965..18dcf9d881 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModelTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModelTest.kt @@ -23,7 +23,9 @@ import com.simprints.infra.config.store.models.isSimprintsEventDownSyncAllowed import com.simprints.infra.eventsync.status.models.EventSyncState import com.simprints.infra.recent.user.activity.RecentUserActivityManager import com.simprints.infra.sync.ImageSyncStatus +import com.simprints.infra.sync.SyncCommands import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.SyncResponse import com.simprints.infra.sync.SyncStatus import com.simprints.infra.sync.usecase.SyncUseCase import com.simprints.testtools.common.coroutines.TestCoroutineRule @@ -32,6 +34,7 @@ import com.simprints.testtools.common.livedata.getOrAwaitValues import io.mockk.* import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf @@ -147,7 +150,10 @@ class SyncInfoViewModelTest { syncStatusFlow = MutableStateFlow( SyncStatus(eventSyncState = mockEventSyncState, imageSyncStatus = mockImageSyncStatus), ) - every { sync.invoke(any(), any()) } returns syncStatusFlow + every { sync.invoke(SyncCommands.ObserveOnly) } returns SyncResponse( + syncCommandJob = Job().apply { complete() }, + syncStatusFlow = syncStatusFlow, + ) coEvery { syncOrchestrator.startEventSync(any()) } returns Unit coEvery { syncOrchestrator.stopEventSync() } returns Unit coEvery { syncOrchestrator.startImageSync() } returns Unit diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCaseTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCaseTest.kt index 832c394ad9..0813bcbc32 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCaseTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCaseTest.kt @@ -23,12 +23,14 @@ import com.simprints.infra.eventsync.permission.CommCarePermissionChecker import com.simprints.infra.eventsync.status.models.EventSyncState import com.simprints.infra.network.ConnectivityTracker import com.simprints.infra.sync.ImageSyncStatus +import com.simprints.infra.sync.SyncResponse import com.simprints.infra.sync.SyncStatus import com.simprints.infra.sync.SyncableCounts import com.simprints.infra.sync.usecase.ObserveSyncableCountsUseCase import com.simprints.infra.sync.usecase.SyncUseCase import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.* +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf @@ -131,7 +133,10 @@ internal class ObserveSyncInfoUseCaseTest { every { connectivityTracker.observeIsConnected() } returns flowOf(true) syncStatusFlow.value = SyncStatus(eventSyncState = mockEventSyncState, imageSyncStatus = mockImageSyncStatus) - every { sync.invoke(any(), any()) } returns syncStatusFlow + every { sync.invoke(any()) } returns SyncResponse( + syncCommandJob = Job().apply { complete() }, + syncStatusFlow = syncStatusFlow, + ) every { mockEventSyncState.lastSyncTime } returns TEST_TIMESTAMP syncableCountsFlow.value = SyncableCounts( diff --git a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt index 890f9caeb3..af46d0ffc8 100644 --- a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt @@ -1,6 +1,6 @@ package com.simprints.feature.validatepool.usecase -import com.simprints.infra.sync.SyncCommand +import com.simprints.infra.sync.SyncCommands import com.simprints.infra.sync.SyncOrchestrator import com.simprints.infra.sync.usecase.SyncUseCase import kotlinx.coroutines.flow.firstOrNull @@ -14,13 +14,13 @@ internal class RunBlockingEventSyncUseCase @Inject constructor( suspend operator fun invoke() { // First item in the flow (except uninitialized) is the state of last sync, // so it can be used to as a filter out old sync states - val lastSyncId = sync(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) + val lastSyncId = sync(SyncCommands.ObserveOnly).syncStatusFlow .map { it.eventSyncState } .firstOrNull { !it.isUninitialized() } ?.syncId syncOrchestrator.startEventSync() - sync(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) + sync(SyncCommands.ObserveOnly).syncStatusFlow .map { it.eventSyncState } .firstOrNull { it.syncId != lastSyncId && it.isSyncReporterCompleted() } } diff --git a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCase.kt b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCase.kt index 19e538b1fe..590bd0676f 100644 --- a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCase.kt +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCase.kt @@ -2,7 +2,7 @@ package com.simprints.feature.validatepool.usecase import com.simprints.core.tools.time.TimeHelper import com.simprints.infra.config.store.ConfigRepository -import com.simprints.infra.sync.SyncCommand +import com.simprints.infra.sync.SyncCommands import com.simprints.infra.sync.usecase.SyncUseCase import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map @@ -14,7 +14,7 @@ internal class ShouldSuggestSyncUseCase @Inject constructor( private val sync: SyncUseCase, private val configRepository: ConfigRepository, ) { - suspend operator fun invoke(): Boolean = sync(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) + suspend operator fun invoke(): Boolean = sync(SyncCommands.ObserveOnly).syncStatusFlow .map { it.eventSyncState } .firstOrNull() ?.lastSyncTime diff --git a/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt index 8b32684f08..716a2b21d6 100644 --- a/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt +++ b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt @@ -5,14 +5,17 @@ import com.simprints.infra.eventsync.status.models.EventSyncState import com.simprints.infra.eventsync.status.models.EventSyncWorkerState import com.simprints.infra.eventsync.status.models.EventSyncWorkerType import com.simprints.infra.sync.ImageSyncStatus -import com.simprints.infra.sync.SyncCommand +import com.simprints.infra.sync.SyncCommands import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.SyncResponse import com.simprints.infra.sync.SyncStatus import com.simprints.infra.sync.usecase.SyncUseCase import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.* import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import org.junit.Before @@ -49,7 +52,7 @@ class RunBlockingEventSyncUseCaseTest { @Test fun `finishes execution when sync reporters are finished`() = runTest { val syncFlow = MutableStateFlow(createSyncStatus("oldSync", EventSyncWorkerState.Succeeded)) - every { sync.invoke(any(), any()) } returns syncFlow + setUpSync(syncFlow) launch { usecase.invoke() } testScheduler.advanceUntilIdle() @@ -58,13 +61,13 @@ class RunBlockingEventSyncUseCaseTest { testScheduler.advanceUntilIdle() coVerify { syncOrchestrator.startEventSync(any()) } - verify(exactly = 2) { sync.invoke(SyncCommand.ObserveOnly, SyncCommand.ObserveOnly) } + verify(exactly = 2) { sync.invoke(SyncCommands.ObserveOnly) } } @Test fun `finishes execution when sync reporters have failed`() = runTest { val syncFlow = MutableStateFlow(createSyncStatus("oldSync", EventSyncWorkerState.Succeeded)) - every { sync.invoke(any(), any()) } returns syncFlow + setUpSync(syncFlow) launch { usecase.invoke() } testScheduler.advanceUntilIdle() @@ -73,13 +76,13 @@ class RunBlockingEventSyncUseCaseTest { testScheduler.advanceUntilIdle() coVerify { syncOrchestrator.startEventSync(any()) } - verify(exactly = 2) { sync.invoke(SyncCommand.ObserveOnly, SyncCommand.ObserveOnly) } + verify(exactly = 2) { sync.invoke(SyncCommands.ObserveOnly) } } @Test fun `finishes execution when sync reporters have been cancelled`() = runTest { val syncFlow = MutableStateFlow(createSyncStatus("oldSync", EventSyncWorkerState.Succeeded)) - every { sync.invoke(any(), any()) } returns syncFlow + setUpSync(syncFlow) launch { usecase.invoke() } testScheduler.advanceUntilIdle() @@ -88,13 +91,13 @@ class RunBlockingEventSyncUseCaseTest { testScheduler.advanceUntilIdle() coVerify { syncOrchestrator.startEventSync(any()) } - verify(exactly = 2) { sync.invoke(SyncCommand.ObserveOnly, SyncCommand.ObserveOnly) } + verify(exactly = 2) { sync.invoke(SyncCommands.ObserveOnly) } } @Test fun `does not start sync early when initial default state is emitted before last completed sync`() = runTest { val syncFlow = MutableStateFlow(createPlaceholderSyncStatus()) - every { sync.invoke(any(), any()) } returns syncFlow + setUpSync(syncFlow) val job = launch { usecase.invoke() } testScheduler.advanceUntilIdle() @@ -133,5 +136,12 @@ class RunBlockingEventSyncUseCaseTest { ) } + private fun setUpSync(syncFlow: StateFlow) { + every { sync.invoke(SyncCommands.ObserveOnly) } returns SyncResponse( + syncCommandJob = Job().apply { complete() }, + syncStatusFlow = syncFlow, + ) + } + private fun createPlaceholderSyncStatus(): SyncStatus = createSyncStatus("", null, null, null) } diff --git a/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCaseTest.kt b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCaseTest.kt index 2f6a55a360..460a0b908d 100644 --- a/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCaseTest.kt +++ b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCaseTest.kt @@ -6,12 +6,15 @@ import com.simprints.core.tools.time.Timestamp import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.eventsync.status.models.EventSyncState import com.simprints.infra.sync.ImageSyncStatus +import com.simprints.infra.sync.SyncCommands +import com.simprints.infra.sync.SyncResponse import com.simprints.infra.sync.SyncStatus import com.simprints.infra.sync.usecase.SyncUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.every import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Before @@ -35,7 +38,10 @@ class ShouldSuggestSyncUseCaseTest { MockKAnnotations.init(this) syncStatusFlow = MutableStateFlow(createSyncStatus(lastSyncTime = null)) - every { sync.invoke(any(), any()) } returns syncStatusFlow + every { sync.invoke(SyncCommands.ObserveOnly) } returns SyncResponse( + syncCommandJob = Job().apply { complete() }, + syncStatusFlow = syncStatusFlow, + ) usecase = ShouldSuggestSyncUseCase(timeHelper, sync, configRepository) } diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommand.kt b/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommand.kt deleted file mode 100644 index 0ba182e13d..0000000000 --- a/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommand.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.simprints.infra.sync - -enum class SyncCommand { ObserveOnly } diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt b/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt new file mode 100644 index 0000000000..dfa4ad1a39 --- /dev/null +++ b/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt @@ -0,0 +1,127 @@ +package com.simprints.infra.sync + +/** + * Builders for sync control instructions passed to SyncUseCase. + * + * To construct a sync command, + * Start with SyncCommands., and the rest is reachable in a structured way, with appropriate branching and params. + * + * See also SyncUseCase.invoke. + */ +object SyncCommands { + + object ObserveOnly : SyncCommand() + + object OneTime { + val Events = buildSyncCommandsWithDownSyncParam(SyncTarget.ONE_TIME_EVENTS) + val Images = buildSyncCommands(SyncTarget.ONE_TIME_IMAGES) + } + + object Schedule { + val Everything = buildSyncCommandsWithDelayParam(SyncTarget.SCHEDULE_EVERYTHING) + val Events = buildSyncCommandsWithDelayParam(SyncTarget.SCHEDULE_EVENTS) + val Images = buildSyncCommands(SyncTarget.SCHEDULE_IMAGES) + } + + + // builders + + interface SyncCommandBuilder { + fun stop(): SyncCommand + fun start(): SyncCommand + fun stopAndStart(): SyncCommand + fun stopAndStartAround(block: suspend () -> Unit): SyncCommand + } + + interface SyncCommandBuilderWithDownSyncParam { + fun stop(): SyncCommand + fun start(isDownSyncAllowed: Boolean = true): SyncCommand + fun stopAndStart(isDownSyncAllowed: Boolean = true): SyncCommand + fun stopAndStartAround(isDownSyncAllowed: Boolean = true, block: suspend () -> Unit): SyncCommand + } + + interface SyncCommandBuilderWithDelayParam { + fun stop(): SyncCommand + fun start(withDelay: Boolean = false): SyncCommand + fun stopAndStart(withDelay: Boolean = false): SyncCommand + fun stopAndStartAround(withDelay: Boolean = false, block: suspend () -> Unit): SyncCommand + } + + private fun buildSyncCommands(target: SyncTarget): SyncCommandBuilder = object : SyncCommandBuilder { + override fun stop() = getCommand(target, SyncAction.STOP) + + override fun start() = getCommand(target, SyncAction.START) + + override fun stopAndStart() = getCommand(target, SyncAction.STOP_AND_START) + + override fun stopAndStartAround(block: suspend () -> Unit) = getCommand(target, SyncAction.STOP_AND_START, null, block) + } + + private fun buildSyncCommandsWithDownSyncParam(target: SyncTarget) = object : SyncCommandBuilderWithDownSyncParam { + override fun stop() = getCommand(target, SyncAction.STOP) + + override fun start(isDownSyncAllowed: Boolean) = + getCommand(target, SyncAction.START, SyncParam.IS_DOWN_SYNC_ALLOWED to isDownSyncAllowed) + + override fun stopAndStart(isDownSyncAllowed: Boolean) = + getCommand(target, SyncAction.STOP_AND_START, SyncParam.IS_DOWN_SYNC_ALLOWED to isDownSyncAllowed) + + override fun stopAndStartAround(isDownSyncAllowed: Boolean, block: suspend () -> Unit) = + getCommand(target, SyncAction.STOP_AND_START, SyncParam.IS_DOWN_SYNC_ALLOWED to isDownSyncAllowed, block) + } + + private fun buildSyncCommandsWithDelayParam(target: SyncTarget) = object : SyncCommandBuilderWithDelayParam { + override fun stop() = getCommand(target, SyncAction.STOP) + + override fun start(withDelay: Boolean) = getCommand(target, SyncAction.START, SyncParam.WITH_DELAY to withDelay) + + override fun stopAndStart(withDelay: Boolean) = getCommand(target, SyncAction.STOP_AND_START, SyncParam.WITH_DELAY to withDelay) + + override fun stopAndStartAround(withDelay: Boolean, block: suspend () -> Unit) = + getCommand(target, SyncAction.STOP_AND_START, SyncParam.WITH_DELAY to withDelay, block) + } + + private fun getCommand( + target: SyncTarget, + action: SyncAction, + param: Pair? = null, + block: (suspend () -> Unit)? = null, + ) = ExecutableSyncCommand( + target, + action, + param?.run { mapOf(first to second) } ?: emptyMap(), + block, + ) + +} + +/** + * Complete command built from SyncCommands and bundled with instructions ready to be processed by SyncUseCase. + */ +sealed class SyncCommand + +internal data class ExecutableSyncCommand( + val target: SyncTarget, + val action: SyncAction, + val params: Map = emptyMap(), + val blockToRunWhileStopped: (suspend () -> Unit)? = null, +) : SyncCommand() + +enum class SyncTarget { + SCHEDULE_EVERYTHING, + ONE_TIME_EVENTS, + SCHEDULE_EVENTS, + ONE_TIME_IMAGES, + SCHEDULE_IMAGES, +} + +internal enum class SyncAction { + STOP, + START, + STOP_AND_START, +} + +internal enum class SyncParam { + IS_DOWN_SYNC_ALLOWED, + WITH_DELAY, +} diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/SyncResponse.kt b/infra/sync/src/main/java/com/simprints/infra/sync/SyncResponse.kt new file mode 100644 index 0000000000..1613b5097e --- /dev/null +++ b/infra/sync/src/main/java/com/simprints/infra/sync/SyncResponse.kt @@ -0,0 +1,11 @@ +package com.simprints.infra.sync + +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow + +data class SyncResponse( + val syncCommandJob: Job, + val syncStatusFlow: StateFlow, +) + +suspend fun SyncResponse.await() = syncCommandJob.join() diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt index 5148274799..62340b1ca9 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt @@ -3,11 +3,14 @@ package com.simprints.infra.sync.usecase import com.simprints.core.AppScope import com.simprints.infra.eventsync.status.models.EventSyncState import com.simprints.infra.eventsync.sync.EventSyncStateProcessor +import com.simprints.infra.sync.ExecutableSyncCommand import com.simprints.infra.sync.ImageSyncStatus import com.simprints.infra.sync.SyncCommand +import com.simprints.infra.sync.SyncResponse import com.simprints.infra.sync.SyncStatus import com.simprints.infra.sync.usecase.internal.ObserveImageSyncStatusUseCase import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -26,6 +29,7 @@ import javax.inject.Singleton class SyncUseCase @Inject internal constructor( eventSyncStateProcessor: EventSyncStateProcessor, imageSync: ObserveImageSyncStatusUseCase, + // todo MS-1299 use private val executeSyncCommand: ExecuteSyncCommandUseCase, @param:AppScope private val appScope: CoroutineScope, ) { private val defaultEventSyncState = EventSyncState( @@ -58,15 +62,50 @@ class SyncUseCase @Inject internal constructor( } /** - * Takes sync control commands (incl. no action) for syncable entities, and returns their combined sync status, + * Takes sync control commands (incl. no action) for syncable entities. + * Returns the command progress job and the syncable entities' combined sync status, * with a .value also available to the callers synchronously. * + * Usage: + * todo MS-1299 + * sync( + * SyncCommands. + * +- ObserveOnly. + * +- Schedule. + * | +- Everything. --->| + * | +- Events. --->| |---> stop() + * | +- Images. --->| for |---> start() + * +- OneTime. |---------->|---> stopAndStart() + * +- Events. --->| all |---> stopAndStartAround { /* stop, run this block, then start */ } + * +- Images. --->| + * ) + * + * Examples: + * + * sync(SyncCommands.ObserveOnly) + * sync(SyncCommands.OneTime.Events.stop()) + * sync(SyncCommands.OneTime.Images.stopAndStart()) // starts even if wasn't running at stop command time + * sync(SyncCommands.Schedule.Events.start()) + * sync(SyncCommands.Schedule.Everything.stopAndStartAround { + * delay(10_000) // transaction to wait for... + * }).await() // ...now complete + * val lastEventSyncTime = sync(SyncCommands.ObserveOnly).syncStatusFlow.value.eventSyncState.lastSyncTime + * * Sync commands intentionally do not have default values, * to prevent a `sync()` usage from being interpreted as a command to start syncing. + * + * Sync returns a combo of a Job for the command and the flow of sync statuses. + * For non-blocking use, the job doesn't matter. + * If the command was for a inherently non-blocking job, it will be returned already completed. + * To suspend until the command completes, add .await(), or .syncCommandJob.join() - they are the same. */ - operator fun invoke( - eventSync: SyncCommand, // todo MS-1299 finalize the signature of sync controls - imageSync: SyncCommand, // todo MS-1299 finalize the signature of sync controls - ): StateFlow = sharedSyncStatus - // todo MS-1299 move sync commands here from SyncOrchestrator (use helper usecases if needed), add to SyncCommand, and implement them + operator fun invoke(syncCommand: SyncCommand): SyncResponse = + SyncResponse( + syncCommandJob = when (syncCommand) { + is ExecutableSyncCommand -> throw NotImplementedError("Executable sync commands not implemented") // todo MS-1299 replace with executeSyncCommand(syncCommand) + else -> Job().apply { complete() } // no-op + }, + syncStatusFlow = sharedSyncStatus, + ) + } diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt index 890d85c58d..afba5db6c0 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt @@ -5,7 +5,7 @@ import com.google.common.truth.Truth.assertThat import com.simprints.infra.eventsync.status.models.EventSyncState import com.simprints.infra.eventsync.sync.EventSyncStateProcessor import com.simprints.infra.sync.ImageSyncStatus -import com.simprints.infra.sync.SyncCommand +import com.simprints.infra.sync.SyncCommands import com.simprints.infra.sync.SyncStatus import com.simprints.infra.sync.usecase.internal.ObserveImageSyncStatusUseCase import io.mockk.MockKAnnotations @@ -61,7 +61,7 @@ class SyncUseCaseTest { ) val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, appScope = backgroundScope) - val resultFlow = useCase(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) + val resultFlow = useCase(SyncCommands.ObserveOnly).syncStatusFlow assertThat(resultFlow.value).isEqualTo(expected) } @@ -85,7 +85,7 @@ class SyncUseCaseTest { val expected = SyncStatus(event, image) val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, appScope = backgroundScope) - val resultFlow = useCase(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) + val resultFlow = useCase(SyncCommands.ObserveOnly).syncStatusFlow runCurrent() // ensure upstream flows are collected before emitting eventSyncStatusFlow.emit(event) @@ -108,7 +108,7 @@ class SyncUseCaseTest { ) val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, appScope = backgroundScope) - val resultFlow = useCase(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) + val resultFlow = useCase(SyncCommands.ObserveOnly).syncStatusFlow runCurrent() val expected = with(resultFlow.value) { @@ -129,7 +129,7 @@ class SyncUseCaseTest { ) val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, appScope = backgroundScope) - val resultFlow = useCase(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) + val resultFlow = useCase(SyncCommands.ObserveOnly).syncStatusFlow runCurrent() val expected = with(resultFlow.value) { @@ -164,7 +164,7 @@ class SyncUseCaseTest { val expected2 = SyncStatus(event2, image) val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, appScope = backgroundScope) - val resultFlow = useCase(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) + val resultFlow = useCase(SyncCommands.ObserveOnly).syncStatusFlow runCurrent() eventSyncStatusFlow.emit(event1) @@ -202,7 +202,7 @@ class SyncUseCaseTest { val expected2 = SyncStatus(event, image2) val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, appScope = backgroundScope) - val resultFlow = useCase(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) + val resultFlow = useCase(SyncCommands.ObserveOnly).syncStatusFlow runCurrent() eventSyncStatusFlow.emit(event) @@ -238,7 +238,7 @@ class SyncUseCaseTest { ) val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, appScope = backgroundScope) - val resultFlow1 = useCase(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) + val resultFlow1 = useCase(SyncCommands.ObserveOnly).syncStatusFlow runCurrent() eventSyncStatusFlow.emit(event) @@ -248,7 +248,7 @@ class SyncUseCaseTest { imageSyncStatusFlow.emit(image2) runCurrent() - val resultFlow2 = useCase(eventSync = SyncCommand.ObserveOnly, imageSync = SyncCommand.ObserveOnly) + val resultFlow2 = useCase(SyncCommands.ObserveOnly).syncStatusFlow assertThat(resultFlow1).isSameInstanceAs(resultFlow2) verify(exactly = 1) { eventSyncStateProcessor.getLastSyncState() } From 1dbb6e291f67cc538aeb0fefa58a63c6d2df9d06 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 27 Jan 2026 10:30:16 +0000 Subject: [PATCH 02/22] MS-1299 Sync revamp: SyncCommandsTest --- .../simprints/infra/sync/SyncCommandsTest.kt | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt new file mode 100644 index 0000000000..a31b968bee --- /dev/null +++ b/infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt @@ -0,0 +1,207 @@ +package com.simprints.infra.sync + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class SyncCommandsTest { + + private val buildersWithoutParams = listOf( + SyncCommands.OneTime.Images to SyncTarget.ONE_TIME_IMAGES, + SyncCommands.Schedule.Images to SyncTarget.SCHEDULE_IMAGES, + ) + + private val buildersWithDelayParam = listOf( + SyncCommands.Schedule.Everything to SyncTarget.SCHEDULE_EVERYTHING, + SyncCommands.Schedule.Events to SyncTarget.SCHEDULE_EVENTS, + ) + + private val buildersWithDownSyncAllowedParam = listOf( + SyncCommands.OneTime.Events to SyncTarget.ONE_TIME_EVENTS, + ) + + @Test + fun `stop builds executable command without params`() { + buildersWithoutParams.forEach { (builder, expectedTarget) -> + assertThat(builder.stop()) + .isEqualTo(expectedCommand(expectedTarget, SyncAction.STOP)) + } + buildersWithDownSyncAllowedParam.forEach { (builder, expectedTarget) -> + assertThat(builder.stop()) + .isEqualTo(expectedCommand(expectedTarget, SyncAction.STOP)) + } + buildersWithDelayParam.forEach { (builder, expectedTarget) -> + assertThat(builder.stop()) + .isEqualTo(expectedCommand(expectedTarget, SyncAction.STOP)) + } + } + + @Test + fun `start builds executable command with expected params`() { + buildersWithoutParams.forEach { (builder, expectedTarget) -> + assertThat(builder.start()) + .isEqualTo(expectedCommand(expectedTarget, SyncAction.START)) + } + + buildersWithDownSyncAllowedParam.forEach { (builder, expectedTarget) -> + assertThat(builder.start()) + .isEqualTo( + expectedCommand( + target = expectedTarget, + action = SyncAction.START, + params = mapOf(SyncParam.IS_DOWN_SYNC_ALLOWED to true), + ), + ) + assertThat(builder.start(isDownSyncAllowed = false)) + .isEqualTo( + expectedCommand( + target = expectedTarget, + action = SyncAction.START, + params = mapOf(SyncParam.IS_DOWN_SYNC_ALLOWED to false), + ), + ) + } + + buildersWithDelayParam.forEach { (builder, expectedTarget) -> + assertThat(builder.start()) + .isEqualTo( + expectedCommand( + target = expectedTarget, + action = SyncAction.START, + params = mapOf(SyncParam.WITH_DELAY to false), + ), + ) + assertThat(builder.start(withDelay = true)) + .isEqualTo( + expectedCommand( + target = expectedTarget, + action = SyncAction.START, + params = mapOf(SyncParam.WITH_DELAY to true), + ), + ) + } + } + + @Test + fun `stopAndStart builds executable command with expected params`() { + val action = SyncAction.STOP_AND_START + buildersWithoutParams.forEach { (builder, expectedTarget) -> + assertThat(builder.stopAndStart()) + .isEqualTo(expectedCommand(expectedTarget, action)) + } + + buildersWithDelayParam.forEach { (builder, expectedTarget) -> + assertThat(builder.stopAndStart()) + .isEqualTo( + expectedCommand( + target = expectedTarget, + action, + params = mapOf(SyncParam.WITH_DELAY to false), + ), + ) + assertThat(builder.stopAndStart(withDelay = true)) + .isEqualTo( + expectedCommand( + target = expectedTarget, + action, + params = mapOf(SyncParam.WITH_DELAY to true), + ), + ) + } + + buildersWithDelayParam.forEach { (builder, expectedTarget) -> + assertThat(builder.stopAndStart()) + .isEqualTo( + expectedCommand( + target = expectedTarget, + action, + params = mapOf(SyncParam.WITH_DELAY to false), + ), + ) + assertThat(builder.stopAndStart(withDelay = true)) + .isEqualTo( + expectedCommand( + target = expectedTarget, + action, + params = mapOf(SyncParam.WITH_DELAY to true), + ), + ) + } + } + + @Test + fun `stopAndStartAround builds executable command and stores block`() { + val block: suspend () -> Unit = { } + val action = SyncAction.STOP_AND_START + + buildersWithoutParams.forEach { (builder, expectedTarget) -> + assertThat(builder.stopAndStartAround(block)) + .isEqualTo( + expectedCommand( + target = expectedTarget, + action, + block = block, + ), + ) + } + + buildersWithDownSyncAllowedParam.forEach { (builder, expectedTarget) -> + assertThat(builder.stopAndStartAround(block = block)) + .isEqualTo( + expectedCommand( + target = expectedTarget, + action, + params = mapOf(SyncParam.IS_DOWN_SYNC_ALLOWED to true), + block, + ), + ) + assertThat(builder.stopAndStartAround(isDownSyncAllowed = false, block = block)) + .isEqualTo( + expectedCommand( + target = expectedTarget, + action, + params = mapOf(SyncParam.IS_DOWN_SYNC_ALLOWED to false), + block, + ), + ) + } + + buildersWithDelayParam.forEach { (builder, expectedTarget) -> + assertThat(builder.stopAndStartAround(block = block)) + .isEqualTo( + expectedCommand( + target = expectedTarget, + action, + params = mapOf(SyncParam.WITH_DELAY to false), + block, + ), + ) + assertThat(builder.stopAndStartAround(withDelay = true, block = block)) + .isEqualTo( + expectedCommand( + target = expectedTarget, + action, + params = mapOf(SyncParam.WITH_DELAY to true), + block, + ), + ) + } + } + + @Test + fun `observe only is not an executable command`() { + assertThat(ExecutableSyncCommand::class.java.isInstance(SyncCommands.ObserveOnly)) + .isFalse() + } + + private fun expectedCommand( + target: SyncTarget, + action: SyncAction, + params: Map = emptyMap(), + block: (suspend () -> Unit)? = null, + ) = ExecutableSyncCommand( + target = target, + action = action, + params = params, + blockToRunWhileStopped = block, + ) +} From c76c593483834aad7a063a899c3fc1ec707af1d7 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 27 Jan 2026 10:58:12 +0000 Subject: [PATCH 03/22] MS-1299 Sync revamp: ExecuteSyncCommandUseCase with logic ported from SyncOrchestrator --- .../internal/ExecuteSyncCommandUseCase.kt | 210 +++++++++ .../internal/ExecuteSyncCommandUseCaseTest.kt | 417 ++++++++++++++++++ 2 files changed, 627 insertions(+) create mode 100644 infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt create mode 100644 infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt new file mode 100644 index 0000000000..ac473dd170 --- /dev/null +++ b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt @@ -0,0 +1,210 @@ +package com.simprints.infra.sync.usecase.internal + +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.WorkManager +import androidx.work.WorkQuery +import androidx.work.workDataOf +import com.simprints.core.AppScope +import com.simprints.infra.authstore.AuthStore +import com.simprints.infra.config.store.ConfigRepository +import com.simprints.infra.config.store.models.imagesUploadRequiresUnmeteredConnection +import com.simprints.infra.config.store.models.isCommCareEventDownSyncAllowed +import com.simprints.infra.eventsync.EventSyncManager +import com.simprints.infra.eventsync.sync.master.EventSyncMasterWorker +import com.simprints.infra.sync.ExecutableSyncCommand +import com.simprints.infra.sync.SyncAction +import com.simprints.infra.sync.SyncConstants +import com.simprints.infra.sync.SyncParam +import com.simprints.infra.sync.SyncTarget +import com.simprints.infra.sync.config.worker.DeviceConfigDownSyncWorker +import com.simprints.infra.sync.config.worker.ProjectConfigDownSyncWorker +import com.simprints.infra.sync.extensions.anyRunning +import com.simprints.infra.sync.extensions.cancelWorkers +import com.simprints.infra.sync.extensions.schedulePeriodicWorker +import com.simprints.infra.sync.extensions.startWorker +import com.simprints.infra.sync.files.FileUpSyncWorker +import com.simprints.infra.sync.firmware.FirmwareFileUpdateWorker +import com.simprints.infra.sync.firmware.ShouldScheduleFirmwareUpdateUseCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class ExecuteSyncCommandUseCase @Inject constructor( + private val workManager: WorkManager, + private val authStore: AuthStore, + private val configRepository: ConfigRepository, + private val eventSyncManager: EventSyncManager, + private val shouldScheduleFirmwareUpdate: ShouldScheduleFirmwareUpdateUseCase, + @param:AppScope private val appScope: CoroutineScope, +) { + init { + appScope.launch { + // Stop image upload when event sync starts + workManager + .getWorkInfosFlow( + WorkQuery.fromUniqueWorkNames( + SyncConstants.EVENT_SYNC_WORK_NAME, + SyncConstants.EVENT_SYNC_WORK_NAME_ONE_TIME, + ), + ).collect { workInfoList -> + if (workInfoList.anyRunning()) rescheduleImageUpSync() + } + } + } + + internal operator fun invoke(syncCommand: ExecutableSyncCommand): Job { + with(syncCommand) { + val isStopNeeded = action in listOf(SyncAction.STOP, SyncAction.STOP_AND_START) + if (isStopNeeded) { + stop() + } + val isStartNeeded = action in listOf(SyncAction.START, SyncAction.STOP_AND_START) + val isFurtherAsyncActionNeeded = blockToRunWhileStopped != null || isStartNeeded + return if (isFurtherAsyncActionNeeded) { + appScope.launch { + blockToRunWhileStopped?.invoke() + if (isStartNeeded) { + start() + } + } + } else { + Job().apply { complete() } // no-op + } + } + } + + private fun ExecutableSyncCommand.stop() { + when (target) { + SyncTarget.SCHEDULE_EVERYTHING -> cancelBackgroundWork() + SyncTarget.SCHEDULE_EVENTS -> cancelEventSync() + SyncTarget.SCHEDULE_IMAGES -> stopImageSync() // uses same worker as for OneTimeImages + SyncTarget.ONE_TIME_EVENTS -> stopEventSync() + SyncTarget.ONE_TIME_IMAGES -> stopImageSync() + } + } + + private suspend fun ExecutableSyncCommand.start() { + val isDownSyncAllowed = params[SyncParam.IS_DOWN_SYNC_ALLOWED] as? Boolean ?: true + val withDelay = params[SyncParam.WITH_DELAY] as? Boolean ?: false + when (target) { + SyncTarget.SCHEDULE_EVERYTHING -> scheduleBackgroundWork(withDelay) + SyncTarget.SCHEDULE_EVENTS -> rescheduleEventSync(withDelay) + SyncTarget.SCHEDULE_IMAGES -> rescheduleImageUpSync() + SyncTarget.ONE_TIME_EVENTS -> startEventSync(isDownSyncAllowed) + SyncTarget.ONE_TIME_IMAGES -> startImageSync() + } + } + + private suspend fun scheduleBackgroundWork(withDelay: Boolean) { + if (authStore.signedInProjectId.isNotEmpty()) { + workManager.schedulePeriodicWorker( + SyncConstants.PROJECT_SYNC_WORK_NAME, + SyncConstants.PROJECT_SYNC_REPEAT_INTERVAL, + ) + workManager.schedulePeriodicWorker( + SyncConstants.DEVICE_SYNC_WORK_NAME, + SyncConstants.DEVICE_SYNC_REPEAT_INTERVAL, + ) + workManager.schedulePeriodicWorker( + SyncConstants.FILE_UP_SYNC_WORK_NAME, + SyncConstants.FILE_UP_SYNC_REPEAT_INTERVAL, + constraints = getImageUploadConstraints(), + ) + rescheduleEventSync(withDelay) + if (shouldScheduleFirmwareUpdate()) { + workManager.schedulePeriodicWorker( + SyncConstants.FIRMWARE_UPDATE_WORK_NAME, + SyncConstants.FIRMWARE_UPDATE_REPEAT_INTERVAL, + ) + } else { + workManager.cancelWorkers(SyncConstants.FIRMWARE_UPDATE_WORK_NAME) + } + } + } + + private fun cancelBackgroundWork() { + workManager.cancelWorkers( + SyncConstants.PROJECT_SYNC_WORK_NAME, + SyncConstants.DEVICE_SYNC_WORK_NAME, + SyncConstants.FILE_UP_SYNC_WORK_NAME, + SyncConstants.EVENT_SYNC_WORK_NAME, + SyncConstants.FIRMWARE_UPDATE_WORK_NAME, + ) + stopEventSync() + } + + private suspend fun rescheduleEventSync(withDelay: Boolean) { + workManager.schedulePeriodicWorker( + workName = SyncConstants.EVENT_SYNC_WORK_NAME, + repeatInterval = SyncConstants.EVENT_SYNC_WORKER_INTERVAL, + initialDelay = if (withDelay) SyncConstants.EVENT_SYNC_WORKER_INTERVAL else 0, + constraints = getEventSyncConstraints(), + tags = eventSyncManager.getPeriodicWorkTags(), + ) + } + + private fun cancelEventSync() { + workManager.cancelWorkers(SyncConstants.EVENT_SYNC_WORK_NAME) + stopEventSync() + } + + private suspend fun startEventSync(isDownSyncAllowed: Boolean) { + workManager.startWorker( + workName = SyncConstants.EVENT_SYNC_WORK_NAME_ONE_TIME, + constraints = getEventSyncConstraints(), + tags = eventSyncManager.getOneTimeWorkTags(), + inputData = workDataOf(EventSyncMasterWorker.IS_DOWN_SYNC_ALLOWED to isDownSyncAllowed), + ) + } + + private fun stopEventSync() { + workManager.cancelWorkers(SyncConstants.EVENT_SYNC_WORK_NAME_ONE_TIME) + // Event sync consists of multiple workers, so we cancel them all by tag + workManager.cancelAllWorkByTag(eventSyncManager.getAllWorkerTag()) + } + + private fun startImageSync() { + stopImageSync() + workManager.startWorker(SyncConstants.FILE_UP_SYNC_WORK_NAME) + } + + private fun stopImageSync() { + workManager.cancelWorkers(SyncConstants.FILE_UP_SYNC_WORK_NAME) + } + + /** + * Fully reschedule the background worker. + * Should be used in when the configuration that affects scheduling has changed. + */ + private suspend fun rescheduleImageUpSync() { + workManager.schedulePeriodicWorker( + SyncConstants.FILE_UP_SYNC_WORK_NAME, + SyncConstants.FILE_UP_SYNC_REPEAT_INTERVAL, + initialDelay = SyncConstants.DEFAULT_BACKOFF_INTERVAL_MINUTES, + existingWorkPolicy = ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + constraints = getImageUploadConstraints(), + ) + } + + private suspend fun getImageUploadConstraints(): Constraints { + val networkType = configRepository + .getProjectConfiguration() + .imagesUploadRequiresUnmeteredConnection() + .let { if (it) NetworkType.UNMETERED else NetworkType.CONNECTED } + return Constraints.Builder().setRequiredNetworkType(networkType).build() + } + + private suspend fun getEventSyncConstraints(): Constraints { + // CommCare doesn't require network connection + val networkType = configRepository + .getProjectConfiguration() + .isCommCareEventDownSyncAllowed() + .let { if (it) NetworkType.NOT_REQUIRED else NetworkType.CONNECTED } + return Constraints.Builder().setRequiredNetworkType(networkType).build() + } +} diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt new file mode 100644 index 0000000000..1f024e73c3 --- /dev/null +++ b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt @@ -0,0 +1,417 @@ +package com.simprints.infra.sync.usecase.internal + +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkInfo +import androidx.work.WorkManager +import com.google.common.truth.Truth.assertThat +import com.simprints.infra.authstore.AuthStore +import com.simprints.infra.config.store.ConfigRepository +import com.simprints.infra.eventsync.EventSyncManager +import com.simprints.infra.eventsync.sync.master.EventSyncMasterWorker +import com.simprints.infra.sync.ExecutableSyncCommand +import com.simprints.infra.sync.SyncCommands +import com.simprints.infra.sync.SyncConstants.DEVICE_SYNC_WORK_NAME +import com.simprints.infra.sync.SyncConstants.EVENT_SYNC_WORK_NAME +import com.simprints.infra.sync.SyncConstants.EVENT_SYNC_WORK_NAME_ONE_TIME +import com.simprints.infra.sync.SyncConstants.FILE_UP_SYNC_WORK_NAME +import com.simprints.infra.sync.SyncConstants.FIRMWARE_UPDATE_WORK_NAME +import com.simprints.infra.sync.SyncConstants.PROJECT_SYNC_WORK_NAME +import com.simprints.infra.sync.firmware.ShouldScheduleFirmwareUpdateUseCase +import com.simprints.testtools.common.coroutines.TestCoroutineRule +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.UUID + +class ExecuteSyncCommandUseCaseTest { + @get:Rule + val testCoroutineRule = TestCoroutineRule() + + @MockK + private lateinit var workManager: WorkManager + + @MockK + private lateinit var authStore: AuthStore + + @MockK + private lateinit var configRepository: ConfigRepository + + @MockK + private lateinit var eventSyncManager: EventSyncManager + + @MockK + private lateinit var shouldScheduleFirmwareUpdate: ShouldScheduleFirmwareUpdateUseCase + + private lateinit var useCase: ExecuteSyncCommandUseCase + + @Before + fun setup() { + MockKAnnotations.init(this, relaxed = true) + useCase = createUseCase() + } + + @Test + fun `does not schedules any workers if not logged in`() = runTest { + every { authStore.signedInProjectId } returns "" + coEvery { shouldScheduleFirmwareUpdate.invoke() } returns false + + useCase(executable(SyncCommands.Schedule.Everything.start())).join() + + verify(exactly = 0) { workManager.enqueueUniquePeriodicWork(any(), any(), any()) } + } + + @Test + fun `schedules all necessary background workers if logged in`() = runTest { + every { authStore.signedInProjectId } returns "projectId" + coEvery { shouldScheduleFirmwareUpdate.invoke() } returns true + + useCase(executable(SyncCommands.Schedule.Everything.start())).join() + + verify { + workManager.enqueueUniquePeriodicWork(PROJECT_SYNC_WORK_NAME, any(), any()) + workManager.enqueueUniquePeriodicWork(DEVICE_SYNC_WORK_NAME, any(), any()) + workManager.enqueueUniquePeriodicWork(FILE_UP_SYNC_WORK_NAME, any(), any()) + workManager.enqueueUniquePeriodicWork(EVENT_SYNC_WORK_NAME, any(), any()) + workManager.enqueueUniquePeriodicWork(FIRMWARE_UPDATE_WORK_NAME, any(), any()) + } + } + + @Test + fun `schedules images with any connection if not specified`() = runTest { + coEvery { + configRepository + .getProjectConfiguration() + .synchronization.up.simprints.imagesRequireUnmeteredConnection + } returns false + every { authStore.signedInProjectId } returns "projectId" + + useCase(executable(SyncCommands.Schedule.Everything.start())).join() + + verify { + workManager.enqueueUniquePeriodicWork( + FILE_UP_SYNC_WORK_NAME, + any(), + match { it.workSpec.constraints.requiredNetworkType == NetworkType.CONNECTED }, + ) + } + } + + @Test + fun `schedules images with unmetered constraint if requested`() = runTest { + coEvery { + configRepository + .getProjectConfiguration() + .synchronization.up.simprints.imagesRequireUnmeteredConnection + } returns true + every { authStore.signedInProjectId } returns "projectId" + coEvery { shouldScheduleFirmwareUpdate.invoke() } returns false + + useCase(executable(SyncCommands.Schedule.Everything.start())).join() + + verify { + workManager.enqueueUniquePeriodicWork( + FILE_UP_SYNC_WORK_NAME, + any(), + match { it.workSpec.constraints.requiredNetworkType == NetworkType.UNMETERED }, + ) + } + } + + @Test + fun `cancels firmware update worker if firmware update should not be scheduled`() = runTest { + every { authStore.signedInProjectId } returns "projectId" + coEvery { shouldScheduleFirmwareUpdate.invoke() } returns false + + useCase(executable(SyncCommands.Schedule.Everything.start())).join() + + verify { workManager.cancelUniqueWork(FIRMWARE_UPDATE_WORK_NAME) } + } + + @Test + fun `cancels all necessary background workers`() = runTest { + every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" + + useCase(executable(SyncCommands.Schedule.Everything.stop())) + + verify { + workManager.cancelUniqueWork(PROJECT_SYNC_WORK_NAME) + workManager.cancelUniqueWork(DEVICE_SYNC_WORK_NAME) + workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) + workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME) + workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME_ONE_TIME) + workManager.cancelUniqueWork(FIRMWARE_UPDATE_WORK_NAME) + workManager.cancelAllWorkByTag("syncWorkers") + } + } + + @Test + fun `reschedules event sync worker with correct tags`() = runTest { + every { eventSyncManager.getPeriodicWorkTags() } returns listOf("tag1", "tag2") + + useCase(executable(SyncCommands.Schedule.Events.start())).join() + + verify { + workManager.enqueueUniquePeriodicWork( + EVENT_SYNC_WORK_NAME, + any(), + match { it.tags.containsAll(setOf("tag1", "tag2")) }, + ) + } + } + + @Test + fun `reschedules event sync worker with correct delay`() = runTest { + every { eventSyncManager.getPeriodicWorkTags() } returns listOf("tag1", "tag2") + + useCase(executable(SyncCommands.Schedule.Events.start(withDelay = true))).join() + + verify { + workManager.enqueueUniquePeriodicWork( + EVENT_SYNC_WORK_NAME, + any(), + match { it.workSpec.initialDelay > 0 }, + ) + } + } + + @Test + fun `stop and start schedule events routes to cancel and reschedule with delay`() = runTest { + every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" + every { eventSyncManager.getPeriodicWorkTags() } returns listOf("tag1", "tag2") + + useCase(executable(SyncCommands.Schedule.Events.stopAndStart(withDelay = true))).join() + + verify { + workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME) + workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME_ONE_TIME) + workManager.cancelAllWorkByTag("syncWorkers") + workManager.enqueueUniquePeriodicWork( + EVENT_SYNC_WORK_NAME, + any(), + match { + it.workSpec.initialDelay > 0 && + it.tags.containsAll(setOf("tag1", "tag2")) + }, + ) + } + } + + @Test + fun `cancel event sync worker cancels correct worker`() = runTest { + every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" + + useCase(executable(SyncCommands.Schedule.Events.stop())) + + verify { + workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME) + workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME_ONE_TIME) + workManager.cancelAllWorkByTag("syncWorkers") + } + } + + @Test + fun `start event sync worker with correct tags`() = runTest { + every { eventSyncManager.getOneTimeWorkTags() } returns listOf("tag1", "tag2") + + useCase(executable(SyncCommands.OneTime.Events.start())).join() + + verify { + workManager.enqueueUniqueWork( + EVENT_SYNC_WORK_NAME_ONE_TIME, + any(), + match { it.tags.containsAll(setOf("tag1", "tag2")) }, + ) + } + } + + @Test + fun `start event sync worker with correct input data`() = runTest { + every { eventSyncManager.getOneTimeWorkTags() } returns listOf("tag1", "tag2") + + useCase(executable(SyncCommands.OneTime.Events.start(isDownSyncAllowed = false))).join() + + verify { + workManager.enqueueUniqueWork( + EVENT_SYNC_WORK_NAME_ONE_TIME, + any(), + match { + !it.workSpec.input.getBoolean(EventSyncMasterWorker.IS_DOWN_SYNC_ALLOWED, true) + }, + ) + } + } + + @Test + fun `stop and start one-time event sync routes to stop and start with expected input param`() = runTest { + every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" + every { eventSyncManager.getOneTimeWorkTags() } returns listOf("tag1", "tag2") + + useCase(executable(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = false))).join() + + verify { + workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME_ONE_TIME) + workManager.cancelAllWorkByTag("syncWorkers") + workManager.enqueueUniqueWork( + EVENT_SYNC_WORK_NAME_ONE_TIME, + any(), + match { + it.tags.containsAll(setOf("tag1", "tag2")) && + !it.workSpec.input.getBoolean(EventSyncMasterWorker.IS_DOWN_SYNC_ALLOWED, true) + }, + ) + } + } + + @Test + fun `stop event sync worker cancels correct workers`() = runTest { + every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" + + useCase(executable(SyncCommands.OneTime.Events.stop())) + + verify { + workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME_ONE_TIME) + workManager.cancelAllWorkByTag("syncWorkers") + } + } + + @Test + fun `reschedules image worker when requested`() = runTest { + useCase(executable(SyncCommands.Schedule.Images.start())).join() + + verify { + workManager.enqueueUniquePeriodicWork( + FILE_UP_SYNC_WORK_NAME, + ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + any(), + ) + } + } + + @Test + fun `start image sync re-starts image worker`() = runTest { + useCase(executable(SyncCommands.OneTime.Images.start())).join() + + verify { + workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) + workManager.enqueueUniqueWork( + FILE_UP_SYNC_WORK_NAME, + any(), + any(), + ) + } + } + + @Test + fun `stop image sync cancels image worker`() = runTest { + useCase(executable(SyncCommands.OneTime.Images.stop())) + + verify { workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) } + } + + @Test + fun `invoke stop command returns completed job and routes to stop logic`() = runTest { + val job = useCase(executable(SyncCommands.Schedule.Images.stop())) + + assertThat(job.isCompleted).isTrue() + verify { workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) } + } + + @Test + fun `stop and start around runs block before starting`() = runTest { + val blockStarted = Channel(Channel.UNLIMITED) + val unblock = Channel(Channel.UNLIMITED) + val block: suspend () -> Unit = { + blockStarted.trySend(Unit) + unblock.receive() + } + + val job = useCase(executable(SyncCommands.Schedule.Images.stopAndStartAround(block))) + + verify { + workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) + } + + blockStarted.receive() + + verify(exactly = 0) { + workManager.enqueueUniquePeriodicWork( + FILE_UP_SYNC_WORK_NAME, + ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + any(), + ) + } + + unblock.trySend(Unit) + job.join() + + verify { + workManager.enqueueUniquePeriodicWork( + FILE_UP_SYNC_WORK_NAME, + ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + any(), + ) + } + } + + @Test + fun `stops image worker when event sync starts`() = runTest { // init block test + val eventStartFlow = MutableSharedFlow>() + every { workManager.getWorkInfosFlow(any()) } returns eventStartFlow + + useCase = createUseCase() + + eventStartFlow.emit(createWorkInfo(WorkInfo.State.RUNNING)) + + verify { + workManager.enqueueUniquePeriodicWork( + FILE_UP_SYNC_WORK_NAME, + ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + any(), + ) + } + } + + @Test + fun `does not stop image worker when event sync is not running`() = runTest { // init block test + val eventStartFlow = MutableSharedFlow>() + every { workManager.getWorkInfosFlow(any()) } returns eventStartFlow + + useCase = createUseCase() + + eventStartFlow.emit(createWorkInfo(WorkInfo.State.CANCELLED)) + + verify(exactly = 0) { + workManager.enqueueUniquePeriodicWork( + FILE_UP_SYNC_WORK_NAME, + ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + any(), + ) + } + } + + private fun executable(syncCommand: com.simprints.infra.sync.SyncCommand) = syncCommand as ExecutableSyncCommand + + private fun createUseCase() = ExecuteSyncCommandUseCase( + workManager = workManager, + authStore = authStore, + configRepository = configRepository, + eventSyncManager = eventSyncManager, + shouldScheduleFirmwareUpdate = shouldScheduleFirmwareUpdate, + appScope = CoroutineScope(testCoroutineRule.testCoroutineDispatcher), + ) + + private fun createWorkInfo(state: WorkInfo.State) = listOf( + WorkInfo(UUID.randomUUID(), state, emptySet()), + ) +} From 6a1df965d62e6fc4ca485da1fafff5c1f1468ee7 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 27 Jan 2026 12:51:05 +0000 Subject: [PATCH 04/22] MS-1299 Sync revamp: SyncUseCase replaces most of SyncOrchestrator at call sites --- .../feature/dashboard/debug/DebugFragment.kt | 18 +++++----------- .../dashboard/logout/usecase/LogoutUseCase.kt | 5 ++++- .../settings/syncinfo/SyncInfoViewModel.kt | 12 ++++------- .../ModuleSelectionViewModel.kt | 8 +++---- .../feature/logincheck/LoginCheckViewModel.kt | 7 ++++--- .../usecases/StartBackgroundSyncUseCase.kt | 11 +++++----- .../usecase/RunBlockingEventSyncUseCase.kt | 21 +++++++++---------- .../main/java/com/simprints/id/Application.kt | 7 ++++++- .../events/down/EventDownSyncResetService.kt | 7 ++++--- .../com/simprints/infra/sync/SyncResponse.kt | 2 ++ .../sync/config/usecase/LogoutUseCase.kt | 5 ++++- ...RescheduleWorkersIfConfigChangedUseCase.kt | 8 ++++--- ...ResetLocalRecordsIfConfigChangedUseCase.kt | 18 +++++++++------- .../infra/sync/usecase/SyncUseCase.kt | 5 +++-- 14 files changed, 72 insertions(+), 62 deletions(-) diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt index 77e4d79c85..286868c865 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt @@ -20,7 +20,6 @@ import com.simprints.infra.events.EventRepository import com.simprints.infra.eventsync.EventSyncManager import com.simprints.infra.eventsync.status.models.EventSyncWorkerState import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.SyncOrchestrator import com.simprints.infra.sync.usecase.SyncUseCase import com.simprints.infra.uibase.view.applySystemBarInsets import com.simprints.infra.uibase.viewbinding.viewBinding @@ -40,9 +39,6 @@ internal class DebugFragment : Fragment(R.layout.fragment_debug) { @Inject lateinit var eventSyncManager: EventSyncManager - @Inject - lateinit var syncOrchestrator: SyncOrchestrator - @Inject lateinit var authStore: AuthStore @@ -90,19 +86,15 @@ internal class DebugFragment : Fragment(R.layout.fragment_debug) { } binding.syncStart.setOnClickListener { - lifecycleScope.launch(dispatcher) { - syncOrchestrator.startEventSync() - } + sync(SyncCommands.OneTime.Events.start()) } binding.syncStop.setOnClickListener { - syncOrchestrator.stopEventSync() + sync(SyncCommands.OneTime.Events.stop()) } binding.syncSchedule.setOnClickListener { - lifecycleScope.launch(dispatcher) { - syncOrchestrator.rescheduleEventSync() - } + sync(SyncCommands.Schedule.Events.start()) } binding.clearFirebaseToken.setOnClickListener { @@ -127,8 +119,8 @@ internal class DebugFragment : Fragment(R.layout.fragment_debug) { binding.cleanAll.setOnClickListener { lifecycleScope.launch(dispatcher) { - syncOrchestrator.stopEventSync() - syncOrchestrator.cancelEventSync() + sync(SyncCommands.OneTime.Events.stop()) + sync(SyncCommands.Schedule.Events.stop()) eventRepository.deleteAll() eventSyncManager.resetDownSyncInfo() diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCase.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCase.kt index b1bf6d0d65..abf44eccbc 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCase.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCase.kt @@ -4,12 +4,15 @@ import com.simprints.core.DispatcherIO import com.simprints.infra.authlogic.AuthManager import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository import com.simprints.infra.enrolment.records.repository.local.migration.RealmToRoomMigrationFlagsStore +import com.simprints.infra.sync.SyncCommands import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.usecase.SyncUseCase import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.runBlocking import javax.inject.Inject internal class LogoutUseCase @Inject constructor( + private val sync: SyncUseCase, private val syncOrchestrator: SyncOrchestrator, private val authManager: AuthManager, private val flagsStore: RealmToRoomMigrationFlagsStore, @@ -19,7 +22,7 @@ internal class LogoutUseCase @Inject constructor( // To prevent a race between wiping data and navigation, this use case must block the executing thread operator fun invoke() = runBlocking(ioDispatcher) { // Cancel all background sync - syncOrchestrator.cancelBackgroundWork() + sync(SyncCommands.Schedule.Everything.stop()) syncOrchestrator.deleteEventSyncInfo() // sign out the user authManager.signOut() diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt index ae44bbb06d..3c38b74db1 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt @@ -17,7 +17,6 @@ import com.simprints.infra.config.store.models.ProjectState import com.simprints.infra.config.store.models.isModuleSelectionAvailable import com.simprints.infra.recent.user.activity.RecentUserActivityManager import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.SyncOrchestrator import com.simprints.infra.sync.usecase.SyncUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher @@ -43,7 +42,6 @@ import javax.inject.Inject internal class SyncInfoViewModel @Inject constructor( private val configRepository: ConfigRepository, private val authStore: AuthStore, - private val syncOrchestrator: SyncOrchestrator, private val recentUserActivityManager: RecentUserActivityManager, private val timeHelper: TimeHelper, observeSyncInfo: ObserveSyncInfoUseCase, @@ -146,10 +144,8 @@ internal class SyncInfoViewModel @Inject constructor( } } - syncOrchestrator.stopEventSync() - val isDownSyncAllowed = !isPreLogoutUpSync && configRepository.getProject()?.state == ProjectState.RUNNING - syncOrchestrator.startEventSync(isDownSyncAllowed) + sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed)) } } @@ -157,10 +153,10 @@ internal class SyncInfoViewModel @Inject constructor( viewModelScope.launch { val isImageSyncing = imageSyncStatusFlow.firstOrNull()?.isSyncing == true if (isImageSyncing) { - syncOrchestrator.stopImageSync() + sync(SyncCommands.OneTime.Images.stop()) } else { imageSyncButtonClickFlow.emit(Unit) - syncOrchestrator.startImageSync() + sync(SyncCommands.OneTime.Images.start()) } } } @@ -218,7 +214,7 @@ internal class SyncInfoViewModel @Inject constructor( .distinctUntilChanged() .collect { isEventSyncCompleted -> if (isEventSyncCompleted) { - syncOrchestrator.startImageSync() + sync(SyncCommands.OneTime.Images.start()) } } } diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModel.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModel.kt index b1b9475daf..b9a15b9a77 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModel.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModel.kt @@ -15,7 +15,8 @@ import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.SettingsPasswordConfig import com.simprints.infra.config.store.models.TokenKeyType import com.simprints.infra.config.store.tokenization.TokenizationProcessor -import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.SyncCommands +import com.simprints.infra.sync.usecase.SyncUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -24,7 +25,7 @@ import javax.inject.Inject @HiltViewModel internal class ModuleSelectionViewModel @Inject constructor( private val moduleRepository: ModuleRepository, - private val syncOrchestrator: SyncOrchestrator, + private val sync: SyncUseCase, private val configRepository: ConfigRepository, private val tokenizationProcessor: TokenizationProcessor, @param:ExternalScope private val externalScope: CoroutineScope, @@ -114,8 +115,7 @@ internal class ModuleSelectionViewModel @Inject constructor( module.copy(name = encryptedName) } moduleRepository.saveModules(modules) - syncOrchestrator.stopEventSync() - syncOrchestrator.startEventSync() + sync(SyncCommands.OneTime.Events.stopAndStart()) } } diff --git a/feature/login-check/src/main/java/com/simprints/feature/logincheck/LoginCheckViewModel.kt b/feature/login-check/src/main/java/com/simprints/feature/logincheck/LoginCheckViewModel.kt index 2318f3b666..fe147aef21 100644 --- a/feature/login-check/src/main/java/com/simprints/feature/logincheck/LoginCheckViewModel.kt +++ b/feature/login-check/src/main/java/com/simprints/feature/logincheck/LoginCheckViewModel.kt @@ -30,7 +30,8 @@ import com.simprints.infra.logging.Simber import com.simprints.infra.orchestration.data.ActionRequest import com.simprints.infra.security.SecurityManager import com.simprints.infra.security.exceptions.RootedDeviceException -import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.SyncCommands +import com.simprints.infra.sync.usecase.SyncUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -48,7 +49,7 @@ class LoginCheckViewModel @Inject internal constructor( private val isUserSignedIn: IsUserSignedInUseCase, private val configRepository: ConfigRepository, private val startBackgroundSync: StartBackgroundSyncUseCase, - private val syncOrchestrator: SyncOrchestrator, + private val sync: SyncUseCase, private val updateDatabaseCountsInCurrentSession: UpdateSessionScopePayloadUseCase, private val updateProjectInCurrentSession: UpdateProjectInCurrentSessionUseCase, private val updateStoredUserId: UpdateStoredUserIdUseCase, @@ -105,7 +106,7 @@ class LoginCheckViewModel @Inject internal constructor( cachedRequest = actionRequest loginAlreadyTried.set(true) - syncOrchestrator.cancelBackgroundWork() + sync(SyncCommands.Schedule.Everything.stop()) _showLoginFlow.send(actionRequest) } diff --git a/feature/login-check/src/main/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCase.kt b/feature/login-check/src/main/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCase.kt index 10f3f88dc1..48ac6c497f 100644 --- a/feature/login-check/src/main/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCase.kt +++ b/feature/login-check/src/main/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCase.kt @@ -2,12 +2,14 @@ package com.simprints.feature.logincheck.usecases import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.Frequency -import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.SyncCommands +import com.simprints.infra.sync.await +import com.simprints.infra.sync.usecase.SyncUseCase import javax.inject.Inject internal class StartBackgroundSyncUseCase @Inject constructor( - private val syncOrchestrator: SyncOrchestrator, private val configRepository: ConfigRepository, + private val sync: SyncUseCase, ) { suspend operator fun invoke() { val frequency = configRepository @@ -15,8 +17,7 @@ internal class StartBackgroundSyncUseCase @Inject constructor( .synchronization.down.simprints ?.frequency - syncOrchestrator.scheduleBackgroundWork( - withDelay = frequency != Frequency.PERIODICALLY_AND_ON_SESSION_START, - ) + val withDelay = frequency != Frequency.PERIODICALLY_AND_ON_SESSION_START + sync(SyncCommands.Schedule.Everything.start(withDelay)).await() } } diff --git a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt index af46d0ffc8..edb7e79cb1 100644 --- a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt @@ -1,7 +1,6 @@ package com.simprints.feature.validatepool.usecase import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.SyncOrchestrator import com.simprints.infra.sync.usecase.SyncUseCase import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map @@ -9,19 +8,19 @@ import javax.inject.Inject internal class RunBlockingEventSyncUseCase @Inject constructor( private val sync: SyncUseCase, - private val syncOrchestrator: SyncOrchestrator, ) { suspend operator fun invoke() { // First item in the flow (except uninitialized) is the state of last sync, // so it can be used to as a filter out old sync states - val lastSyncId = sync(SyncCommands.ObserveOnly).syncStatusFlow - .map { it.eventSyncState } - .firstOrNull { !it.isUninitialized() } - ?.syncId - - syncOrchestrator.startEventSync() - sync(SyncCommands.ObserveOnly).syncStatusFlow - .map { it.eventSyncState } - .firstOrNull { it.syncId != lastSyncId && it.isSyncReporterCompleted() } + sync(SyncCommands.OneTime.Events.start()).let { (startJob, syncStatusFlow) -> + val eventSyncStateFlow = syncStatusFlow + .map { it.eventSyncState } + val lastSyncId = eventSyncStateFlow + .firstOrNull { !it.isUninitialized() } + ?.syncId + startJob.join() + eventSyncStateFlow + .firstOrNull { it.syncId != lastSyncId && it.isSyncReporterCompleted() } + } } } diff --git a/id/src/main/java/com/simprints/id/Application.kt b/id/src/main/java/com/simprints/id/Application.kt index 48915db62c..bf71814b87 100644 --- a/id/src/main/java/com/simprints/id/Application.kt +++ b/id/src/main/java/com/simprints/id/Application.kt @@ -16,7 +16,9 @@ import com.simprints.infra.logging.LoggingConstants.CrashReportingCustomKeys.VER import com.simprints.infra.logging.Simber import com.simprints.infra.logging.SimberBuilder import com.simprints.infra.logging.usecases.UpdateAndGetVersionHistoryUseCase +import com.simprints.infra.sync.SyncCommands import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.usecase.SyncUseCase import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.cancel @@ -34,6 +36,9 @@ open class Application : @Inject lateinit var syncOrchestrator: SyncOrchestrator + @Inject + lateinit var sync: SyncUseCase + @Inject lateinit var realmToRoomMigrationScheduler: RealmToRoomMigrationScheduler @@ -85,7 +90,7 @@ open class Application : appScope.launch { realmToRoomMigrationScheduler.scheduleMigrationWorkerIfNeeded() syncOrchestrator.cleanupWorkers() - syncOrchestrator.scheduleBackgroundWork() + sync(SyncCommands.Schedule.Everything.start()) } if (DB_ENCRYPTION) { System.loadLibrary("sqlcipher") diff --git a/id/src/main/java/com/simprints/id/services/sync/events/down/EventDownSyncResetService.kt b/id/src/main/java/com/simprints/id/services/sync/events/down/EventDownSyncResetService.kt index 11d7292e50..2246ab1dca 100644 --- a/id/src/main/java/com/simprints/id/services/sync/events/down/EventDownSyncResetService.kt +++ b/id/src/main/java/com/simprints/id/services/sync/events/down/EventDownSyncResetService.kt @@ -14,7 +14,8 @@ import com.simprints.core.ExternalScope import com.simprints.infra.eventsync.EventSyncManager import com.simprints.infra.logging.LoggingConstants.CrashReportTag.SYNC import com.simprints.infra.logging.Simber -import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.SyncCommands +import com.simprints.infra.sync.usecase.SyncUseCase import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -30,7 +31,7 @@ class EventDownSyncResetService : Service() { lateinit var eventSyncManager: EventSyncManager @Inject - lateinit var syncOrchestrator: SyncOrchestrator + lateinit var sync: SyncUseCase private var resetJob: Job? = null @@ -49,7 +50,7 @@ class EventDownSyncResetService : Service() { // Reset current downsync state eventSyncManager.resetDownSyncInfo() // Trigger a new sync - syncOrchestrator.startEventSync() + sync(SyncCommands.OneTime.Events.start()) } resetJob?.invokeOnCompletion { stopSelf() } diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/SyncResponse.kt b/infra/sync/src/main/java/com/simprints/infra/sync/SyncResponse.kt index 1613b5097e..a04dd2bf7c 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/SyncResponse.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/SyncResponse.kt @@ -1,5 +1,6 @@ package com.simprints.infra.sync +import com.simprints.core.ExcludedFromGeneratedTestCoverageReports import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow @@ -8,4 +9,5 @@ data class SyncResponse( val syncStatusFlow: StateFlow, ) +@ExcludedFromGeneratedTestCoverageReports("There is no complex business logic to test") suspend fun SyncResponse.await() = syncCommandJob.join() diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/LogoutUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/LogoutUseCase.kt index df12c9e505..27119a98b0 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/LogoutUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/LogoutUseCase.kt @@ -1,15 +1,18 @@ package com.simprints.infra.sync.config.usecase import com.simprints.infra.authlogic.AuthManager +import com.simprints.infra.sync.SyncCommands import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.usecase.SyncUseCase import javax.inject.Inject internal class LogoutUseCase @Inject constructor( private val syncOrchestrator: SyncOrchestrator, + private val sync: SyncUseCase, private val authManager: AuthManager, ) { suspend operator fun invoke() { - syncOrchestrator.cancelBackgroundWork() + sync(SyncCommands.Schedule.Everything.stop()) syncOrchestrator.deleteEventSyncInfo() authManager.signOut() } diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCase.kt index fd97a02e0a..b6657d168f 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCase.kt @@ -2,18 +2,20 @@ package com.simprints.infra.sync.config.usecase import com.simprints.infra.config.store.models.ProjectConfiguration import com.simprints.infra.config.store.models.imagesUploadRequiresUnmeteredConnection -import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.SyncCommands +import com.simprints.infra.sync.await +import com.simprints.infra.sync.usecase.SyncUseCase import javax.inject.Inject internal class RescheduleWorkersIfConfigChangedUseCase @Inject constructor( - private val syncOrchestrator: SyncOrchestrator, + private val sync: SyncUseCase, ) { suspend operator fun invoke( oldConfig: ProjectConfiguration, newConfig: ProjectConfiguration, ) { if (shouldRescheduleImageUpload(oldConfig, newConfig)) { - syncOrchestrator.rescheduleImageUpSync() + sync(SyncCommands.Schedule.Images.start()).await() } } diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCase.kt index cabc592266..56bb327f85 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCase.kt @@ -3,23 +3,27 @@ package com.simprints.infra.sync.config.usecase import com.simprints.infra.config.store.models.ProjectConfiguration import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository import com.simprints.infra.eventsync.EventSyncManager -import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.SyncCommands +import com.simprints.infra.sync.await +import com.simprints.infra.sync.usecase.SyncUseCase import javax.inject.Inject internal class ResetLocalRecordsIfConfigChangedUseCase @Inject constructor( - private val syncOrchestrator: SyncOrchestrator, private val eventSyncManager: EventSyncManager, private val enrolmentRecordRepository: EnrolmentRecordRepository, + private val sync: SyncUseCase, ) { suspend operator fun invoke( oldConfig: ProjectConfiguration, newConfig: ProjectConfiguration, ) { if (hasPartitionTypeChanged(oldConfig, newConfig)) { - syncOrchestrator.cancelEventSync() - eventSyncManager.resetDownSyncInfo() - enrolmentRecordRepository.deleteAll() - syncOrchestrator.rescheduleEventSync() + sync( + SyncCommands.Schedule.Events.stopAndStartAround { + eventSyncManager.resetDownSyncInfo() + enrolmentRecordRepository.deleteAll() + } + ).await() } } @@ -33,5 +37,5 @@ internal class ResetLocalRecordsIfConfigChangedUseCase @Inject constructor( oldConfig.synchronization.down.simprints ?.partitionType != newConfig.synchronization.down.simprints ?.partitionType - ) + ) } diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt index 62340b1ca9..500fd0482e 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt @@ -8,6 +8,7 @@ import com.simprints.infra.sync.ImageSyncStatus import com.simprints.infra.sync.SyncCommand import com.simprints.infra.sync.SyncResponse import com.simprints.infra.sync.SyncStatus +import com.simprints.infra.sync.usecase.internal.ExecuteSyncCommandUseCase import com.simprints.infra.sync.usecase.internal.ObserveImageSyncStatusUseCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -29,7 +30,7 @@ import javax.inject.Singleton class SyncUseCase @Inject internal constructor( eventSyncStateProcessor: EventSyncStateProcessor, imageSync: ObserveImageSyncStatusUseCase, - // todo MS-1299 use private val executeSyncCommand: ExecuteSyncCommandUseCase, + private val executeSyncCommand: ExecuteSyncCommandUseCase, @param:AppScope private val appScope: CoroutineScope, ) { private val defaultEventSyncState = EventSyncState( @@ -102,7 +103,7 @@ class SyncUseCase @Inject internal constructor( operator fun invoke(syncCommand: SyncCommand): SyncResponse = SyncResponse( syncCommandJob = when (syncCommand) { - is ExecutableSyncCommand -> throw NotImplementedError("Executable sync commands not implemented") // todo MS-1299 replace with executeSyncCommand(syncCommand) + is ExecutableSyncCommand -> executeSyncCommand(syncCommand) else -> Job().apply { complete() } // no-op }, syncStatusFlow = sharedSyncStatus, From 898a74c16a38f56481fbf37c57bc07e45147a693 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 27 Jan 2026 14:23:43 +0000 Subject: [PATCH 05/22] MS-1299 Sync revamp: SyncUseCase replaces most of SyncOrchestrator at call sites - test adjustments --- .../logout/usecase/LogoutUseCaseTest.kt | 12 ++- .../syncinfo/SyncInfoViewModelTest.kt | 57 +++++------- .../ModuleSelectionViewModelTest.kt | 11 +-- .../logincheck/LoginCheckViewModelTest.kt | 10 ++- .../StartBackgroundSyncUseCaseTest.kt | 51 ++++++++--- .../sync/config/usecase/LogoutUseCaseTest.kt | 24 +++++- ...heduleWorkersIfConfigChangedUseCaseTest.kt | 73 +++++++++++----- ...tLocalRecordsIfConfigChangedUseCaseTest.kt | 86 ++++++++++++++----- 8 files changed, 224 insertions(+), 100 deletions(-) diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCaseTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCaseTest.kt index 3a4f1c2ebc..caa3292436 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCaseTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCaseTest.kt @@ -4,11 +4,16 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.simprints.infra.authlogic.AuthManager import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository import com.simprints.infra.enrolment.records.repository.local.migration.RealmToRoomMigrationFlagsStore +import com.simprints.infra.sync.SyncCommands import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.usecase.SyncUseCase import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.MockKAnnotations import io.mockk.coVerify +import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule @@ -21,6 +26,9 @@ class LogoutUseCaseTest { @get:Rule val testCoroutineRule = TestCoroutineRule() + @MockK + private lateinit var sync: SyncUseCase + @MockK private lateinit var syncOrchestrator: SyncOrchestrator @@ -37,8 +45,10 @@ class LogoutUseCaseTest { @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) + every { sync(any()) } returns mockk() useCase = LogoutUseCase( + sync = sync, syncOrchestrator = syncOrchestrator, authManager = authManager, flagsStore = flagsStore, @@ -51,8 +61,8 @@ class LogoutUseCaseTest { fun `Fully logs out when called`() = runTest { useCase.invoke() + verify { sync(SyncCommands.Schedule.Everything.stop()) } coVerify { - syncOrchestrator.cancelBackgroundWork() syncOrchestrator.deleteEventSyncInfo() authManager.signOut() flagsStore.clearMigrationFlags() diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModelTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModelTest.kt index 18dcf9d881..f63990203a 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModelTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModelTest.kt @@ -24,7 +24,6 @@ import com.simprints.infra.eventsync.status.models.EventSyncState import com.simprints.infra.recent.user.activity.RecentUserActivityManager import com.simprints.infra.sync.ImageSyncStatus import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.SyncOrchestrator import com.simprints.infra.sync.SyncResponse import com.simprints.infra.sync.SyncStatus import com.simprints.infra.sync.usecase.SyncUseCase @@ -60,9 +59,6 @@ class SyncInfoViewModelTest { @MockK private lateinit var authStore: AuthStore - @MockK - private lateinit var syncOrchestrator: SyncOrchestrator - @MockK private lateinit var recentUserActivityManager: RecentUserActivityManager @@ -154,10 +150,6 @@ class SyncInfoViewModelTest { syncCommandJob = Job().apply { complete() }, syncStatusFlow = syncStatusFlow, ) - coEvery { syncOrchestrator.startEventSync(any()) } returns Unit - coEvery { syncOrchestrator.stopEventSync() } returns Unit - coEvery { syncOrchestrator.startImageSync() } returns Unit - coEvery { syncOrchestrator.stopImageSync() } returns Unit every { timeHelper.now() } returns TEST_TIMESTAMP every { timeHelper.msBetweenNowAndTime(any()) } returns 0L @@ -176,7 +168,6 @@ class SyncInfoViewModelTest { viewModel = SyncInfoViewModel( configRepository = configRepository, authStore = authStore, - syncOrchestrator = syncOrchestrator, recentUserActivityManager = recentUserActivityManager, timeHelper = timeHelper, observeSyncInfo = observeSyncInfo, @@ -397,8 +388,7 @@ class SyncInfoViewModelTest { viewModel.forceEventSync() - coVerify { syncOrchestrator.stopEventSync() } - coVerify { syncOrchestrator.startEventSync(isDownSyncAllowed = true) } + verify { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = true)) } } @Test @@ -407,8 +397,7 @@ class SyncInfoViewModelTest { viewModel.forceEventSync() - coVerify { syncOrchestrator.stopEventSync() } - coVerify { syncOrchestrator.startEventSync(isDownSyncAllowed = false) } + verify { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = false)) } } @Test @@ -422,8 +411,7 @@ class SyncInfoViewModelTest { viewModel.forceEventSync() - coVerify { syncOrchestrator.stopEventSync() } - coVerify { syncOrchestrator.startEventSync(isDownSyncAllowed = false) } + verify { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = false)) } } @Test @@ -437,8 +425,7 @@ class SyncInfoViewModelTest { viewModel.forceEventSync() - coVerify { syncOrchestrator.stopEventSync() } - coVerify { syncOrchestrator.startEventSync(isDownSyncAllowed = false) } + verify { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = false)) } } @Test @@ -449,16 +436,14 @@ class SyncInfoViewModelTest { viewModel.forceEventSync() - coVerify { syncOrchestrator.stopEventSync() } - coVerify { syncOrchestrator.startEventSync(isDownSyncAllowed = false) } + verify { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = false)) } } @Test fun `should stop current event sync before starting new one`() = runTest { viewModel.forceEventSync() - coVerify { syncOrchestrator.stopEventSync() } - coVerify { syncOrchestrator.startEventSync(any()) } + verify { sync(SyncCommands.OneTime.Events.stopAndStart()) } } // toggleImageSync() tests @@ -473,8 +458,8 @@ class SyncInfoViewModelTest { viewModel.toggleImageSync() - coVerify { syncOrchestrator.startImageSync() } - coVerify(exactly = 0) { syncOrchestrator.stopImageSync() } + verify { sync(SyncCommands.OneTime.Images.start()) } + verify(exactly = 0) { sync(SyncCommands.OneTime.Images.stop()) } } @Test @@ -487,8 +472,8 @@ class SyncInfoViewModelTest { viewModel.toggleImageSync() - coVerify { syncOrchestrator.stopImageSync() } - coVerify(exactly = 0) { syncOrchestrator.startImageSync() } + verify { sync(SyncCommands.OneTime.Images.stop()) } + verify(exactly = 0) { sync(SyncCommands.OneTime.Images.start()) } } // logout() tests @@ -542,7 +527,7 @@ class SyncInfoViewModelTest { viewModel.handleLoginResult(successResult) - coVerify { syncOrchestrator.startEventSync(any()) } + verify { sync(SyncCommands.OneTime.Events.stopAndStart()) } } @Test @@ -553,7 +538,8 @@ class SyncInfoViewModelTest { viewModel.handleLoginResult(failureResult) - coVerify(exactly = 0) { syncOrchestrator.startEventSync(any()) } + verify(exactly = 0) { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = true)) } + verify(exactly = 0) { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = false)) } } // Sync button responsiveness optimization @@ -689,7 +675,7 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - coVerify { syncOrchestrator.startEventSync(any()) } + verify { sync(SyncCommands.OneTime.Events.stopAndStart()) } } @Test @@ -705,7 +691,7 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - coVerify { syncOrchestrator.startEventSync(any()) } + verify { sync(SyncCommands.OneTime.Events.stopAndStart()) } } @Test @@ -721,7 +707,8 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - coVerify(exactly = 0) { syncOrchestrator.startEventSync(any()) } + verify(exactly = 0) { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = true)) } + verify(exactly = 0) { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = false)) } } @Test @@ -735,7 +722,8 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - coVerify(exactly = 0) { syncOrchestrator.startEventSync(any()) } + verify(exactly = 0) { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = true)) } + verify(exactly = 0) { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = false)) } } @Test @@ -752,7 +740,7 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - coVerify(atLeast = 0) { syncOrchestrator.startEventSync(any()) } + verify { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = false)) } } @Test @@ -773,7 +761,7 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - coVerify(exactly = 1) { syncOrchestrator.startEventSync(any()) } + verify(exactly = 1) { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = false)) } } @Test @@ -794,7 +782,8 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - coVerify(exactly = 0) { syncOrchestrator.startEventSync(any()) } + verify(exactly = 0) { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = true)) } + verify(exactly = 0) { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = false)) } } private companion object { diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModelTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModelTest.kt index e80e6ed74a..ecf1c21dee 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModelTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModelTest.kt @@ -15,7 +15,8 @@ import com.simprints.infra.config.store.models.Project import com.simprints.infra.config.store.models.SettingsPasswordConfig import com.simprints.infra.config.store.models.TokenKeyType import com.simprints.infra.config.store.tokenization.TokenizationProcessor -import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.SyncCommands +import com.simprints.infra.sync.usecase.SyncUseCase import com.simprints.testtools.common.coroutines.TestCoroutineRule import com.simprints.testtools.common.livedata.getOrAwaitValue import com.simprints.testtools.common.syntax.assertThrows @@ -39,7 +40,7 @@ class ModuleSelectionViewModelTest { private lateinit var repository: ModuleRepository @MockK - private lateinit var syncOrchestrator: SyncOrchestrator + private lateinit var sync: SyncUseCase @MockK private lateinit var configRepository: ConfigRepository @@ -55,6 +56,7 @@ class ModuleSelectionViewModelTest { @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) + every { sync(any()) } returns mockk() val modulesDefault = listOf( Module("a".asTokenizableEncrypted(), false), @@ -80,7 +82,7 @@ class ModuleSelectionViewModelTest { viewModel = ModuleSelectionViewModel( moduleRepository = repository, - syncOrchestrator = syncOrchestrator, + sync = sync, configRepository = configRepository, tokenizationProcessor = tokenizationProcessor, externalScope = CoroutineScope(testCoroutineRule.testCoroutineDispatcher), @@ -173,8 +175,7 @@ class ModuleSelectionViewModelTest { viewModel.saveModules() coVerify(exactly = 1) { repository.saveModules(updatedModules) } - coVerify(exactly = 1) { syncOrchestrator.stopEventSync() } - coVerify(exactly = 1) { syncOrchestrator.startEventSync() } + verify(exactly = 1) { sync(SyncCommands.OneTime.Events.stopAndStart()) } } @Test diff --git a/feature/login-check/src/test/java/com/simprints/feature/logincheck/LoginCheckViewModelTest.kt b/feature/login-check/src/test/java/com/simprints/feature/logincheck/LoginCheckViewModelTest.kt index cfb64d5713..14b4ac216c 100644 --- a/feature/login-check/src/test/java/com/simprints/feature/logincheck/LoginCheckViewModelTest.kt +++ b/feature/login-check/src/test/java/com/simprints/feature/logincheck/LoginCheckViewModelTest.kt @@ -21,7 +21,8 @@ import com.simprints.infra.config.store.models.ProjectState import com.simprints.infra.enrolment.records.repository.local.migration.RealmToRoomMigrationScheduler import com.simprints.infra.security.SecurityManager import com.simprints.infra.security.exceptions.RootedDeviceException -import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.SyncCommands +import com.simprints.infra.sync.usecase.SyncUseCase import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.* import io.mockk.impl.annotations.MockK @@ -62,7 +63,7 @@ internal class LoginCheckViewModelTest { lateinit var startBackgroundSync: StartBackgroundSyncUseCase @MockK - lateinit var syncOrchestrator: SyncOrchestrator + lateinit var sync: SyncUseCase @MockK lateinit var updateSessionScopePayloadUseCase: UpdateSessionScopePayloadUseCase @@ -84,6 +85,7 @@ internal class LoginCheckViewModelTest { @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) + every { sync(any()) } returns mockk() viewModel = LoginCheckViewModel( rootManager = rootMatchers, @@ -94,7 +96,7 @@ internal class LoginCheckViewModelTest { isUserSignedIn = isUserSignedInUseCase, configRepository = configRepository, startBackgroundSync = startBackgroundSync, - syncOrchestrator = syncOrchestrator, + sync = sync, updateDatabaseCountsInCurrentSession = updateSessionScopePayloadUseCase, updateProjectInCurrentSession = updateProjectStateUseCase, updateStoredUserId = updateStoredUserIdUseCase, @@ -169,8 +171,8 @@ internal class LoginCheckViewModelTest { coVerify { addAuthorizationEventUseCase.invoke(any(), eq(false)) - syncOrchestrator.cancelBackgroundWork() } + verify { sync(SyncCommands.Schedule.Everything.stop()) } viewModel.showLoginFlow .test() .assertValue { it.peekContent() == ActionFactory.getIdentifyRequest() } diff --git a/feature/login-check/src/test/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCaseTest.kt b/feature/login-check/src/test/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCaseTest.kt index 6d8fe722b1..7c433d0264 100644 --- a/feature/login-check/src/test/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCaseTest.kt +++ b/feature/login-check/src/test/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCaseTest.kt @@ -1,17 +1,28 @@ package com.simprints.feature.logincheck.usecases +import com.google.common.truth.Truth.assertThat import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.Frequency -import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.SyncCommand +import com.simprints.infra.sync.SyncCommands +import com.simprints.infra.sync.SyncResponse +import com.simprints.infra.sync.usecase.SyncUseCase import io.mockk.* import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent import org.junit.Before import org.junit.Test +@OptIn(ExperimentalCoroutinesApi::class) class StartBackgroundSyncUseCaseTest { @MockK - lateinit var syncOrchestrator: SyncOrchestrator + lateinit var sync: SyncUseCase @MockK lateinit var configRepository: ConfigRepository @@ -21,10 +32,11 @@ class StartBackgroundSyncUseCaseTest { @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) + every { sync(any()) } returns mockk() useCase = StartBackgroundSyncUseCase( - syncOrchestrator, configRepository, + sync, ) } @@ -37,9 +49,7 @@ class StartBackgroundSyncUseCaseTest { } returns Frequency.PERIODICALLY - useCase.invoke() - - coVerify { syncOrchestrator.scheduleBackgroundWork(any()) } + assertUseCaseAwaitsSync(SyncCommands.Schedule.Everything.start(withDelay = true)) } @Test @@ -51,9 +61,7 @@ class StartBackgroundSyncUseCaseTest { } returns Frequency.PERIODICALLY_AND_ON_SESSION_START - useCase.invoke() - - coVerify { syncOrchestrator.scheduleBackgroundWork(eq(false)) } + assertUseCaseAwaitsSync(SyncCommands.Schedule.Everything.start()) } @Test @@ -65,9 +73,7 @@ class StartBackgroundSyncUseCaseTest { } returns Frequency.PERIODICALLY - useCase.invoke() - - coVerify { syncOrchestrator.scheduleBackgroundWork(eq(true)) } + assertUseCaseAwaitsSync(SyncCommands.Schedule.Everything.start(withDelay = true)) } @Test @@ -78,8 +84,25 @@ class StartBackgroundSyncUseCaseTest { .synchronization.down.simprints } returns null - useCase.invoke() + assertUseCaseAwaitsSync(SyncCommands.Schedule.Everything.start(withDelay = true)) + } + + private suspend fun TestScope.assertUseCaseAwaitsSync(expectedCommand: SyncCommand) { + val syncCommandJob = Job() + every { sync(any()) } returns SyncResponse( + syncCommandJob = syncCommandJob, + syncStatusFlow = MutableStateFlow(mockk(relaxed = true)), + ) + + val useCaseJob = async { useCase.invoke() } + + runCurrent() + assertThat(useCaseJob.isCompleted).isFalse() + + syncCommandJob.complete() + runCurrent() + useCaseJob.await() - coVerify { syncOrchestrator.scheduleBackgroundWork(eq(true)) } + verify { sync(expectedCommand) } } } diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/LogoutUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/LogoutUseCaseTest.kt index 2159a9cfd4..a5094e899b 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/LogoutUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/LogoutUseCaseTest.kt @@ -1,15 +1,29 @@ package com.simprints.infra.sync.config.usecase +import com.google.common.truth.Truth.assertThat import com.simprints.infra.authlogic.AuthManager +import com.simprints.infra.sync.ExecutableSyncCommand +import com.simprints.infra.sync.SyncAction +import com.simprints.infra.sync.SyncCommands +import com.simprints.infra.sync.SyncCommand +import com.simprints.infra.sync.SyncTarget import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.usecase.SyncUseCase import io.mockk.MockKAnnotations import io.mockk.coVerify +import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test class LogoutUseCaseTest { + @MockK + private lateinit var sync: SyncUseCase + @MockK private lateinit var syncOrchestrator: SyncOrchestrator @@ -17,13 +31,16 @@ class LogoutUseCaseTest { private lateinit var authManager: AuthManager private lateinit var useCase: LogoutUseCase + private val syncCommandSlot = slot() @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) + every { sync(capture(syncCommandSlot)) } returns mockk() useCase = LogoutUseCase( syncOrchestrator = syncOrchestrator, + sync = sync, authManager = authManager, ) } @@ -32,8 +49,13 @@ class LogoutUseCaseTest { fun `Fully logs out when called`() = runTest { useCase.invoke() + val command = syncCommandSlot.captured as ExecutableSyncCommand + assertThat(command.target).isEqualTo(SyncTarget.SCHEDULE_EVERYTHING) + assertThat(command.action).isEqualTo(SyncAction.STOP) + assertThat(command).isEqualTo(SyncCommands.Schedule.Everything.stop()) + + verify { sync(SyncCommands.Schedule.Everything.stop()) } coVerify { - syncOrchestrator.cancelBackgroundWork() syncOrchestrator.deleteEventSyncInfo() authManager.signOut() } diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCaseTest.kt index 067a2c0f64..442e9c31fb 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCaseTest.kt @@ -1,27 +1,39 @@ package com.simprints.infra.sync.config.usecase -import com.simprints.infra.sync.SyncOrchestrator +import com.google.common.truth.Truth.assertThat +import com.simprints.infra.sync.SyncCommands +import com.simprints.infra.sync.SyncResponse import com.simprints.infra.sync.config.testtools.projectConfiguration import com.simprints.infra.sync.config.testtools.simprintsUpSyncConfigurationConfiguration import com.simprints.infra.sync.config.testtools.synchronizationConfiguration +import com.simprints.infra.sync.usecase.SyncUseCase import io.mockk.MockKAnnotations -import io.mockk.coVerify +import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test +@OptIn(ExperimentalCoroutinesApi::class) class RescheduleWorkersIfConfigChangedUseCaseTest { @MockK - private lateinit var syncOrchestrator: SyncOrchestrator + private lateinit var sync: SyncUseCase private lateinit var useCase: RescheduleWorkersIfConfigChangedUseCase @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) + every { sync(any()) } returns noopSyncResponse() - useCase = RescheduleWorkersIfConfigChangedUseCase(syncOrchestrator) + useCase = RescheduleWorkersIfConfigChangedUseCase(sync) } @Test @@ -47,32 +59,53 @@ class RescheduleWorkersIfConfigChangedUseCaseTest { ), ) - coVerify(exactly = 0) { syncOrchestrator.rescheduleImageUpSync() } + verify(exactly = 0) { sync(any()) } } @Test fun `should reschedule image upload when unmetered connection flag changes`() = runTest { - useCase( - projectConfiguration.copy( - synchronization = synchronizationConfiguration.copy( - up = synchronizationConfiguration.up.copy( - simprints = simprintsUpSyncConfigurationConfiguration.copy( - imagesRequireUnmeteredConnection = false, + val syncCommandJob = Job() + every { sync(any()) } returns SyncResponse( + syncCommandJob = syncCommandJob, + syncStatusFlow = MutableStateFlow(mockk(relaxed = true)), + ) + + val useCaseJob = async { + useCase( + projectConfiguration.copy( + synchronization = synchronizationConfiguration.copy( + up = synchronizationConfiguration.up.copy( + simprints = simprintsUpSyncConfigurationConfiguration.copy( + imagesRequireUnmeteredConnection = false, + ), ), ), ), - ), - projectConfiguration.copy( - synchronization = synchronizationConfiguration.copy( - up = synchronizationConfiguration.up.copy( - simprints = simprintsUpSyncConfigurationConfiguration.copy( - imagesRequireUnmeteredConnection = true, + projectConfiguration.copy( + synchronization = synchronizationConfiguration.copy( + up = synchronizationConfiguration.up.copy( + simprints = simprintsUpSyncConfigurationConfiguration.copy( + imagesRequireUnmeteredConnection = true, + ), ), ), ), - ), - ) + ) + } + + runCurrent() + assertThat(useCaseJob.isCompleted).isFalse() - coVerify { syncOrchestrator.rescheduleImageUpSync() } + syncCommandJob.complete() + runCurrent() + useCaseJob.await() + + verify { sync(SyncCommands.Schedule.Images.start()) } + assertThat(useCaseJob.isCompleted).isTrue() } + + private fun noopSyncResponse() = SyncResponse( + syncCommandJob = Job().apply { complete() }, + syncStatusFlow = MutableStateFlow(mockk(relaxed = true)), + ) } diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt index 3390fa1b06..e524448c19 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt @@ -1,22 +1,34 @@ package com.simprints.infra.sync.config.usecase +import com.google.common.truth.Truth.assertThat import com.simprints.core.domain.tokenization.asTokenizableEncrypted import com.simprints.infra.config.store.models.DownSynchronizationConfiguration import com.simprints.infra.config.store.models.Frequency import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository import com.simprints.infra.eventsync.EventSyncManager -import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.ExecutableSyncCommand +import com.simprints.infra.sync.SyncAction +import com.simprints.infra.sync.SyncCommand +import com.simprints.infra.sync.SyncParam +import com.simprints.infra.sync.SyncResponse +import com.simprints.infra.sync.SyncTarget import com.simprints.infra.sync.config.testtools.projectConfiguration import com.simprints.infra.sync.config.testtools.synchronizationConfiguration +import com.simprints.infra.sync.usecase.SyncUseCase import io.mockk.* import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test +@OptIn(ExperimentalCoroutinesApi::class) class ResetLocalRecordsIfConfigChangedUseCaseTest { @MockK - private lateinit var syncOrchestrator: SyncOrchestrator + private lateinit var sync: SyncUseCase @MockK private lateinit var eventSyncManager: EventSyncManager @@ -25,15 +37,17 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { private lateinit var enrolmentRecordRepository: EnrolmentRecordRepository private lateinit var useCase: ResetLocalRecordsIfConfigChangedUseCase + private val syncCommandSlot = slot() @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) + every { sync(capture(syncCommandSlot)) } returns noopSyncResponse() useCase = ResetLocalRecordsIfConfigChangedUseCase( - syncOrchestrator = syncOrchestrator, eventSyncManager = eventSyncManager, enrolmentRecordRepository = enrolmentRecordRepository, + sync = sync, ) } @@ -60,9 +74,8 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ), ) + verify(exactly = 0) { sync(any()) } coVerify(exactly = 0) { - syncOrchestrator.cancelEventSync() - syncOrchestrator.rescheduleEventSync() eventSyncManager.resetDownSyncInfo() enrolmentRecordRepository.deleteAll() } @@ -91,9 +104,18 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ), ) + verify { sync(any()) } + val command = syncCommandSlot.captured as ExecutableSyncCommand + assertThat(command.target) + .isEqualTo(SyncTarget.SCHEDULE_EVENTS) + assertThat(command.action) + .isEqualTo(SyncAction.STOP_AND_START) + assertThat(command.params[SyncParam.WITH_DELAY]) + .isEqualTo(false) + + command.blockToRunWhileStopped?.invoke() + runCurrent() coVerify { - syncOrchestrator.cancelEventSync() - syncOrchestrator.rescheduleEventSync() eventSyncManager.resetDownSyncInfo() enrolmentRecordRepository.deleteAll() } @@ -122,9 +144,16 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ), ) + verify { sync(any()) } + val command = syncCommandSlot.captured as ExecutableSyncCommand + assertThat(command.target) + .isEqualTo(SyncTarget.SCHEDULE_EVENTS) + assertThat(command.action) + .isEqualTo(SyncAction.STOP_AND_START) + + command.blockToRunWhileStopped?.invoke() + runCurrent() coVerify { - syncOrchestrator.cancelEventSync() - syncOrchestrator.rescheduleEventSync() eventSyncManager.resetDownSyncInfo() enrolmentRecordRepository.deleteAll() } @@ -153,9 +182,16 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ), ) + verify { sync(any()) } + val command = syncCommandSlot.captured as ExecutableSyncCommand + assertThat(command.target) + .isEqualTo(SyncTarget.SCHEDULE_EVENTS) + assertThat(command.action) + .isEqualTo(SyncAction.STOP_AND_START) + + command.blockToRunWhileStopped?.invoke() + runCurrent() coVerify { - syncOrchestrator.cancelEventSync() - syncOrchestrator.rescheduleEventSync() eventSyncManager.resetDownSyncInfo() enrolmentRecordRepository.deleteAll() } @@ -184,9 +220,16 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ), ) + verify { sync(any()) } + val command = syncCommandSlot.captured as ExecutableSyncCommand + assertThat(command.target) + .isEqualTo(SyncTarget.SCHEDULE_EVENTS) + assertThat(command.action) + .isEqualTo(SyncAction.STOP_AND_START) + + command.blockToRunWhileStopped?.invoke() + runCurrent() coVerify { - syncOrchestrator.cancelEventSync() - syncOrchestrator.rescheduleEventSync() eventSyncManager.resetDownSyncInfo() enrolmentRecordRepository.deleteAll() } @@ -215,9 +258,8 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ), ) + verify(exactly = 0) { sync(any()) } coVerify(exactly = 0) { - syncOrchestrator.cancelEventSync() - syncOrchestrator.rescheduleEventSync() eventSyncManager.resetDownSyncInfo() enrolmentRecordRepository.deleteAll() } @@ -246,9 +288,8 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ), ) + verify(exactly = 0) { sync(any()) } coVerify(exactly = 0) { - syncOrchestrator.cancelEventSync() - syncOrchestrator.rescheduleEventSync() eventSyncManager.resetDownSyncInfo() enrolmentRecordRepository.deleteAll() } @@ -277,9 +318,8 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ), ) + verify(exactly = 0) { sync(any()) } coVerify(exactly = 0) { - syncOrchestrator.cancelEventSync() - syncOrchestrator.rescheduleEventSync() eventSyncManager.resetDownSyncInfo() enrolmentRecordRepository.deleteAll() } @@ -308,11 +348,15 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ), ) + verify(exactly = 0) { sync(any()) } coVerify(exactly = 0) { - syncOrchestrator.cancelEventSync() - syncOrchestrator.rescheduleEventSync() eventSyncManager.resetDownSyncInfo() enrolmentRecordRepository.deleteAll() } } + + private fun noopSyncResponse() = SyncResponse( + syncCommandJob = Job().apply { complete() }, + syncStatusFlow = MutableStateFlow(mockk(relaxed = true)), + ) } From 9757289c1e447093fb2516a1ebc92a379edad345 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 27 Jan 2026 15:04:29 +0000 Subject: [PATCH 06/22] MS-1299 Sync revamp: RunBlockingEventSyncUseCase adjusted for safer lastSyncId retrieval --- .../usecase/RunBlockingEventSyncUseCase.kt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt index edb7e79cb1..72ada889f0 100644 --- a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt @@ -11,15 +11,17 @@ internal class RunBlockingEventSyncUseCase @Inject constructor( ) { suspend operator fun invoke() { // First item in the flow (except uninitialized) is the state of last sync, - // so it can be used to as a filter out old sync states + // so it can be used to as a filter out old sync states. + // To guarantee it's not associated with the newly run sync, + // the value needs to be taken before it starts. + val lastSyncId = sync(SyncCommands.ObserveOnly).syncStatusFlow + .map { it.eventSyncState } + .firstOrNull { !it.isUninitialized() } + ?.syncId sync(SyncCommands.OneTime.Events.start()).let { (startJob, syncStatusFlow) -> - val eventSyncStateFlow = syncStatusFlow - .map { it.eventSyncState } - val lastSyncId = eventSyncStateFlow - .firstOrNull { !it.isUninitialized() } - ?.syncId startJob.join() - eventSyncStateFlow + syncStatusFlow + .map { it.eventSyncState } .firstOrNull { it.syncId != lastSyncId && it.isSyncReporterCompleted() } } } From 0f9764557ac02e1fa5058adb0eb5c8391e753de0 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 27 Jan 2026 15:04:55 +0000 Subject: [PATCH 07/22] MS-1299 Sync revamp: SyncUseCase replaces most of SyncOrchestrator at call sites - test adjustments 2 --- .../RunBlockingEventSyncUseCaseTest.kt | 31 +++++-------- .../infra/sync/usecase/SyncUseCaseTest.kt | 44 ++++++++++++++++--- 2 files changed, 49 insertions(+), 26 deletions(-) diff --git a/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt index 716a2b21d6..1ed2755eeb 100644 --- a/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt +++ b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt @@ -6,7 +6,6 @@ import com.simprints.infra.eventsync.status.models.EventSyncWorkerState import com.simprints.infra.eventsync.status.models.EventSyncWorkerType import com.simprints.infra.sync.ImageSyncStatus import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.SyncOrchestrator import com.simprints.infra.sync.SyncResponse import com.simprints.infra.sync.SyncStatus import com.simprints.infra.sync.usecase.SyncUseCase @@ -32,21 +31,13 @@ class RunBlockingEventSyncUseCaseTest { @MockK private lateinit var sync: SyncUseCase - @MockK - private lateinit var syncOrchestrator: SyncOrchestrator - private lateinit var usecase: RunBlockingEventSyncUseCase @Before fun setUp() { MockKAnnotations.init(this) - coJustRun { syncOrchestrator.startEventSync(any()) } - - usecase = RunBlockingEventSyncUseCase( - sync, - syncOrchestrator, - ) + usecase = RunBlockingEventSyncUseCase(sync) } @Test @@ -60,8 +51,8 @@ class RunBlockingEventSyncUseCaseTest { syncFlow.value = createSyncStatus("sync", EventSyncWorkerState.Succeeded) testScheduler.advanceUntilIdle() - coVerify { syncOrchestrator.startEventSync(any()) } - verify(exactly = 2) { sync.invoke(SyncCommands.ObserveOnly) } + verify(exactly = 1) { sync(SyncCommands.ObserveOnly) } + verify(exactly = 1) { sync.invoke(SyncCommands.OneTime.Events.start()) } } @Test @@ -75,8 +66,8 @@ class RunBlockingEventSyncUseCaseTest { syncFlow.value = createSyncStatus("sync", EventSyncWorkerState.Failed()) testScheduler.advanceUntilIdle() - coVerify { syncOrchestrator.startEventSync(any()) } - verify(exactly = 2) { sync.invoke(SyncCommands.ObserveOnly) } + verify(exactly = 1) { sync(SyncCommands.ObserveOnly) } + verify(exactly = 1) { sync.invoke(SyncCommands.OneTime.Events.start()) } } @Test @@ -90,8 +81,8 @@ class RunBlockingEventSyncUseCaseTest { syncFlow.value = createSyncStatus("sync", EventSyncWorkerState.Cancelled) testScheduler.advanceUntilIdle() - coVerify { syncOrchestrator.startEventSync(any()) } - verify(exactly = 2) { sync.invoke(SyncCommands.ObserveOnly) } + verify(exactly = 1) { sync(SyncCommands.ObserveOnly) } + verify(exactly = 1) { sync.invoke(SyncCommands.OneTime.Events.start()) } } @Test @@ -102,12 +93,12 @@ class RunBlockingEventSyncUseCaseTest { val job = launch { usecase.invoke() } testScheduler.advanceUntilIdle() - coVerify(exactly = 0) { syncOrchestrator.startEventSync(any()) } + verify(exactly = 0) { sync(SyncCommands.OneTime.Events.start()) } syncFlow.value = createSyncStatus("sync", EventSyncWorkerState.Succeeded) testScheduler.advanceUntilIdle() - coVerify(exactly = 1) { syncOrchestrator.startEventSync(any()) } + verify(exactly = 1) { sync(SyncCommands.OneTime.Events.start()) } job.cancel() } @@ -137,10 +128,12 @@ class RunBlockingEventSyncUseCaseTest { } private fun setUpSync(syncFlow: StateFlow) { - every { sync.invoke(SyncCommands.ObserveOnly) } returns SyncResponse( + val syncResponse = SyncResponse( syncCommandJob = Job().apply { complete() }, syncStatusFlow = syncFlow, ) + every { sync.invoke(SyncCommands.ObserveOnly) } returns syncResponse + every { sync.invoke(SyncCommands.OneTime.Events.start()) } returns syncResponse } private fun createPlaceholderSyncStatus(): SyncStatus = createSyncStatus("", null, null, null) diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt index afba5db6c0..1f5a70a410 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt @@ -4,14 +4,17 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.google.common.truth.Truth.assertThat import com.simprints.infra.eventsync.status.models.EventSyncState import com.simprints.infra.eventsync.sync.EventSyncStateProcessor +import com.simprints.infra.sync.ExecutableSyncCommand import com.simprints.infra.sync.ImageSyncStatus import com.simprints.infra.sync.SyncCommands import com.simprints.infra.sync.SyncStatus +import com.simprints.infra.sync.usecase.internal.ExecuteSyncCommandUseCase import com.simprints.infra.sync.usecase.internal.ObserveImageSyncStatusUseCase import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.verify +import kotlinx.coroutines.Job import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.test.runCurrent @@ -31,6 +34,9 @@ class SyncUseCaseTest { @MockK private lateinit var imageSync: ObserveImageSyncStatusUseCase + @MockK + private lateinit var executeSyncCommand: ExecuteSyncCommandUseCase + private val eventSyncStatusFlow = MutableSharedFlow() private val imageSyncStatusFlow = MutableSharedFlow() @@ -39,6 +45,7 @@ class SyncUseCaseTest { MockKAnnotations.init(this, relaxed = true) every { eventSyncStateProcessor.getLastSyncState() } returns eventSyncStatusFlow every { imageSync.invoke() } returns imageSyncStatusFlow + every { executeSyncCommand.invoke(any()) } returns Job().apply { complete() } } @Test @@ -59,7 +66,7 @@ class SyncUseCaseTest { lastUpdateTimeMillis = -1L, ), ) - val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, appScope = backgroundScope) + val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, executeSyncCommand, appScope = backgroundScope) val resultFlow = useCase(SyncCommands.ObserveOnly).syncStatusFlow @@ -83,7 +90,7 @@ class SyncUseCaseTest { lastUpdateTimeMillis = 123L, ) val expected = SyncStatus(event, image) - val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, appScope = backgroundScope) + val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, executeSyncCommand, appScope = backgroundScope) val resultFlow = useCase(SyncCommands.ObserveOnly).syncStatusFlow @@ -106,7 +113,7 @@ class SyncUseCaseTest { reporterStates = emptyList(), lastSyncTime = null, ) - val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, appScope = backgroundScope) + val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, executeSyncCommand, appScope = backgroundScope) val resultFlow = useCase(SyncCommands.ObserveOnly).syncStatusFlow @@ -127,7 +134,7 @@ class SyncUseCaseTest { progress = 2 to 5, lastUpdateTimeMillis = 123L, ) - val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, appScope = backgroundScope) + val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, executeSyncCommand, appScope = backgroundScope) val resultFlow = useCase(SyncCommands.ObserveOnly).syncStatusFlow @@ -162,7 +169,7 @@ class SyncUseCaseTest { ) val expected1 = SyncStatus(event1, image) val expected2 = SyncStatus(event2, image) - val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, appScope = backgroundScope) + val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, executeSyncCommand, appScope = backgroundScope) val resultFlow = useCase(SyncCommands.ObserveOnly).syncStatusFlow @@ -200,7 +207,7 @@ class SyncUseCaseTest { ) val expected1 = SyncStatus(event, image1) val expected2 = SyncStatus(event, image2) - val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, appScope = backgroundScope) + val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, executeSyncCommand, appScope = backgroundScope) val resultFlow = useCase(SyncCommands.ObserveOnly).syncStatusFlow @@ -236,7 +243,7 @@ class SyncUseCaseTest { val image2 = image1.copy( isSyncing = false, ) - val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, appScope = backgroundScope) + val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, executeSyncCommand, appScope = backgroundScope) val resultFlow1 = useCase(SyncCommands.ObserveOnly).syncStatusFlow @@ -254,4 +261,27 @@ class SyncUseCaseTest { verify(exactly = 1) { eventSyncStateProcessor.getLastSyncState() } verify(exactly = 1) { imageSync() } } + + @Test + fun `does not execute sync command for observe-only`() = runTest { + val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, executeSyncCommand, appScope = backgroundScope) + + val response = useCase(SyncCommands.ObserveOnly) + + assertThat(response.syncCommandJob.isCompleted).isTrue() + verify(exactly = 0) { executeSyncCommand.invoke(any()) } + } + + @Test + fun `executes executable sync command and returns its job`() = runTest { + val expectedJob = Job().apply { complete() } + every { executeSyncCommand.invoke(any()) } returns expectedJob + val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, executeSyncCommand, appScope = backgroundScope) + val command = SyncCommands.Schedule.Everything.stopAndStart() as ExecutableSyncCommand + + val response = useCase(command) + + assertThat(response.syncCommandJob).isSameInstanceAs(expectedJob) + verify(exactly = 1) { executeSyncCommand.invoke(command) } + } } From 79d3809ca60bb736eb55060aa1a3606929081114 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 27 Jan 2026 15:27:09 +0000 Subject: [PATCH 08/22] MS-1299 Sync revamp: SyncOrchestrator unused functions removed - they are in ExecuteSyncCommandUseCase --- .../simprints/infra/sync/SyncOrchestrator.kt | 24 +- .../infra/sync/SyncOrchestratorImpl.kt | 139 --------- .../infra/sync/SyncOrchestratorImplTest.kt | 292 ------------------ 3 files changed, 1 insertion(+), 454 deletions(-) diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestrator.kt b/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestrator.kt index 7616a935ad..0e8e870b60 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestrator.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestrator.kt @@ -2,12 +2,8 @@ package com.simprints.infra.sync import kotlinx.coroutines.flow.Flow -// todo MS-1299, MS-1300 move sync controls into SyncUseCase & its helper usecases, disband the rest into new other usecases +// todo MS-1300 disband the rest into new other usecases interface SyncOrchestrator { - suspend fun scheduleBackgroundWork(withDelay: Boolean = false) - - suspend fun cancelBackgroundWork() - fun startConfigSync() /** @@ -16,24 +12,6 @@ interface SyncOrchestrator { */ fun refreshConfiguration(): Flow - suspend fun rescheduleEventSync(withDelay: Boolean = false) - - fun cancelEventSync() - - suspend fun startEventSync(isDownSyncAllowed: Boolean = true) - - fun stopEventSync() - - fun startImageSync() - - fun stopImageSync() - - /** - * Fully reschedule the background worker. - * Should be used in when the configuration that affects scheduling has changed. - */ - suspend fun rescheduleImageUpSync() - /** * Schedule a worker to upload subjects with IDs in the provided list. */ diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestratorImpl.kt b/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestratorImpl.kt index d534f3e6db..25b8957ebf 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestratorImpl.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestratorImpl.kt @@ -1,101 +1,28 @@ package com.simprints.infra.sync -import androidx.work.Constraints -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.NetworkType import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.WorkQuery import androidx.work.workDataOf -import com.simprints.core.AppScope -import com.simprints.infra.authstore.AuthStore -import com.simprints.infra.config.store.ConfigRepository -import com.simprints.infra.config.store.models.imagesUploadRequiresUnmeteredConnection -import com.simprints.infra.config.store.models.isCommCareEventDownSyncAllowed import com.simprints.infra.eventsync.EventSyncManager -import com.simprints.infra.eventsync.sync.master.EventSyncMasterWorker import com.simprints.infra.sync.config.worker.DeviceConfigDownSyncWorker import com.simprints.infra.sync.config.worker.ProjectConfigDownSyncWorker import com.simprints.infra.sync.enrolments.EnrolmentRecordWorker -import com.simprints.infra.sync.extensions.anyRunning -import com.simprints.infra.sync.extensions.cancelWorkers -import com.simprints.infra.sync.extensions.schedulePeriodicWorker import com.simprints.infra.sync.extensions.startWorker -import com.simprints.infra.sync.files.FileUpSyncWorker -import com.simprints.infra.sync.firmware.FirmwareFileUpdateWorker -import com.simprints.infra.sync.firmware.ShouldScheduleFirmwareUpdateUseCase import com.simprints.infra.sync.usecase.CleanupDeprecatedWorkersUseCase -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton @Singleton internal class SyncOrchestratorImpl @Inject constructor( private val workManager: WorkManager, - private val authStore: AuthStore, - private val configRepository: ConfigRepository, private val eventSyncManager: EventSyncManager, - private val shouldScheduleFirmwareUpdate: ShouldScheduleFirmwareUpdateUseCase, private val cleanupDeprecatedWorkers: CleanupDeprecatedWorkersUseCase, private val imageSyncTimestampProvider: ImageSyncTimestampProvider, - @param:AppScope private val appScope: CoroutineScope, ) : SyncOrchestrator { - init { - appScope.launch { - // Stop image upload when event sync starts - workManager - .getWorkInfosFlow( - WorkQuery.fromUniqueWorkNames( - SyncConstants.EVENT_SYNC_WORK_NAME, - SyncConstants.EVENT_SYNC_WORK_NAME_ONE_TIME, - ), - ).collect { workInfoList -> - if (workInfoList.anyRunning()) rescheduleImageUpSync() - } - } - } - - override suspend fun scheduleBackgroundWork(withDelay: Boolean) { - if (authStore.signedInProjectId.isNotEmpty()) { - workManager.schedulePeriodicWorker( - SyncConstants.PROJECT_SYNC_WORK_NAME, - SyncConstants.PROJECT_SYNC_REPEAT_INTERVAL, - ) - workManager.schedulePeriodicWorker( - SyncConstants.DEVICE_SYNC_WORK_NAME, - SyncConstants.DEVICE_SYNC_REPEAT_INTERVAL, - ) - workManager.schedulePeriodicWorker( - SyncConstants.FILE_UP_SYNC_WORK_NAME, - SyncConstants.FILE_UP_SYNC_REPEAT_INTERVAL, - constraints = getImageUploadConstraints(), - ) - rescheduleEventSync(withDelay) - if (shouldScheduleFirmwareUpdate()) { - workManager.schedulePeriodicWorker( - SyncConstants.FIRMWARE_UPDATE_WORK_NAME, - SyncConstants.FIRMWARE_UPDATE_REPEAT_INTERVAL, - ) - } else { - workManager.cancelWorkers(SyncConstants.FIRMWARE_UPDATE_WORK_NAME) - } - } - } - - override suspend fun cancelBackgroundWork() { - workManager.cancelWorkers( - SyncConstants.PROJECT_SYNC_WORK_NAME, - SyncConstants.DEVICE_SYNC_WORK_NAME, - SyncConstants.FILE_UP_SYNC_WORK_NAME, - SyncConstants.EVENT_SYNC_WORK_NAME, - SyncConstants.FIRMWARE_UPDATE_WORK_NAME, - ) - stopEventSync() - } override fun startConfigSync() { workManager.startWorker(SyncConstants.PROJECT_SYNC_WORK_NAME_ONE_TIME) @@ -115,55 +42,6 @@ internal class SyncOrchestratorImpl @Inject constructor( }.map { } // Converts flow emissions to Unit value as we only care about when it happens, not the value } - override suspend fun rescheduleEventSync(withDelay: Boolean) { - workManager.schedulePeriodicWorker( - workName = SyncConstants.EVENT_SYNC_WORK_NAME, - repeatInterval = SyncConstants.EVENT_SYNC_WORKER_INTERVAL, - initialDelay = if (withDelay) SyncConstants.EVENT_SYNC_WORKER_INTERVAL else 0, - constraints = getEventSyncConstraints(), - tags = eventSyncManager.getPeriodicWorkTags(), - ) - } - - override fun cancelEventSync() { - workManager.cancelWorkers(SyncConstants.EVENT_SYNC_WORK_NAME) - stopEventSync() - } - - override suspend fun startEventSync(isDownSyncAllowed: Boolean) { - workManager.startWorker( - workName = SyncConstants.EVENT_SYNC_WORK_NAME_ONE_TIME, - constraints = getEventSyncConstraints(), - tags = eventSyncManager.getOneTimeWorkTags(), - inputData = workDataOf(EventSyncMasterWorker.IS_DOWN_SYNC_ALLOWED to isDownSyncAllowed), - ) - } - - override fun stopEventSync() { - workManager.cancelWorkers(SyncConstants.EVENT_SYNC_WORK_NAME_ONE_TIME) - // Event sync consists of multiple workers, so we cancel them all by tag - workManager.cancelAllWorkByTag(eventSyncManager.getAllWorkerTag()) - } - - override fun startImageSync() { - stopImageSync() - workManager.startWorker(SyncConstants.FILE_UP_SYNC_WORK_NAME) - } - - override fun stopImageSync() { - workManager.cancelWorkers(SyncConstants.FILE_UP_SYNC_WORK_NAME) - } - - override suspend fun rescheduleImageUpSync() { - workManager.schedulePeriodicWorker( - SyncConstants.FILE_UP_SYNC_WORK_NAME, - SyncConstants.FILE_UP_SYNC_REPEAT_INTERVAL, - initialDelay = SyncConstants.DEFAULT_BACKOFF_INTERVAL_MINUTES, - existingWorkPolicy = ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, - constraints = getImageUploadConstraints(), - ) - } - override fun uploadEnrolmentRecords( id: String, subjectIds: List, @@ -186,21 +64,4 @@ internal class SyncOrchestratorImpl @Inject constructor( override fun cleanupWorkers() { cleanupDeprecatedWorkers() } - - private suspend fun getImageUploadConstraints(): Constraints { - val networkType = configRepository - .getProjectConfiguration() - .imagesUploadRequiresUnmeteredConnection() - .let { if (it) NetworkType.UNMETERED else NetworkType.CONNECTED } - return Constraints.Builder().setRequiredNetworkType(networkType).build() - } - - private suspend fun getEventSyncConstraints(): Constraints { - // CommCare doesn't require network connection - val networkType = configRepository - .getProjectConfiguration() - .isCommCareEventDownSyncAllowed() - .let { if (it) NetworkType.NOT_REQUIRED else NetworkType.CONNECTED } - return Constraints.Builder().setRequiredNetworkType(networkType).build() - } } diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/SyncOrchestratorImplTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/SyncOrchestratorImplTest.kt index 15fa765193..14a6f11b54 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/SyncOrchestratorImplTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/SyncOrchestratorImplTest.kt @@ -1,38 +1,21 @@ package com.simprints.infra.sync -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.NetworkType import androidx.work.OneTimeWorkRequest import androidx.work.WorkInfo import androidx.work.WorkManager import com.google.common.truth.Truth.assertThat -import com.google.common.util.concurrent.ListenableFuture -import com.simprints.infra.authstore.AuthStore -import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.eventsync.EventSyncManager -import com.simprints.infra.eventsync.sync.master.EventSyncMasterWorker -import com.simprints.infra.sync.SyncConstants.DEVICE_SYNC_WORK_NAME import com.simprints.infra.sync.SyncConstants.DEVICE_SYNC_WORK_NAME_ONE_TIME -import com.simprints.infra.sync.SyncConstants.EVENT_SYNC_WORK_NAME -import com.simprints.infra.sync.SyncConstants.EVENT_SYNC_WORK_NAME_ONE_TIME -import com.simprints.infra.sync.SyncConstants.FILE_UP_SYNC_WORK_NAME -import com.simprints.infra.sync.SyncConstants.FIRMWARE_UPDATE_WORK_NAME -import com.simprints.infra.sync.SyncConstants.PROJECT_SYNC_WORK_NAME import com.simprints.infra.sync.SyncConstants.PROJECT_SYNC_WORK_NAME_ONE_TIME import com.simprints.infra.sync.SyncConstants.RECORD_UPLOAD_INPUT_ID_NAME import com.simprints.infra.sync.SyncConstants.RECORD_UPLOAD_INPUT_SUBJECT_IDS_NAME -import com.simprints.infra.sync.firmware.ShouldScheduleFirmwareUpdateUseCase import com.simprints.infra.sync.usecase.CleanupDeprecatedWorkersUseCase import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.MockKAnnotations -import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK -import io.mockk.mockk import io.mockk.verify -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.count import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -48,18 +31,9 @@ class SyncOrchestratorImplTest { @MockK private lateinit var workManager: WorkManager - @MockK - private lateinit var authStore: AuthStore - - @MockK - private lateinit var configRepository: ConfigRepository - @MockK private lateinit var eventSyncManager: EventSyncManager - @MockK - private lateinit var shouldScheduleFirmwareUpdate: ShouldScheduleFirmwareUpdateUseCase - @MockK private lateinit var cleanupDeprecatedWorkers: CleanupDeprecatedWorkersUseCase @@ -75,106 +49,6 @@ class SyncOrchestratorImplTest { syncOrchestrator = createSyncOrchestrator() } - @Test - fun `does not schedules any workers if not logged in`() = runTest { - every { authStore.signedInProjectId } returns "" - coEvery { shouldScheduleFirmwareUpdate.invoke() } returns false - - syncOrchestrator.scheduleBackgroundWork() - - verify(exactly = 0) { - workManager.enqueueUniquePeriodicWork(any(), any(), any()) - } - } - - @Test - fun `schedules all necessary background workers if logged in`() = runTest { - every { authStore.signedInProjectId } returns "projectId" - coEvery { shouldScheduleFirmwareUpdate.invoke() } returns true - - syncOrchestrator.scheduleBackgroundWork() - - verify { - workManager.enqueueUniquePeriodicWork(PROJECT_SYNC_WORK_NAME, any(), any()) - workManager.enqueueUniquePeriodicWork(DEVICE_SYNC_WORK_NAME, any(), any()) - workManager.enqueueUniquePeriodicWork(FILE_UP_SYNC_WORK_NAME, any(), any()) - workManager.enqueueUniquePeriodicWork(EVENT_SYNC_WORK_NAME, any(), any()) - workManager.enqueueUniquePeriodicWork(FIRMWARE_UPDATE_WORK_NAME, any(), any()) - } - } - - @Test - fun `schedules images with any connection if not specified`() = runTest { - coEvery { - configRepository - .getProjectConfiguration() - .synchronization.up.simprints.imagesRequireUnmeteredConnection - } returns false - every { authStore.signedInProjectId } returns "projectId" - - syncOrchestrator.scheduleBackgroundWork() - - verify { - workManager.enqueueUniquePeriodicWork( - FILE_UP_SYNC_WORK_NAME, - any(), - match { it.workSpec.constraints.requiredNetworkType == NetworkType.CONNECTED }, - ) - } - } - - @Test - fun `schedules images with unmetered constraint if requested`() = runTest { - coEvery { - configRepository - .getProjectConfiguration() - .synchronization.up.simprints.imagesRequireUnmeteredConnection - } returns true - every { authStore.signedInProjectId } returns "projectId" - coEvery { shouldScheduleFirmwareUpdate.invoke() } returns false - - syncOrchestrator.scheduleBackgroundWork() - - verify { - workManager.enqueueUniquePeriodicWork( - FILE_UP_SYNC_WORK_NAME, - any(), - match { it.workSpec.constraints.requiredNetworkType == NetworkType.UNMETERED }, - ) - } - } - - @Test - fun `schedules cancel firmware update worker if no support for vero 2`() = runTest { - every { authStore.signedInProjectId } returns "projectId" - coEvery { shouldScheduleFirmwareUpdate.invoke() } returns false - - syncOrchestrator.scheduleBackgroundWork() - - verify { - workManager.cancelUniqueWork(FIRMWARE_UPDATE_WORK_NAME) - } - } - - @Test - fun `cancels all necessary background workers`() = runTest { - every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" - - syncOrchestrator.cancelBackgroundWork() - - verify { - workManager.cancelUniqueWork(PROJECT_SYNC_WORK_NAME) - workManager.cancelUniqueWork(DEVICE_SYNC_WORK_NAME) - workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) - workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME) - workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME_ONE_TIME) - workManager.cancelUniqueWork(FIRMWARE_UPDATE_WORK_NAME) - - // Explicitly cancel event sync sub-workers - workManager.cancelAllWorkByTag("syncWorkers") - } - } - @Test fun `schedules device worker when refresh requested`() = runTest { syncOrchestrator.refreshConfiguration() @@ -208,130 +82,6 @@ class SyncOrchestratorImplTest { assertThat(syncOrchestrator.refreshConfiguration().count()).isEqualTo(1) } - @Test - fun `reschedules event sync worker with correct tags`() = runTest { - every { eventSyncManager.getPeriodicWorkTags() } returns listOf("tag1", "tag2") - - syncOrchestrator.rescheduleEventSync() - - verify { - workManager.enqueueUniquePeriodicWork( - EVENT_SYNC_WORK_NAME, - any(), - match { it.tags.containsAll(setOf("tag1", "tag2")) }, - ) - } - } - - @Test - fun `reschedules event sync worker with correct delay`() = runTest { - every { eventSyncManager.getPeriodicWorkTags() } returns listOf("tag1", "tag2") - - syncOrchestrator.rescheduleEventSync(true) - - verify { - workManager.enqueueUniquePeriodicWork( - EVENT_SYNC_WORK_NAME, - any(), - match { it.workSpec.initialDelay > 0 }, - ) - } - } - - @Test - fun `cancel event sync worker cancels correct worker`() = runTest { - every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" - - syncOrchestrator.cancelEventSync() - - verify { - workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME) - workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME_ONE_TIME) - workManager.cancelAllWorkByTag("syncWorkers") - } - } - - @Test - fun `start event sync worker with correct tags`() = runTest { - every { eventSyncManager.getOneTimeWorkTags() } returns listOf("tag1", "tag2") - - syncOrchestrator.startEventSync() - - verify { - workManager.enqueueUniqueWork( - EVENT_SYNC_WORK_NAME_ONE_TIME, - any(), - match { it.tags.containsAll(setOf("tag1", "tag2")) }, - ) - } - } - - @Test - fun `start event sync worker with correct input data`() = runTest { - every { eventSyncManager.getOneTimeWorkTags() } returns listOf("tag1", "tag2") - - syncOrchestrator.startEventSync(isDownSyncAllowed = false) - - verify { - workManager.enqueueUniqueWork( - EVENT_SYNC_WORK_NAME_ONE_TIME, - any(), - match { - !it.workSpec.input.getBoolean(EventSyncMasterWorker.IS_DOWN_SYNC_ALLOWED, true) - }, - ) - } - } - - @Test - fun `stop event sync worker cancels correct worker`() = runTest { - every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" - - syncOrchestrator.cancelEventSync() - - verify { - workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME) - workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME_ONE_TIME) - workManager.cancelAllWorkByTag("syncWorkers") - } - } - - @Test - fun `reschedules image worker when requested`() = runTest { - syncOrchestrator.rescheduleImageUpSync() - - verify { - workManager.enqueueUniquePeriodicWork( - FILE_UP_SYNC_WORK_NAME, - ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, - any(), - ) - } - } - - @Test - fun `start image sync re-starts image worker`() = runTest { - syncOrchestrator.startImageSync() - - verify { - workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) - workManager.enqueueUniqueWork( - FILE_UP_SYNC_WORK_NAME, - any(), - any(), - ) - } - } - - @Test - fun `stop image sync cancels image worker`() = runTest { - syncOrchestrator.stopImageSync() - - verify { - workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) - } - } - @Test fun `schedules record upload`() = runTest { syncOrchestrator.uploadEnrolmentRecords(INSTRUCTION_ID, listOf(SUBJECT_ID)) @@ -368,55 +118,13 @@ class SyncOrchestratorImplTest { verify { cleanupDeprecatedWorkers.invoke() } } - @Test - fun `stops image worker when event sync starts`() = runTest { - val eventStartFlow = MutableSharedFlow>() - every { workManager.getWorkInfosFlow(any()) } returns eventStartFlow - every { - workManager.getWorkInfosForUniqueWork(FILE_UP_SYNC_WORK_NAME) - } returns mockFuture(createWorkInfo(WorkInfo.State.RUNNING)) - - // Recreating orchestrator with new mocks since the subscription is done in init - syncOrchestrator = createSyncOrchestrator() - eventStartFlow.emit(createWorkInfo(WorkInfo.State.RUNNING)) - - verify { - workManager.enqueueUniquePeriodicWork( - FILE_UP_SYNC_WORK_NAME, - ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, - any(), - ) - } - } - - @Test - fun `does not stop image worker when event sync is not running`() = runTest { - val eventStartFlow = MutableSharedFlow>() - every { workManager.getWorkInfosFlow(any()) } returns eventStartFlow - - // Recreating orchestrator with new mocks since the subscription is done in init - syncOrchestrator = createSyncOrchestrator() - eventStartFlow.emit(createWorkInfo(WorkInfo.State.CANCELLED)) - - verify(exactly = 0) { - workManager.getWorkInfosForUniqueWork(FILE_UP_SYNC_WORK_NAME) - workManager.cancelWorkById(any()) - } - } - private fun createSyncOrchestrator() = SyncOrchestratorImpl( workManager, - authStore, - configRepository, eventSyncManager, - shouldScheduleFirmwareUpdate, cleanupDeprecatedWorkers, imageSyncTimestampProvider, - CoroutineScope(testCoroutineRule.testCoroutineDispatcher), ) - private fun mockFuture(workInfo: List) = mockk>> { every { get() } returns workInfo } - private fun createWorkInfo(state: WorkInfo.State) = listOf( WorkInfo(UUID.randomUUID(), state, emptySet()), ) From 91d645f4c66c7e163a5da2fb0aa6912133bd64e3 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 27 Jan 2026 17:02:34 +0000 Subject: [PATCH 09/22] MS-1299 Sync revamp: Explicit matching for command types in SyncUseCase --- .../main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt index 500fd0482e..27a999c2ec 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt @@ -6,6 +6,7 @@ import com.simprints.infra.eventsync.sync.EventSyncStateProcessor import com.simprints.infra.sync.ExecutableSyncCommand import com.simprints.infra.sync.ImageSyncStatus import com.simprints.infra.sync.SyncCommand +import com.simprints.infra.sync.SyncCommands import com.simprints.infra.sync.SyncResponse import com.simprints.infra.sync.SyncStatus import com.simprints.infra.sync.usecase.internal.ExecuteSyncCommandUseCase @@ -104,7 +105,7 @@ class SyncUseCase @Inject internal constructor( SyncResponse( syncCommandJob = when (syncCommand) { is ExecutableSyncCommand -> executeSyncCommand(syncCommand) - else -> Job().apply { complete() } // no-op + is SyncCommands.ObserveOnly -> Job().apply { complete() } // no-op }, syncStatusFlow = sharedSyncStatus, ) From a3c6f36e06f32cd450fb80804bfac21f0e6975df Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 27 Jan 2026 17:38:32 +0000 Subject: [PATCH 10/22] MS-1299 Sync revamp: Sync command await restored where SyncOrchestrator awaited before --- .../id/services/sync/events/down/EventDownSyncResetService.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/id/src/main/java/com/simprints/id/services/sync/events/down/EventDownSyncResetService.kt b/id/src/main/java/com/simprints/id/services/sync/events/down/EventDownSyncResetService.kt index 2246ab1dca..b8c65ee120 100644 --- a/id/src/main/java/com/simprints/id/services/sync/events/down/EventDownSyncResetService.kt +++ b/id/src/main/java/com/simprints/id/services/sync/events/down/EventDownSyncResetService.kt @@ -15,6 +15,7 @@ import com.simprints.infra.eventsync.EventSyncManager import com.simprints.infra.logging.LoggingConstants.CrashReportTag.SYNC import com.simprints.infra.logging.Simber import com.simprints.infra.sync.SyncCommands +import com.simprints.infra.sync.await import com.simprints.infra.sync.usecase.SyncUseCase import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope @@ -50,7 +51,7 @@ class EventDownSyncResetService : Service() { // Reset current downsync state eventSyncManager.resetDownSyncInfo() // Trigger a new sync - sync(SyncCommands.OneTime.Events.start()) + sync(SyncCommands.OneTime.Events.start()).await() } resetJob?.invokeOnCompletion { stopSelf() } From dac27067379e63398c4c7d822e7d52ff47736ef3 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 27 Jan 2026 17:48:40 +0000 Subject: [PATCH 11/22] MS-1299 Sync revamp: IO dispatcher for sync command & init block running --- .../internal/ExecuteSyncCommandUseCase.kt | 20 +++++++++++++++---- .../internal/ExecuteSyncCommandUseCaseTest.kt | 1 + 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt index ac473dd170..f6f1f72d30 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt @@ -7,6 +7,7 @@ import androidx.work.WorkManager import androidx.work.WorkQuery import androidx.work.workDataOf import com.simprints.core.AppScope +import com.simprints.core.DispatcherIO import com.simprints.infra.authstore.AuthStore import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.imagesUploadRequiresUnmeteredConnection @@ -27,8 +28,12 @@ import com.simprints.infra.sync.extensions.startWorker import com.simprints.infra.sync.files.FileUpSyncWorker import com.simprints.infra.sync.firmware.FirmwareFileUpdateWorker import com.simprints.infra.sync.firmware.ShouldScheduleFirmwareUpdateUseCase +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton @@ -41,9 +46,11 @@ internal class ExecuteSyncCommandUseCase @Inject constructor( private val eventSyncManager: EventSyncManager, private val shouldScheduleFirmwareUpdate: ShouldScheduleFirmwareUpdateUseCase, @param:AppScope private val appScope: CoroutineScope, + @param:DispatcherIO private val ioDispatcher: CoroutineDispatcher, ) { init { - appScope.launch { + appScope.launch(ioDispatcher) { + // Automatically conditioned sync command: // Stop image upload when event sync starts workManager .getWorkInfosFlow( @@ -51,8 +58,13 @@ internal class ExecuteSyncCommandUseCase @Inject constructor( SyncConstants.EVENT_SYNC_WORK_NAME, SyncConstants.EVENT_SYNC_WORK_NAME_ONE_TIME, ), - ).collect { workInfoList -> - if (workInfoList.anyRunning()) rescheduleImageUpSync() + ).map { workInfoList -> + workInfoList.anyRunning() + } + .distinctUntilChanged() + .filter { it } // only if any running + .collect { + rescheduleImageUpSync() } } } @@ -66,7 +78,7 @@ internal class ExecuteSyncCommandUseCase @Inject constructor( val isStartNeeded = action in listOf(SyncAction.START, SyncAction.STOP_AND_START) val isFurtherAsyncActionNeeded = blockToRunWhileStopped != null || isStartNeeded return if (isFurtherAsyncActionNeeded) { - appScope.launch { + appScope.launch(ioDispatcher) { blockToRunWhileStopped?.invoke() if (isStartNeeded) { start() diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt index 1f024e73c3..cd447c8749 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt @@ -409,6 +409,7 @@ class ExecuteSyncCommandUseCaseTest { eventSyncManager = eventSyncManager, shouldScheduleFirmwareUpdate = shouldScheduleFirmwareUpdate, appScope = CoroutineScope(testCoroutineRule.testCoroutineDispatcher), + ioDispatcher = testCoroutineRule.testCoroutineDispatcher, ) private fun createWorkInfo(state: WorkInfo.State) = listOf( From 2b4912d03f77dd8c24a53e959dd7e9ae6707a60c Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 27 Jan 2026 17:56:05 +0000 Subject: [PATCH 12/22] MS-1299 Sync revamp: SyncCommandsTest duplication fixed --- .../java/com/simprints/infra/sync/SyncCommandsTest.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt index a31b968bee..b17a2e5bd6 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt @@ -108,21 +108,21 @@ class SyncCommandsTest { ) } - buildersWithDelayParam.forEach { (builder, expectedTarget) -> + buildersWithDownSyncAllowedParam.forEach { (builder, expectedTarget) -> assertThat(builder.stopAndStart()) .isEqualTo( expectedCommand( target = expectedTarget, action, - params = mapOf(SyncParam.WITH_DELAY to false), + params = mapOf(SyncParam.IS_DOWN_SYNC_ALLOWED to true), ), ) - assertThat(builder.stopAndStart(withDelay = true)) + assertThat(builder.stopAndStart(isDownSyncAllowed = false)) .isEqualTo( expectedCommand( target = expectedTarget, action, - params = mapOf(SyncParam.WITH_DELAY to true), + params = mapOf(SyncParam.IS_DOWN_SYNC_ALLOWED to false), ), ) } From 7fdbe8f71b94714dd60797203bb3c45a74637031 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 27 Jan 2026 18:28:17 +0000 Subject: [PATCH 13/22] MS-1299 Sync revamp: ktlint --- .../feature/dashboard/debug/DebugFragment.kt | 3 +- .../dashboard/logout/LogoutSyncViewModel.kt | 3 +- .../StartBackgroundSyncUseCaseTest.kt | 11 +++--- .../usecase/RunBlockingEventSyncUseCase.kt | 3 +- .../usecase/ShouldSuggestSyncUseCase.kt | 3 +- .../com/simprints/infra/sync/SyncCommands.kt | 34 ++++++++++++++----- .../simprints/infra/sync/SyncOrchestrator.kt | 2 +- .../infra/sync/SyncOrchestratorImpl.kt | 1 - ...ResetLocalRecordsIfConfigChangedUseCase.kt | 4 +-- .../infra/sync/usecase/SyncUseCase.kt | 17 ++++------ .../internal/ExecuteSyncCommandUseCase.kt | 3 +- .../simprints/infra/sync/SyncCommandsTest.kt | 1 - .../sync/config/usecase/LogoutUseCaseTest.kt | 4 +-- .../infra/sync/usecase/SyncUseCaseTest.kt | 2 +- .../internal/ExecuteSyncCommandUseCaseTest.kt | 6 ++-- 15 files changed, 58 insertions(+), 39 deletions(-) diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt index 286868c865..6ddc93f0ab 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt @@ -63,7 +63,8 @@ internal class DebugFragment : Fragment(R.layout.fragment_debug) { super.onViewCreated(view, savedInstanceState) applySystemBarInsets(view) - sync(SyncCommands.ObserveOnly).syncStatusFlow + sync(SyncCommands.ObserveOnly) + .syncStatusFlow .map { it.eventSyncState }.asLiveData() diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModel.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModel.kt index 2c16a4c299..2c636404b6 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModel.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModel.kt @@ -36,7 +36,8 @@ internal class LogoutSyncViewModel @Inject constructor( .asLiveData(viewModelScope.coroutineContext) val isLogoutWithoutSyncVisibleLiveData: LiveData = - sync(SyncCommands.ObserveOnly).syncStatusFlow + sync(SyncCommands.ObserveOnly) + .syncStatusFlow .map { syncStatus -> !syncStatus.eventSyncState.isSyncCompleted() || syncStatus.imageSyncStatus.isSyncing }.debounce(timeoutMillis = ANTI_JITTER_DELAY_MILLIS) diff --git a/feature/login-check/src/test/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCaseTest.kt b/feature/login-check/src/test/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCaseTest.kt index 7c433d0264..e4614d4294 100644 --- a/feature/login-check/src/test/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCaseTest.kt +++ b/feature/login-check/src/test/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCaseTest.kt @@ -13,9 +13,9 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test @@ -45,7 +45,8 @@ class StartBackgroundSyncUseCaseTest { coEvery { configRepository .getProjectConfiguration() - .synchronization.down.simprints?.frequency + .synchronization.down.simprints + ?.frequency } returns Frequency.PERIODICALLY @@ -57,7 +58,8 @@ class StartBackgroundSyncUseCaseTest { coEvery { configRepository .getProjectConfiguration() - .synchronization.down.simprints?.frequency + .synchronization.down.simprints + ?.frequency } returns Frequency.PERIODICALLY_AND_ON_SESSION_START @@ -69,7 +71,8 @@ class StartBackgroundSyncUseCaseTest { coEvery { configRepository .getProjectConfiguration() - .synchronization.down.simprints?.frequency + .synchronization.down.simprints + ?.frequency } returns Frequency.PERIODICALLY diff --git a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt index 72ada889f0..243d14fb33 100644 --- a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt @@ -14,7 +14,8 @@ internal class RunBlockingEventSyncUseCase @Inject constructor( // so it can be used to as a filter out old sync states. // To guarantee it's not associated with the newly run sync, // the value needs to be taken before it starts. - val lastSyncId = sync(SyncCommands.ObserveOnly).syncStatusFlow + val lastSyncId = sync(SyncCommands.ObserveOnly) + .syncStatusFlow .map { it.eventSyncState } .firstOrNull { !it.isUninitialized() } ?.syncId diff --git a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCase.kt b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCase.kt index 590bd0676f..2edc9d9af9 100644 --- a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCase.kt +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCase.kt @@ -14,7 +14,8 @@ internal class ShouldSuggestSyncUseCase @Inject constructor( private val sync: SyncUseCase, private val configRepository: ConfigRepository, ) { - suspend operator fun invoke(): Boolean = sync(SyncCommands.ObserveOnly).syncStatusFlow + suspend operator fun invoke(): Boolean = sync(SyncCommands.ObserveOnly) + .syncStatusFlow .map { it.eventSyncState } .firstOrNull() ?.lastSyncTime diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt b/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt index dfa4ad1a39..7d1472aaec 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt @@ -9,7 +9,6 @@ package com.simprints.infra.sync * See also SyncUseCase.invoke. */ object SyncCommands { - object ObserveOnly : SyncCommand() object OneTime { @@ -23,28 +22,42 @@ object SyncCommands { val Images = buildSyncCommands(SyncTarget.SCHEDULE_IMAGES) } - // builders interface SyncCommandBuilder { fun stop(): SyncCommand + fun start(): SyncCommand + fun stopAndStart(): SyncCommand + fun stopAndStartAround(block: suspend () -> Unit): SyncCommand } interface SyncCommandBuilderWithDownSyncParam { fun stop(): SyncCommand + fun start(isDownSyncAllowed: Boolean = true): SyncCommand + fun stopAndStart(isDownSyncAllowed: Boolean = true): SyncCommand - fun stopAndStartAround(isDownSyncAllowed: Boolean = true, block: suspend () -> Unit): SyncCommand + + fun stopAndStartAround( + isDownSyncAllowed: Boolean = true, + block: suspend () -> Unit, + ): SyncCommand } interface SyncCommandBuilderWithDelayParam { fun stop(): SyncCommand + fun start(withDelay: Boolean = false): SyncCommand + fun stopAndStart(withDelay: Boolean = false): SyncCommand - fun stopAndStartAround(withDelay: Boolean = false, block: suspend () -> Unit): SyncCommand + + fun stopAndStartAround( + withDelay: Boolean = false, + block: suspend () -> Unit, + ): SyncCommand } private fun buildSyncCommands(target: SyncTarget): SyncCommandBuilder = object : SyncCommandBuilder { @@ -66,8 +79,10 @@ object SyncCommands { override fun stopAndStart(isDownSyncAllowed: Boolean) = getCommand(target, SyncAction.STOP_AND_START, SyncParam.IS_DOWN_SYNC_ALLOWED to isDownSyncAllowed) - override fun stopAndStartAround(isDownSyncAllowed: Boolean, block: suspend () -> Unit) = - getCommand(target, SyncAction.STOP_AND_START, SyncParam.IS_DOWN_SYNC_ALLOWED to isDownSyncAllowed, block) + override fun stopAndStartAround( + isDownSyncAllowed: Boolean, + block: suspend () -> Unit, + ) = getCommand(target, SyncAction.STOP_AND_START, SyncParam.IS_DOWN_SYNC_ALLOWED to isDownSyncAllowed, block) } private fun buildSyncCommandsWithDelayParam(target: SyncTarget) = object : SyncCommandBuilderWithDelayParam { @@ -77,8 +92,10 @@ object SyncCommands { override fun stopAndStart(withDelay: Boolean) = getCommand(target, SyncAction.STOP_AND_START, SyncParam.WITH_DELAY to withDelay) - override fun stopAndStartAround(withDelay: Boolean, block: suspend () -> Unit) = - getCommand(target, SyncAction.STOP_AND_START, SyncParam.WITH_DELAY to withDelay, block) + override fun stopAndStartAround( + withDelay: Boolean, + block: suspend () -> Unit, + ) = getCommand(target, SyncAction.STOP_AND_START, SyncParam.WITH_DELAY to withDelay, block) } private fun getCommand( @@ -92,7 +109,6 @@ object SyncCommands { param?.run { mapOf(first to second) } ?: emptyMap(), block, ) - } /** diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestrator.kt b/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestrator.kt index 0e8e870b60..d7fe54105d 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestrator.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestrator.kt @@ -2,7 +2,7 @@ package com.simprints.infra.sync import kotlinx.coroutines.flow.Flow -// todo MS-1300 disband the rest into new other usecases +// todo MS-1300 disband into usecases interface SyncOrchestrator { fun startConfigSync() diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestratorImpl.kt b/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestratorImpl.kt index 25b8957ebf..722d980df5 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestratorImpl.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestratorImpl.kt @@ -23,7 +23,6 @@ internal class SyncOrchestratorImpl @Inject constructor( private val cleanupDeprecatedWorkers: CleanupDeprecatedWorkersUseCase, private val imageSyncTimestampProvider: ImageSyncTimestampProvider, ) : SyncOrchestrator { - override fun startConfigSync() { workManager.startWorker(SyncConstants.PROJECT_SYNC_WORK_NAME_ONE_TIME) workManager.startWorker(SyncConstants.DEVICE_SYNC_WORK_NAME_ONE_TIME) diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCase.kt index 56bb327f85..b12ebafb3a 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCase.kt @@ -22,7 +22,7 @@ internal class ResetLocalRecordsIfConfigChangedUseCase @Inject constructor( SyncCommands.Schedule.Events.stopAndStartAround { eventSyncManager.resetDownSyncInfo() enrolmentRecordRepository.deleteAll() - } + }, ).await() } } @@ -37,5 +37,5 @@ internal class ResetLocalRecordsIfConfigChangedUseCase @Inject constructor( oldConfig.synchronization.down.simprints ?.partitionType != newConfig.synchronization.down.simprints ?.partitionType - ) + ) } diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt index 27a999c2ec..8c775c1c0a 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt @@ -69,7 +69,6 @@ class SyncUseCase @Inject internal constructor( * with a .value also available to the callers synchronously. * * Usage: - * todo MS-1299 * sync( * SyncCommands. * +- ObserveOnly. @@ -101,13 +100,11 @@ class SyncUseCase @Inject internal constructor( * If the command was for a inherently non-blocking job, it will be returned already completed. * To suspend until the command completes, add .await(), or .syncCommandJob.join() - they are the same. */ - operator fun invoke(syncCommand: SyncCommand): SyncResponse = - SyncResponse( - syncCommandJob = when (syncCommand) { - is ExecutableSyncCommand -> executeSyncCommand(syncCommand) - is SyncCommands.ObserveOnly -> Job().apply { complete() } // no-op - }, - syncStatusFlow = sharedSyncStatus, - ) - + operator fun invoke(syncCommand: SyncCommand): SyncResponse = SyncResponse( + syncCommandJob = when (syncCommand) { + is ExecutableSyncCommand -> executeSyncCommand(syncCommand) + is SyncCommands.ObserveOnly -> Job().apply { complete() } // no-op + }, + syncStatusFlow = sharedSyncStatus, + ) } diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt index f6f1f72d30..7cfaaca334 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt @@ -60,8 +60,7 @@ internal class ExecuteSyncCommandUseCase @Inject constructor( ), ).map { workInfoList -> workInfoList.anyRunning() - } - .distinctUntilChanged() + }.distinctUntilChanged() .filter { it } // only if any running .collect { rescheduleImageUpSync() diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt index b17a2e5bd6..5bf0e4ea0f 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt @@ -4,7 +4,6 @@ import com.google.common.truth.Truth.assertThat import org.junit.Test class SyncCommandsTest { - private val buildersWithoutParams = listOf( SyncCommands.OneTime.Images to SyncTarget.ONE_TIME_IMAGES, SyncCommands.Schedule.Images to SyncTarget.SCHEDULE_IMAGES, diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/LogoutUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/LogoutUseCaseTest.kt index a5094e899b..5596156cd6 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/LogoutUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/LogoutUseCaseTest.kt @@ -4,10 +4,10 @@ import com.google.common.truth.Truth.assertThat import com.simprints.infra.authlogic.AuthManager import com.simprints.infra.sync.ExecutableSyncCommand import com.simprints.infra.sync.SyncAction -import com.simprints.infra.sync.SyncCommands import com.simprints.infra.sync.SyncCommand -import com.simprints.infra.sync.SyncTarget +import com.simprints.infra.sync.SyncCommands import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.SyncTarget import com.simprints.infra.sync.usecase.SyncUseCase import io.mockk.MockKAnnotations import io.mockk.coVerify diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt index 1f5a70a410..f035882f19 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt @@ -14,8 +14,8 @@ import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.verify -import kotlinx.coroutines.Job import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt index cd447c8749..547c8648d2 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt @@ -365,7 +365,8 @@ class ExecuteSyncCommandUseCaseTest { } @Test - fun `stops image worker when event sync starts`() = runTest { // init block test + fun `stops image worker when event sync starts`() = runTest { + // init block test val eventStartFlow = MutableSharedFlow>() every { workManager.getWorkInfosFlow(any()) } returns eventStartFlow @@ -383,7 +384,8 @@ class ExecuteSyncCommandUseCaseTest { } @Test - fun `does not stop image worker when event sync is not running`() = runTest { // init block test + fun `does not stop image worker when event sync is not running`() = runTest { + // init block test val eventStartFlow = MutableSharedFlow>() every { workManager.getWorkInfosFlow(any()) } returns eventStartFlow From 49a0b8109c2fbf79f50d34554dc48602188ced8b Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 27 Jan 2026 19:44:31 +0000 Subject: [PATCH 14/22] MS-1299 Sync revamp: sync use case accepts optional scope (added for coroutine management correctness) --- .../events/down/EventDownSyncResetService.kt | 1 + .../com/simprints/infra/sync/SyncCommands.kt | 1 + .../infra/sync/usecase/SyncUseCase.kt | 9 ++- .../internal/ExecuteSyncCommandUseCase.kt | 4 +- .../infra/sync/usecase/SyncUseCaseTest.kt | 24 +++++-- .../internal/ExecuteSyncCommandUseCaseTest.kt | 67 +++++++++++++------ 6 files changed, 79 insertions(+), 27 deletions(-) diff --git a/id/src/main/java/com/simprints/id/services/sync/events/down/EventDownSyncResetService.kt b/id/src/main/java/com/simprints/id/services/sync/events/down/EventDownSyncResetService.kt index b8c65ee120..8c243319f9 100644 --- a/id/src/main/java/com/simprints/id/services/sync/events/down/EventDownSyncResetService.kt +++ b/id/src/main/java/com/simprints/id/services/sync/events/down/EventDownSyncResetService.kt @@ -51,6 +51,7 @@ class EventDownSyncResetService : Service() { // Reset current downsync state eventSyncManager.resetDownSyncInfo() // Trigger a new sync + // Scope isn't passed to sync here to prevent a timeout cancellation leaving it in a stopped state sync(SyncCommands.OneTime.Events.start()).await() } resetJob?.invokeOnCompletion { stopSelf() } diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt b/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt index 7d1472aaec..1790eb65b8 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt @@ -12,6 +12,7 @@ object SyncCommands { object ObserveOnly : SyncCommand() object OneTime { + // DSL-style capitalization to fit well when used like: sync(SyncCommands.OneTime.Events.start()) val Events = buildSyncCommandsWithDownSyncParam(SyncTarget.ONE_TIME_EVENTS) val Images = buildSyncCommands(SyncTarget.ONE_TIME_IMAGES) } diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt index 8c775c1c0a..300dde0017 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt @@ -99,10 +99,15 @@ class SyncUseCase @Inject internal constructor( * For non-blocking use, the job doesn't matter. * If the command was for a inherently non-blocking job, it will be returned already completed. * To suspend until the command completes, add .await(), or .syncCommandJob.join() - they are the same. + * + * The commandScope param allows the sync command (incl. the optional stopAndStartAround block) + * be cancelable when the passed scope's coroutine is cancelled, + * and to allow stopAndStartAround throw exceptions in the passed scopes coroutine's context. + * Note: cancelling a command may leave the corresponding sync in a stopped state. The stopping is synchronous. */ - operator fun invoke(syncCommand: SyncCommand): SyncResponse = SyncResponse( + operator fun invoke(syncCommand: SyncCommand, commandScope: CoroutineScope = appScope): SyncResponse = SyncResponse( syncCommandJob = when (syncCommand) { - is ExecutableSyncCommand -> executeSyncCommand(syncCommand) + is ExecutableSyncCommand -> executeSyncCommand(syncCommand, commandScope) is SyncCommands.ObserveOnly -> Job().apply { complete() } // no-op }, syncStatusFlow = sharedSyncStatus, diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt index 7cfaaca334..8758111f51 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt @@ -68,7 +68,7 @@ internal class ExecuteSyncCommandUseCase @Inject constructor( } } - internal operator fun invoke(syncCommand: ExecutableSyncCommand): Job { + internal operator fun invoke(syncCommand: ExecutableSyncCommand, commandScope: CoroutineScope = appScope): Job { with(syncCommand) { val isStopNeeded = action in listOf(SyncAction.STOP, SyncAction.STOP_AND_START) if (isStopNeeded) { @@ -77,7 +77,7 @@ internal class ExecuteSyncCommandUseCase @Inject constructor( val isStartNeeded = action in listOf(SyncAction.START, SyncAction.STOP_AND_START) val isFurtherAsyncActionNeeded = blockToRunWhileStopped != null || isStartNeeded return if (isFurtherAsyncActionNeeded) { - appScope.launch(ioDispatcher) { + commandScope.launch(ioDispatcher) { blockToRunWhileStopped?.invoke() if (isStartNeeded) { start() diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt index f035882f19..50a72a3575 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt @@ -14,6 +14,7 @@ import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.verify +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow @@ -45,7 +46,7 @@ class SyncUseCaseTest { MockKAnnotations.init(this, relaxed = true) every { eventSyncStateProcessor.getLastSyncState() } returns eventSyncStatusFlow every { imageSync.invoke() } returns imageSyncStatusFlow - every { executeSyncCommand.invoke(any()) } returns Job().apply { complete() } + every { executeSyncCommand.invoke(any(), any()) } returns Job().apply { complete() } } @Test @@ -269,19 +270,34 @@ class SyncUseCaseTest { val response = useCase(SyncCommands.ObserveOnly) assertThat(response.syncCommandJob.isCompleted).isTrue() - verify(exactly = 0) { executeSyncCommand.invoke(any()) } + verify(exactly = 0) { executeSyncCommand.invoke(any(), any()) } } @Test fun `executes executable sync command and returns its job`() = runTest { val expectedJob = Job().apply { complete() } - every { executeSyncCommand.invoke(any()) } returns expectedJob val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, executeSyncCommand, appScope = backgroundScope) val command = SyncCommands.Schedule.Everything.stopAndStart() as ExecutableSyncCommand + every { executeSyncCommand.invoke(command, backgroundScope) } returns expectedJob val response = useCase(command) assertThat(response.syncCommandJob).isSameInstanceAs(expectedJob) - verify(exactly = 1) { executeSyncCommand.invoke(command) } + verify(exactly = 1) { executeSyncCommand.invoke(command, backgroundScope) } + } + + @Test + fun `executes executable sync command using provided commandScope`() = runTest { + val expectedJob = Job().apply { complete() } + val customScope = CoroutineScope(backgroundScope.coroutineContext + Job()) + val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, executeSyncCommand, appScope = backgroundScope) + val command = SyncCommands.Schedule.Everything.stopAndStart() as ExecutableSyncCommand + + every { executeSyncCommand.invoke(command, customScope) } returns expectedJob + + val response = useCase(command, commandScope = customScope) + + assertThat(response.syncCommandJob).isSameInstanceAs(expectedJob) + verify(exactly = 1) { executeSyncCommand.invoke(command, customScope) } } } diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt index 547c8648d2..1aa1b969b4 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt @@ -26,6 +26,7 @@ import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.verify import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.test.runTest @@ -66,7 +67,7 @@ class ExecuteSyncCommandUseCaseTest { every { authStore.signedInProjectId } returns "" coEvery { shouldScheduleFirmwareUpdate.invoke() } returns false - useCase(executable(SyncCommands.Schedule.Everything.start())).join() + useCase(executable(SyncCommands.Schedule.Everything.start()), commandScope()).join() verify(exactly = 0) { workManager.enqueueUniquePeriodicWork(any(), any(), any()) } } @@ -76,7 +77,7 @@ class ExecuteSyncCommandUseCaseTest { every { authStore.signedInProjectId } returns "projectId" coEvery { shouldScheduleFirmwareUpdate.invoke() } returns true - useCase(executable(SyncCommands.Schedule.Everything.start())).join() + useCase(executable(SyncCommands.Schedule.Everything.start()), commandScope()).join() verify { workManager.enqueueUniquePeriodicWork(PROJECT_SYNC_WORK_NAME, any(), any()) @@ -96,7 +97,7 @@ class ExecuteSyncCommandUseCaseTest { } returns false every { authStore.signedInProjectId } returns "projectId" - useCase(executable(SyncCommands.Schedule.Everything.start())).join() + useCase(executable(SyncCommands.Schedule.Everything.start()), commandScope()).join() verify { workManager.enqueueUniquePeriodicWork( @@ -117,7 +118,7 @@ class ExecuteSyncCommandUseCaseTest { every { authStore.signedInProjectId } returns "projectId" coEvery { shouldScheduleFirmwareUpdate.invoke() } returns false - useCase(executable(SyncCommands.Schedule.Everything.start())).join() + useCase(executable(SyncCommands.Schedule.Everything.start()), commandScope()).join() verify { workManager.enqueueUniquePeriodicWork( @@ -133,7 +134,7 @@ class ExecuteSyncCommandUseCaseTest { every { authStore.signedInProjectId } returns "projectId" coEvery { shouldScheduleFirmwareUpdate.invoke() } returns false - useCase(executable(SyncCommands.Schedule.Everything.start())).join() + useCase(executable(SyncCommands.Schedule.Everything.start()), commandScope()).join() verify { workManager.cancelUniqueWork(FIRMWARE_UPDATE_WORK_NAME) } } @@ -142,7 +143,7 @@ class ExecuteSyncCommandUseCaseTest { fun `cancels all necessary background workers`() = runTest { every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" - useCase(executable(SyncCommands.Schedule.Everything.stop())) + useCase(executable(SyncCommands.Schedule.Everything.stop()), commandScope()) verify { workManager.cancelUniqueWork(PROJECT_SYNC_WORK_NAME) @@ -159,7 +160,7 @@ class ExecuteSyncCommandUseCaseTest { fun `reschedules event sync worker with correct tags`() = runTest { every { eventSyncManager.getPeriodicWorkTags() } returns listOf("tag1", "tag2") - useCase(executable(SyncCommands.Schedule.Events.start())).join() + useCase(executable(SyncCommands.Schedule.Events.start()), commandScope()).join() verify { workManager.enqueueUniquePeriodicWork( @@ -174,7 +175,7 @@ class ExecuteSyncCommandUseCaseTest { fun `reschedules event sync worker with correct delay`() = runTest { every { eventSyncManager.getPeriodicWorkTags() } returns listOf("tag1", "tag2") - useCase(executable(SyncCommands.Schedule.Events.start(withDelay = true))).join() + useCase(executable(SyncCommands.Schedule.Events.start(withDelay = true)), commandScope()).join() verify { workManager.enqueueUniquePeriodicWork( @@ -190,7 +191,7 @@ class ExecuteSyncCommandUseCaseTest { every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" every { eventSyncManager.getPeriodicWorkTags() } returns listOf("tag1", "tag2") - useCase(executable(SyncCommands.Schedule.Events.stopAndStart(withDelay = true))).join() + useCase(executable(SyncCommands.Schedule.Events.stopAndStart(withDelay = true)), commandScope()).join() verify { workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME) @@ -211,7 +212,7 @@ class ExecuteSyncCommandUseCaseTest { fun `cancel event sync worker cancels correct worker`() = runTest { every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" - useCase(executable(SyncCommands.Schedule.Events.stop())) + useCase(executable(SyncCommands.Schedule.Events.stop()), commandScope()) verify { workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME) @@ -224,7 +225,7 @@ class ExecuteSyncCommandUseCaseTest { fun `start event sync worker with correct tags`() = runTest { every { eventSyncManager.getOneTimeWorkTags() } returns listOf("tag1", "tag2") - useCase(executable(SyncCommands.OneTime.Events.start())).join() + useCase(executable(SyncCommands.OneTime.Events.start()), commandScope()).join() verify { workManager.enqueueUniqueWork( @@ -239,7 +240,7 @@ class ExecuteSyncCommandUseCaseTest { fun `start event sync worker with correct input data`() = runTest { every { eventSyncManager.getOneTimeWorkTags() } returns listOf("tag1", "tag2") - useCase(executable(SyncCommands.OneTime.Events.start(isDownSyncAllowed = false))).join() + useCase(executable(SyncCommands.OneTime.Events.start(isDownSyncAllowed = false)), commandScope()).join() verify { workManager.enqueueUniqueWork( @@ -257,7 +258,7 @@ class ExecuteSyncCommandUseCaseTest { every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" every { eventSyncManager.getOneTimeWorkTags() } returns listOf("tag1", "tag2") - useCase(executable(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = false))).join() + useCase(executable(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = false)), commandScope()).join() verify { workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME_ONE_TIME) @@ -277,7 +278,7 @@ class ExecuteSyncCommandUseCaseTest { fun `stop event sync worker cancels correct workers`() = runTest { every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" - useCase(executable(SyncCommands.OneTime.Events.stop())) + useCase(executable(SyncCommands.OneTime.Events.stop()), commandScope()) verify { workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME_ONE_TIME) @@ -287,7 +288,7 @@ class ExecuteSyncCommandUseCaseTest { @Test fun `reschedules image worker when requested`() = runTest { - useCase(executable(SyncCommands.Schedule.Images.start())).join() + useCase(executable(SyncCommands.Schedule.Images.start()), commandScope()).join() verify { workManager.enqueueUniquePeriodicWork( @@ -300,7 +301,7 @@ class ExecuteSyncCommandUseCaseTest { @Test fun `start image sync re-starts image worker`() = runTest { - useCase(executable(SyncCommands.OneTime.Images.start())).join() + useCase(executable(SyncCommands.OneTime.Images.start()), commandScope()).join() verify { workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) @@ -314,14 +315,14 @@ class ExecuteSyncCommandUseCaseTest { @Test fun `stop image sync cancels image worker`() = runTest { - useCase(executable(SyncCommands.OneTime.Images.stop())) + useCase(executable(SyncCommands.OneTime.Images.stop()), commandScope()) verify { workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) } } @Test fun `invoke stop command returns completed job and routes to stop logic`() = runTest { - val job = useCase(executable(SyncCommands.Schedule.Images.stop())) + val job = useCase(executable(SyncCommands.Schedule.Images.stop()), commandScope()) assertThat(job.isCompleted).isTrue() verify { workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) } @@ -336,7 +337,7 @@ class ExecuteSyncCommandUseCaseTest { unblock.receive() } - val job = useCase(executable(SyncCommands.Schedule.Images.stopAndStartAround(block))) + val job = useCase(executable(SyncCommands.Schedule.Images.stopAndStartAround(block)), commandScope()) verify { workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) @@ -364,6 +365,32 @@ class ExecuteSyncCommandUseCaseTest { } } + @Test + fun `uses passed scope to launch async commands`() = runTest { + val parentJob = Job() + val scope = CoroutineScope(parentJob + testCoroutineRule.testCoroutineDispatcher) + val blockStarted = Channel(Channel.UNLIMITED) + val unblock = Channel(Channel.UNLIMITED) + val block: suspend () -> Unit = { + blockStarted.trySend(Unit) + unblock.receive() + } + + val job = useCase(executable(SyncCommands.Schedule.Images.stopAndStartAround(block)), scope) + + blockStarted.receive() + parentJob.cancel() + job.join() + assertThat(job.isCancelled).isTrue() + verify(exactly = 0) { + workManager.enqueueUniquePeriodicWork( + FILE_UP_SYNC_WORK_NAME, + ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + any(), + ) + } + } + @Test fun `stops image worker when event sync starts`() = runTest { // init block test @@ -404,6 +431,8 @@ class ExecuteSyncCommandUseCaseTest { private fun executable(syncCommand: com.simprints.infra.sync.SyncCommand) = syncCommand as ExecutableSyncCommand + private fun commandScope() = CoroutineScope(testCoroutineRule.testCoroutineDispatcher) + private fun createUseCase() = ExecuteSyncCommandUseCase( workManager = workManager, authStore = authStore, From 6aef01bb31c82ac476ca17ae4022c0172b70a7bf Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 27 Jan 2026 21:30:39 +0000 Subject: [PATCH 15/22] MS-1299 Sync revamp: SyncResponse.await() error handling --- .../com/simprints/infra/sync/SyncResponse.kt | 20 ++++- .../simprints/infra/sync/SyncResponseTest.kt | 77 +++++++++++++++++++ 2 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 infra/sync/src/test/java/com/simprints/infra/sync/SyncResponseTest.kt diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/SyncResponse.kt b/infra/sync/src/main/java/com/simprints/infra/sync/SyncResponse.kt index a04dd2bf7c..e6377e86da 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/SyncResponse.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/SyncResponse.kt @@ -1,13 +1,27 @@ package com.simprints.infra.sync -import com.simprints.core.ExcludedFromGeneratedTestCoverageReports import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException data class SyncResponse( val syncCommandJob: Job, val syncStatusFlow: StateFlow, ) -@ExcludedFromGeneratedTestCoverageReports("There is no complex business logic to test") -suspend fun SyncResponse.await() = syncCommandJob.join() +/** + * Waits for the sync command job to complete, passes exceptions (incl. cancellations) to the caller. + */ +suspend fun SyncResponse.await() { + suspendCancellableCoroutine { continuation -> + val handle = syncCommandJob.invokeOnCompletion { cause -> + when (cause) { + null -> continuation.resume(Unit) + else -> continuation.resumeWithException(cause) + } + } + continuation.invokeOnCancellation { handle.dispose() } + } +} diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/SyncResponseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/SyncResponseTest.kt new file mode 100644 index 0000000000..baa57fa94d --- /dev/null +++ b/infra/sync/src/test/java/com/simprints/infra/sync/SyncResponseTest.kt @@ -0,0 +1,77 @@ +package com.simprints.infra.sync + +import com.google.common.truth.Truth.assertThat +import io.mockk.mockk +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SyncResponseTest { + @Test + fun `await completes when syncCommandJob completes normally`() = runTest { + val syncCommandJob = Job() + val response = SyncResponse( + syncCommandJob = syncCommandJob, + syncStatusFlow = MutableStateFlow(mockk(relaxed = true)), + ) + val awaitDeferred = backgroundScope.async { response.await() } + + runCurrent() + assertThat(awaitDeferred.isCompleted).isFalse() + + syncCommandJob.complete() + runCurrent() + + awaitDeferred.await() + assertThat(awaitDeferred.isCompleted).isTrue() + } + + @Test + fun `await rethrows failure from syncCommandJob`() = runTest { + val syncCommandJob = Job() + val response = SyncResponse( + syncCommandJob = syncCommandJob, + syncStatusFlow = MutableStateFlow(mockk(relaxed = true)), + ) + val expected = IllegalStateException("ExceptionMessage") + + launch { syncCommandJob.completeExceptionally(expected) } + + val thrown = try { + response.await() + null + } catch (throwable: Throwable) { + throwable + } + assertThat(thrown).isNotNull() + assertThat(thrown).isInstanceOf(IllegalStateException::class.java) + assertThat(thrown!!.message).isEqualTo("ExceptionMessage") + assertThat(thrown === expected || thrown.cause === expected).isTrue() + } + + @Test + fun `await throws CancellationException when syncCommandJob is cancelled`() = runTest { + val syncCommandJob = Job() + val response = SyncResponse( + syncCommandJob = syncCommandJob, + syncStatusFlow = MutableStateFlow(mockk(relaxed = true)), + ) + + launch { syncCommandJob.cancel() } + + val thrown = try { + response.await() + null + } catch (throwable: Throwable) { + throwable + } + assertThat(thrown).isInstanceOf(CancellationException::class.java) + } +} From 4dab9c2dd7a0f096209dcea955d690ed2b18a4c0 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 27 Jan 2026 21:33:15 +0000 Subject: [PATCH 16/22] MS-1299 Sync revamp: ExecutableSyncCommand payload type hardening --- .../com/simprints/infra/sync/SyncCommands.kt | 39 ++++++++++++------- .../internal/ExecuteSyncCommandUseCase.kt | 10 ++--- .../simprints/infra/sync/SyncCommandsTest.kt | 30 +++++++------- ...tLocalRecordsIfConfigChangedUseCaseTest.kt | 6 +-- 4 files changed, 48 insertions(+), 37 deletions(-) diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt b/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt index 1790eb65b8..7eaac111ab 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt @@ -68,46 +68,52 @@ object SyncCommands { override fun stopAndStart() = getCommand(target, SyncAction.STOP_AND_START) - override fun stopAndStartAround(block: suspend () -> Unit) = getCommand(target, SyncAction.STOP_AND_START, null, block) + override fun stopAndStartAround(block: suspend () -> Unit) = getCommand(target, SyncAction.STOP_AND_START, block = block) } private fun buildSyncCommandsWithDownSyncParam(target: SyncTarget) = object : SyncCommandBuilderWithDownSyncParam { override fun stop() = getCommand(target, SyncAction.STOP) override fun start(isDownSyncAllowed: Boolean) = - getCommand(target, SyncAction.START, SyncParam.IS_DOWN_SYNC_ALLOWED to isDownSyncAllowed) + getCommand(target, SyncAction.START, payload = SyncCommandPayload.WithDownSyncAllowed(isDownSyncAllowed)) override fun stopAndStart(isDownSyncAllowed: Boolean) = - getCommand(target, SyncAction.STOP_AND_START, SyncParam.IS_DOWN_SYNC_ALLOWED to isDownSyncAllowed) + getCommand(target, SyncAction.STOP_AND_START, payload = SyncCommandPayload.WithDownSyncAllowed(isDownSyncAllowed)) override fun stopAndStartAround( isDownSyncAllowed: Boolean, block: suspend () -> Unit, - ) = getCommand(target, SyncAction.STOP_AND_START, SyncParam.IS_DOWN_SYNC_ALLOWED to isDownSyncAllowed, block) + ) = getCommand( + target, + SyncAction.STOP_AND_START, + payload = SyncCommandPayload.WithDownSyncAllowed(isDownSyncAllowed), + block = block + ) } private fun buildSyncCommandsWithDelayParam(target: SyncTarget) = object : SyncCommandBuilderWithDelayParam { override fun stop() = getCommand(target, SyncAction.STOP) - override fun start(withDelay: Boolean) = getCommand(target, SyncAction.START, SyncParam.WITH_DELAY to withDelay) + override fun start(withDelay: Boolean) = getCommand(target, SyncAction.START, payload = SyncCommandPayload.WithDelay(withDelay)) - override fun stopAndStart(withDelay: Boolean) = getCommand(target, SyncAction.STOP_AND_START, SyncParam.WITH_DELAY to withDelay) + override fun stopAndStart(withDelay: Boolean) = + getCommand(target, SyncAction.STOP_AND_START, payload = SyncCommandPayload.WithDelay(withDelay)) override fun stopAndStartAround( withDelay: Boolean, block: suspend () -> Unit, - ) = getCommand(target, SyncAction.STOP_AND_START, SyncParam.WITH_DELAY to withDelay, block) + ) = getCommand(target, SyncAction.STOP_AND_START, payload = SyncCommandPayload.WithDelay(withDelay), block = block) } private fun getCommand( target: SyncTarget, action: SyncAction, - param: Pair? = null, + payload: SyncCommandPayload = SyncCommandPayload.None, block: (suspend () -> Unit)? = null, ) = ExecutableSyncCommand( target, action, - param?.run { mapOf(first to second) } ?: emptyMap(), + payload, block, ) } @@ -120,7 +126,7 @@ sealed class SyncCommand internal data class ExecutableSyncCommand( val target: SyncTarget, val action: SyncAction, - val params: Map = emptyMap(), + val payload: SyncCommandPayload = SyncCommandPayload.None, val blockToRunWhileStopped: (suspend () -> Unit)? = null, ) : SyncCommand() @@ -138,7 +144,14 @@ internal enum class SyncAction { STOP_AND_START, } -internal enum class SyncParam { - IS_DOWN_SYNC_ALLOWED, - WITH_DELAY, +internal sealed class SyncCommandPayload { + object None : SyncCommandPayload() + + data class WithDelay( + val withDelay: Boolean, + ) : SyncCommandPayload() + + data class WithDownSyncAllowed( + val isDownSyncAllowed: Boolean, + ) : SyncCommandPayload() } diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt index 8758111f51..69f7827e10 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt @@ -15,9 +15,9 @@ import com.simprints.infra.config.store.models.isCommCareEventDownSyncAllowed import com.simprints.infra.eventsync.EventSyncManager import com.simprints.infra.eventsync.sync.master.EventSyncMasterWorker import com.simprints.infra.sync.ExecutableSyncCommand +import com.simprints.infra.sync.SyncCommandPayload import com.simprints.infra.sync.SyncAction import com.simprints.infra.sync.SyncConstants -import com.simprints.infra.sync.SyncParam import com.simprints.infra.sync.SyncTarget import com.simprints.infra.sync.config.worker.DeviceConfigDownSyncWorker import com.simprints.infra.sync.config.worker.ProjectConfigDownSyncWorker @@ -100,13 +100,11 @@ internal class ExecuteSyncCommandUseCase @Inject constructor( } private suspend fun ExecutableSyncCommand.start() { - val isDownSyncAllowed = params[SyncParam.IS_DOWN_SYNC_ALLOWED] as? Boolean ?: true - val withDelay = params[SyncParam.WITH_DELAY] as? Boolean ?: false when (target) { - SyncTarget.SCHEDULE_EVERYTHING -> scheduleBackgroundWork(withDelay) - SyncTarget.SCHEDULE_EVENTS -> rescheduleEventSync(withDelay) + SyncTarget.SCHEDULE_EVERYTHING -> scheduleBackgroundWork((payload as SyncCommandPayload.WithDelay).withDelay) + SyncTarget.SCHEDULE_EVENTS -> rescheduleEventSync((payload as SyncCommandPayload.WithDelay).withDelay) SyncTarget.SCHEDULE_IMAGES -> rescheduleImageUpSync() - SyncTarget.ONE_TIME_EVENTS -> startEventSync(isDownSyncAllowed) + SyncTarget.ONE_TIME_EVENTS -> startEventSync((payload as SyncCommandPayload.WithDownSyncAllowed).isDownSyncAllowed) SyncTarget.ONE_TIME_IMAGES -> startImageSync() } } diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt index 5bf0e4ea0f..1fd2465170 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt @@ -47,7 +47,7 @@ class SyncCommandsTest { expectedCommand( target = expectedTarget, action = SyncAction.START, - params = mapOf(SyncParam.IS_DOWN_SYNC_ALLOWED to true), + payload = SyncCommandPayload.WithDownSyncAllowed(true), ), ) assertThat(builder.start(isDownSyncAllowed = false)) @@ -55,7 +55,7 @@ class SyncCommandsTest { expectedCommand( target = expectedTarget, action = SyncAction.START, - params = mapOf(SyncParam.IS_DOWN_SYNC_ALLOWED to false), + payload = SyncCommandPayload.WithDownSyncAllowed(false), ), ) } @@ -66,7 +66,7 @@ class SyncCommandsTest { expectedCommand( target = expectedTarget, action = SyncAction.START, - params = mapOf(SyncParam.WITH_DELAY to false), + payload = SyncCommandPayload.WithDelay(false), ), ) assertThat(builder.start(withDelay = true)) @@ -74,7 +74,7 @@ class SyncCommandsTest { expectedCommand( target = expectedTarget, action = SyncAction.START, - params = mapOf(SyncParam.WITH_DELAY to true), + payload = SyncCommandPayload.WithDelay(true), ), ) } @@ -94,7 +94,7 @@ class SyncCommandsTest { expectedCommand( target = expectedTarget, action, - params = mapOf(SyncParam.WITH_DELAY to false), + payload = SyncCommandPayload.WithDelay(false), ), ) assertThat(builder.stopAndStart(withDelay = true)) @@ -102,7 +102,7 @@ class SyncCommandsTest { expectedCommand( target = expectedTarget, action, - params = mapOf(SyncParam.WITH_DELAY to true), + payload = SyncCommandPayload.WithDelay(true), ), ) } @@ -113,7 +113,7 @@ class SyncCommandsTest { expectedCommand( target = expectedTarget, action, - params = mapOf(SyncParam.IS_DOWN_SYNC_ALLOWED to true), + payload = SyncCommandPayload.WithDownSyncAllowed(true), ), ) assertThat(builder.stopAndStart(isDownSyncAllowed = false)) @@ -121,7 +121,7 @@ class SyncCommandsTest { expectedCommand( target = expectedTarget, action, - params = mapOf(SyncParam.IS_DOWN_SYNC_ALLOWED to false), + payload = SyncCommandPayload.WithDownSyncAllowed(false), ), ) } @@ -137,7 +137,7 @@ class SyncCommandsTest { .isEqualTo( expectedCommand( target = expectedTarget, - action, + action = action, block = block, ), ) @@ -149,7 +149,7 @@ class SyncCommandsTest { expectedCommand( target = expectedTarget, action, - params = mapOf(SyncParam.IS_DOWN_SYNC_ALLOWED to true), + payload = SyncCommandPayload.WithDownSyncAllowed(true), block, ), ) @@ -158,7 +158,7 @@ class SyncCommandsTest { expectedCommand( target = expectedTarget, action, - params = mapOf(SyncParam.IS_DOWN_SYNC_ALLOWED to false), + payload = SyncCommandPayload.WithDownSyncAllowed(false), block, ), ) @@ -170,7 +170,7 @@ class SyncCommandsTest { expectedCommand( target = expectedTarget, action, - params = mapOf(SyncParam.WITH_DELAY to false), + payload = SyncCommandPayload.WithDelay(false), block, ), ) @@ -179,7 +179,7 @@ class SyncCommandsTest { expectedCommand( target = expectedTarget, action, - params = mapOf(SyncParam.WITH_DELAY to true), + payload = SyncCommandPayload.WithDelay(true), block, ), ) @@ -195,12 +195,12 @@ class SyncCommandsTest { private fun expectedCommand( target: SyncTarget, action: SyncAction, - params: Map = emptyMap(), + payload: SyncCommandPayload = SyncCommandPayload.None, block: (suspend () -> Unit)? = null, ) = ExecutableSyncCommand( target = target, action = action, - params = params, + payload = payload, blockToRunWhileStopped = block, ) } diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt index e524448c19..69735bed13 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt @@ -9,9 +9,9 @@ import com.simprints.infra.eventsync.EventSyncManager import com.simprints.infra.sync.ExecutableSyncCommand import com.simprints.infra.sync.SyncAction import com.simprints.infra.sync.SyncCommand -import com.simprints.infra.sync.SyncParam import com.simprints.infra.sync.SyncResponse import com.simprints.infra.sync.SyncTarget +import com.simprints.infra.sync.SyncCommandPayload import com.simprints.infra.sync.config.testtools.projectConfiguration import com.simprints.infra.sync.config.testtools.synchronizationConfiguration import com.simprints.infra.sync.usecase.SyncUseCase @@ -110,8 +110,8 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { .isEqualTo(SyncTarget.SCHEDULE_EVENTS) assertThat(command.action) .isEqualTo(SyncAction.STOP_AND_START) - assertThat(command.params[SyncParam.WITH_DELAY]) - .isEqualTo(false) + assertThat((command.payload as SyncCommandPayload.WithDelay).withDelay) + .isFalse() command.blockToRunWhileStopped?.invoke() runCurrent() From e01420e0271bce0701bdafe9bbebba2ace6eaccd Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 27 Jan 2026 21:41:25 +0000 Subject: [PATCH 17/22] MS-1299 Sync revamp: ktlint applied to branch's .kt files --- .../src/main/java/com/simprints/infra/sync/SyncCommands.kt | 2 +- .../java/com/simprints/infra/sync/usecase/SyncUseCase.kt | 5 ++++- .../sync/usecase/internal/ExecuteSyncCommandUseCase.kt | 7 +++++-- .../usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt b/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt index 7eaac111ab..797eb85a6f 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt @@ -87,7 +87,7 @@ object SyncCommands { target, SyncAction.STOP_AND_START, payload = SyncCommandPayload.WithDownSyncAllowed(isDownSyncAllowed), - block = block + block = block, ) } diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt index 300dde0017..f087a3594e 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt @@ -105,7 +105,10 @@ class SyncUseCase @Inject internal constructor( * and to allow stopAndStartAround throw exceptions in the passed scopes coroutine's context. * Note: cancelling a command may leave the corresponding sync in a stopped state. The stopping is synchronous. */ - operator fun invoke(syncCommand: SyncCommand, commandScope: CoroutineScope = appScope): SyncResponse = SyncResponse( + operator fun invoke( + syncCommand: SyncCommand, + commandScope: CoroutineScope = appScope, + ): SyncResponse = SyncResponse( syncCommandJob = when (syncCommand) { is ExecutableSyncCommand -> executeSyncCommand(syncCommand, commandScope) is SyncCommands.ObserveOnly -> Job().apply { complete() } // no-op diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt index 69f7827e10..4cec7b77a7 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt @@ -15,8 +15,8 @@ import com.simprints.infra.config.store.models.isCommCareEventDownSyncAllowed import com.simprints.infra.eventsync.EventSyncManager import com.simprints.infra.eventsync.sync.master.EventSyncMasterWorker import com.simprints.infra.sync.ExecutableSyncCommand -import com.simprints.infra.sync.SyncCommandPayload import com.simprints.infra.sync.SyncAction +import com.simprints.infra.sync.SyncCommandPayload import com.simprints.infra.sync.SyncConstants import com.simprints.infra.sync.SyncTarget import com.simprints.infra.sync.config.worker.DeviceConfigDownSyncWorker @@ -68,7 +68,10 @@ internal class ExecuteSyncCommandUseCase @Inject constructor( } } - internal operator fun invoke(syncCommand: ExecutableSyncCommand, commandScope: CoroutineScope = appScope): Job { + internal operator fun invoke( + syncCommand: ExecutableSyncCommand, + commandScope: CoroutineScope = appScope, + ): Job { with(syncCommand) { val isStopNeeded = action in listOf(SyncAction.STOP, SyncAction.STOP_AND_START) if (isStopNeeded) { diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt index 69735bed13..f46bd92055 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt @@ -9,9 +9,9 @@ import com.simprints.infra.eventsync.EventSyncManager import com.simprints.infra.sync.ExecutableSyncCommand import com.simprints.infra.sync.SyncAction import com.simprints.infra.sync.SyncCommand +import com.simprints.infra.sync.SyncCommandPayload import com.simprints.infra.sync.SyncResponse import com.simprints.infra.sync.SyncTarget -import com.simprints.infra.sync.SyncCommandPayload import com.simprints.infra.sync.config.testtools.projectConfiguration import com.simprints.infra.sync.config.testtools.synchronizationConfiguration import com.simprints.infra.sync.usecase.SyncUseCase From 3acdd88648e3f8bc4078f5b46f05fa339bb45825 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 27 Jan 2026 22:23:11 +0000 Subject: [PATCH 18/22] MS-1299 Sync revamp: SyncResponse.await() applied for its improved error handling --- .../usecase/RunBlockingEventSyncUseCase.kt | 13 +++++++------ .../com/simprints/infra/sync/usecase/SyncUseCase.kt | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt index 243d14fb33..1da3d37b6c 100644 --- a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt @@ -1,6 +1,7 @@ package com.simprints.feature.validatepool.usecase import com.simprints.infra.sync.SyncCommands +import com.simprints.infra.sync.await import com.simprints.infra.sync.usecase.SyncUseCase import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map @@ -19,11 +20,11 @@ internal class RunBlockingEventSyncUseCase @Inject constructor( .map { it.eventSyncState } .firstOrNull { !it.isUninitialized() } ?.syncId - sync(SyncCommands.OneTime.Events.start()).let { (startJob, syncStatusFlow) -> - startJob.join() - syncStatusFlow - .map { it.eventSyncState } - .firstOrNull { it.syncId != lastSyncId && it.isSyncReporterCompleted() } - } + sync(SyncCommands.OneTime.Events.start()) + .apply { + await() + }.syncStatusFlow + .map { it.eventSyncState } + .firstOrNull { it.syncId != lastSyncId && it.isSyncReporterCompleted() } } } diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt index f087a3594e..58fa8f4963 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt @@ -98,7 +98,7 @@ class SyncUseCase @Inject internal constructor( * Sync returns a combo of a Job for the command and the flow of sync statuses. * For non-blocking use, the job doesn't matter. * If the command was for a inherently non-blocking job, it will be returned already completed. - * To suspend until the command completes, add .await(), or .syncCommandJob.join() - they are the same. + * To suspend until the command completes, add .await(), it rethrows cancellations / other exceptions. * * The commandScope param allows the sync command (incl. the optional stopAndStartAround block) * be cancelable when the passed scope's coroutine is cancelled, From 26dd08e31cd34fa881df5a6e26beaab5c0650b05 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 28 Jan 2026 10:18:00 +0000 Subject: [PATCH 19/22] MS-1299 Sync revamp: SyncCommand type consistency --- .../java/com/simprints/infra/sync/SyncCommands.kt | 14 +++++++------- .../simprints/infra/sync/usecase/SyncUseCase.kt | 3 +-- .../usecase/internal/ExecuteSyncCommandUseCase.kt | 8 ++++---- .../com/simprints/infra/sync/SyncCommandsTest.kt | 4 ++-- .../infra/sync/config/usecase/LogoutUseCaseTest.kt | 3 +-- .../ResetLocalRecordsIfConfigChangedUseCaseTest.kt | 10 +++++----- .../infra/sync/usecase/SyncUseCaseTest.kt | 5 ++--- .../internal/ExecuteSyncCommandUseCaseTest.kt | 3 +-- 8 files changed, 23 insertions(+), 27 deletions(-) diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt b/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt index 797eb85a6f..0bc6564aff 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt @@ -23,6 +23,13 @@ object SyncCommands { val Images = buildSyncCommands(SyncTarget.SCHEDULE_IMAGES) } + internal data class ExecutableSyncCommand( + val target: SyncTarget, + val action: SyncAction, + val payload: SyncCommandPayload = SyncCommandPayload.None, + val blockToRunWhileStopped: (suspend () -> Unit)? = null, + ) : SyncCommand() + // builders interface SyncCommandBuilder { @@ -123,13 +130,6 @@ object SyncCommands { */ sealed class SyncCommand -internal data class ExecutableSyncCommand( - val target: SyncTarget, - val action: SyncAction, - val payload: SyncCommandPayload = SyncCommandPayload.None, - val blockToRunWhileStopped: (suspend () -> Unit)? = null, -) : SyncCommand() - enum class SyncTarget { SCHEDULE_EVERYTHING, ONE_TIME_EVENTS, diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt index 58fa8f4963..c8d514840c 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt @@ -3,7 +3,6 @@ package com.simprints.infra.sync.usecase import com.simprints.core.AppScope import com.simprints.infra.eventsync.status.models.EventSyncState import com.simprints.infra.eventsync.sync.EventSyncStateProcessor -import com.simprints.infra.sync.ExecutableSyncCommand import com.simprints.infra.sync.ImageSyncStatus import com.simprints.infra.sync.SyncCommand import com.simprints.infra.sync.SyncCommands @@ -110,7 +109,7 @@ class SyncUseCase @Inject internal constructor( commandScope: CoroutineScope = appScope, ): SyncResponse = SyncResponse( syncCommandJob = when (syncCommand) { - is ExecutableSyncCommand -> executeSyncCommand(syncCommand, commandScope) + is SyncCommands.ExecutableSyncCommand -> executeSyncCommand(syncCommand, commandScope) is SyncCommands.ObserveOnly -> Job().apply { complete() } // no-op }, syncStatusFlow = sharedSyncStatus, diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt index 4cec7b77a7..4d850806f1 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt @@ -14,9 +14,9 @@ import com.simprints.infra.config.store.models.imagesUploadRequiresUnmeteredConn import com.simprints.infra.config.store.models.isCommCareEventDownSyncAllowed import com.simprints.infra.eventsync.EventSyncManager import com.simprints.infra.eventsync.sync.master.EventSyncMasterWorker -import com.simprints.infra.sync.ExecutableSyncCommand import com.simprints.infra.sync.SyncAction import com.simprints.infra.sync.SyncCommandPayload +import com.simprints.infra.sync.SyncCommands import com.simprints.infra.sync.SyncConstants import com.simprints.infra.sync.SyncTarget import com.simprints.infra.sync.config.worker.DeviceConfigDownSyncWorker @@ -69,7 +69,7 @@ internal class ExecuteSyncCommandUseCase @Inject constructor( } internal operator fun invoke( - syncCommand: ExecutableSyncCommand, + syncCommand: SyncCommands.ExecutableSyncCommand, commandScope: CoroutineScope = appScope, ): Job { with(syncCommand) { @@ -92,7 +92,7 @@ internal class ExecuteSyncCommandUseCase @Inject constructor( } } - private fun ExecutableSyncCommand.stop() { + private fun SyncCommands.ExecutableSyncCommand.stop() { when (target) { SyncTarget.SCHEDULE_EVERYTHING -> cancelBackgroundWork() SyncTarget.SCHEDULE_EVENTS -> cancelEventSync() @@ -102,7 +102,7 @@ internal class ExecuteSyncCommandUseCase @Inject constructor( } } - private suspend fun ExecutableSyncCommand.start() { + private suspend fun SyncCommands.ExecutableSyncCommand.start() { when (target) { SyncTarget.SCHEDULE_EVERYTHING -> scheduleBackgroundWork((payload as SyncCommandPayload.WithDelay).withDelay) SyncTarget.SCHEDULE_EVENTS -> rescheduleEventSync((payload as SyncCommandPayload.WithDelay).withDelay) diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt index 1fd2465170..3e86ac1cc7 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt @@ -188,7 +188,7 @@ class SyncCommandsTest { @Test fun `observe only is not an executable command`() { - assertThat(ExecutableSyncCommand::class.java.isInstance(SyncCommands.ObserveOnly)) + assertThat(SyncCommands.ExecutableSyncCommand::class.java.isInstance(SyncCommands.ObserveOnly)) .isFalse() } @@ -197,7 +197,7 @@ class SyncCommandsTest { action: SyncAction, payload: SyncCommandPayload = SyncCommandPayload.None, block: (suspend () -> Unit)? = null, - ) = ExecutableSyncCommand( + ) = SyncCommands.ExecutableSyncCommand( target = target, action = action, payload = payload, diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/LogoutUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/LogoutUseCaseTest.kt index 5596156cd6..c15fa6cce3 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/LogoutUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/LogoutUseCaseTest.kt @@ -2,7 +2,6 @@ package com.simprints.infra.sync.config.usecase import com.google.common.truth.Truth.assertThat import com.simprints.infra.authlogic.AuthManager -import com.simprints.infra.sync.ExecutableSyncCommand import com.simprints.infra.sync.SyncAction import com.simprints.infra.sync.SyncCommand import com.simprints.infra.sync.SyncCommands @@ -49,7 +48,7 @@ class LogoutUseCaseTest { fun `Fully logs out when called`() = runTest { useCase.invoke() - val command = syncCommandSlot.captured as ExecutableSyncCommand + val command = syncCommandSlot.captured as SyncCommands.ExecutableSyncCommand assertThat(command.target).isEqualTo(SyncTarget.SCHEDULE_EVERYTHING) assertThat(command.action).isEqualTo(SyncAction.STOP) assertThat(command).isEqualTo(SyncCommands.Schedule.Everything.stop()) diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt index f46bd92055..220ed1d36f 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt @@ -6,10 +6,10 @@ import com.simprints.infra.config.store.models.DownSynchronizationConfiguration import com.simprints.infra.config.store.models.Frequency import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository import com.simprints.infra.eventsync.EventSyncManager -import com.simprints.infra.sync.ExecutableSyncCommand import com.simprints.infra.sync.SyncAction import com.simprints.infra.sync.SyncCommand import com.simprints.infra.sync.SyncCommandPayload +import com.simprints.infra.sync.SyncCommands import com.simprints.infra.sync.SyncResponse import com.simprints.infra.sync.SyncTarget import com.simprints.infra.sync.config.testtools.projectConfiguration @@ -105,7 +105,7 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ) verify { sync(any()) } - val command = syncCommandSlot.captured as ExecutableSyncCommand + val command = syncCommandSlot.captured as SyncCommands.ExecutableSyncCommand assertThat(command.target) .isEqualTo(SyncTarget.SCHEDULE_EVENTS) assertThat(command.action) @@ -145,7 +145,7 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ) verify { sync(any()) } - val command = syncCommandSlot.captured as ExecutableSyncCommand + val command = syncCommandSlot.captured as SyncCommands.ExecutableSyncCommand assertThat(command.target) .isEqualTo(SyncTarget.SCHEDULE_EVENTS) assertThat(command.action) @@ -183,7 +183,7 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ) verify { sync(any()) } - val command = syncCommandSlot.captured as ExecutableSyncCommand + val command = syncCommandSlot.captured as SyncCommands.ExecutableSyncCommand assertThat(command.target) .isEqualTo(SyncTarget.SCHEDULE_EVENTS) assertThat(command.action) @@ -221,7 +221,7 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ) verify { sync(any()) } - val command = syncCommandSlot.captured as ExecutableSyncCommand + val command = syncCommandSlot.captured as SyncCommands.ExecutableSyncCommand assertThat(command.target) .isEqualTo(SyncTarget.SCHEDULE_EVENTS) assertThat(command.action) diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt index 50a72a3575..8cf7b2b5d3 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt @@ -4,7 +4,6 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.google.common.truth.Truth.assertThat import com.simprints.infra.eventsync.status.models.EventSyncState import com.simprints.infra.eventsync.sync.EventSyncStateProcessor -import com.simprints.infra.sync.ExecutableSyncCommand import com.simprints.infra.sync.ImageSyncStatus import com.simprints.infra.sync.SyncCommands import com.simprints.infra.sync.SyncStatus @@ -277,7 +276,7 @@ class SyncUseCaseTest { fun `executes executable sync command and returns its job`() = runTest { val expectedJob = Job().apply { complete() } val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, executeSyncCommand, appScope = backgroundScope) - val command = SyncCommands.Schedule.Everything.stopAndStart() as ExecutableSyncCommand + val command = SyncCommands.Schedule.Everything.stopAndStart() as SyncCommands.ExecutableSyncCommand every { executeSyncCommand.invoke(command, backgroundScope) } returns expectedJob val response = useCase(command) @@ -291,7 +290,7 @@ class SyncUseCaseTest { val expectedJob = Job().apply { complete() } val customScope = CoroutineScope(backgroundScope.coroutineContext + Job()) val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, executeSyncCommand, appScope = backgroundScope) - val command = SyncCommands.Schedule.Everything.stopAndStart() as ExecutableSyncCommand + val command = SyncCommands.Schedule.Everything.stopAndStart() as SyncCommands.ExecutableSyncCommand every { executeSyncCommand.invoke(command, customScope) } returns expectedJob diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt index 1aa1b969b4..db7197b9a4 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt @@ -10,7 +10,6 @@ import com.simprints.infra.authstore.AuthStore import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.eventsync.EventSyncManager import com.simprints.infra.eventsync.sync.master.EventSyncMasterWorker -import com.simprints.infra.sync.ExecutableSyncCommand import com.simprints.infra.sync.SyncCommands import com.simprints.infra.sync.SyncConstants.DEVICE_SYNC_WORK_NAME import com.simprints.infra.sync.SyncConstants.EVENT_SYNC_WORK_NAME @@ -429,7 +428,7 @@ class ExecuteSyncCommandUseCaseTest { } } - private fun executable(syncCommand: com.simprints.infra.sync.SyncCommand) = syncCommand as ExecutableSyncCommand + private fun executable(syncCommand: com.simprints.infra.sync.SyncCommand) = syncCommand as SyncCommands.ExecutableSyncCommand private fun commandScope() = CoroutineScope(testCoroutineRule.testCoroutineDispatcher) From ae6440872e5d28c979f60e3aa0ae2051a0b679e4 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 28 Jan 2026 16:29:38 +0000 Subject: [PATCH 20/22] MS-1299 Sync revamp: test function typo cleanup --- .../sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt index db7197b9a4..5bf7c6d01c 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt @@ -62,7 +62,7 @@ class ExecuteSyncCommandUseCaseTest { } @Test - fun `does not schedules any workers if not logged in`() = runTest { + fun `does not schedule any workers if not logged in`() = runTest { every { authStore.signedInProjectId } returns "" coEvery { shouldScheduleFirmwareUpdate.invoke() } returns false From 05d303ed1d8df0c5fe2398c6460cf5aab8b54a66 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 2 Feb 2026 10:29:13 +0000 Subject: [PATCH 21/22] MS-1299 Sync revamp: terminology cleanup --- .../feature/dashboard/debug/DebugFragment.kt | 10 ++-- .../dashboard/logout/usecase/LogoutUseCase.kt | 2 +- .../settings/syncinfo/SyncInfoViewModel.kt | 8 ++-- .../ModuleSelectionViewModel.kt | 2 +- .../logout/usecase/LogoutUseCaseTest.kt | 2 +- .../syncinfo/SyncInfoViewModelTest.kt | 46 +++++++++---------- .../ModuleSelectionViewModelTest.kt | 2 +- .../feature/logincheck/LoginCheckViewModel.kt | 2 +- .../usecases/StartBackgroundSyncUseCase.kt | 2 +- .../logincheck/LoginCheckViewModelTest.kt | 2 +- .../StartBackgroundSyncUseCaseTest.kt | 8 ++-- .../usecase/RunBlockingEventSyncUseCase.kt | 2 +- .../RunBlockingEventSyncUseCaseTest.kt | 12 ++--- .../main/java/com/simprints/id/Application.kt | 2 +- .../events/down/EventDownSyncResetService.kt | 2 +- .../com/simprints/infra/sync/SyncCommands.kt | 38 +++++++-------- .../sync/config/usecase/LogoutUseCase.kt | 2 +- ...RescheduleWorkersIfConfigChangedUseCase.kt | 2 +- ...ResetLocalRecordsIfConfigChangedUseCase.kt | 2 +- .../infra/sync/usecase/SyncUseCase.kt | 18 ++++---- .../internal/ExecuteSyncCommandUseCase.kt | 4 +- .../simprints/infra/sync/SyncCommandsTest.kt | 38 +++++++-------- .../sync/config/usecase/LogoutUseCaseTest.kt | 4 +- ...heduleWorkersIfConfigChangedUseCaseTest.kt | 2 +- ...tLocalRecordsIfConfigChangedUseCaseTest.kt | 8 ++-- .../infra/sync/usecase/SyncUseCaseTest.kt | 4 +- .../internal/ExecuteSyncCommandUseCaseTest.kt | 40 ++++++++-------- 27 files changed, 133 insertions(+), 133 deletions(-) diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt index 6ddc93f0ab..c4765cf3ea 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt @@ -87,15 +87,15 @@ internal class DebugFragment : Fragment(R.layout.fragment_debug) { } binding.syncStart.setOnClickListener { - sync(SyncCommands.OneTime.Events.start()) + sync(SyncCommands.OneTimeNow.Events.start()) } binding.syncStop.setOnClickListener { - sync(SyncCommands.OneTime.Events.stop()) + sync(SyncCommands.OneTimeNow.Events.stop()) } binding.syncSchedule.setOnClickListener { - sync(SyncCommands.Schedule.Events.start()) + sync(SyncCommands.ScheduleOf.Events.start()) } binding.clearFirebaseToken.setOnClickListener { @@ -120,8 +120,8 @@ internal class DebugFragment : Fragment(R.layout.fragment_debug) { binding.cleanAll.setOnClickListener { lifecycleScope.launch(dispatcher) { - sync(SyncCommands.OneTime.Events.stop()) - sync(SyncCommands.Schedule.Events.stop()) + sync(SyncCommands.OneTimeNow.Events.stop()) + sync(SyncCommands.ScheduleOf.Events.stop()) eventRepository.deleteAll() eventSyncManager.resetDownSyncInfo() diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCase.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCase.kt index abf44eccbc..3642b4271d 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCase.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCase.kt @@ -22,7 +22,7 @@ internal class LogoutUseCase @Inject constructor( // To prevent a race between wiping data and navigation, this use case must block the executing thread operator fun invoke() = runBlocking(ioDispatcher) { // Cancel all background sync - sync(SyncCommands.Schedule.Everything.stop()) + sync(SyncCommands.ScheduleOf.Everything.stop()) syncOrchestrator.deleteEventSyncInfo() // sign out the user authManager.signOut() diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt index 3c38b74db1..35d0723a6d 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt @@ -145,7 +145,7 @@ internal class SyncInfoViewModel @Inject constructor( } val isDownSyncAllowed = !isPreLogoutUpSync && configRepository.getProject()?.state == ProjectState.RUNNING - sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed)) + sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed)) } } @@ -153,10 +153,10 @@ internal class SyncInfoViewModel @Inject constructor( viewModelScope.launch { val isImageSyncing = imageSyncStatusFlow.firstOrNull()?.isSyncing == true if (isImageSyncing) { - sync(SyncCommands.OneTime.Images.stop()) + sync(SyncCommands.OneTimeNow.Images.stop()) } else { imageSyncButtonClickFlow.emit(Unit) - sync(SyncCommands.OneTime.Images.start()) + sync(SyncCommands.OneTimeNow.Images.start()) } } } @@ -214,7 +214,7 @@ internal class SyncInfoViewModel @Inject constructor( .distinctUntilChanged() .collect { isEventSyncCompleted -> if (isEventSyncCompleted) { - sync(SyncCommands.OneTime.Images.start()) + sync(SyncCommands.OneTimeNow.Images.start()) } } } diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModel.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModel.kt index b9a15b9a77..81c37f054e 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModel.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModel.kt @@ -115,7 +115,7 @@ internal class ModuleSelectionViewModel @Inject constructor( module.copy(name = encryptedName) } moduleRepository.saveModules(modules) - sync(SyncCommands.OneTime.Events.stopAndStart()) + sync(SyncCommands.OneTimeNow.Events.restart()) } } diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCaseTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCaseTest.kt index caa3292436..44f06245b6 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCaseTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCaseTest.kt @@ -61,7 +61,7 @@ class LogoutUseCaseTest { fun `Fully logs out when called`() = runTest { useCase.invoke() - verify { sync(SyncCommands.Schedule.Everything.stop()) } + verify { sync(SyncCommands.ScheduleOf.Everything.stop()) } coVerify { syncOrchestrator.deleteEventSyncInfo() authManager.signOut() diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModelTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModelTest.kt index f63990203a..450a584d12 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModelTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModelTest.kt @@ -388,7 +388,7 @@ class SyncInfoViewModelTest { viewModel.forceEventSync() - verify { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = true)) } + verify { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = true)) } } @Test @@ -397,7 +397,7 @@ class SyncInfoViewModelTest { viewModel.forceEventSync() - verify { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = false)) } + verify { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = false)) } } @Test @@ -411,7 +411,7 @@ class SyncInfoViewModelTest { viewModel.forceEventSync() - verify { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = false)) } + verify { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = false)) } } @Test @@ -425,7 +425,7 @@ class SyncInfoViewModelTest { viewModel.forceEventSync() - verify { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = false)) } + verify { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = false)) } } @Test @@ -436,14 +436,14 @@ class SyncInfoViewModelTest { viewModel.forceEventSync() - verify { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = false)) } + verify { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = false)) } } @Test fun `should stop current event sync before starting new one`() = runTest { viewModel.forceEventSync() - verify { sync(SyncCommands.OneTime.Events.stopAndStart()) } + verify { sync(SyncCommands.OneTimeNow.Events.restart()) } } // toggleImageSync() tests @@ -458,8 +458,8 @@ class SyncInfoViewModelTest { viewModel.toggleImageSync() - verify { sync(SyncCommands.OneTime.Images.start()) } - verify(exactly = 0) { sync(SyncCommands.OneTime.Images.stop()) } + verify { sync(SyncCommands.OneTimeNow.Images.start()) } + verify(exactly = 0) { sync(SyncCommands.OneTimeNow.Images.stop()) } } @Test @@ -472,8 +472,8 @@ class SyncInfoViewModelTest { viewModel.toggleImageSync() - verify { sync(SyncCommands.OneTime.Images.stop()) } - verify(exactly = 0) { sync(SyncCommands.OneTime.Images.start()) } + verify { sync(SyncCommands.OneTimeNow.Images.stop()) } + verify(exactly = 0) { sync(SyncCommands.OneTimeNow.Images.start()) } } // logout() tests @@ -527,7 +527,7 @@ class SyncInfoViewModelTest { viewModel.handleLoginResult(successResult) - verify { sync(SyncCommands.OneTime.Events.stopAndStart()) } + verify { sync(SyncCommands.OneTimeNow.Events.restart()) } } @Test @@ -538,8 +538,8 @@ class SyncInfoViewModelTest { viewModel.handleLoginResult(failureResult) - verify(exactly = 0) { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = true)) } - verify(exactly = 0) { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = false)) } + verify(exactly = 0) { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = true)) } + verify(exactly = 0) { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = false)) } } // Sync button responsiveness optimization @@ -675,7 +675,7 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - verify { sync(SyncCommands.OneTime.Events.stopAndStart()) } + verify { sync(SyncCommands.OneTimeNow.Events.restart()) } } @Test @@ -691,7 +691,7 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - verify { sync(SyncCommands.OneTime.Events.stopAndStart()) } + verify { sync(SyncCommands.OneTimeNow.Events.restart()) } } @Test @@ -707,8 +707,8 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - verify(exactly = 0) { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = true)) } - verify(exactly = 0) { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = false)) } + verify(exactly = 0) { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = true)) } + verify(exactly = 0) { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = false)) } } @Test @@ -722,8 +722,8 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - verify(exactly = 0) { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = true)) } - verify(exactly = 0) { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = false)) } + verify(exactly = 0) { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = true)) } + verify(exactly = 0) { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = false)) } } @Test @@ -740,7 +740,7 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - verify { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = false)) } + verify { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = false)) } } @Test @@ -761,7 +761,7 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - verify(exactly = 1) { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = false)) } + verify(exactly = 1) { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = false)) } } @Test @@ -782,8 +782,8 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - verify(exactly = 0) { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = true)) } - verify(exactly = 0) { sync(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = false)) } + verify(exactly = 0) { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = true)) } + verify(exactly = 0) { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = false)) } } private companion object { diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModelTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModelTest.kt index ecf1c21dee..547ee043fb 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModelTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModelTest.kt @@ -175,7 +175,7 @@ class ModuleSelectionViewModelTest { viewModel.saveModules() coVerify(exactly = 1) { repository.saveModules(updatedModules) } - verify(exactly = 1) { sync(SyncCommands.OneTime.Events.stopAndStart()) } + verify(exactly = 1) { sync(SyncCommands.OneTimeNow.Events.restart()) } } @Test diff --git a/feature/login-check/src/main/java/com/simprints/feature/logincheck/LoginCheckViewModel.kt b/feature/login-check/src/main/java/com/simprints/feature/logincheck/LoginCheckViewModel.kt index fe147aef21..6e3cfca69e 100644 --- a/feature/login-check/src/main/java/com/simprints/feature/logincheck/LoginCheckViewModel.kt +++ b/feature/login-check/src/main/java/com/simprints/feature/logincheck/LoginCheckViewModel.kt @@ -106,7 +106,7 @@ class LoginCheckViewModel @Inject internal constructor( cachedRequest = actionRequest loginAlreadyTried.set(true) - sync(SyncCommands.Schedule.Everything.stop()) + sync(SyncCommands.ScheduleOf.Everything.stop()) _showLoginFlow.send(actionRequest) } diff --git a/feature/login-check/src/main/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCase.kt b/feature/login-check/src/main/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCase.kt index 48ac6c497f..6464c6b26c 100644 --- a/feature/login-check/src/main/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCase.kt +++ b/feature/login-check/src/main/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCase.kt @@ -18,6 +18,6 @@ internal class StartBackgroundSyncUseCase @Inject constructor( ?.frequency val withDelay = frequency != Frequency.PERIODICALLY_AND_ON_SESSION_START - sync(SyncCommands.Schedule.Everything.start(withDelay)).await() + sync(SyncCommands.ScheduleOf.Everything.start(withDelay)).await() } } diff --git a/feature/login-check/src/test/java/com/simprints/feature/logincheck/LoginCheckViewModelTest.kt b/feature/login-check/src/test/java/com/simprints/feature/logincheck/LoginCheckViewModelTest.kt index 14b4ac216c..e1a16ac1c2 100644 --- a/feature/login-check/src/test/java/com/simprints/feature/logincheck/LoginCheckViewModelTest.kt +++ b/feature/login-check/src/test/java/com/simprints/feature/logincheck/LoginCheckViewModelTest.kt @@ -172,7 +172,7 @@ internal class LoginCheckViewModelTest { coVerify { addAuthorizationEventUseCase.invoke(any(), eq(false)) } - verify { sync(SyncCommands.Schedule.Everything.stop()) } + verify { sync(SyncCommands.ScheduleOf.Everything.stop()) } viewModel.showLoginFlow .test() .assertValue { it.peekContent() == ActionFactory.getIdentifyRequest() } diff --git a/feature/login-check/src/test/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCaseTest.kt b/feature/login-check/src/test/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCaseTest.kt index e4614d4294..8da27206a1 100644 --- a/feature/login-check/src/test/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCaseTest.kt +++ b/feature/login-check/src/test/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCaseTest.kt @@ -50,7 +50,7 @@ class StartBackgroundSyncUseCaseTest { } returns Frequency.PERIODICALLY - assertUseCaseAwaitsSync(SyncCommands.Schedule.Everything.start(withDelay = true)) + assertUseCaseAwaitsSync(SyncCommands.ScheduleOf.Everything.start(withDelay = true)) } @Test @@ -63,7 +63,7 @@ class StartBackgroundSyncUseCaseTest { } returns Frequency.PERIODICALLY_AND_ON_SESSION_START - assertUseCaseAwaitsSync(SyncCommands.Schedule.Everything.start()) + assertUseCaseAwaitsSync(SyncCommands.ScheduleOf.Everything.start()) } @Test @@ -76,7 +76,7 @@ class StartBackgroundSyncUseCaseTest { } returns Frequency.PERIODICALLY - assertUseCaseAwaitsSync(SyncCommands.Schedule.Everything.start(withDelay = true)) + assertUseCaseAwaitsSync(SyncCommands.ScheduleOf.Everything.start(withDelay = true)) } @Test @@ -87,7 +87,7 @@ class StartBackgroundSyncUseCaseTest { .synchronization.down.simprints } returns null - assertUseCaseAwaitsSync(SyncCommands.Schedule.Everything.start(withDelay = true)) + assertUseCaseAwaitsSync(SyncCommands.ScheduleOf.Everything.start(withDelay = true)) } private suspend fun TestScope.assertUseCaseAwaitsSync(expectedCommand: SyncCommand) { diff --git a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt index 1da3d37b6c..9a45465e9e 100644 --- a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt @@ -20,7 +20,7 @@ internal class RunBlockingEventSyncUseCase @Inject constructor( .map { it.eventSyncState } .firstOrNull { !it.isUninitialized() } ?.syncId - sync(SyncCommands.OneTime.Events.start()) + sync(SyncCommands.OneTimeNow.Events.start()) .apply { await() }.syncStatusFlow diff --git a/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt index 1ed2755eeb..28b138e34b 100644 --- a/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt +++ b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt @@ -52,7 +52,7 @@ class RunBlockingEventSyncUseCaseTest { testScheduler.advanceUntilIdle() verify(exactly = 1) { sync(SyncCommands.ObserveOnly) } - verify(exactly = 1) { sync.invoke(SyncCommands.OneTime.Events.start()) } + verify(exactly = 1) { sync.invoke(SyncCommands.OneTimeNow.Events.start()) } } @Test @@ -67,7 +67,7 @@ class RunBlockingEventSyncUseCaseTest { testScheduler.advanceUntilIdle() verify(exactly = 1) { sync(SyncCommands.ObserveOnly) } - verify(exactly = 1) { sync.invoke(SyncCommands.OneTime.Events.start()) } + verify(exactly = 1) { sync.invoke(SyncCommands.OneTimeNow.Events.start()) } } @Test @@ -82,7 +82,7 @@ class RunBlockingEventSyncUseCaseTest { testScheduler.advanceUntilIdle() verify(exactly = 1) { sync(SyncCommands.ObserveOnly) } - verify(exactly = 1) { sync.invoke(SyncCommands.OneTime.Events.start()) } + verify(exactly = 1) { sync.invoke(SyncCommands.OneTimeNow.Events.start()) } } @Test @@ -93,12 +93,12 @@ class RunBlockingEventSyncUseCaseTest { val job = launch { usecase.invoke() } testScheduler.advanceUntilIdle() - verify(exactly = 0) { sync(SyncCommands.OneTime.Events.start()) } + verify(exactly = 0) { sync(SyncCommands.OneTimeNow.Events.start()) } syncFlow.value = createSyncStatus("sync", EventSyncWorkerState.Succeeded) testScheduler.advanceUntilIdle() - verify(exactly = 1) { sync(SyncCommands.OneTime.Events.start()) } + verify(exactly = 1) { sync(SyncCommands.OneTimeNow.Events.start()) } job.cancel() } @@ -133,7 +133,7 @@ class RunBlockingEventSyncUseCaseTest { syncStatusFlow = syncFlow, ) every { sync.invoke(SyncCommands.ObserveOnly) } returns syncResponse - every { sync.invoke(SyncCommands.OneTime.Events.start()) } returns syncResponse + every { sync.invoke(SyncCommands.OneTimeNow.Events.start()) } returns syncResponse } private fun createPlaceholderSyncStatus(): SyncStatus = createSyncStatus("", null, null, null) diff --git a/id/src/main/java/com/simprints/id/Application.kt b/id/src/main/java/com/simprints/id/Application.kt index bf71814b87..263a172dda 100644 --- a/id/src/main/java/com/simprints/id/Application.kt +++ b/id/src/main/java/com/simprints/id/Application.kt @@ -90,7 +90,7 @@ open class Application : appScope.launch { realmToRoomMigrationScheduler.scheduleMigrationWorkerIfNeeded() syncOrchestrator.cleanupWorkers() - sync(SyncCommands.Schedule.Everything.start()) + sync(SyncCommands.ScheduleOf.Everything.start()) } if (DB_ENCRYPTION) { System.loadLibrary("sqlcipher") diff --git a/id/src/main/java/com/simprints/id/services/sync/events/down/EventDownSyncResetService.kt b/id/src/main/java/com/simprints/id/services/sync/events/down/EventDownSyncResetService.kt index 8c243319f9..a68f6aed9c 100644 --- a/id/src/main/java/com/simprints/id/services/sync/events/down/EventDownSyncResetService.kt +++ b/id/src/main/java/com/simprints/id/services/sync/events/down/EventDownSyncResetService.kt @@ -52,7 +52,7 @@ class EventDownSyncResetService : Service() { eventSyncManager.resetDownSyncInfo() // Trigger a new sync // Scope isn't passed to sync here to prevent a timeout cancellation leaving it in a stopped state - sync(SyncCommands.OneTime.Events.start()).await() + sync(SyncCommands.OneTimeNow.Events.start()).await() } resetJob?.invokeOnCompletion { stopSelf() } diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt b/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt index 0bc6564aff..38ae9c49c4 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt @@ -11,13 +11,13 @@ package com.simprints.infra.sync object SyncCommands { object ObserveOnly : SyncCommand() - object OneTime { + object OneTimeNow { // DSL-style capitalization to fit well when used like: sync(SyncCommands.OneTime.Events.start()) val Events = buildSyncCommandsWithDownSyncParam(SyncTarget.ONE_TIME_EVENTS) val Images = buildSyncCommands(SyncTarget.ONE_TIME_IMAGES) } - object Schedule { + object ScheduleOf { val Everything = buildSyncCommandsWithDelayParam(SyncTarget.SCHEDULE_EVERYTHING) val Events = buildSyncCommandsWithDelayParam(SyncTarget.SCHEDULE_EVENTS) val Images = buildSyncCommands(SyncTarget.SCHEDULE_IMAGES) @@ -37,9 +37,9 @@ object SyncCommands { fun start(): SyncCommand - fun stopAndStart(): SyncCommand + fun restart(): SyncCommand - fun stopAndStartAround(block: suspend () -> Unit): SyncCommand + fun restartAfter(block: suspend () -> Unit): SyncCommand } interface SyncCommandBuilderWithDownSyncParam { @@ -47,9 +47,9 @@ object SyncCommands { fun start(isDownSyncAllowed: Boolean = true): SyncCommand - fun stopAndStart(isDownSyncAllowed: Boolean = true): SyncCommand + fun restart(isDownSyncAllowed: Boolean = true): SyncCommand - fun stopAndStartAround( + fun restartAfter( isDownSyncAllowed: Boolean = true, block: suspend () -> Unit, ): SyncCommand @@ -60,9 +60,9 @@ object SyncCommands { fun start(withDelay: Boolean = false): SyncCommand - fun stopAndStart(withDelay: Boolean = false): SyncCommand + fun restart(withDelay: Boolean = false): SyncCommand - fun stopAndStartAround( + fun restartAfter( withDelay: Boolean = false, block: suspend () -> Unit, ): SyncCommand @@ -73,9 +73,9 @@ object SyncCommands { override fun start() = getCommand(target, SyncAction.START) - override fun stopAndStart() = getCommand(target, SyncAction.STOP_AND_START) + override fun restart() = getCommand(target, SyncAction.RESTART) - override fun stopAndStartAround(block: suspend () -> Unit) = getCommand(target, SyncAction.STOP_AND_START, block = block) + override fun restartAfter(block: suspend () -> Unit) = getCommand(target, SyncAction.RESTART, block = block) } private fun buildSyncCommandsWithDownSyncParam(target: SyncTarget) = object : SyncCommandBuilderWithDownSyncParam { @@ -84,15 +84,15 @@ object SyncCommands { override fun start(isDownSyncAllowed: Boolean) = getCommand(target, SyncAction.START, payload = SyncCommandPayload.WithDownSyncAllowed(isDownSyncAllowed)) - override fun stopAndStart(isDownSyncAllowed: Boolean) = - getCommand(target, SyncAction.STOP_AND_START, payload = SyncCommandPayload.WithDownSyncAllowed(isDownSyncAllowed)) + override fun restart(isDownSyncAllowed: Boolean) = + getCommand(target, SyncAction.RESTART, payload = SyncCommandPayload.WithDownSyncAllowed(isDownSyncAllowed)) - override fun stopAndStartAround( + override fun restartAfter( isDownSyncAllowed: Boolean, block: suspend () -> Unit, ) = getCommand( target, - SyncAction.STOP_AND_START, + SyncAction.RESTART, payload = SyncCommandPayload.WithDownSyncAllowed(isDownSyncAllowed), block = block, ) @@ -103,13 +103,13 @@ object SyncCommands { override fun start(withDelay: Boolean) = getCommand(target, SyncAction.START, payload = SyncCommandPayload.WithDelay(withDelay)) - override fun stopAndStart(withDelay: Boolean) = - getCommand(target, SyncAction.STOP_AND_START, payload = SyncCommandPayload.WithDelay(withDelay)) + override fun restart(withDelay: Boolean) = + getCommand(target, SyncAction.RESTART, payload = SyncCommandPayload.WithDelay(withDelay)) - override fun stopAndStartAround( + override fun restartAfter( withDelay: Boolean, block: suspend () -> Unit, - ) = getCommand(target, SyncAction.STOP_AND_START, payload = SyncCommandPayload.WithDelay(withDelay), block = block) + ) = getCommand(target, SyncAction.RESTART, payload = SyncCommandPayload.WithDelay(withDelay), block = block) } private fun getCommand( @@ -141,7 +141,7 @@ enum class SyncTarget { internal enum class SyncAction { STOP, START, - STOP_AND_START, + RESTART, } internal sealed class SyncCommandPayload { diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/LogoutUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/LogoutUseCase.kt index 27119a98b0..213c95d35b 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/LogoutUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/LogoutUseCase.kt @@ -12,7 +12,7 @@ internal class LogoutUseCase @Inject constructor( private val authManager: AuthManager, ) { suspend operator fun invoke() { - sync(SyncCommands.Schedule.Everything.stop()) + sync(SyncCommands.ScheduleOf.Everything.stop()) syncOrchestrator.deleteEventSyncInfo() authManager.signOut() } diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCase.kt index b6657d168f..1a00bbbc82 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCase.kt @@ -15,7 +15,7 @@ internal class RescheduleWorkersIfConfigChangedUseCase @Inject constructor( newConfig: ProjectConfiguration, ) { if (shouldRescheduleImageUpload(oldConfig, newConfig)) { - sync(SyncCommands.Schedule.Images.start()).await() + sync(SyncCommands.ScheduleOf.Images.start()).await() } } diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCase.kt index b12ebafb3a..b2e288a51c 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCase.kt @@ -19,7 +19,7 @@ internal class ResetLocalRecordsIfConfigChangedUseCase @Inject constructor( ) { if (hasPartitionTypeChanged(oldConfig, newConfig)) { sync( - SyncCommands.Schedule.Events.stopAndStartAround { + SyncCommands.ScheduleOf.Events.restartAfter { eventSyncManager.resetDownSyncInfo() enrolmentRecordRepository.deleteAll() }, diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt index c8d514840c..89e6bcf520 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt @@ -71,22 +71,22 @@ class SyncUseCase @Inject internal constructor( * sync( * SyncCommands. * +- ObserveOnly. - * +- Schedule. + * +- ScheduleOf. * | +- Everything. --->| * | +- Events. --->| |---> stop() * | +- Images. --->| for |---> start() - * +- OneTime. |---------->|---> stopAndStart() - * +- Events. --->| all |---> stopAndStartAround { /* stop, run this block, then start */ } + * +- OneTimeNow. |---------->|---> restart() + * +- Events. --->| all |---> restartAfter { /* stop, run this block, then start */ } * +- Images. --->| * ) * * Examples: * * sync(SyncCommands.ObserveOnly) - * sync(SyncCommands.OneTime.Events.stop()) - * sync(SyncCommands.OneTime.Images.stopAndStart()) // starts even if wasn't running at stop command time - * sync(SyncCommands.Schedule.Events.start()) - * sync(SyncCommands.Schedule.Everything.stopAndStartAround { + * sync(SyncCommands.OneTimeNow.Events.stop()) + * sync(SyncCommands.OneTimeNow.Images.restart()) // starts even if wasn't running at stop command time + * sync(SyncCommands.ScheduleOf.Events.start()) + * sync(SyncCommands.ScheduleOf.Everything.restartAfter { * delay(10_000) // transaction to wait for... * }).await() // ...now complete * val lastEventSyncTime = sync(SyncCommands.ObserveOnly).syncStatusFlow.value.eventSyncState.lastSyncTime @@ -99,9 +99,9 @@ class SyncUseCase @Inject internal constructor( * If the command was for a inherently non-blocking job, it will be returned already completed. * To suspend until the command completes, add .await(), it rethrows cancellations / other exceptions. * - * The commandScope param allows the sync command (incl. the optional stopAndStartAround block) + * The commandScope param allows the sync command (incl. the optional restartAfter block) * be cancelable when the passed scope's coroutine is cancelled, - * and to allow stopAndStartAround throw exceptions in the passed scopes coroutine's context. + * and to allow restartAfter throw exceptions in the passed scopes coroutine's context. * Note: cancelling a command may leave the corresponding sync in a stopped state. The stopping is synchronous. */ operator fun invoke( diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt index 4d850806f1..ee6bc00b7b 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt @@ -73,11 +73,11 @@ internal class ExecuteSyncCommandUseCase @Inject constructor( commandScope: CoroutineScope = appScope, ): Job { with(syncCommand) { - val isStopNeeded = action in listOf(SyncAction.STOP, SyncAction.STOP_AND_START) + val isStopNeeded = action in listOf(SyncAction.STOP, SyncAction.RESTART) if (isStopNeeded) { stop() } - val isStartNeeded = action in listOf(SyncAction.START, SyncAction.STOP_AND_START) + val isStartNeeded = action in listOf(SyncAction.START, SyncAction.RESTART) val isFurtherAsyncActionNeeded = blockToRunWhileStopped != null || isStartNeeded return if (isFurtherAsyncActionNeeded) { commandScope.launch(ioDispatcher) { diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt index 3e86ac1cc7..2a1a5e6abf 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt @@ -5,17 +5,17 @@ import org.junit.Test class SyncCommandsTest { private val buildersWithoutParams = listOf( - SyncCommands.OneTime.Images to SyncTarget.ONE_TIME_IMAGES, - SyncCommands.Schedule.Images to SyncTarget.SCHEDULE_IMAGES, + SyncCommands.OneTimeNow.Images to SyncTarget.ONE_TIME_IMAGES, + SyncCommands.ScheduleOf.Images to SyncTarget.SCHEDULE_IMAGES, ) private val buildersWithDelayParam = listOf( - SyncCommands.Schedule.Everything to SyncTarget.SCHEDULE_EVERYTHING, - SyncCommands.Schedule.Events to SyncTarget.SCHEDULE_EVENTS, + SyncCommands.ScheduleOf.Everything to SyncTarget.SCHEDULE_EVERYTHING, + SyncCommands.ScheduleOf.Events to SyncTarget.SCHEDULE_EVENTS, ) private val buildersWithDownSyncAllowedParam = listOf( - SyncCommands.OneTime.Events to SyncTarget.ONE_TIME_EVENTS, + SyncCommands.OneTimeNow.Events to SyncTarget.ONE_TIME_EVENTS, ) @Test @@ -81,15 +81,15 @@ class SyncCommandsTest { } @Test - fun `stopAndStart builds executable command with expected params`() { - val action = SyncAction.STOP_AND_START + fun `restart builds executable command with expected params`() { + val action = SyncAction.RESTART buildersWithoutParams.forEach { (builder, expectedTarget) -> - assertThat(builder.stopAndStart()) + assertThat(builder.restart()) .isEqualTo(expectedCommand(expectedTarget, action)) } buildersWithDelayParam.forEach { (builder, expectedTarget) -> - assertThat(builder.stopAndStart()) + assertThat(builder.restart()) .isEqualTo( expectedCommand( target = expectedTarget, @@ -97,7 +97,7 @@ class SyncCommandsTest { payload = SyncCommandPayload.WithDelay(false), ), ) - assertThat(builder.stopAndStart(withDelay = true)) + assertThat(builder.restart(withDelay = true)) .isEqualTo( expectedCommand( target = expectedTarget, @@ -108,7 +108,7 @@ class SyncCommandsTest { } buildersWithDownSyncAllowedParam.forEach { (builder, expectedTarget) -> - assertThat(builder.stopAndStart()) + assertThat(builder.restart()) .isEqualTo( expectedCommand( target = expectedTarget, @@ -116,7 +116,7 @@ class SyncCommandsTest { payload = SyncCommandPayload.WithDownSyncAllowed(true), ), ) - assertThat(builder.stopAndStart(isDownSyncAllowed = false)) + assertThat(builder.restart(isDownSyncAllowed = false)) .isEqualTo( expectedCommand( target = expectedTarget, @@ -128,12 +128,12 @@ class SyncCommandsTest { } @Test - fun `stopAndStartAround builds executable command and stores block`() { + fun `restartAfter builds executable command and stores block`() { val block: suspend () -> Unit = { } - val action = SyncAction.STOP_AND_START + val action = SyncAction.RESTART buildersWithoutParams.forEach { (builder, expectedTarget) -> - assertThat(builder.stopAndStartAround(block)) + assertThat(builder.restartAfter(block)) .isEqualTo( expectedCommand( target = expectedTarget, @@ -144,7 +144,7 @@ class SyncCommandsTest { } buildersWithDownSyncAllowedParam.forEach { (builder, expectedTarget) -> - assertThat(builder.stopAndStartAround(block = block)) + assertThat(builder.restartAfter(block = block)) .isEqualTo( expectedCommand( target = expectedTarget, @@ -153,7 +153,7 @@ class SyncCommandsTest { block, ), ) - assertThat(builder.stopAndStartAround(isDownSyncAllowed = false, block = block)) + assertThat(builder.restartAfter(isDownSyncAllowed = false, block = block)) .isEqualTo( expectedCommand( target = expectedTarget, @@ -165,7 +165,7 @@ class SyncCommandsTest { } buildersWithDelayParam.forEach { (builder, expectedTarget) -> - assertThat(builder.stopAndStartAround(block = block)) + assertThat(builder.restartAfter(block = block)) .isEqualTo( expectedCommand( target = expectedTarget, @@ -174,7 +174,7 @@ class SyncCommandsTest { block, ), ) - assertThat(builder.stopAndStartAround(withDelay = true, block = block)) + assertThat(builder.restartAfter(withDelay = true, block = block)) .isEqualTo( expectedCommand( target = expectedTarget, diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/LogoutUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/LogoutUseCaseTest.kt index c15fa6cce3..e672395deb 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/LogoutUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/LogoutUseCaseTest.kt @@ -51,9 +51,9 @@ class LogoutUseCaseTest { val command = syncCommandSlot.captured as SyncCommands.ExecutableSyncCommand assertThat(command.target).isEqualTo(SyncTarget.SCHEDULE_EVERYTHING) assertThat(command.action).isEqualTo(SyncAction.STOP) - assertThat(command).isEqualTo(SyncCommands.Schedule.Everything.stop()) + assertThat(command).isEqualTo(SyncCommands.ScheduleOf.Everything.stop()) - verify { sync(SyncCommands.Schedule.Everything.stop()) } + verify { sync(SyncCommands.ScheduleOf.Everything.stop()) } coVerify { syncOrchestrator.deleteEventSyncInfo() authManager.signOut() diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCaseTest.kt index 442e9c31fb..87253fcee0 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCaseTest.kt @@ -100,7 +100,7 @@ class RescheduleWorkersIfConfigChangedUseCaseTest { runCurrent() useCaseJob.await() - verify { sync(SyncCommands.Schedule.Images.start()) } + verify { sync(SyncCommands.ScheduleOf.Images.start()) } assertThat(useCaseJob.isCompleted).isTrue() } diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt index 220ed1d36f..da87aee366 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt @@ -109,7 +109,7 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { assertThat(command.target) .isEqualTo(SyncTarget.SCHEDULE_EVENTS) assertThat(command.action) - .isEqualTo(SyncAction.STOP_AND_START) + .isEqualTo(SyncAction.RESTART) assertThat((command.payload as SyncCommandPayload.WithDelay).withDelay) .isFalse() @@ -149,7 +149,7 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { assertThat(command.target) .isEqualTo(SyncTarget.SCHEDULE_EVENTS) assertThat(command.action) - .isEqualTo(SyncAction.STOP_AND_START) + .isEqualTo(SyncAction.RESTART) command.blockToRunWhileStopped?.invoke() runCurrent() @@ -187,7 +187,7 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { assertThat(command.target) .isEqualTo(SyncTarget.SCHEDULE_EVENTS) assertThat(command.action) - .isEqualTo(SyncAction.STOP_AND_START) + .isEqualTo(SyncAction.RESTART) command.blockToRunWhileStopped?.invoke() runCurrent() @@ -225,7 +225,7 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { assertThat(command.target) .isEqualTo(SyncTarget.SCHEDULE_EVENTS) assertThat(command.action) - .isEqualTo(SyncAction.STOP_AND_START) + .isEqualTo(SyncAction.RESTART) command.blockToRunWhileStopped?.invoke() runCurrent() diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt index 8cf7b2b5d3..70cabf8943 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt @@ -276,7 +276,7 @@ class SyncUseCaseTest { fun `executes executable sync command and returns its job`() = runTest { val expectedJob = Job().apply { complete() } val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, executeSyncCommand, appScope = backgroundScope) - val command = SyncCommands.Schedule.Everything.stopAndStart() as SyncCommands.ExecutableSyncCommand + val command = SyncCommands.ScheduleOf.Everything.restart() as SyncCommands.ExecutableSyncCommand every { executeSyncCommand.invoke(command, backgroundScope) } returns expectedJob val response = useCase(command) @@ -290,7 +290,7 @@ class SyncUseCaseTest { val expectedJob = Job().apply { complete() } val customScope = CoroutineScope(backgroundScope.coroutineContext + Job()) val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, executeSyncCommand, appScope = backgroundScope) - val command = SyncCommands.Schedule.Everything.stopAndStart() as SyncCommands.ExecutableSyncCommand + val command = SyncCommands.ScheduleOf.Everything.restart() as SyncCommands.ExecutableSyncCommand every { executeSyncCommand.invoke(command, customScope) } returns expectedJob diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt index 5bf7c6d01c..ae5850378b 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt @@ -66,7 +66,7 @@ class ExecuteSyncCommandUseCaseTest { every { authStore.signedInProjectId } returns "" coEvery { shouldScheduleFirmwareUpdate.invoke() } returns false - useCase(executable(SyncCommands.Schedule.Everything.start()), commandScope()).join() + useCase(executable(SyncCommands.ScheduleOf.Everything.start()), commandScope()).join() verify(exactly = 0) { workManager.enqueueUniquePeriodicWork(any(), any(), any()) } } @@ -76,7 +76,7 @@ class ExecuteSyncCommandUseCaseTest { every { authStore.signedInProjectId } returns "projectId" coEvery { shouldScheduleFirmwareUpdate.invoke() } returns true - useCase(executable(SyncCommands.Schedule.Everything.start()), commandScope()).join() + useCase(executable(SyncCommands.ScheduleOf.Everything.start()), commandScope()).join() verify { workManager.enqueueUniquePeriodicWork(PROJECT_SYNC_WORK_NAME, any(), any()) @@ -96,7 +96,7 @@ class ExecuteSyncCommandUseCaseTest { } returns false every { authStore.signedInProjectId } returns "projectId" - useCase(executable(SyncCommands.Schedule.Everything.start()), commandScope()).join() + useCase(executable(SyncCommands.ScheduleOf.Everything.start()), commandScope()).join() verify { workManager.enqueueUniquePeriodicWork( @@ -117,7 +117,7 @@ class ExecuteSyncCommandUseCaseTest { every { authStore.signedInProjectId } returns "projectId" coEvery { shouldScheduleFirmwareUpdate.invoke() } returns false - useCase(executable(SyncCommands.Schedule.Everything.start()), commandScope()).join() + useCase(executable(SyncCommands.ScheduleOf.Everything.start()), commandScope()).join() verify { workManager.enqueueUniquePeriodicWork( @@ -133,7 +133,7 @@ class ExecuteSyncCommandUseCaseTest { every { authStore.signedInProjectId } returns "projectId" coEvery { shouldScheduleFirmwareUpdate.invoke() } returns false - useCase(executable(SyncCommands.Schedule.Everything.start()), commandScope()).join() + useCase(executable(SyncCommands.ScheduleOf.Everything.start()), commandScope()).join() verify { workManager.cancelUniqueWork(FIRMWARE_UPDATE_WORK_NAME) } } @@ -142,7 +142,7 @@ class ExecuteSyncCommandUseCaseTest { fun `cancels all necessary background workers`() = runTest { every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" - useCase(executable(SyncCommands.Schedule.Everything.stop()), commandScope()) + useCase(executable(SyncCommands.ScheduleOf.Everything.stop()), commandScope()) verify { workManager.cancelUniqueWork(PROJECT_SYNC_WORK_NAME) @@ -159,7 +159,7 @@ class ExecuteSyncCommandUseCaseTest { fun `reschedules event sync worker with correct tags`() = runTest { every { eventSyncManager.getPeriodicWorkTags() } returns listOf("tag1", "tag2") - useCase(executable(SyncCommands.Schedule.Events.start()), commandScope()).join() + useCase(executable(SyncCommands.ScheduleOf.Events.start()), commandScope()).join() verify { workManager.enqueueUniquePeriodicWork( @@ -174,7 +174,7 @@ class ExecuteSyncCommandUseCaseTest { fun `reschedules event sync worker with correct delay`() = runTest { every { eventSyncManager.getPeriodicWorkTags() } returns listOf("tag1", "tag2") - useCase(executable(SyncCommands.Schedule.Events.start(withDelay = true)), commandScope()).join() + useCase(executable(SyncCommands.ScheduleOf.Events.start(withDelay = true)), commandScope()).join() verify { workManager.enqueueUniquePeriodicWork( @@ -190,7 +190,7 @@ class ExecuteSyncCommandUseCaseTest { every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" every { eventSyncManager.getPeriodicWorkTags() } returns listOf("tag1", "tag2") - useCase(executable(SyncCommands.Schedule.Events.stopAndStart(withDelay = true)), commandScope()).join() + useCase(executable(SyncCommands.ScheduleOf.Events.restart(withDelay = true)), commandScope()).join() verify { workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME) @@ -211,7 +211,7 @@ class ExecuteSyncCommandUseCaseTest { fun `cancel event sync worker cancels correct worker`() = runTest { every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" - useCase(executable(SyncCommands.Schedule.Events.stop()), commandScope()) + useCase(executable(SyncCommands.ScheduleOf.Events.stop()), commandScope()) verify { workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME) @@ -224,7 +224,7 @@ class ExecuteSyncCommandUseCaseTest { fun `start event sync worker with correct tags`() = runTest { every { eventSyncManager.getOneTimeWorkTags() } returns listOf("tag1", "tag2") - useCase(executable(SyncCommands.OneTime.Events.start()), commandScope()).join() + useCase(executable(SyncCommands.OneTimeNow.Events.start()), commandScope()).join() verify { workManager.enqueueUniqueWork( @@ -239,7 +239,7 @@ class ExecuteSyncCommandUseCaseTest { fun `start event sync worker with correct input data`() = runTest { every { eventSyncManager.getOneTimeWorkTags() } returns listOf("tag1", "tag2") - useCase(executable(SyncCommands.OneTime.Events.start(isDownSyncAllowed = false)), commandScope()).join() + useCase(executable(SyncCommands.OneTimeNow.Events.start(isDownSyncAllowed = false)), commandScope()).join() verify { workManager.enqueueUniqueWork( @@ -257,7 +257,7 @@ class ExecuteSyncCommandUseCaseTest { every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" every { eventSyncManager.getOneTimeWorkTags() } returns listOf("tag1", "tag2") - useCase(executable(SyncCommands.OneTime.Events.stopAndStart(isDownSyncAllowed = false)), commandScope()).join() + useCase(executable(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = false)), commandScope()).join() verify { workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME_ONE_TIME) @@ -277,7 +277,7 @@ class ExecuteSyncCommandUseCaseTest { fun `stop event sync worker cancels correct workers`() = runTest { every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" - useCase(executable(SyncCommands.OneTime.Events.stop()), commandScope()) + useCase(executable(SyncCommands.OneTimeNow.Events.stop()), commandScope()) verify { workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME_ONE_TIME) @@ -287,7 +287,7 @@ class ExecuteSyncCommandUseCaseTest { @Test fun `reschedules image worker when requested`() = runTest { - useCase(executable(SyncCommands.Schedule.Images.start()), commandScope()).join() + useCase(executable(SyncCommands.ScheduleOf.Images.start()), commandScope()).join() verify { workManager.enqueueUniquePeriodicWork( @@ -300,7 +300,7 @@ class ExecuteSyncCommandUseCaseTest { @Test fun `start image sync re-starts image worker`() = runTest { - useCase(executable(SyncCommands.OneTime.Images.start()), commandScope()).join() + useCase(executable(SyncCommands.OneTimeNow.Images.start()), commandScope()).join() verify { workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) @@ -314,14 +314,14 @@ class ExecuteSyncCommandUseCaseTest { @Test fun `stop image sync cancels image worker`() = runTest { - useCase(executable(SyncCommands.OneTime.Images.stop()), commandScope()) + useCase(executable(SyncCommands.OneTimeNow.Images.stop()), commandScope()) verify { workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) } } @Test fun `invoke stop command returns completed job and routes to stop logic`() = runTest { - val job = useCase(executable(SyncCommands.Schedule.Images.stop()), commandScope()) + val job = useCase(executable(SyncCommands.ScheduleOf.Images.stop()), commandScope()) assertThat(job.isCompleted).isTrue() verify { workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) } @@ -336,7 +336,7 @@ class ExecuteSyncCommandUseCaseTest { unblock.receive() } - val job = useCase(executable(SyncCommands.Schedule.Images.stopAndStartAround(block)), commandScope()) + val job = useCase(executable(SyncCommands.ScheduleOf.Images.restartAfter(block)), commandScope()) verify { workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) @@ -375,7 +375,7 @@ class ExecuteSyncCommandUseCaseTest { unblock.receive() } - val job = useCase(executable(SyncCommands.Schedule.Images.stopAndStartAround(block)), scope) + val job = useCase(executable(SyncCommands.ScheduleOf.Images.restartAfter(block)), scope) blockStarted.receive() parentJob.cancel() From 67470fdd61e545efb3f975b6aa404dfe532f41bf Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 2 Feb 2026 14:25:06 +0000 Subject: [PATCH 22/22] MS-1299 Sync revamp: observeSyncState, executeOneTime and executeSchedulingCommand moved to SyncOrchestrator to avoid a stateful usecase --- .../feature/dashboard/debug/DebugFragment.kt | 23 +- .../dashboard/logout/LogoutSyncViewModel.kt | 9 +- .../dashboard/logout/usecase/LogoutUseCase.kt | 6 +- .../settings/syncinfo/SyncInfoViewModel.kt | 16 +- .../ModuleSelectionViewModel.kt | 8 +- .../usecase/ObserveSyncInfoUseCase.kt | 7 +- .../logout/LogoutSyncViewModelTest.kt | 14 +- .../logout/usecase/LogoutUseCaseTest.kt | 13 +- .../syncinfo/SyncInfoViewModelTest.kt | 61 ++-- .../ModuleSelectionViewModelTest.kt | 13 +- .../usecase/ObserveSyncInfoUseCaseTest.kt | 13 +- .../feature/logincheck/LoginCheckViewModel.kt | 8 +- .../usecases/StartBackgroundSyncUseCase.kt | 10 +- .../logincheck/LoginCheckViewModelTest.kt | 13 +- .../StartBackgroundSyncUseCaseTest.kt | 34 +- .../usecase/RunBlockingEventSyncUseCase.kt | 20 +- .../usecase/ShouldSuggestSyncUseCase.kt | 9 +- .../RunBlockingEventSyncUseCaseTest.kt | 33 +- .../usecase/ShouldSuggestSyncUseCaseTest.kt | 14 +- .../main/java/com/simprints/id/Application.kt | 8 +- .../events/down/EventDownSyncResetService.kt | 12 +- .../infra/eventsync/EventSyncManager.kt | 1 - .../infra/sync/OneTimeSyncCommand.kt | 39 +++ .../infra/sync/ScheduleSyncCommand.kt | 61 ++++ .../com/simprints/infra/sync/SyncCommands.kt | 157 --------- .../simprints/infra/sync/SyncOrchestrator.kt | 20 +- .../infra/sync/SyncOrchestratorImpl.kt | 282 ++++++++++++++++ .../sync/config/usecase/LogoutUseCase.kt | 6 +- ...RescheduleWorkersIfConfigChangedUseCase.kt | 10 +- ...ResetLocalRecordsIfConfigChangedUseCase.kt | 12 +- .../Job.ext.kt} | 14 +- .../infra/sync/usecase/SyncUseCase.kt | 117 ------- .../internal/ExecuteSyncCommandUseCase.kt | 222 ------------- .../infra/sync/OneTimeSyncCommandTest.kt | 42 +++ .../infra/sync/ScheduleSyncCommandTest.kt | 56 ++++ .../simprints/infra/sync/SyncCommandsTest.kt | 206 ------------ ...> SyncOrchestratorCommandExecutionTest.kt} | 140 ++++---- .../infra/sync/SyncOrchestratorImplTest.kt | 29 ++ .../SyncOrchestratorObserveSyncStateTest.kt | 157 +++++++++ .../simprints/infra/sync/SyncResponseTest.kt | 77 ----- .../sync/config/usecase/LogoutUseCaseTest.kt | 24 +- ...heduleWorkersIfConfigChangedUseCaseTest.kt | 27 +- ...tLocalRecordsIfConfigChangedUseCaseTest.kt | 84 ++--- .../infra/sync/extensions/JobExtTest.kt | 61 ++++ .../infra/sync/usecase/SyncUseCaseTest.kt | 302 ------------------ 45 files changed, 1023 insertions(+), 1467 deletions(-) create mode 100644 infra/sync/src/main/java/com/simprints/infra/sync/OneTimeSyncCommand.kt create mode 100644 infra/sync/src/main/java/com/simprints/infra/sync/ScheduleSyncCommand.kt delete mode 100644 infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt rename infra/sync/src/main/java/com/simprints/infra/sync/{SyncResponse.kt => extensions/Job.ext.kt} (54%) delete mode 100644 infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt delete mode 100644 infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt create mode 100644 infra/sync/src/test/java/com/simprints/infra/sync/OneTimeSyncCommandTest.kt create mode 100644 infra/sync/src/test/java/com/simprints/infra/sync/ScheduleSyncCommandTest.kt delete mode 100644 infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt rename infra/sync/src/test/java/com/simprints/infra/sync/{usecase/internal/ExecuteSyncCommandUseCaseTest.kt => SyncOrchestratorCommandExecutionTest.kt} (72%) create mode 100644 infra/sync/src/test/java/com/simprints/infra/sync/SyncOrchestratorObserveSyncStateTest.kt delete mode 100644 infra/sync/src/test/java/com/simprints/infra/sync/SyncResponseTest.kt create mode 100644 infra/sync/src/test/java/com/simprints/infra/sync/extensions/JobExtTest.kt delete mode 100644 infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt index c4765cf3ea..1984116c53 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt @@ -19,8 +19,9 @@ import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepositor import com.simprints.infra.events.EventRepository import com.simprints.infra.eventsync.EventSyncManager import com.simprints.infra.eventsync.status.models.EventSyncWorkerState -import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.usecase.SyncUseCase +import com.simprints.infra.sync.OneTime +import com.simprints.infra.sync.ScheduleCommand +import com.simprints.infra.sync.SyncOrchestrator import com.simprints.infra.uibase.view.applySystemBarInsets import com.simprints.infra.uibase.viewbinding.viewBinding import dagger.hilt.android.AndroidEntryPoint @@ -34,7 +35,7 @@ import javax.inject.Inject @AndroidEntryPoint internal class DebugFragment : Fragment(R.layout.fragment_debug) { @Inject - lateinit var sync: SyncUseCase + lateinit var syncOrchestrator: SyncOrchestrator @Inject lateinit var eventSyncManager: EventSyncManager @@ -63,8 +64,8 @@ internal class DebugFragment : Fragment(R.layout.fragment_debug) { super.onViewCreated(view, savedInstanceState) applySystemBarInsets(view) - sync(SyncCommands.ObserveOnly) - .syncStatusFlow + syncOrchestrator + .observeSyncState() .map { it.eventSyncState }.asLiveData() @@ -84,18 +85,18 @@ internal class DebugFragment : Fragment(R.layout.fragment_debug) { ) binding.logs.append(ssb) - } + } binding.syncStart.setOnClickListener { - sync(SyncCommands.OneTimeNow.Events.start()) + syncOrchestrator.executeOneTime(OneTime.Events.start()) } binding.syncStop.setOnClickListener { - sync(SyncCommands.OneTimeNow.Events.stop()) + syncOrchestrator.executeOneTime(OneTime.Events.stop()) } binding.syncSchedule.setOnClickListener { - sync(SyncCommands.ScheduleOf.Events.start()) + syncOrchestrator.executeSchedulingCommand(ScheduleCommand.Events.reschedule()) } binding.clearFirebaseToken.setOnClickListener { @@ -120,8 +121,8 @@ internal class DebugFragment : Fragment(R.layout.fragment_debug) { binding.cleanAll.setOnClickListener { lifecycleScope.launch(dispatcher) { - sync(SyncCommands.OneTimeNow.Events.stop()) - sync(SyncCommands.ScheduleOf.Events.stop()) + syncOrchestrator.executeOneTime(OneTime.Events.stop()) + syncOrchestrator.executeSchedulingCommand(ScheduleCommand.Events.unschedule()) eventRepository.deleteAll() eventSyncManager.resetDownSyncInfo() diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModel.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModel.kt index 2c636404b6..ce2960fc9f 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModel.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModel.kt @@ -10,8 +10,7 @@ import com.simprints.feature.dashboard.logout.usecase.LogoutUseCase import com.simprints.infra.authstore.AuthStore import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.SettingsPasswordConfig -import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.usecase.SyncUseCase +import com.simprints.infra.sync.SyncOrchestrator import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged @@ -22,7 +21,7 @@ import javax.inject.Inject @HiltViewModel internal class LogoutSyncViewModel @Inject constructor( private val configRepository: ConfigRepository, - sync: SyncUseCase, + syncOrchestrator: SyncOrchestrator, authStore: AuthStore, private val logoutUseCase: LogoutUseCase, ) : ViewModel() { @@ -36,8 +35,8 @@ internal class LogoutSyncViewModel @Inject constructor( .asLiveData(viewModelScope.coroutineContext) val isLogoutWithoutSyncVisibleLiveData: LiveData = - sync(SyncCommands.ObserveOnly) - .syncStatusFlow + syncOrchestrator + .observeSyncState() .map { syncStatus -> !syncStatus.eventSyncState.isSyncCompleted() || syncStatus.imageSyncStatus.isSyncing }.debounce(timeoutMillis = ANTI_JITTER_DELAY_MILLIS) diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCase.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCase.kt index 3642b4271d..ae2e0f094a 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCase.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCase.kt @@ -4,15 +4,13 @@ import com.simprints.core.DispatcherIO import com.simprints.infra.authlogic.AuthManager import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository import com.simprints.infra.enrolment.records.repository.local.migration.RealmToRoomMigrationFlagsStore -import com.simprints.infra.sync.SyncCommands +import com.simprints.infra.sync.ScheduleCommand import com.simprints.infra.sync.SyncOrchestrator -import com.simprints.infra.sync.usecase.SyncUseCase import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.runBlocking import javax.inject.Inject internal class LogoutUseCase @Inject constructor( - private val sync: SyncUseCase, private val syncOrchestrator: SyncOrchestrator, private val authManager: AuthManager, private val flagsStore: RealmToRoomMigrationFlagsStore, @@ -22,7 +20,7 @@ internal class LogoutUseCase @Inject constructor( // To prevent a race between wiping data and navigation, this use case must block the executing thread operator fun invoke() = runBlocking(ioDispatcher) { // Cancel all background sync - sync(SyncCommands.ScheduleOf.Everything.stop()) + syncOrchestrator.executeSchedulingCommand(ScheduleCommand.Everything.unschedule()) syncOrchestrator.deleteEventSyncInfo() // sign out the user authManager.signOut() diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt index 35d0723a6d..da520eefac 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt @@ -16,8 +16,8 @@ import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.ProjectState import com.simprints.infra.config.store.models.isModuleSelectionAvailable import com.simprints.infra.recent.user.activity.RecentUserActivityManager -import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.usecase.SyncUseCase +import com.simprints.infra.sync.OneTime +import com.simprints.infra.sync.SyncOrchestrator import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow @@ -45,7 +45,7 @@ internal class SyncInfoViewModel @Inject constructor( private val recentUserActivityManager: RecentUserActivityManager, private val timeHelper: TimeHelper, observeSyncInfo: ObserveSyncInfoUseCase, - private val sync: SyncUseCase, + private val syncOrchestrator: SyncOrchestrator, private val logoutUseCase: LogoutUseCase, @param:DispatcherIO private val ioDispatcher: CoroutineDispatcher, ) : ViewModel() { @@ -55,7 +55,7 @@ internal class SyncInfoViewModel @Inject constructor( get() = _loginNavigationEventLiveData private val _loginNavigationEventLiveData = MutableLiveData() - private val syncStatusFlow = sync(SyncCommands.ObserveOnly).syncStatusFlow + private val syncStatusFlow = syncOrchestrator.observeSyncState() private val eventSyncStateFlow = syncStatusFlow.map { it.eventSyncState } private val imageSyncStatusFlow = @@ -145,7 +145,7 @@ internal class SyncInfoViewModel @Inject constructor( } val isDownSyncAllowed = !isPreLogoutUpSync && configRepository.getProject()?.state == ProjectState.RUNNING - sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed)) + syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed)) } } @@ -153,10 +153,10 @@ internal class SyncInfoViewModel @Inject constructor( viewModelScope.launch { val isImageSyncing = imageSyncStatusFlow.firstOrNull()?.isSyncing == true if (isImageSyncing) { - sync(SyncCommands.OneTimeNow.Images.stop()) + syncOrchestrator.executeOneTime(OneTime.Images.stop()) } else { imageSyncButtonClickFlow.emit(Unit) - sync(SyncCommands.OneTimeNow.Images.start()) + syncOrchestrator.executeOneTime(OneTime.Images.start()) } } } @@ -214,7 +214,7 @@ internal class SyncInfoViewModel @Inject constructor( .distinctUntilChanged() .collect { isEventSyncCompleted -> if (isEventSyncCompleted) { - sync(SyncCommands.OneTimeNow.Images.start()) + syncOrchestrator.executeOneTime(OneTime.Images.start()) } } } diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModel.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModel.kt index 81c37f054e..da65c66be2 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModel.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModel.kt @@ -15,8 +15,8 @@ import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.SettingsPasswordConfig import com.simprints.infra.config.store.models.TokenKeyType import com.simprints.infra.config.store.tokenization.TokenizationProcessor -import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.usecase.SyncUseCase +import com.simprints.infra.sync.OneTime +import com.simprints.infra.sync.SyncOrchestrator import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -25,7 +25,7 @@ import javax.inject.Inject @HiltViewModel internal class ModuleSelectionViewModel @Inject constructor( private val moduleRepository: ModuleRepository, - private val sync: SyncUseCase, + private val syncOrchestrator: SyncOrchestrator, private val configRepository: ConfigRepository, private val tokenizationProcessor: TokenizationProcessor, @param:ExternalScope private val externalScope: CoroutineScope, @@ -115,7 +115,7 @@ internal class ModuleSelectionViewModel @Inject constructor( module.copy(name = encryptedName) } moduleRepository.saveModules(modules) - sync(SyncCommands.OneTimeNow.Events.restart()) + syncOrchestrator.executeOneTime(OneTime.Events.restart()) } } diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCase.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCase.kt index 8dfe22858a..f4c2ec5559 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCase.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCase.kt @@ -23,9 +23,8 @@ import com.simprints.infra.config.store.models.isSimprintsEventDownSyncAllowed import com.simprints.infra.eventsync.permission.CommCarePermissionChecker import com.simprints.infra.eventsync.status.models.DownSyncCounts import com.simprints.infra.network.ConnectivityTracker -import com.simprints.infra.sync.SyncCommands +import com.simprints.infra.sync.SyncOrchestrator import com.simprints.infra.sync.usecase.ObserveSyncableCountsUseCase -import com.simprints.infra.sync.usecase.SyncUseCase import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -45,7 +44,7 @@ internal class ObserveSyncInfoUseCase @Inject constructor( private val commCarePermissionChecker: CommCarePermissionChecker, private val observeConfigurationFlow: ObserveConfigurationChangesUseCase, private val observeSyncableCounts: ObserveSyncableCountsUseCase, - private val sync: SyncUseCase, + private val syncOrchestrator: SyncOrchestrator, @param:DispatcherBG private val dispatcher: CoroutineDispatcher, ) { // Since we are not using distinctUntilChanged any emission from combined flows will trigger the main flow as well @@ -58,7 +57,7 @@ internal class ObserveSyncInfoUseCase @Inject constructor( operator fun invoke(isPreLogoutUpSync: Boolean = false): Flow = combine( combinedRefreshSignals(), authStore.observeSignedInProjectId(), - sync(SyncCommands.ObserveOnly).syncStatusFlow, + syncOrchestrator.observeSyncState(), observeSyncableCounts(), observeConfigurationFlow(), ) { isOnline, projectId, (eventSyncState, imageSyncStatus), counts, (isRefreshing, isProjectRunning, moduleCounts, projectConfig) -> diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModelTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModelTest.kt index 5804938bd1..f49b73cfaa 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModelTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModelTest.kt @@ -8,16 +8,13 @@ import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.SettingsPasswordConfig import com.simprints.infra.eventsync.status.models.EventSyncState import com.simprints.infra.sync.ImageSyncStatus -import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.SyncResponse import com.simprints.infra.sync.SyncStatus -import com.simprints.infra.sync.usecase.SyncUseCase +import com.simprints.infra.sync.SyncOrchestrator import com.simprints.testtools.common.coroutines.TestCoroutineRule import com.simprints.testtools.common.livedata.getOrAwaitValue import io.mockk.* import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -34,7 +31,7 @@ internal class LogoutSyncViewModelTest { lateinit var configRepository: ConfigRepository @MockK - lateinit var sync: SyncUseCase + lateinit var syncOrchestrator: SyncOrchestrator @MockK lateinit var authStore: AuthStore @@ -143,15 +140,12 @@ internal class LogoutSyncViewModelTest { imageSyncStatus: ImageSyncStatus, ) { val statusFlow = MutableStateFlow(SyncStatus(eventSyncState = eventSyncState, imageSyncStatus = imageSyncStatus)) - every { sync.invoke(SyncCommands.ObserveOnly) } returns SyncResponse( - syncCommandJob = Job().apply { complete() }, - syncStatusFlow = statusFlow, - ) + every { syncOrchestrator.observeSyncState() } returns statusFlow } private fun createViewModel() = LogoutSyncViewModel( configRepository = configRepository, - sync = sync, + syncOrchestrator = syncOrchestrator, authStore = authStore, logoutUseCase = logoutUseCase, ) diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCaseTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCaseTest.kt index 44f06245b6..78dee91485 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCaseTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCaseTest.kt @@ -4,16 +4,15 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.simprints.infra.authlogic.AuthManager import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository import com.simprints.infra.enrolment.records.repository.local.migration.RealmToRoomMigrationFlagsStore -import com.simprints.infra.sync.SyncCommands +import com.simprints.infra.sync.ScheduleCommand import com.simprints.infra.sync.SyncOrchestrator -import com.simprints.infra.sync.usecase.SyncUseCase import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.MockKAnnotations import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK -import io.mockk.mockk import io.mockk.verify +import kotlinx.coroutines.Job import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule @@ -26,9 +25,6 @@ class LogoutUseCaseTest { @get:Rule val testCoroutineRule = TestCoroutineRule() - @MockK - private lateinit var sync: SyncUseCase - @MockK private lateinit var syncOrchestrator: SyncOrchestrator @@ -45,10 +41,9 @@ class LogoutUseCaseTest { @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) - every { sync(any()) } returns mockk() + every { syncOrchestrator.executeSchedulingCommand(any()) } returns Job().apply { complete() } useCase = LogoutUseCase( - sync = sync, syncOrchestrator = syncOrchestrator, authManager = authManager, flagsStore = flagsStore, @@ -61,7 +56,7 @@ class LogoutUseCaseTest { fun `Fully logs out when called`() = runTest { useCase.invoke() - verify { sync(SyncCommands.ScheduleOf.Everything.stop()) } + verify { syncOrchestrator.executeSchedulingCommand(ScheduleCommand.Everything.unschedule()) } coVerify { syncOrchestrator.deleteEventSyncInfo() authManager.signOut() diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModelTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModelTest.kt index 450a584d12..03d1890e22 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModelTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModelTest.kt @@ -23,10 +23,9 @@ import com.simprints.infra.config.store.models.isSimprintsEventDownSyncAllowed import com.simprints.infra.eventsync.status.models.EventSyncState import com.simprints.infra.recent.user.activity.RecentUserActivityManager import com.simprints.infra.sync.ImageSyncStatus -import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.SyncResponse +import com.simprints.infra.sync.OneTime import com.simprints.infra.sync.SyncStatus -import com.simprints.infra.sync.usecase.SyncUseCase +import com.simprints.infra.sync.SyncOrchestrator import com.simprints.testtools.common.coroutines.TestCoroutineRule import com.simprints.testtools.common.livedata.getOrAwaitValue import com.simprints.testtools.common.livedata.getOrAwaitValues @@ -69,7 +68,7 @@ class SyncInfoViewModelTest { private lateinit var observeSyncInfo: ObserveSyncInfoUseCase @MockK - private lateinit var sync: SyncUseCase + private lateinit var syncOrchestrator: SyncOrchestrator @MockK private lateinit var logoutUseCase: LogoutUseCase @@ -146,10 +145,8 @@ class SyncInfoViewModelTest { syncStatusFlow = MutableStateFlow( SyncStatus(eventSyncState = mockEventSyncState, imageSyncStatus = mockImageSyncStatus), ) - every { sync.invoke(SyncCommands.ObserveOnly) } returns SyncResponse( - syncCommandJob = Job().apply { complete() }, - syncStatusFlow = syncStatusFlow, - ) + every { syncOrchestrator.observeSyncState() } returns syncStatusFlow + every { syncOrchestrator.executeOneTime(any()) } returns Job().apply { complete() } every { timeHelper.now() } returns TEST_TIMESTAMP every { timeHelper.msBetweenNowAndTime(any()) } returns 0L @@ -171,7 +168,7 @@ class SyncInfoViewModelTest { recentUserActivityManager = recentUserActivityManager, timeHelper = timeHelper, observeSyncInfo = observeSyncInfo, - sync = sync, + syncOrchestrator = syncOrchestrator, logoutUseCase = logoutUseCase, ioDispatcher = testCoroutineRule.testCoroutineDispatcher, ) @@ -388,7 +385,7 @@ class SyncInfoViewModelTest { viewModel.forceEventSync() - verify { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = true)) } + verify { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = true)) } } @Test @@ -397,7 +394,7 @@ class SyncInfoViewModelTest { viewModel.forceEventSync() - verify { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = false)) } + verify { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = false)) } } @Test @@ -411,7 +408,7 @@ class SyncInfoViewModelTest { viewModel.forceEventSync() - verify { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = false)) } + verify { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = false)) } } @Test @@ -425,7 +422,7 @@ class SyncInfoViewModelTest { viewModel.forceEventSync() - verify { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = false)) } + verify { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = false)) } } @Test @@ -436,14 +433,14 @@ class SyncInfoViewModelTest { viewModel.forceEventSync() - verify { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = false)) } + verify { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = false)) } } @Test fun `should stop current event sync before starting new one`() = runTest { viewModel.forceEventSync() - verify { sync(SyncCommands.OneTimeNow.Events.restart()) } + verify { syncOrchestrator.executeOneTime(OneTime.Events.restart()) } } // toggleImageSync() tests @@ -458,8 +455,8 @@ class SyncInfoViewModelTest { viewModel.toggleImageSync() - verify { sync(SyncCommands.OneTimeNow.Images.start()) } - verify(exactly = 0) { sync(SyncCommands.OneTimeNow.Images.stop()) } + verify { syncOrchestrator.executeOneTime(OneTime.Images.start()) } + verify(exactly = 0) { syncOrchestrator.executeOneTime(OneTime.Images.stop()) } } @Test @@ -472,8 +469,8 @@ class SyncInfoViewModelTest { viewModel.toggleImageSync() - verify { sync(SyncCommands.OneTimeNow.Images.stop()) } - verify(exactly = 0) { sync(SyncCommands.OneTimeNow.Images.start()) } + verify { syncOrchestrator.executeOneTime(OneTime.Images.stop()) } + verify(exactly = 0) { syncOrchestrator.executeOneTime(OneTime.Images.start()) } } // logout() tests @@ -527,7 +524,7 @@ class SyncInfoViewModelTest { viewModel.handleLoginResult(successResult) - verify { sync(SyncCommands.OneTimeNow.Events.restart()) } + verify { syncOrchestrator.executeOneTime(OneTime.Events.restart()) } } @Test @@ -538,8 +535,8 @@ class SyncInfoViewModelTest { viewModel.handleLoginResult(failureResult) - verify(exactly = 0) { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = true)) } - verify(exactly = 0) { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = false)) } + verify(exactly = 0) { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = true)) } + verify(exactly = 0) { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = false)) } } // Sync button responsiveness optimization @@ -675,7 +672,7 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - verify { sync(SyncCommands.OneTimeNow.Events.restart()) } + verify { syncOrchestrator.executeOneTime(OneTime.Events.restart()) } } @Test @@ -691,7 +688,7 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - verify { sync(SyncCommands.OneTimeNow.Events.restart()) } + verify { syncOrchestrator.executeOneTime(OneTime.Events.restart()) } } @Test @@ -707,8 +704,8 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - verify(exactly = 0) { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = true)) } - verify(exactly = 0) { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = false)) } + verify(exactly = 0) { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = true)) } + verify(exactly = 0) { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = false)) } } @Test @@ -722,8 +719,8 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - verify(exactly = 0) { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = true)) } - verify(exactly = 0) { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = false)) } + verify(exactly = 0) { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = true)) } + verify(exactly = 0) { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = false)) } } @Test @@ -740,7 +737,7 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - verify { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = false)) } + verify { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = false)) } } @Test @@ -761,7 +758,7 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - verify(exactly = 1) { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = false)) } + verify(exactly = 1) { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = false)) } } @Test @@ -782,8 +779,8 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() - verify(exactly = 0) { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = true)) } - verify(exactly = 0) { sync(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = false)) } + verify(exactly = 0) { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = true)) } + verify(exactly = 0) { syncOrchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = false)) } } private companion object { diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModelTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModelTest.kt index 547ee043fb..7be2967611 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModelTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/moduleselection/ModuleSelectionViewModelTest.kt @@ -15,14 +15,15 @@ import com.simprints.infra.config.store.models.Project import com.simprints.infra.config.store.models.SettingsPasswordConfig import com.simprints.infra.config.store.models.TokenKeyType import com.simprints.infra.config.store.tokenization.TokenizationProcessor -import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.usecase.SyncUseCase +import com.simprints.infra.sync.OneTime +import com.simprints.infra.sync.SyncOrchestrator import com.simprints.testtools.common.coroutines.TestCoroutineRule import com.simprints.testtools.common.livedata.getOrAwaitValue import com.simprints.testtools.common.syntax.assertThrows import io.mockk.* import io.mockk.impl.annotations.MockK import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import org.junit.Before import org.junit.Rule import org.junit.Test @@ -40,7 +41,7 @@ class ModuleSelectionViewModelTest { private lateinit var repository: ModuleRepository @MockK - private lateinit var sync: SyncUseCase + private lateinit var syncOrchestrator: SyncOrchestrator @MockK private lateinit var configRepository: ConfigRepository @@ -56,7 +57,7 @@ class ModuleSelectionViewModelTest { @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) - every { sync(any()) } returns mockk() + every { syncOrchestrator.executeOneTime(any()) } returns Job().apply { complete() } val modulesDefault = listOf( Module("a".asTokenizableEncrypted(), false), @@ -82,7 +83,7 @@ class ModuleSelectionViewModelTest { viewModel = ModuleSelectionViewModel( moduleRepository = repository, - sync = sync, + syncOrchestrator = syncOrchestrator, configRepository = configRepository, tokenizationProcessor = tokenizationProcessor, externalScope = CoroutineScope(testCoroutineRule.testCoroutineDispatcher), @@ -175,7 +176,7 @@ class ModuleSelectionViewModelTest { viewModel.saveModules() coVerify(exactly = 1) { repository.saveModules(updatedModules) } - verify(exactly = 1) { sync(SyncCommands.OneTimeNow.Events.restart()) } + verify(exactly = 1) { syncOrchestrator.executeOneTime(OneTime.Events.restart()) } } @Test diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCaseTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCaseTest.kt index 0813bcbc32..25512c8d76 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCaseTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCaseTest.kt @@ -23,14 +23,12 @@ import com.simprints.infra.eventsync.permission.CommCarePermissionChecker import com.simprints.infra.eventsync.status.models.EventSyncState import com.simprints.infra.network.ConnectivityTracker import com.simprints.infra.sync.ImageSyncStatus -import com.simprints.infra.sync.SyncResponse import com.simprints.infra.sync.SyncStatus +import com.simprints.infra.sync.SyncOrchestrator import com.simprints.infra.sync.SyncableCounts import com.simprints.infra.sync.usecase.ObserveSyncableCountsUseCase -import com.simprints.infra.sync.usecase.SyncUseCase import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.* -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf @@ -50,7 +48,7 @@ internal class ObserveSyncInfoUseCaseTest { private val connectivityTracker = mockk() private val authStore = mockk() private val observeSyncableCounts = mockk() - private val sync = mockk() + private val syncOrchestrator = mockk() private val timeHelper = mockk() private val ticker = mockk() private val appForegroundStateTracker = mockk() @@ -133,10 +131,7 @@ internal class ObserveSyncInfoUseCaseTest { every { connectivityTracker.observeIsConnected() } returns flowOf(true) syncStatusFlow.value = SyncStatus(eventSyncState = mockEventSyncState, imageSyncStatus = mockImageSyncStatus) - every { sync.invoke(any()) } returns SyncResponse( - syncCommandJob = Job().apply { complete() }, - syncStatusFlow = syncStatusFlow, - ) + every { syncOrchestrator.observeSyncState() } returns syncStatusFlow every { mockEventSyncState.lastSyncTime } returns TEST_TIMESTAMP syncableCountsFlow.value = SyncableCounts( @@ -175,7 +170,7 @@ internal class ObserveSyncInfoUseCaseTest { commCarePermissionChecker = commCarePermissionChecker, observeConfigurationFlow = observeConfigurationFlow, observeSyncableCounts = observeSyncableCounts, - sync = sync, + syncOrchestrator = syncOrchestrator, dispatcher = testCoroutineRule.testCoroutineDispatcher, ) } diff --git a/feature/login-check/src/main/java/com/simprints/feature/logincheck/LoginCheckViewModel.kt b/feature/login-check/src/main/java/com/simprints/feature/logincheck/LoginCheckViewModel.kt index 6e3cfca69e..8b78821165 100644 --- a/feature/login-check/src/main/java/com/simprints/feature/logincheck/LoginCheckViewModel.kt +++ b/feature/login-check/src/main/java/com/simprints/feature/logincheck/LoginCheckViewModel.kt @@ -30,8 +30,8 @@ import com.simprints.infra.logging.Simber import com.simprints.infra.orchestration.data.ActionRequest import com.simprints.infra.security.SecurityManager import com.simprints.infra.security.exceptions.RootedDeviceException -import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.usecase.SyncUseCase +import com.simprints.infra.sync.ScheduleCommand +import com.simprints.infra.sync.SyncOrchestrator import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -49,7 +49,7 @@ class LoginCheckViewModel @Inject internal constructor( private val isUserSignedIn: IsUserSignedInUseCase, private val configRepository: ConfigRepository, private val startBackgroundSync: StartBackgroundSyncUseCase, - private val sync: SyncUseCase, + private val syncOrchestrator: SyncOrchestrator, private val updateDatabaseCountsInCurrentSession: UpdateSessionScopePayloadUseCase, private val updateProjectInCurrentSession: UpdateProjectInCurrentSessionUseCase, private val updateStoredUserId: UpdateStoredUserIdUseCase, @@ -106,7 +106,7 @@ class LoginCheckViewModel @Inject internal constructor( cachedRequest = actionRequest loginAlreadyTried.set(true) - sync(SyncCommands.ScheduleOf.Everything.stop()) + syncOrchestrator.executeSchedulingCommand(ScheduleCommand.Everything.unschedule()) _showLoginFlow.send(actionRequest) } diff --git a/feature/login-check/src/main/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCase.kt b/feature/login-check/src/main/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCase.kt index 6464c6b26c..2b9f98072e 100644 --- a/feature/login-check/src/main/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCase.kt +++ b/feature/login-check/src/main/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCase.kt @@ -2,14 +2,14 @@ package com.simprints.feature.logincheck.usecases import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.Frequency -import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.await -import com.simprints.infra.sync.usecase.SyncUseCase +import com.simprints.infra.sync.ScheduleCommand +import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.extensions.await import javax.inject.Inject internal class StartBackgroundSyncUseCase @Inject constructor( private val configRepository: ConfigRepository, - private val sync: SyncUseCase, + private val syncOrchestrator: SyncOrchestrator, ) { suspend operator fun invoke() { val frequency = configRepository @@ -18,6 +18,6 @@ internal class StartBackgroundSyncUseCase @Inject constructor( ?.frequency val withDelay = frequency != Frequency.PERIODICALLY_AND_ON_SESSION_START - sync(SyncCommands.ScheduleOf.Everything.start(withDelay)).await() + syncOrchestrator.executeSchedulingCommand(ScheduleCommand.Everything.reschedule(withDelay)).await() } } diff --git a/feature/login-check/src/test/java/com/simprints/feature/logincheck/LoginCheckViewModelTest.kt b/feature/login-check/src/test/java/com/simprints/feature/logincheck/LoginCheckViewModelTest.kt index e1a16ac1c2..4f450829b9 100644 --- a/feature/login-check/src/test/java/com/simprints/feature/logincheck/LoginCheckViewModelTest.kt +++ b/feature/login-check/src/test/java/com/simprints/feature/logincheck/LoginCheckViewModelTest.kt @@ -21,11 +21,12 @@ import com.simprints.infra.config.store.models.ProjectState import com.simprints.infra.enrolment.records.repository.local.migration.RealmToRoomMigrationScheduler import com.simprints.infra.security.SecurityManager import com.simprints.infra.security.exceptions.RootedDeviceException -import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.usecase.SyncUseCase +import com.simprints.infra.sync.ScheduleCommand +import com.simprints.infra.sync.SyncOrchestrator import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.* import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.Job import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule @@ -63,7 +64,7 @@ internal class LoginCheckViewModelTest { lateinit var startBackgroundSync: StartBackgroundSyncUseCase @MockK - lateinit var sync: SyncUseCase + lateinit var syncOrchestrator: SyncOrchestrator @MockK lateinit var updateSessionScopePayloadUseCase: UpdateSessionScopePayloadUseCase @@ -85,7 +86,7 @@ internal class LoginCheckViewModelTest { @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) - every { sync(any()) } returns mockk() + every { syncOrchestrator.executeSchedulingCommand(any()) } returns Job().apply { complete() } viewModel = LoginCheckViewModel( rootManager = rootMatchers, @@ -96,7 +97,7 @@ internal class LoginCheckViewModelTest { isUserSignedIn = isUserSignedInUseCase, configRepository = configRepository, startBackgroundSync = startBackgroundSync, - sync = sync, + syncOrchestrator = syncOrchestrator, updateDatabaseCountsInCurrentSession = updateSessionScopePayloadUseCase, updateProjectInCurrentSession = updateProjectStateUseCase, updateStoredUserId = updateStoredUserIdUseCase, @@ -172,7 +173,7 @@ internal class LoginCheckViewModelTest { coVerify { addAuthorizationEventUseCase.invoke(any(), eq(false)) } - verify { sync(SyncCommands.ScheduleOf.Everything.stop()) } + verify { syncOrchestrator.executeSchedulingCommand(ScheduleCommand.Everything.unschedule()) } viewModel.showLoginFlow .test() .assertValue { it.peekContent() == ActionFactory.getIdentifyRequest() } diff --git a/feature/login-check/src/test/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCaseTest.kt b/feature/login-check/src/test/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCaseTest.kt index 8da27206a1..727e87fa67 100644 --- a/feature/login-check/src/test/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCaseTest.kt +++ b/feature/login-check/src/test/java/com/simprints/feature/logincheck/usecases/StartBackgroundSyncUseCaseTest.kt @@ -3,16 +3,13 @@ package com.simprints.feature.logincheck.usecases import com.google.common.truth.Truth.assertThat import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.Frequency -import com.simprints.infra.sync.SyncCommand -import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.SyncResponse -import com.simprints.infra.sync.usecase.SyncUseCase +import com.simprints.infra.sync.ScheduleCommand +import com.simprints.infra.sync.SyncOrchestrator import io.mockk.* import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -22,7 +19,7 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class StartBackgroundSyncUseCaseTest { @MockK - lateinit var sync: SyncUseCase + lateinit var syncOrchestrator: SyncOrchestrator @MockK lateinit var configRepository: ConfigRepository @@ -32,11 +29,11 @@ class StartBackgroundSyncUseCaseTest { @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) - every { sync(any()) } returns mockk() + every { syncOrchestrator.executeSchedulingCommand(any()) } returns Job().apply { complete() } useCase = StartBackgroundSyncUseCase( configRepository, - sync, + syncOrchestrator, ) } @@ -50,7 +47,7 @@ class StartBackgroundSyncUseCaseTest { } returns Frequency.PERIODICALLY - assertUseCaseAwaitsSync(SyncCommands.ScheduleOf.Everything.start(withDelay = true)) + assertUseCaseAwaitsSync(ScheduleCommand.Everything.reschedule(withDelay = true)) } @Test @@ -63,7 +60,7 @@ class StartBackgroundSyncUseCaseTest { } returns Frequency.PERIODICALLY_AND_ON_SESSION_START - assertUseCaseAwaitsSync(SyncCommands.ScheduleOf.Everything.start()) + assertUseCaseAwaitsSync(ScheduleCommand.Everything.reschedule(withDelay = false)) } @Test @@ -76,26 +73,23 @@ class StartBackgroundSyncUseCaseTest { } returns Frequency.PERIODICALLY - assertUseCaseAwaitsSync(SyncCommands.ScheduleOf.Everything.start(withDelay = true)) + assertUseCaseAwaitsSync(ScheduleCommand.Everything.reschedule(withDelay = true)) } @Test fun `Does not start event sync on start if not Simprints sync`() = runTest { coEvery { configRepository - .getProjectConfiguration() - .synchronization.down.simprints + .getProjectConfiguration() + .synchronization.down.simprints } returns null - assertUseCaseAwaitsSync(SyncCommands.ScheduleOf.Everything.start(withDelay = true)) + assertUseCaseAwaitsSync(ScheduleCommand.Everything.reschedule(withDelay = true)) } - private suspend fun TestScope.assertUseCaseAwaitsSync(expectedCommand: SyncCommand) { + private suspend fun TestScope.assertUseCaseAwaitsSync(expectedCommand: ScheduleCommand) { val syncCommandJob = Job() - every { sync(any()) } returns SyncResponse( - syncCommandJob = syncCommandJob, - syncStatusFlow = MutableStateFlow(mockk(relaxed = true)), - ) + every { syncOrchestrator.executeSchedulingCommand(any()) } returns syncCommandJob val useCaseJob = async { useCase.invoke() } @@ -106,6 +100,6 @@ class StartBackgroundSyncUseCaseTest { runCurrent() useCaseJob.await() - verify { sync(expectedCommand) } + verify { syncOrchestrator.executeSchedulingCommand(expectedCommand) } } } diff --git a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt index 9a45465e9e..1c0da834b7 100644 --- a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCase.kt @@ -1,29 +1,29 @@ package com.simprints.feature.validatepool.usecase -import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.await -import com.simprints.infra.sync.usecase.SyncUseCase +import com.simprints.infra.sync.OneTime +import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.extensions.await import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import javax.inject.Inject internal class RunBlockingEventSyncUseCase @Inject constructor( - private val sync: SyncUseCase, + private val syncOrchestrator: SyncOrchestrator, ) { suspend operator fun invoke() { + val syncState = syncOrchestrator.observeSyncState() // First item in the flow (except uninitialized) is the state of last sync, // so it can be used to as a filter out old sync states. // To guarantee it's not associated with the newly run sync, // the value needs to be taken before it starts. - val lastSyncId = sync(SyncCommands.ObserveOnly) - .syncStatusFlow + val lastSyncId = syncState .map { it.eventSyncState } .firstOrNull { !it.isUninitialized() } ?.syncId - sync(SyncCommands.OneTimeNow.Events.start()) - .apply { - await() - }.syncStatusFlow + syncOrchestrator + .executeOneTime(OneTime.Events.start()) + .await() + syncState .map { it.eventSyncState } .firstOrNull { it.syncId != lastSyncId && it.isSyncReporterCompleted() } } diff --git a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCase.kt b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCase.kt index 2edc9d9af9..aa954e5a89 100644 --- a/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCase.kt +++ b/feature/validate-subject-pool/src/main/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCase.kt @@ -2,8 +2,7 @@ package com.simprints.feature.validatepool.usecase import com.simprints.core.tools.time.TimeHelper import com.simprints.infra.config.store.ConfigRepository -import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.usecase.SyncUseCase +import com.simprints.infra.sync.SyncOrchestrator import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -11,11 +10,11 @@ import kotlin.time.Duration internal class ShouldSuggestSyncUseCase @Inject constructor( private val timeHelper: TimeHelper, - private val sync: SyncUseCase, + private val syncOrchestrator: SyncOrchestrator, private val configRepository: ConfigRepository, ) { - suspend operator fun invoke(): Boolean = sync(SyncCommands.ObserveOnly) - .syncStatusFlow + suspend operator fun invoke(): Boolean = syncOrchestrator + .observeSyncState() .map { it.eventSyncState } .firstOrNull() ?.lastSyncTime diff --git a/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt index 28b138e34b..a906f7ac9b 100644 --- a/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt +++ b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt @@ -5,10 +5,9 @@ import com.simprints.infra.eventsync.status.models.EventSyncState import com.simprints.infra.eventsync.status.models.EventSyncWorkerState import com.simprints.infra.eventsync.status.models.EventSyncWorkerType import com.simprints.infra.sync.ImageSyncStatus -import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.SyncResponse +import com.simprints.infra.sync.OneTime import com.simprints.infra.sync.SyncStatus -import com.simprints.infra.sync.usecase.SyncUseCase +import com.simprints.infra.sync.SyncOrchestrator import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.* import io.mockk.impl.annotations.MockK @@ -29,7 +28,7 @@ class RunBlockingEventSyncUseCaseTest { val testCoroutineRule = TestCoroutineRule() @MockK - private lateinit var sync: SyncUseCase + private lateinit var syncOrchestrator: SyncOrchestrator private lateinit var usecase: RunBlockingEventSyncUseCase @@ -37,7 +36,7 @@ class RunBlockingEventSyncUseCaseTest { fun setUp() { MockKAnnotations.init(this) - usecase = RunBlockingEventSyncUseCase(sync) + usecase = RunBlockingEventSyncUseCase(syncOrchestrator) } @Test @@ -51,8 +50,8 @@ class RunBlockingEventSyncUseCaseTest { syncFlow.value = createSyncStatus("sync", EventSyncWorkerState.Succeeded) testScheduler.advanceUntilIdle() - verify(exactly = 1) { sync(SyncCommands.ObserveOnly) } - verify(exactly = 1) { sync.invoke(SyncCommands.OneTimeNow.Events.start()) } + verify(exactly = 1) { syncOrchestrator.observeSyncState() } + verify(exactly = 1) { syncOrchestrator.executeOneTime(OneTime.Events.start()) } } @Test @@ -66,8 +65,8 @@ class RunBlockingEventSyncUseCaseTest { syncFlow.value = createSyncStatus("sync", EventSyncWorkerState.Failed()) testScheduler.advanceUntilIdle() - verify(exactly = 1) { sync(SyncCommands.ObserveOnly) } - verify(exactly = 1) { sync.invoke(SyncCommands.OneTimeNow.Events.start()) } + verify(exactly = 1) { syncOrchestrator.observeSyncState() } + verify(exactly = 1) { syncOrchestrator.executeOneTime(OneTime.Events.start()) } } @Test @@ -81,8 +80,8 @@ class RunBlockingEventSyncUseCaseTest { syncFlow.value = createSyncStatus("sync", EventSyncWorkerState.Cancelled) testScheduler.advanceUntilIdle() - verify(exactly = 1) { sync(SyncCommands.ObserveOnly) } - verify(exactly = 1) { sync.invoke(SyncCommands.OneTimeNow.Events.start()) } + verify(exactly = 1) { syncOrchestrator.observeSyncState() } + verify(exactly = 1) { syncOrchestrator.executeOneTime(OneTime.Events.start()) } } @Test @@ -93,12 +92,12 @@ class RunBlockingEventSyncUseCaseTest { val job = launch { usecase.invoke() } testScheduler.advanceUntilIdle() - verify(exactly = 0) { sync(SyncCommands.OneTimeNow.Events.start()) } + verify(exactly = 0) { syncOrchestrator.executeOneTime(OneTime.Events.start()) } syncFlow.value = createSyncStatus("sync", EventSyncWorkerState.Succeeded) testScheduler.advanceUntilIdle() - verify(exactly = 1) { sync(SyncCommands.OneTimeNow.Events.start()) } + verify(exactly = 1) { syncOrchestrator.executeOneTime(OneTime.Events.start()) } job.cancel() } @@ -128,12 +127,8 @@ class RunBlockingEventSyncUseCaseTest { } private fun setUpSync(syncFlow: StateFlow) { - val syncResponse = SyncResponse( - syncCommandJob = Job().apply { complete() }, - syncStatusFlow = syncFlow, - ) - every { sync.invoke(SyncCommands.ObserveOnly) } returns syncResponse - every { sync.invoke(SyncCommands.OneTimeNow.Events.start()) } returns syncResponse + every { syncOrchestrator.observeSyncState() } returns syncFlow + every { syncOrchestrator.executeOneTime(OneTime.Events.start()) } returns Job().apply { complete() } } private fun createPlaceholderSyncStatus(): SyncStatus = createSyncStatus("", null, null, null) diff --git a/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCaseTest.kt b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCaseTest.kt index 460a0b908d..58827959f6 100644 --- a/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCaseTest.kt +++ b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/ShouldSuggestSyncUseCaseTest.kt @@ -6,15 +6,12 @@ import com.simprints.core.tools.time.Timestamp import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.eventsync.status.models.EventSyncState import com.simprints.infra.sync.ImageSyncStatus -import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.SyncResponse import com.simprints.infra.sync.SyncStatus -import com.simprints.infra.sync.usecase.SyncUseCase +import com.simprints.infra.sync.SyncOrchestrator import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.every import io.mockk.impl.annotations.MockK -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Before @@ -25,7 +22,7 @@ class ShouldSuggestSyncUseCaseTest { lateinit var timeHelper: TimeHelper @MockK - lateinit var sync: SyncUseCase + lateinit var syncOrchestrator: SyncOrchestrator @MockK lateinit var configRepository: ConfigRepository @@ -38,12 +35,9 @@ class ShouldSuggestSyncUseCaseTest { MockKAnnotations.init(this) syncStatusFlow = MutableStateFlow(createSyncStatus(lastSyncTime = null)) - every { sync.invoke(SyncCommands.ObserveOnly) } returns SyncResponse( - syncCommandJob = Job().apply { complete() }, - syncStatusFlow = syncStatusFlow, - ) + every { syncOrchestrator.observeSyncState() } returns syncStatusFlow - usecase = ShouldSuggestSyncUseCase(timeHelper, sync, configRepository) + usecase = ShouldSuggestSyncUseCase(timeHelper, syncOrchestrator, configRepository) } @Test diff --git a/id/src/main/java/com/simprints/id/Application.kt b/id/src/main/java/com/simprints/id/Application.kt index 263a172dda..97ab6836bd 100644 --- a/id/src/main/java/com/simprints/id/Application.kt +++ b/id/src/main/java/com/simprints/id/Application.kt @@ -16,9 +16,8 @@ import com.simprints.infra.logging.LoggingConstants.CrashReportingCustomKeys.VER import com.simprints.infra.logging.Simber import com.simprints.infra.logging.SimberBuilder import com.simprints.infra.logging.usecases.UpdateAndGetVersionHistoryUseCase -import com.simprints.infra.sync.SyncCommands +import com.simprints.infra.sync.ScheduleCommand import com.simprints.infra.sync.SyncOrchestrator -import com.simprints.infra.sync.usecase.SyncUseCase import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.cancel @@ -36,9 +35,6 @@ open class Application : @Inject lateinit var syncOrchestrator: SyncOrchestrator - @Inject - lateinit var sync: SyncUseCase - @Inject lateinit var realmToRoomMigrationScheduler: RealmToRoomMigrationScheduler @@ -90,7 +86,7 @@ open class Application : appScope.launch { realmToRoomMigrationScheduler.scheduleMigrationWorkerIfNeeded() syncOrchestrator.cleanupWorkers() - sync(SyncCommands.ScheduleOf.Everything.start()) + syncOrchestrator.executeSchedulingCommand(ScheduleCommand.Everything.reschedule()) } if (DB_ENCRYPTION) { System.loadLibrary("sqlcipher") diff --git a/id/src/main/java/com/simprints/id/services/sync/events/down/EventDownSyncResetService.kt b/id/src/main/java/com/simprints/id/services/sync/events/down/EventDownSyncResetService.kt index a68f6aed9c..dc73691326 100644 --- a/id/src/main/java/com/simprints/id/services/sync/events/down/EventDownSyncResetService.kt +++ b/id/src/main/java/com/simprints/id/services/sync/events/down/EventDownSyncResetService.kt @@ -14,9 +14,9 @@ import com.simprints.core.ExternalScope import com.simprints.infra.eventsync.EventSyncManager import com.simprints.infra.logging.LoggingConstants.CrashReportTag.SYNC import com.simprints.infra.logging.Simber -import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.await -import com.simprints.infra.sync.usecase.SyncUseCase +import com.simprints.infra.sync.OneTime +import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.extensions.await import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -32,7 +32,7 @@ class EventDownSyncResetService : Service() { lateinit var eventSyncManager: EventSyncManager @Inject - lateinit var sync: SyncUseCase + lateinit var syncOrchestrator: SyncOrchestrator private var resetJob: Job? = null @@ -51,8 +51,8 @@ class EventDownSyncResetService : Service() { // Reset current downsync state eventSyncManager.resetDownSyncInfo() // Trigger a new sync - // Scope isn't passed to sync here to prevent a timeout cancellation leaving it in a stopped state - sync(SyncCommands.OneTimeNow.Events.start()).await() + // Execute in app scope to prevent a timeout cancellation leaving it in a stopped state + syncOrchestrator.executeOneTime(OneTime.Events.start()).await() } resetJob?.invokeOnCompletion { stopSelf() } diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManager.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManager.kt index 8c563bbdbb..901b54d3fa 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManager.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManager.kt @@ -1,6 +1,5 @@ package com.simprints.infra.eventsync -// todo MS-1300 disband into usecases interface EventSyncManager { fun getPeriodicWorkTags(): List diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/OneTimeSyncCommand.kt b/infra/sync/src/main/java/com/simprints/infra/sync/OneTimeSyncCommand.kt new file mode 100644 index 0000000000..ea65a71d28 --- /dev/null +++ b/infra/sync/src/main/java/com/simprints/infra/sync/OneTimeSyncCommand.kt @@ -0,0 +1,39 @@ +package com.simprints.infra.sync + +/** + * One-time (immediate) sync control commands. + * + * Intended to be executed via [SyncOrchestrator.executeOneTime]. + */ +sealed class OneTime { + internal enum class Action { + START, + STOP, + RESTART, + } + + internal data class EventsCommand( + val action: Action, + val isDownSyncAllowed: Boolean = true, + ) : OneTime() + + internal data class ImagesCommand( + val action: Action, + ) : OneTime() + + object Events { + fun start(isDownSyncAllowed: Boolean = true): OneTime = EventsCommand(Action.START, isDownSyncAllowed) + + fun stop(): OneTime = EventsCommand(Action.STOP) + + fun restart(isDownSyncAllowed: Boolean = true): OneTime = EventsCommand(Action.RESTART, isDownSyncAllowed) + } + + object Images { + fun start(): OneTime = ImagesCommand(Action.START) + + fun stop(): OneTime = ImagesCommand(Action.STOP) + + fun restart(): OneTime = ImagesCommand(Action.RESTART) + } +} diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/ScheduleSyncCommand.kt b/infra/sync/src/main/java/com/simprints/infra/sync/ScheduleSyncCommand.kt new file mode 100644 index 0000000000..3e06396f9b --- /dev/null +++ b/infra/sync/src/main/java/com/simprints/infra/sync/ScheduleSyncCommand.kt @@ -0,0 +1,61 @@ +package com.simprints.infra.sync + +/** + * Periodic/background scheduling commands. + * + * Intended to be executed via [SyncOrchestrator.executeSchedulingCommand]. + */ +sealed class ScheduleCommand { + internal enum class Action { + RESCHEDULE, + UNSCHEDULE, + } + + internal data class EverythingCommand( + val action: Action, + val withDelay: Boolean = false, + val blockWhileUnscheduled: (suspend () -> Unit)? = null, + ) : ScheduleCommand() + + internal data class EventsCommand( + val action: Action, + val withDelay: Boolean = false, + val blockWhileUnscheduled: (suspend () -> Unit)? = null, + ) : ScheduleCommand() + + internal data class ImagesCommand( + val action: Action, + val blockWhileUnscheduled: (suspend () -> Unit)? = null, + ) : ScheduleCommand() + + object Everything { + fun reschedule(withDelay: Boolean = false): ScheduleCommand = EverythingCommand(action = Action.RESCHEDULE, withDelay = withDelay) + + fun unschedule(): ScheduleCommand = EverythingCommand(action = Action.UNSCHEDULE) + + fun rescheduleAfter( + withDelay: Boolean = false, + block: suspend () -> Unit, + ): ScheduleCommand = EverythingCommand(action = Action.RESCHEDULE, withDelay = withDelay, blockWhileUnscheduled = block) + } + + object Events { + fun reschedule(withDelay: Boolean = false): ScheduleCommand = EventsCommand(action = Action.RESCHEDULE, withDelay = withDelay) + + fun unschedule(): ScheduleCommand = EventsCommand(action = Action.UNSCHEDULE) + + fun rescheduleAfter( + withDelay: Boolean = false, + block: suspend () -> Unit, + ): ScheduleCommand = EventsCommand(action = Action.RESCHEDULE, withDelay = withDelay, blockWhileUnscheduled = block) + } + + object Images { + fun reschedule(): ScheduleCommand = ImagesCommand(action = Action.RESCHEDULE) + + fun unschedule(): ScheduleCommand = ImagesCommand(action = Action.UNSCHEDULE) + + fun rescheduleAfter(block: suspend () -> Unit): ScheduleCommand = + ImagesCommand(action = Action.RESCHEDULE, blockWhileUnscheduled = block) + } +} diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt b/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt deleted file mode 100644 index 38ae9c49c4..0000000000 --- a/infra/sync/src/main/java/com/simprints/infra/sync/SyncCommands.kt +++ /dev/null @@ -1,157 +0,0 @@ -package com.simprints.infra.sync - -/** - * Builders for sync control instructions passed to SyncUseCase. - * - * To construct a sync command, - * Start with SyncCommands., and the rest is reachable in a structured way, with appropriate branching and params. - * - * See also SyncUseCase.invoke. - */ -object SyncCommands { - object ObserveOnly : SyncCommand() - - object OneTimeNow { - // DSL-style capitalization to fit well when used like: sync(SyncCommands.OneTime.Events.start()) - val Events = buildSyncCommandsWithDownSyncParam(SyncTarget.ONE_TIME_EVENTS) - val Images = buildSyncCommands(SyncTarget.ONE_TIME_IMAGES) - } - - object ScheduleOf { - val Everything = buildSyncCommandsWithDelayParam(SyncTarget.SCHEDULE_EVERYTHING) - val Events = buildSyncCommandsWithDelayParam(SyncTarget.SCHEDULE_EVENTS) - val Images = buildSyncCommands(SyncTarget.SCHEDULE_IMAGES) - } - - internal data class ExecutableSyncCommand( - val target: SyncTarget, - val action: SyncAction, - val payload: SyncCommandPayload = SyncCommandPayload.None, - val blockToRunWhileStopped: (suspend () -> Unit)? = null, - ) : SyncCommand() - - // builders - - interface SyncCommandBuilder { - fun stop(): SyncCommand - - fun start(): SyncCommand - - fun restart(): SyncCommand - - fun restartAfter(block: suspend () -> Unit): SyncCommand - } - - interface SyncCommandBuilderWithDownSyncParam { - fun stop(): SyncCommand - - fun start(isDownSyncAllowed: Boolean = true): SyncCommand - - fun restart(isDownSyncAllowed: Boolean = true): SyncCommand - - fun restartAfter( - isDownSyncAllowed: Boolean = true, - block: suspend () -> Unit, - ): SyncCommand - } - - interface SyncCommandBuilderWithDelayParam { - fun stop(): SyncCommand - - fun start(withDelay: Boolean = false): SyncCommand - - fun restart(withDelay: Boolean = false): SyncCommand - - fun restartAfter( - withDelay: Boolean = false, - block: suspend () -> Unit, - ): SyncCommand - } - - private fun buildSyncCommands(target: SyncTarget): SyncCommandBuilder = object : SyncCommandBuilder { - override fun stop() = getCommand(target, SyncAction.STOP) - - override fun start() = getCommand(target, SyncAction.START) - - override fun restart() = getCommand(target, SyncAction.RESTART) - - override fun restartAfter(block: suspend () -> Unit) = getCommand(target, SyncAction.RESTART, block = block) - } - - private fun buildSyncCommandsWithDownSyncParam(target: SyncTarget) = object : SyncCommandBuilderWithDownSyncParam { - override fun stop() = getCommand(target, SyncAction.STOP) - - override fun start(isDownSyncAllowed: Boolean) = - getCommand(target, SyncAction.START, payload = SyncCommandPayload.WithDownSyncAllowed(isDownSyncAllowed)) - - override fun restart(isDownSyncAllowed: Boolean) = - getCommand(target, SyncAction.RESTART, payload = SyncCommandPayload.WithDownSyncAllowed(isDownSyncAllowed)) - - override fun restartAfter( - isDownSyncAllowed: Boolean, - block: suspend () -> Unit, - ) = getCommand( - target, - SyncAction.RESTART, - payload = SyncCommandPayload.WithDownSyncAllowed(isDownSyncAllowed), - block = block, - ) - } - - private fun buildSyncCommandsWithDelayParam(target: SyncTarget) = object : SyncCommandBuilderWithDelayParam { - override fun stop() = getCommand(target, SyncAction.STOP) - - override fun start(withDelay: Boolean) = getCommand(target, SyncAction.START, payload = SyncCommandPayload.WithDelay(withDelay)) - - override fun restart(withDelay: Boolean) = - getCommand(target, SyncAction.RESTART, payload = SyncCommandPayload.WithDelay(withDelay)) - - override fun restartAfter( - withDelay: Boolean, - block: suspend () -> Unit, - ) = getCommand(target, SyncAction.RESTART, payload = SyncCommandPayload.WithDelay(withDelay), block = block) - } - - private fun getCommand( - target: SyncTarget, - action: SyncAction, - payload: SyncCommandPayload = SyncCommandPayload.None, - block: (suspend () -> Unit)? = null, - ) = ExecutableSyncCommand( - target, - action, - payload, - block, - ) -} - -/** - * Complete command built from SyncCommands and bundled with instructions ready to be processed by SyncUseCase. - */ -sealed class SyncCommand - -enum class SyncTarget { - SCHEDULE_EVERYTHING, - ONE_TIME_EVENTS, - SCHEDULE_EVENTS, - ONE_TIME_IMAGES, - SCHEDULE_IMAGES, -} - -internal enum class SyncAction { - STOP, - START, - RESTART, -} - -internal sealed class SyncCommandPayload { - object None : SyncCommandPayload() - - data class WithDelay( - val withDelay: Boolean, - ) : SyncCommandPayload() - - data class WithDownSyncAllowed( - val isDownSyncAllowed: Boolean, - ) : SyncCommandPayload() -} diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestrator.kt b/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestrator.kt index d7fe54105d..a0a132c773 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestrator.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestrator.kt @@ -1,9 +1,27 @@ package com.simprints.infra.sync +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.Flow -// todo MS-1300 disband into usecases interface SyncOrchestrator { + /** + * A combined reactive stream of sync state for all syncable entities. + */ + fun observeSyncState(): StateFlow + + /** + * Executes an immediate (one-time) sync control command. + * Returns a job of the ongoing command execution. + */ + fun executeOneTime(command: OneTime): Job + + /** + * Executes a periodic/background scheduling command. + * Returns a job of the ongoing command execution. + */ + fun executeSchedulingCommand(command: ScheduleCommand): Job + fun startConfigSync() /** diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestratorImpl.kt b/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestratorImpl.kt index 722d980df5..9fd119a16c 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestratorImpl.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestratorImpl.kt @@ -1,28 +1,156 @@ package com.simprints.infra.sync +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.WorkQuery import androidx.work.workDataOf +import com.simprints.core.AppScope +import com.simprints.core.DispatcherIO +import com.simprints.infra.authstore.AuthStore +import com.simprints.infra.config.store.ConfigRepository +import com.simprints.infra.config.store.models.imagesUploadRequiresUnmeteredConnection +import com.simprints.infra.config.store.models.isCommCareEventDownSyncAllowed import com.simprints.infra.eventsync.EventSyncManager +import com.simprints.infra.eventsync.status.models.EventSyncState +import com.simprints.infra.eventsync.sync.EventSyncStateProcessor +import com.simprints.infra.eventsync.sync.master.EventSyncMasterWorker import com.simprints.infra.sync.config.worker.DeviceConfigDownSyncWorker import com.simprints.infra.sync.config.worker.ProjectConfigDownSyncWorker import com.simprints.infra.sync.enrolments.EnrolmentRecordWorker +import com.simprints.infra.sync.extensions.anyRunning +import com.simprints.infra.sync.extensions.cancelWorkers +import com.simprints.infra.sync.extensions.schedulePeriodicWorker import com.simprints.infra.sync.extensions.startWorker import com.simprints.infra.sync.usecase.CleanupDeprecatedWorkersUseCase +import com.simprints.infra.sync.files.FileUpSyncWorker +import com.simprints.infra.sync.firmware.FirmwareFileUpdateWorker +import com.simprints.infra.sync.firmware.ShouldScheduleFirmwareUpdateUseCase +import com.simprints.infra.sync.usecase.internal.ObserveImageSyncStatusUseCase +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton @Singleton internal class SyncOrchestratorImpl @Inject constructor( private val workManager: WorkManager, + private val authStore: AuthStore, + private val configRepository: ConfigRepository, private val eventSyncManager: EventSyncManager, + private val eventSyncStateProcessor: EventSyncStateProcessor, + private val observeImageSyncStatus: ObserveImageSyncStatusUseCase, + private val shouldScheduleFirmwareUpdate: ShouldScheduleFirmwareUpdateUseCase, private val cleanupDeprecatedWorkers: CleanupDeprecatedWorkersUseCase, private val imageSyncTimestampProvider: ImageSyncTimestampProvider, + @param:AppScope private val appScope: CoroutineScope, + @param:DispatcherIO private val ioDispatcher: CoroutineDispatcher, ) : SyncOrchestrator { + private val defaultEventSyncState = EventSyncState( + syncId = "", + progress = null, + total = null, + upSyncWorkersInfo = emptyList(), + downSyncWorkersInfo = emptyList(), + reporterStates = emptyList(), + lastSyncTime = null, + ) + private val defaultImageSyncStatus = ImageSyncStatus( + isSyncing = false, + progress = null, + lastUpdateTimeMillis = -1L, + ) + private val defaultSyncStatus = SyncStatus(defaultEventSyncState, defaultImageSyncStatus) + + private val sharedSyncState: StateFlow by lazy { + combine( + eventSyncStateProcessor.getLastSyncState().onStart { emit(defaultEventSyncState) }, + observeImageSyncStatus().onStart { emit(defaultImageSyncStatus) }, + ) { eventSyncState, imageSyncStatus -> + SyncStatus(eventSyncState, imageSyncStatus) + }.stateIn( + appScope, + SharingStarted.Eagerly, + defaultSyncStatus, + ) + } + + init { + // Automatically conditioned scheduling rule: + // stop image upload when event sync starts. + appScope.launch(ioDispatcher) { + workManager + .getWorkInfosFlow( + WorkQuery.fromUniqueWorkNames( + SyncConstants.EVENT_SYNC_WORK_NAME, + SyncConstants.EVENT_SYNC_WORK_NAME_ONE_TIME, + ), + ).map { workInfoList -> + workInfoList.anyRunning() + }.distinctUntilChanged() + .filter { it } // only when event sync becomes running + .collect { + rescheduleImageUpSync() + } + } + } + + override fun observeSyncState(): StateFlow = sharedSyncState + + override fun executeOneTime(command: OneTime): Job { + return when (command) { + is OneTime.EventsCommand -> executeOneTimeAction( + action = command.action, + stop = ::stopEventSync, + start = { startEventSync(isDownSyncAllowed = command.isDownSyncAllowed) }, + ) + + is OneTime.ImagesCommand -> executeOneTimeAction( + action = command.action, + stop = ::stopImageSync, + start = { startImageSync() }, + ) + } + } + + override fun executeSchedulingCommand(command: ScheduleCommand): Job { + return when (command) { + is ScheduleCommand.EverythingCommand -> executeSchedulingAction( + action = command.action, + blockWhileUnscheduled = command.blockWhileUnscheduled, + unschedule = ::cancelBackgroundWork, + reschedule = { scheduleBackgroundWork(withDelay = command.withDelay) }, + ) + + is ScheduleCommand.EventsCommand -> executeSchedulingAction( + action = command.action, + blockWhileUnscheduled = command.blockWhileUnscheduled, + unschedule = ::cancelEventSync, + reschedule = { rescheduleEventSync(withDelay = command.withDelay) }, + ) + + is ScheduleCommand.ImagesCommand -> executeSchedulingAction( + action = command.action, + blockWhileUnscheduled = command.blockWhileUnscheduled, + unschedule = ::stopImageSync, + reschedule = { rescheduleImageUpSync() }, + ) + } + } + override fun startConfigSync() { workManager.startWorker(SyncConstants.PROJECT_SYNC_WORK_NAME_ONE_TIME) workManager.startWorker(SyncConstants.DEVICE_SYNC_WORK_NAME_ONE_TIME) @@ -63,4 +191,158 @@ internal class SyncOrchestratorImpl @Inject constructor( override fun cleanupWorkers() { cleanupDeprecatedWorkers() } + + private fun executeOneTimeAction( + action: OneTime.Action, + stop: () -> Unit, + start: suspend () -> Unit, + ): Job { + val shouldStop = + action == OneTime.Action.STOP || action == OneTime.Action.RESTART + if (shouldStop) { + stop() + } + + val shouldStart = + action == OneTime.Action.START || action == OneTime.Action.RESTART + if (!shouldStart) { + return Job().apply { complete() } + } + + return appScope.launch(ioDispatcher) { + start() + } + } + + private fun executeSchedulingAction( + action: ScheduleCommand.Action, + blockWhileUnscheduled: (suspend () -> Unit)?, + unschedule: () -> Unit, + reschedule: suspend () -> Unit, + ): Job { + val shouldUnschedule = + action == ScheduleCommand.Action.UNSCHEDULE || blockWhileUnscheduled != null + if (shouldUnschedule) { + unschedule() + } + + val shouldSchedule = + action == ScheduleCommand.Action.RESCHEDULE + if (!shouldSchedule) { + return Job().apply { complete() } + } + + return appScope.launch(ioDispatcher) { + blockWhileUnscheduled?.invoke() + reschedule() + } + } + + private suspend fun scheduleBackgroundWork(withDelay: Boolean) { + if (authStore.signedInProjectId.isNotEmpty()) { + workManager.schedulePeriodicWorker( + SyncConstants.PROJECT_SYNC_WORK_NAME, + SyncConstants.PROJECT_SYNC_REPEAT_INTERVAL, + ) + workManager.schedulePeriodicWorker( + SyncConstants.DEVICE_SYNC_WORK_NAME, + SyncConstants.DEVICE_SYNC_REPEAT_INTERVAL, + ) + workManager.schedulePeriodicWorker( + SyncConstants.FILE_UP_SYNC_WORK_NAME, + SyncConstants.FILE_UP_SYNC_REPEAT_INTERVAL, + constraints = getImageUploadConstraints(), + ) + rescheduleEventSync(withDelay = withDelay) + if (shouldScheduleFirmwareUpdate()) { + workManager.schedulePeriodicWorker( + SyncConstants.FIRMWARE_UPDATE_WORK_NAME, + SyncConstants.FIRMWARE_UPDATE_REPEAT_INTERVAL, + ) + } else { + workManager.cancelWorkers(SyncConstants.FIRMWARE_UPDATE_WORK_NAME) + } + } + } + + private fun cancelBackgroundWork() { + workManager.cancelWorkers( + SyncConstants.PROJECT_SYNC_WORK_NAME, + SyncConstants.DEVICE_SYNC_WORK_NAME, + SyncConstants.FILE_UP_SYNC_WORK_NAME, + SyncConstants.EVENT_SYNC_WORK_NAME, + SyncConstants.FIRMWARE_UPDATE_WORK_NAME, + ) + stopEventSync() + } + + private suspend fun rescheduleEventSync(withDelay: Boolean) { + workManager.schedulePeriodicWorker( + workName = SyncConstants.EVENT_SYNC_WORK_NAME, + repeatInterval = SyncConstants.EVENT_SYNC_WORKER_INTERVAL, + initialDelay = if (withDelay) SyncConstants.EVENT_SYNC_WORKER_INTERVAL else 0, + constraints = getEventSyncConstraints(), + tags = eventSyncManager.getPeriodicWorkTags(), + ) + } + + private fun cancelEventSync() { + workManager.cancelWorkers(SyncConstants.EVENT_SYNC_WORK_NAME) + stopEventSync() + } + + private suspend fun startEventSync(isDownSyncAllowed: Boolean) { + workManager.startWorker( + workName = SyncConstants.EVENT_SYNC_WORK_NAME_ONE_TIME, + constraints = getEventSyncConstraints(), + tags = eventSyncManager.getOneTimeWorkTags(), + inputData = workDataOf(EventSyncMasterWorker.IS_DOWN_SYNC_ALLOWED to isDownSyncAllowed), + ) + } + + private fun stopEventSync() { + workManager.cancelWorkers(SyncConstants.EVENT_SYNC_WORK_NAME_ONE_TIME) + // Event sync consists of multiple workers, so we cancel them all by tag. + workManager.cancelAllWorkByTag(eventSyncManager.getAllWorkerTag()) + } + + private fun startImageSync() { + stopImageSync() + workManager.startWorker(SyncConstants.FILE_UP_SYNC_WORK_NAME) + } + + private fun stopImageSync() { + workManager.cancelWorkers(SyncConstants.FILE_UP_SYNC_WORK_NAME) + } + + /** + * Fully reschedule the background worker. + * Should be used when the configuration that affects scheduling has changed. + */ + private suspend fun rescheduleImageUpSync() { + workManager.schedulePeriodicWorker( + SyncConstants.FILE_UP_SYNC_WORK_NAME, + SyncConstants.FILE_UP_SYNC_REPEAT_INTERVAL, + initialDelay = SyncConstants.DEFAULT_BACKOFF_INTERVAL_MINUTES, + existingWorkPolicy = ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, + constraints = getImageUploadConstraints(), + ) + } + + private suspend fun getImageUploadConstraints(): Constraints { + val networkType = configRepository + .getProjectConfiguration() + .imagesUploadRequiresUnmeteredConnection() + .let { if (it) NetworkType.UNMETERED else NetworkType.CONNECTED } + return Constraints.Builder().setRequiredNetworkType(networkType).build() + } + + private suspend fun getEventSyncConstraints(): Constraints { + // CommCare doesn't require network connection. + val networkType = configRepository + .getProjectConfiguration() + .isCommCareEventDownSyncAllowed() + .let { if (it) NetworkType.NOT_REQUIRED else NetworkType.CONNECTED } + return Constraints.Builder().setRequiredNetworkType(networkType).build() + } } diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/LogoutUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/LogoutUseCase.kt index 213c95d35b..99898067be 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/LogoutUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/LogoutUseCase.kt @@ -1,18 +1,16 @@ package com.simprints.infra.sync.config.usecase import com.simprints.infra.authlogic.AuthManager -import com.simprints.infra.sync.SyncCommands import com.simprints.infra.sync.SyncOrchestrator -import com.simprints.infra.sync.usecase.SyncUseCase +import com.simprints.infra.sync.ScheduleCommand import javax.inject.Inject internal class LogoutUseCase @Inject constructor( private val syncOrchestrator: SyncOrchestrator, - private val sync: SyncUseCase, private val authManager: AuthManager, ) { suspend operator fun invoke() { - sync(SyncCommands.ScheduleOf.Everything.stop()) + syncOrchestrator.executeSchedulingCommand(ScheduleCommand.Everything.unschedule()) syncOrchestrator.deleteEventSyncInfo() authManager.signOut() } diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCase.kt index 1a00bbbc82..b3cc402e10 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCase.kt @@ -2,20 +2,20 @@ package com.simprints.infra.sync.config.usecase import com.simprints.infra.config.store.models.ProjectConfiguration import com.simprints.infra.config.store.models.imagesUploadRequiresUnmeteredConnection -import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.await -import com.simprints.infra.sync.usecase.SyncUseCase +import com.simprints.infra.sync.ScheduleCommand +import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.extensions.await import javax.inject.Inject internal class RescheduleWorkersIfConfigChangedUseCase @Inject constructor( - private val sync: SyncUseCase, + private val syncOrchestrator: SyncOrchestrator, ) { suspend operator fun invoke( oldConfig: ProjectConfiguration, newConfig: ProjectConfiguration, ) { if (shouldRescheduleImageUpload(oldConfig, newConfig)) { - sync(SyncCommands.ScheduleOf.Images.start()).await() + syncOrchestrator.executeSchedulingCommand(ScheduleCommand.Images.reschedule()).await() } } diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCase.kt index b2e288a51c..7fc682c26a 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCase.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCase.kt @@ -3,23 +3,23 @@ package com.simprints.infra.sync.config.usecase import com.simprints.infra.config.store.models.ProjectConfiguration import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository import com.simprints.infra.eventsync.EventSyncManager -import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.await -import com.simprints.infra.sync.usecase.SyncUseCase +import com.simprints.infra.sync.ScheduleCommand +import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.extensions.await import javax.inject.Inject internal class ResetLocalRecordsIfConfigChangedUseCase @Inject constructor( private val eventSyncManager: EventSyncManager, private val enrolmentRecordRepository: EnrolmentRecordRepository, - private val sync: SyncUseCase, + private val syncOrchestrator: SyncOrchestrator, ) { suspend operator fun invoke( oldConfig: ProjectConfiguration, newConfig: ProjectConfiguration, ) { if (hasPartitionTypeChanged(oldConfig, newConfig)) { - sync( - SyncCommands.ScheduleOf.Events.restartAfter { + syncOrchestrator.executeSchedulingCommand( + ScheduleCommand.Events.rescheduleAfter { eventSyncManager.resetDownSyncInfo() enrolmentRecordRepository.deleteAll() }, diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/SyncResponse.kt b/infra/sync/src/main/java/com/simprints/infra/sync/extensions/Job.ext.kt similarity index 54% rename from infra/sync/src/main/java/com/simprints/infra/sync/SyncResponse.kt rename to infra/sync/src/main/java/com/simprints/infra/sync/extensions/Job.ext.kt index e6377e86da..0c14de2d81 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/SyncResponse.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/extensions/Job.ext.kt @@ -1,22 +1,16 @@ -package com.simprints.infra.sync +package com.simprints.infra.sync.extensions import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException -data class SyncResponse( - val syncCommandJob: Job, - val syncStatusFlow: StateFlow, -) - /** - * Waits for the sync command job to complete, passes exceptions (incl. cancellations) to the caller. + * Waits for a Job to complete and rethrows failures (including cancellations). */ -suspend fun SyncResponse.await() { +suspend fun Job.await() { suspendCancellableCoroutine { continuation -> - val handle = syncCommandJob.invokeOnCompletion { cause -> + val handle = invokeOnCompletion { cause -> when (cause) { null -> continuation.resume(Unit) else -> continuation.resumeWithException(cause) diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt deleted file mode 100644 index 89e6bcf520..0000000000 --- a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/SyncUseCase.kt +++ /dev/null @@ -1,117 +0,0 @@ -package com.simprints.infra.sync.usecase - -import com.simprints.core.AppScope -import com.simprints.infra.eventsync.status.models.EventSyncState -import com.simprints.infra.eventsync.sync.EventSyncStateProcessor -import com.simprints.infra.sync.ImageSyncStatus -import com.simprints.infra.sync.SyncCommand -import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.SyncResponse -import com.simprints.infra.sync.SyncStatus -import com.simprints.infra.sync.usecase.internal.ExecuteSyncCommandUseCase -import com.simprints.infra.sync.usecase.internal.ObserveImageSyncStatusUseCase -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Combines statuses of syncable entities together in a reactive way. - * - * Because sync state is extensively used throughout the project, including synchronously, - * it is an app-scoped StateFlow. An up-to-date sync state value can be accessed synchronously. - */ -@Singleton -class SyncUseCase @Inject internal constructor( - eventSyncStateProcessor: EventSyncStateProcessor, - imageSync: ObserveImageSyncStatusUseCase, - private val executeSyncCommand: ExecuteSyncCommandUseCase, - @param:AppScope private val appScope: CoroutineScope, -) { - private val defaultEventSyncState = EventSyncState( - syncId = "", - progress = null, - total = null, - upSyncWorkersInfo = emptyList(), - downSyncWorkersInfo = emptyList(), - reporterStates = emptyList(), - lastSyncTime = null, - ) - private val defaultImageSyncStatus = ImageSyncStatus( - isSyncing = false, - progress = null, - lastUpdateTimeMillis = -1L, - ) - private val defaultSyncStatus = SyncStatus(defaultEventSyncState, defaultImageSyncStatus) - - private val sharedSyncStatus: StateFlow by lazy { - combine( - eventSyncStateProcessor.getLastSyncState().onStart { emit(defaultEventSyncState) }, - imageSync().onStart { emit(defaultImageSyncStatus) }, - ) { eventSyncState, imageSyncStatus -> - SyncStatus(eventSyncState, imageSyncStatus) - }.stateIn( - appScope, - SharingStarted.Eagerly, - defaultSyncStatus, - ) - } - - /** - * Takes sync control commands (incl. no action) for syncable entities. - * Returns the command progress job and the syncable entities' combined sync status, - * with a .value also available to the callers synchronously. - * - * Usage: - * sync( - * SyncCommands. - * +- ObserveOnly. - * +- ScheduleOf. - * | +- Everything. --->| - * | +- Events. --->| |---> stop() - * | +- Images. --->| for |---> start() - * +- OneTimeNow. |---------->|---> restart() - * +- Events. --->| all |---> restartAfter { /* stop, run this block, then start */ } - * +- Images. --->| - * ) - * - * Examples: - * - * sync(SyncCommands.ObserveOnly) - * sync(SyncCommands.OneTimeNow.Events.stop()) - * sync(SyncCommands.OneTimeNow.Images.restart()) // starts even if wasn't running at stop command time - * sync(SyncCommands.ScheduleOf.Events.start()) - * sync(SyncCommands.ScheduleOf.Everything.restartAfter { - * delay(10_000) // transaction to wait for... - * }).await() // ...now complete - * val lastEventSyncTime = sync(SyncCommands.ObserveOnly).syncStatusFlow.value.eventSyncState.lastSyncTime - * - * Sync commands intentionally do not have default values, - * to prevent a `sync()` usage from being interpreted as a command to start syncing. - * - * Sync returns a combo of a Job for the command and the flow of sync statuses. - * For non-blocking use, the job doesn't matter. - * If the command was for a inherently non-blocking job, it will be returned already completed. - * To suspend until the command completes, add .await(), it rethrows cancellations / other exceptions. - * - * The commandScope param allows the sync command (incl. the optional restartAfter block) - * be cancelable when the passed scope's coroutine is cancelled, - * and to allow restartAfter throw exceptions in the passed scopes coroutine's context. - * Note: cancelling a command may leave the corresponding sync in a stopped state. The stopping is synchronous. - */ - operator fun invoke( - syncCommand: SyncCommand, - commandScope: CoroutineScope = appScope, - ): SyncResponse = SyncResponse( - syncCommandJob = when (syncCommand) { - is SyncCommands.ExecutableSyncCommand -> executeSyncCommand(syncCommand, commandScope) - is SyncCommands.ObserveOnly -> Job().apply { complete() } // no-op - }, - syncStatusFlow = sharedSyncStatus, - ) -} diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt b/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt deleted file mode 100644 index ee6bc00b7b..0000000000 --- a/infra/sync/src/main/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCase.kt +++ /dev/null @@ -1,222 +0,0 @@ -package com.simprints.infra.sync.usecase.internal - -import androidx.work.Constraints -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.NetworkType -import androidx.work.WorkManager -import androidx.work.WorkQuery -import androidx.work.workDataOf -import com.simprints.core.AppScope -import com.simprints.core.DispatcherIO -import com.simprints.infra.authstore.AuthStore -import com.simprints.infra.config.store.ConfigRepository -import com.simprints.infra.config.store.models.imagesUploadRequiresUnmeteredConnection -import com.simprints.infra.config.store.models.isCommCareEventDownSyncAllowed -import com.simprints.infra.eventsync.EventSyncManager -import com.simprints.infra.eventsync.sync.master.EventSyncMasterWorker -import com.simprints.infra.sync.SyncAction -import com.simprints.infra.sync.SyncCommandPayload -import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.SyncConstants -import com.simprints.infra.sync.SyncTarget -import com.simprints.infra.sync.config.worker.DeviceConfigDownSyncWorker -import com.simprints.infra.sync.config.worker.ProjectConfigDownSyncWorker -import com.simprints.infra.sync.extensions.anyRunning -import com.simprints.infra.sync.extensions.cancelWorkers -import com.simprints.infra.sync.extensions.schedulePeriodicWorker -import com.simprints.infra.sync.extensions.startWorker -import com.simprints.infra.sync.files.FileUpSyncWorker -import com.simprints.infra.sync.firmware.FirmwareFileUpdateWorker -import com.simprints.infra.sync.firmware.ShouldScheduleFirmwareUpdateUseCase -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -internal class ExecuteSyncCommandUseCase @Inject constructor( - private val workManager: WorkManager, - private val authStore: AuthStore, - private val configRepository: ConfigRepository, - private val eventSyncManager: EventSyncManager, - private val shouldScheduleFirmwareUpdate: ShouldScheduleFirmwareUpdateUseCase, - @param:AppScope private val appScope: CoroutineScope, - @param:DispatcherIO private val ioDispatcher: CoroutineDispatcher, -) { - init { - appScope.launch(ioDispatcher) { - // Automatically conditioned sync command: - // Stop image upload when event sync starts - workManager - .getWorkInfosFlow( - WorkQuery.fromUniqueWorkNames( - SyncConstants.EVENT_SYNC_WORK_NAME, - SyncConstants.EVENT_SYNC_WORK_NAME_ONE_TIME, - ), - ).map { workInfoList -> - workInfoList.anyRunning() - }.distinctUntilChanged() - .filter { it } // only if any running - .collect { - rescheduleImageUpSync() - } - } - } - - internal operator fun invoke( - syncCommand: SyncCommands.ExecutableSyncCommand, - commandScope: CoroutineScope = appScope, - ): Job { - with(syncCommand) { - val isStopNeeded = action in listOf(SyncAction.STOP, SyncAction.RESTART) - if (isStopNeeded) { - stop() - } - val isStartNeeded = action in listOf(SyncAction.START, SyncAction.RESTART) - val isFurtherAsyncActionNeeded = blockToRunWhileStopped != null || isStartNeeded - return if (isFurtherAsyncActionNeeded) { - commandScope.launch(ioDispatcher) { - blockToRunWhileStopped?.invoke() - if (isStartNeeded) { - start() - } - } - } else { - Job().apply { complete() } // no-op - } - } - } - - private fun SyncCommands.ExecutableSyncCommand.stop() { - when (target) { - SyncTarget.SCHEDULE_EVERYTHING -> cancelBackgroundWork() - SyncTarget.SCHEDULE_EVENTS -> cancelEventSync() - SyncTarget.SCHEDULE_IMAGES -> stopImageSync() // uses same worker as for OneTimeImages - SyncTarget.ONE_TIME_EVENTS -> stopEventSync() - SyncTarget.ONE_TIME_IMAGES -> stopImageSync() - } - } - - private suspend fun SyncCommands.ExecutableSyncCommand.start() { - when (target) { - SyncTarget.SCHEDULE_EVERYTHING -> scheduleBackgroundWork((payload as SyncCommandPayload.WithDelay).withDelay) - SyncTarget.SCHEDULE_EVENTS -> rescheduleEventSync((payload as SyncCommandPayload.WithDelay).withDelay) - SyncTarget.SCHEDULE_IMAGES -> rescheduleImageUpSync() - SyncTarget.ONE_TIME_EVENTS -> startEventSync((payload as SyncCommandPayload.WithDownSyncAllowed).isDownSyncAllowed) - SyncTarget.ONE_TIME_IMAGES -> startImageSync() - } - } - - private suspend fun scheduleBackgroundWork(withDelay: Boolean) { - if (authStore.signedInProjectId.isNotEmpty()) { - workManager.schedulePeriodicWorker( - SyncConstants.PROJECT_SYNC_WORK_NAME, - SyncConstants.PROJECT_SYNC_REPEAT_INTERVAL, - ) - workManager.schedulePeriodicWorker( - SyncConstants.DEVICE_SYNC_WORK_NAME, - SyncConstants.DEVICE_SYNC_REPEAT_INTERVAL, - ) - workManager.schedulePeriodicWorker( - SyncConstants.FILE_UP_SYNC_WORK_NAME, - SyncConstants.FILE_UP_SYNC_REPEAT_INTERVAL, - constraints = getImageUploadConstraints(), - ) - rescheduleEventSync(withDelay) - if (shouldScheduleFirmwareUpdate()) { - workManager.schedulePeriodicWorker( - SyncConstants.FIRMWARE_UPDATE_WORK_NAME, - SyncConstants.FIRMWARE_UPDATE_REPEAT_INTERVAL, - ) - } else { - workManager.cancelWorkers(SyncConstants.FIRMWARE_UPDATE_WORK_NAME) - } - } - } - - private fun cancelBackgroundWork() { - workManager.cancelWorkers( - SyncConstants.PROJECT_SYNC_WORK_NAME, - SyncConstants.DEVICE_SYNC_WORK_NAME, - SyncConstants.FILE_UP_SYNC_WORK_NAME, - SyncConstants.EVENT_SYNC_WORK_NAME, - SyncConstants.FIRMWARE_UPDATE_WORK_NAME, - ) - stopEventSync() - } - - private suspend fun rescheduleEventSync(withDelay: Boolean) { - workManager.schedulePeriodicWorker( - workName = SyncConstants.EVENT_SYNC_WORK_NAME, - repeatInterval = SyncConstants.EVENT_SYNC_WORKER_INTERVAL, - initialDelay = if (withDelay) SyncConstants.EVENT_SYNC_WORKER_INTERVAL else 0, - constraints = getEventSyncConstraints(), - tags = eventSyncManager.getPeriodicWorkTags(), - ) - } - - private fun cancelEventSync() { - workManager.cancelWorkers(SyncConstants.EVENT_SYNC_WORK_NAME) - stopEventSync() - } - - private suspend fun startEventSync(isDownSyncAllowed: Boolean) { - workManager.startWorker( - workName = SyncConstants.EVENT_SYNC_WORK_NAME_ONE_TIME, - constraints = getEventSyncConstraints(), - tags = eventSyncManager.getOneTimeWorkTags(), - inputData = workDataOf(EventSyncMasterWorker.IS_DOWN_SYNC_ALLOWED to isDownSyncAllowed), - ) - } - - private fun stopEventSync() { - workManager.cancelWorkers(SyncConstants.EVENT_SYNC_WORK_NAME_ONE_TIME) - // Event sync consists of multiple workers, so we cancel them all by tag - workManager.cancelAllWorkByTag(eventSyncManager.getAllWorkerTag()) - } - - private fun startImageSync() { - stopImageSync() - workManager.startWorker(SyncConstants.FILE_UP_SYNC_WORK_NAME) - } - - private fun stopImageSync() { - workManager.cancelWorkers(SyncConstants.FILE_UP_SYNC_WORK_NAME) - } - - /** - * Fully reschedule the background worker. - * Should be used in when the configuration that affects scheduling has changed. - */ - private suspend fun rescheduleImageUpSync() { - workManager.schedulePeriodicWorker( - SyncConstants.FILE_UP_SYNC_WORK_NAME, - SyncConstants.FILE_UP_SYNC_REPEAT_INTERVAL, - initialDelay = SyncConstants.DEFAULT_BACKOFF_INTERVAL_MINUTES, - existingWorkPolicy = ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, - constraints = getImageUploadConstraints(), - ) - } - - private suspend fun getImageUploadConstraints(): Constraints { - val networkType = configRepository - .getProjectConfiguration() - .imagesUploadRequiresUnmeteredConnection() - .let { if (it) NetworkType.UNMETERED else NetworkType.CONNECTED } - return Constraints.Builder().setRequiredNetworkType(networkType).build() - } - - private suspend fun getEventSyncConstraints(): Constraints { - // CommCare doesn't require network connection - val networkType = configRepository - .getProjectConfiguration() - .isCommCareEventDownSyncAllowed() - .let { if (it) NetworkType.NOT_REQUIRED else NetworkType.CONNECTED } - return Constraints.Builder().setRequiredNetworkType(networkType).build() - } -} diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/OneTimeSyncCommandTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/OneTimeSyncCommandTest.kt new file mode 100644 index 0000000000..70f5190222 --- /dev/null +++ b/infra/sync/src/test/java/com/simprints/infra/sync/OneTimeSyncCommandTest.kt @@ -0,0 +1,42 @@ +package com.simprints.infra.sync + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class OneTimeSyncCommandTest { + @Test + fun `events start builds expected command`() { + assertThat(OneTime.Events.start()) + .isEqualTo(OneTime.EventsCommand(action = OneTime.Action.START, isDownSyncAllowed = true)) + + assertThat(OneTime.Events.start(isDownSyncAllowed = false)) + .isEqualTo(OneTime.EventsCommand(action = OneTime.Action.START, isDownSyncAllowed = false)) + } + + @Test + fun `events stop builds expected command`() { + assertThat(OneTime.Events.stop()) + .isEqualTo(OneTime.EventsCommand(action = OneTime.Action.STOP, isDownSyncAllowed = true)) + } + + @Test + fun `events restart builds expected command`() { + assertThat(OneTime.Events.restart()) + .isEqualTo(OneTime.EventsCommand(action = OneTime.Action.RESTART, isDownSyncAllowed = true)) + + assertThat(OneTime.Events.restart(isDownSyncAllowed = false)) + .isEqualTo(OneTime.EventsCommand(action = OneTime.Action.RESTART, isDownSyncAllowed = false)) + } + + @Test + fun `images commands build expected commands`() { + assertThat(OneTime.Images.start()) + .isEqualTo(OneTime.ImagesCommand(action = OneTime.Action.START)) + + assertThat(OneTime.Images.stop()) + .isEqualTo(OneTime.ImagesCommand(action = OneTime.Action.STOP)) + + assertThat(OneTime.Images.restart()) + .isEqualTo(OneTime.ImagesCommand(action = OneTime.Action.RESTART)) + } +} diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/ScheduleSyncCommandTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/ScheduleSyncCommandTest.kt new file mode 100644 index 0000000000..533d314b25 --- /dev/null +++ b/infra/sync/src/test/java/com/simprints/infra/sync/ScheduleSyncCommandTest.kt @@ -0,0 +1,56 @@ +package com.simprints.infra.sync + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class ScheduleSyncCommandTest { + @Test + fun `everything commands build expected command`() { + assertThat(ScheduleCommand.Everything.reschedule()) + .isEqualTo(ScheduleCommand.EverythingCommand(action = ScheduleCommand.Action.RESCHEDULE, withDelay = false)) + + assertThat(ScheduleCommand.Everything.reschedule(withDelay = true)) + .isEqualTo(ScheduleCommand.EverythingCommand(action = ScheduleCommand.Action.RESCHEDULE, withDelay = true)) + + assertThat(ScheduleCommand.Everything.unschedule()) + .isEqualTo(ScheduleCommand.EverythingCommand(action = ScheduleCommand.Action.UNSCHEDULE)) + + val block: suspend () -> Unit = { } + val command = ScheduleCommand.Everything.rescheduleAfter(withDelay = true, block = block) as ScheduleCommand.EverythingCommand + assertThat(command.action).isEqualTo(ScheduleCommand.Action.RESCHEDULE) + assertThat(command.withDelay).isTrue() + assertThat(command.blockWhileUnscheduled).isSameInstanceAs(block) + } + + @Test + fun `events commands build expected command`() { + assertThat(ScheduleCommand.Events.reschedule()) + .isEqualTo(ScheduleCommand.EventsCommand(action = ScheduleCommand.Action.RESCHEDULE, withDelay = false)) + + assertThat(ScheduleCommand.Events.reschedule(withDelay = true)) + .isEqualTo(ScheduleCommand.EventsCommand(action = ScheduleCommand.Action.RESCHEDULE, withDelay = true)) + + assertThat(ScheduleCommand.Events.unschedule()) + .isEqualTo(ScheduleCommand.EventsCommand(action = ScheduleCommand.Action.UNSCHEDULE)) + + val block: suspend () -> Unit = { } + val command = ScheduleCommand.Events.rescheduleAfter(withDelay = false, block = block) as ScheduleCommand.EventsCommand + assertThat(command.action).isEqualTo(ScheduleCommand.Action.RESCHEDULE) + assertThat(command.withDelay).isFalse() + assertThat(command.blockWhileUnscheduled).isSameInstanceAs(block) + } + + @Test + fun `images commands build expected command`() { + assertThat(ScheduleCommand.Images.reschedule()) + .isEqualTo(ScheduleCommand.ImagesCommand(action = ScheduleCommand.Action.RESCHEDULE)) + + assertThat(ScheduleCommand.Images.unschedule()) + .isEqualTo(ScheduleCommand.ImagesCommand(action = ScheduleCommand.Action.UNSCHEDULE)) + + val block: suspend () -> Unit = { } + val command = ScheduleCommand.Images.rescheduleAfter(block) as ScheduleCommand.ImagesCommand + assertThat(command.action).isEqualTo(ScheduleCommand.Action.RESCHEDULE) + assertThat(command.blockWhileUnscheduled).isSameInstanceAs(block) + } +} diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt deleted file mode 100644 index 2a1a5e6abf..0000000000 --- a/infra/sync/src/test/java/com/simprints/infra/sync/SyncCommandsTest.kt +++ /dev/null @@ -1,206 +0,0 @@ -package com.simprints.infra.sync - -import com.google.common.truth.Truth.assertThat -import org.junit.Test - -class SyncCommandsTest { - private val buildersWithoutParams = listOf( - SyncCommands.OneTimeNow.Images to SyncTarget.ONE_TIME_IMAGES, - SyncCommands.ScheduleOf.Images to SyncTarget.SCHEDULE_IMAGES, - ) - - private val buildersWithDelayParam = listOf( - SyncCommands.ScheduleOf.Everything to SyncTarget.SCHEDULE_EVERYTHING, - SyncCommands.ScheduleOf.Events to SyncTarget.SCHEDULE_EVENTS, - ) - - private val buildersWithDownSyncAllowedParam = listOf( - SyncCommands.OneTimeNow.Events to SyncTarget.ONE_TIME_EVENTS, - ) - - @Test - fun `stop builds executable command without params`() { - buildersWithoutParams.forEach { (builder, expectedTarget) -> - assertThat(builder.stop()) - .isEqualTo(expectedCommand(expectedTarget, SyncAction.STOP)) - } - buildersWithDownSyncAllowedParam.forEach { (builder, expectedTarget) -> - assertThat(builder.stop()) - .isEqualTo(expectedCommand(expectedTarget, SyncAction.STOP)) - } - buildersWithDelayParam.forEach { (builder, expectedTarget) -> - assertThat(builder.stop()) - .isEqualTo(expectedCommand(expectedTarget, SyncAction.STOP)) - } - } - - @Test - fun `start builds executable command with expected params`() { - buildersWithoutParams.forEach { (builder, expectedTarget) -> - assertThat(builder.start()) - .isEqualTo(expectedCommand(expectedTarget, SyncAction.START)) - } - - buildersWithDownSyncAllowedParam.forEach { (builder, expectedTarget) -> - assertThat(builder.start()) - .isEqualTo( - expectedCommand( - target = expectedTarget, - action = SyncAction.START, - payload = SyncCommandPayload.WithDownSyncAllowed(true), - ), - ) - assertThat(builder.start(isDownSyncAllowed = false)) - .isEqualTo( - expectedCommand( - target = expectedTarget, - action = SyncAction.START, - payload = SyncCommandPayload.WithDownSyncAllowed(false), - ), - ) - } - - buildersWithDelayParam.forEach { (builder, expectedTarget) -> - assertThat(builder.start()) - .isEqualTo( - expectedCommand( - target = expectedTarget, - action = SyncAction.START, - payload = SyncCommandPayload.WithDelay(false), - ), - ) - assertThat(builder.start(withDelay = true)) - .isEqualTo( - expectedCommand( - target = expectedTarget, - action = SyncAction.START, - payload = SyncCommandPayload.WithDelay(true), - ), - ) - } - } - - @Test - fun `restart builds executable command with expected params`() { - val action = SyncAction.RESTART - buildersWithoutParams.forEach { (builder, expectedTarget) -> - assertThat(builder.restart()) - .isEqualTo(expectedCommand(expectedTarget, action)) - } - - buildersWithDelayParam.forEach { (builder, expectedTarget) -> - assertThat(builder.restart()) - .isEqualTo( - expectedCommand( - target = expectedTarget, - action, - payload = SyncCommandPayload.WithDelay(false), - ), - ) - assertThat(builder.restart(withDelay = true)) - .isEqualTo( - expectedCommand( - target = expectedTarget, - action, - payload = SyncCommandPayload.WithDelay(true), - ), - ) - } - - buildersWithDownSyncAllowedParam.forEach { (builder, expectedTarget) -> - assertThat(builder.restart()) - .isEqualTo( - expectedCommand( - target = expectedTarget, - action, - payload = SyncCommandPayload.WithDownSyncAllowed(true), - ), - ) - assertThat(builder.restart(isDownSyncAllowed = false)) - .isEqualTo( - expectedCommand( - target = expectedTarget, - action, - payload = SyncCommandPayload.WithDownSyncAllowed(false), - ), - ) - } - } - - @Test - fun `restartAfter builds executable command and stores block`() { - val block: suspend () -> Unit = { } - val action = SyncAction.RESTART - - buildersWithoutParams.forEach { (builder, expectedTarget) -> - assertThat(builder.restartAfter(block)) - .isEqualTo( - expectedCommand( - target = expectedTarget, - action = action, - block = block, - ), - ) - } - - buildersWithDownSyncAllowedParam.forEach { (builder, expectedTarget) -> - assertThat(builder.restartAfter(block = block)) - .isEqualTo( - expectedCommand( - target = expectedTarget, - action, - payload = SyncCommandPayload.WithDownSyncAllowed(true), - block, - ), - ) - assertThat(builder.restartAfter(isDownSyncAllowed = false, block = block)) - .isEqualTo( - expectedCommand( - target = expectedTarget, - action, - payload = SyncCommandPayload.WithDownSyncAllowed(false), - block, - ), - ) - } - - buildersWithDelayParam.forEach { (builder, expectedTarget) -> - assertThat(builder.restartAfter(block = block)) - .isEqualTo( - expectedCommand( - target = expectedTarget, - action, - payload = SyncCommandPayload.WithDelay(false), - block, - ), - ) - assertThat(builder.restartAfter(withDelay = true, block = block)) - .isEqualTo( - expectedCommand( - target = expectedTarget, - action, - payload = SyncCommandPayload.WithDelay(true), - block, - ), - ) - } - } - - @Test - fun `observe only is not an executable command`() { - assertThat(SyncCommands.ExecutableSyncCommand::class.java.isInstance(SyncCommands.ObserveOnly)) - .isFalse() - } - - private fun expectedCommand( - target: SyncTarget, - action: SyncAction, - payload: SyncCommandPayload = SyncCommandPayload.None, - block: (suspend () -> Unit)? = null, - ) = SyncCommands.ExecutableSyncCommand( - target = target, - action = action, - payload = payload, - blockToRunWhileStopped = block, - ) -} diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/SyncOrchestratorCommandExecutionTest.kt similarity index 72% rename from infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt rename to infra/sync/src/test/java/com/simprints/infra/sync/SyncOrchestratorCommandExecutionTest.kt index ae5850378b..80059ad8ad 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/internal/ExecuteSyncCommandUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/SyncOrchestratorCommandExecutionTest.kt @@ -1,4 +1,4 @@ -package com.simprints.infra.sync.usecase.internal +package com.simprints.infra.sync import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType @@ -9,8 +9,8 @@ import com.google.common.truth.Truth.assertThat import com.simprints.infra.authstore.AuthStore import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.eventsync.EventSyncManager +import com.simprints.infra.eventsync.sync.EventSyncStateProcessor import com.simprints.infra.eventsync.sync.master.EventSyncMasterWorker -import com.simprints.infra.sync.SyncCommands import com.simprints.infra.sync.SyncConstants.DEVICE_SYNC_WORK_NAME import com.simprints.infra.sync.SyncConstants.EVENT_SYNC_WORK_NAME import com.simprints.infra.sync.SyncConstants.EVENT_SYNC_WORK_NAME_ONE_TIME @@ -18,6 +18,8 @@ import com.simprints.infra.sync.SyncConstants.FILE_UP_SYNC_WORK_NAME import com.simprints.infra.sync.SyncConstants.FIRMWARE_UPDATE_WORK_NAME import com.simprints.infra.sync.SyncConstants.PROJECT_SYNC_WORK_NAME import com.simprints.infra.sync.firmware.ShouldScheduleFirmwareUpdateUseCase +import com.simprints.infra.sync.usecase.CleanupDeprecatedWorkersUseCase +import com.simprints.infra.sync.usecase.internal.ObserveImageSyncStatusUseCase import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.MockKAnnotations import io.mockk.coEvery @@ -25,16 +27,16 @@ import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.verify import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test import java.util.UUID -class ExecuteSyncCommandUseCaseTest { +class SyncOrchestratorCommandExecutionTest { @get:Rule val testCoroutineRule = TestCoroutineRule() @@ -50,15 +52,28 @@ class ExecuteSyncCommandUseCaseTest { @MockK private lateinit var eventSyncManager: EventSyncManager + @MockK + private lateinit var eventSyncStateProcessor: EventSyncStateProcessor + + @MockK + private lateinit var observeImageSyncStatus: ObserveImageSyncStatusUseCase + @MockK private lateinit var shouldScheduleFirmwareUpdate: ShouldScheduleFirmwareUpdateUseCase - private lateinit var useCase: ExecuteSyncCommandUseCase + @MockK + private lateinit var cleanupDeprecatedWorkers: CleanupDeprecatedWorkersUseCase + + @MockK + private lateinit var imageSyncTimestampProvider: ImageSyncTimestampProvider + + private lateinit var orchestrator: SyncOrchestratorImpl @Before fun setup() { MockKAnnotations.init(this, relaxed = true) - useCase = createUseCase() + every { workManager.getWorkInfosFlow(any()) } returns flowOf(emptyList()) + orchestrator = createOrchestrator() } @Test @@ -66,7 +81,7 @@ class ExecuteSyncCommandUseCaseTest { every { authStore.signedInProjectId } returns "" coEvery { shouldScheduleFirmwareUpdate.invoke() } returns false - useCase(executable(SyncCommands.ScheduleOf.Everything.start()), commandScope()).join() + orchestrator.executeSchedulingCommand(ScheduleCommand.Everything.reschedule()).join() verify(exactly = 0) { workManager.enqueueUniquePeriodicWork(any(), any(), any()) } } @@ -76,7 +91,7 @@ class ExecuteSyncCommandUseCaseTest { every { authStore.signedInProjectId } returns "projectId" coEvery { shouldScheduleFirmwareUpdate.invoke() } returns true - useCase(executable(SyncCommands.ScheduleOf.Everything.start()), commandScope()).join() + orchestrator.executeSchedulingCommand(ScheduleCommand.Everything.reschedule()).join() verify { workManager.enqueueUniquePeriodicWork(PROJECT_SYNC_WORK_NAME, any(), any()) @@ -96,7 +111,7 @@ class ExecuteSyncCommandUseCaseTest { } returns false every { authStore.signedInProjectId } returns "projectId" - useCase(executable(SyncCommands.ScheduleOf.Everything.start()), commandScope()).join() + orchestrator.executeSchedulingCommand(ScheduleCommand.Everything.reschedule()).join() verify { workManager.enqueueUniquePeriodicWork( @@ -117,7 +132,7 @@ class ExecuteSyncCommandUseCaseTest { every { authStore.signedInProjectId } returns "projectId" coEvery { shouldScheduleFirmwareUpdate.invoke() } returns false - useCase(executable(SyncCommands.ScheduleOf.Everything.start()), commandScope()).join() + orchestrator.executeSchedulingCommand(ScheduleCommand.Everything.reschedule()).join() verify { workManager.enqueueUniquePeriodicWork( @@ -133,16 +148,16 @@ class ExecuteSyncCommandUseCaseTest { every { authStore.signedInProjectId } returns "projectId" coEvery { shouldScheduleFirmwareUpdate.invoke() } returns false - useCase(executable(SyncCommands.ScheduleOf.Everything.start()), commandScope()).join() + orchestrator.executeSchedulingCommand(ScheduleCommand.Everything.reschedule()).join() verify { workManager.cancelUniqueWork(FIRMWARE_UPDATE_WORK_NAME) } } @Test - fun `cancels all necessary background workers`() = runTest { + fun `unschedule cancels all necessary background workers`() = runTest { every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" - useCase(executable(SyncCommands.ScheduleOf.Everything.stop()), commandScope()) + orchestrator.executeSchedulingCommand(ScheduleCommand.Everything.unschedule()) verify { workManager.cancelUniqueWork(PROJECT_SYNC_WORK_NAME) @@ -159,7 +174,7 @@ class ExecuteSyncCommandUseCaseTest { fun `reschedules event sync worker with correct tags`() = runTest { every { eventSyncManager.getPeriodicWorkTags() } returns listOf("tag1", "tag2") - useCase(executable(SyncCommands.ScheduleOf.Events.start()), commandScope()).join() + orchestrator.executeSchedulingCommand(ScheduleCommand.Events.reschedule()).join() verify { workManager.enqueueUniquePeriodicWork( @@ -174,7 +189,7 @@ class ExecuteSyncCommandUseCaseTest { fun `reschedules event sync worker with correct delay`() = runTest { every { eventSyncManager.getPeriodicWorkTags() } returns listOf("tag1", "tag2") - useCase(executable(SyncCommands.ScheduleOf.Events.start(withDelay = true)), commandScope()).join() + orchestrator.executeSchedulingCommand(ScheduleCommand.Events.reschedule(withDelay = true)).join() verify { workManager.enqueueUniquePeriodicWork( @@ -186,11 +201,14 @@ class ExecuteSyncCommandUseCaseTest { } @Test - fun `stop and start schedule events routes to cancel and reschedule with delay`() = runTest { + fun `rescheduleAfter for schedule events routes to unschedule and reschedule with delay`() = runTest { every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" every { eventSyncManager.getPeriodicWorkTags() } returns listOf("tag1", "tag2") - useCase(executable(SyncCommands.ScheduleOf.Events.restart(withDelay = true)), commandScope()).join() + orchestrator + .executeSchedulingCommand( + ScheduleCommand.Events.rescheduleAfter(withDelay = true) { }, + ).join() verify { workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME) @@ -208,10 +226,10 @@ class ExecuteSyncCommandUseCaseTest { } @Test - fun `cancel event sync worker cancels correct worker`() = runTest { + fun `unschedule events cancels correct workers`() = runTest { every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" - useCase(executable(SyncCommands.ScheduleOf.Events.stop()), commandScope()) + orchestrator.executeSchedulingCommand(ScheduleCommand.Events.unschedule()) verify { workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME) @@ -221,10 +239,10 @@ class ExecuteSyncCommandUseCaseTest { } @Test - fun `start event sync worker with correct tags`() = runTest { + fun `start one-time event sync uses correct tags`() = runTest { every { eventSyncManager.getOneTimeWorkTags() } returns listOf("tag1", "tag2") - useCase(executable(SyncCommands.OneTimeNow.Events.start()), commandScope()).join() + orchestrator.executeOneTime(OneTime.Events.start()).join() verify { workManager.enqueueUniqueWork( @@ -236,10 +254,10 @@ class ExecuteSyncCommandUseCaseTest { } @Test - fun `start event sync worker with correct input data`() = runTest { + fun `start one-time event sync uses correct input data`() = runTest { every { eventSyncManager.getOneTimeWorkTags() } returns listOf("tag1", "tag2") - useCase(executable(SyncCommands.OneTimeNow.Events.start(isDownSyncAllowed = false)), commandScope()).join() + orchestrator.executeOneTime(OneTime.Events.start(isDownSyncAllowed = false)).join() verify { workManager.enqueueUniqueWork( @@ -253,11 +271,11 @@ class ExecuteSyncCommandUseCaseTest { } @Test - fun `stop and start one-time event sync routes to stop and start with expected input param`() = runTest { + fun `restart one-time event sync routes to stop and start with expected input param`() = runTest { every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" every { eventSyncManager.getOneTimeWorkTags() } returns listOf("tag1", "tag2") - useCase(executable(SyncCommands.OneTimeNow.Events.restart(isDownSyncAllowed = false)), commandScope()).join() + orchestrator.executeOneTime(OneTime.Events.restart(isDownSyncAllowed = false)).join() verify { workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME_ONE_TIME) @@ -274,10 +292,10 @@ class ExecuteSyncCommandUseCaseTest { } @Test - fun `stop event sync worker cancels correct workers`() = runTest { + fun `stop one-time event sync cancels correct workers`() = runTest { every { eventSyncManager.getAllWorkerTag() } returns "syncWorkers" - useCase(executable(SyncCommands.OneTimeNow.Events.stop()), commandScope()) + orchestrator.executeOneTime(OneTime.Events.stop()) verify { workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME_ONE_TIME) @@ -287,7 +305,7 @@ class ExecuteSyncCommandUseCaseTest { @Test fun `reschedules image worker when requested`() = runTest { - useCase(executable(SyncCommands.ScheduleOf.Images.start()), commandScope()).join() + orchestrator.executeSchedulingCommand(ScheduleCommand.Images.reschedule()).join() verify { workManager.enqueueUniquePeriodicWork( @@ -299,8 +317,8 @@ class ExecuteSyncCommandUseCaseTest { } @Test - fun `start image sync re-starts image worker`() = runTest { - useCase(executable(SyncCommands.OneTimeNow.Images.start()), commandScope()).join() + fun `start one-time image sync re-starts image worker`() = runTest { + orchestrator.executeOneTime(OneTime.Images.start()).join() verify { workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) @@ -313,22 +331,22 @@ class ExecuteSyncCommandUseCaseTest { } @Test - fun `stop image sync cancels image worker`() = runTest { - useCase(executable(SyncCommands.OneTimeNow.Images.stop()), commandScope()) + fun `stop one-time image sync cancels image worker`() = runTest { + orchestrator.executeOneTime(OneTime.Images.stop()) verify { workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) } } @Test - fun `invoke stop command returns completed job and routes to stop logic`() = runTest { - val job = useCase(executable(SyncCommands.ScheduleOf.Images.stop()), commandScope()) + fun `unschedule images returns completed job and routes to stop logic`() = runTest { + val job = orchestrator.executeSchedulingCommand(ScheduleCommand.Images.unschedule()) assertThat(job.isCompleted).isTrue() verify { workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) } } @Test - fun `stop and start around runs block before starting`() = runTest { + fun `rescheduleAfter runs block before rescheduling images`() = runTest { val blockStarted = Channel(Channel.UNLIMITED) val unblock = Channel(Channel.UNLIMITED) val block: suspend () -> Unit = { @@ -336,11 +354,9 @@ class ExecuteSyncCommandUseCaseTest { unblock.receive() } - val job = useCase(executable(SyncCommands.ScheduleOf.Images.restartAfter(block)), commandScope()) + val job = orchestrator.executeSchedulingCommand(ScheduleCommand.Images.rescheduleAfter(block)) - verify { - workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) - } + verify { workManager.cancelUniqueWork(FILE_UP_SYNC_WORK_NAME) } blockStarted.receive() @@ -365,38 +381,11 @@ class ExecuteSyncCommandUseCaseTest { } @Test - fun `uses passed scope to launch async commands`() = runTest { - val parentJob = Job() - val scope = CoroutineScope(parentJob + testCoroutineRule.testCoroutineDispatcher) - val blockStarted = Channel(Channel.UNLIMITED) - val unblock = Channel(Channel.UNLIMITED) - val block: suspend () -> Unit = { - blockStarted.trySend(Unit) - unblock.receive() - } - - val job = useCase(executable(SyncCommands.ScheduleOf.Images.restartAfter(block)), scope) - - blockStarted.receive() - parentJob.cancel() - job.join() - assertThat(job.isCancelled).isTrue() - verify(exactly = 0) { - workManager.enqueueUniquePeriodicWork( - FILE_UP_SYNC_WORK_NAME, - ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, - any(), - ) - } - } - - @Test - fun `stops image worker when event sync starts`() = runTest { - // init block test + fun `reschedules image worker when event sync starts`() = runTest { val eventStartFlow = MutableSharedFlow>() every { workManager.getWorkInfosFlow(any()) } returns eventStartFlow - useCase = createUseCase() + orchestrator = createOrchestrator() eventStartFlow.emit(createWorkInfo(WorkInfo.State.RUNNING)) @@ -410,12 +399,11 @@ class ExecuteSyncCommandUseCaseTest { } @Test - fun `does not stop image worker when event sync is not running`() = runTest { - // init block test + fun `does not reschedule image worker when event sync is not running`() = runTest { val eventStartFlow = MutableSharedFlow>() every { workManager.getWorkInfosFlow(any()) } returns eventStartFlow - useCase = createUseCase() + orchestrator = createOrchestrator() eventStartFlow.emit(createWorkInfo(WorkInfo.State.CANCELLED)) @@ -428,16 +416,16 @@ class ExecuteSyncCommandUseCaseTest { } } - private fun executable(syncCommand: com.simprints.infra.sync.SyncCommand) = syncCommand as SyncCommands.ExecutableSyncCommand - - private fun commandScope() = CoroutineScope(testCoroutineRule.testCoroutineDispatcher) - - private fun createUseCase() = ExecuteSyncCommandUseCase( + private fun createOrchestrator() = SyncOrchestratorImpl( workManager = workManager, authStore = authStore, configRepository = configRepository, eventSyncManager = eventSyncManager, + eventSyncStateProcessor = eventSyncStateProcessor, + observeImageSyncStatus = observeImageSyncStatus, shouldScheduleFirmwareUpdate = shouldScheduleFirmwareUpdate, + cleanupDeprecatedWorkers = cleanupDeprecatedWorkers, + imageSyncTimestampProvider = imageSyncTimestampProvider, appScope = CoroutineScope(testCoroutineRule.testCoroutineDispatcher), ioDispatcher = testCoroutineRule.testCoroutineDispatcher, ) diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/SyncOrchestratorImplTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/SyncOrchestratorImplTest.kt index 14a6f11b54..8c7594725a 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/SyncOrchestratorImplTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/SyncOrchestratorImplTest.kt @@ -4,18 +4,24 @@ import androidx.work.OneTimeWorkRequest import androidx.work.WorkInfo import androidx.work.WorkManager import com.google.common.truth.Truth.assertThat +import com.simprints.infra.authstore.AuthStore +import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.eventsync.EventSyncManager +import com.simprints.infra.eventsync.sync.EventSyncStateProcessor import com.simprints.infra.sync.SyncConstants.DEVICE_SYNC_WORK_NAME_ONE_TIME import com.simprints.infra.sync.SyncConstants.PROJECT_SYNC_WORK_NAME_ONE_TIME import com.simprints.infra.sync.SyncConstants.RECORD_UPLOAD_INPUT_ID_NAME import com.simprints.infra.sync.SyncConstants.RECORD_UPLOAD_INPUT_SUBJECT_IDS_NAME +import com.simprints.infra.sync.firmware.ShouldScheduleFirmwareUpdateUseCase import com.simprints.infra.sync.usecase.CleanupDeprecatedWorkersUseCase +import com.simprints.infra.sync.usecase.internal.ObserveImageSyncStatusUseCase import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.MockKAnnotations import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.verify +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.count import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest @@ -31,9 +37,24 @@ class SyncOrchestratorImplTest { @MockK private lateinit var workManager: WorkManager + @MockK + private lateinit var authStore: AuthStore + + @MockK + private lateinit var configRepository: ConfigRepository + @MockK private lateinit var eventSyncManager: EventSyncManager + @MockK + private lateinit var eventSyncStateProcessor: EventSyncStateProcessor + + @MockK + private lateinit var observeImageSyncStatus: ObserveImageSyncStatusUseCase + + @MockK + private lateinit var shouldScheduleFirmwareUpdate: ShouldScheduleFirmwareUpdateUseCase + @MockK private lateinit var cleanupDeprecatedWorkers: CleanupDeprecatedWorkersUseCase @@ -45,6 +66,7 @@ class SyncOrchestratorImplTest { @Before fun setup() { MockKAnnotations.init(this, relaxed = true) + every { workManager.getWorkInfosFlow(any()) } returns flowOf(emptyList()) syncOrchestrator = createSyncOrchestrator() } @@ -120,9 +142,16 @@ class SyncOrchestratorImplTest { private fun createSyncOrchestrator() = SyncOrchestratorImpl( workManager, + authStore, + configRepository, eventSyncManager, + eventSyncStateProcessor, + observeImageSyncStatus, + shouldScheduleFirmwareUpdate, cleanupDeprecatedWorkers, imageSyncTimestampProvider, + CoroutineScope(testCoroutineRule.testCoroutineDispatcher), + testCoroutineRule.testCoroutineDispatcher, ) private fun createWorkInfo(state: WorkInfo.State) = listOf( diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/SyncOrchestratorObserveSyncStateTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/SyncOrchestratorObserveSyncStateTest.kt new file mode 100644 index 0000000000..7afc9c8f9c --- /dev/null +++ b/infra/sync/src/test/java/com/simprints/infra/sync/SyncOrchestratorObserveSyncStateTest.kt @@ -0,0 +1,157 @@ +package com.simprints.infra.sync + +import com.google.common.truth.Truth.assertThat +import com.simprints.infra.authstore.AuthStore +import com.simprints.infra.config.store.ConfigRepository +import com.simprints.infra.eventsync.EventSyncManager +import com.simprints.infra.eventsync.status.models.EventSyncState +import com.simprints.infra.eventsync.sync.EventSyncStateProcessor +import com.simprints.infra.sync.firmware.ShouldScheduleFirmwareUpdateUseCase +import com.simprints.infra.sync.usecase.CleanupDeprecatedWorkersUseCase +import com.simprints.infra.sync.usecase.internal.ObserveImageSyncStatusUseCase +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.verify +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SyncOrchestratorObserveSyncStateTest { + @MockK + private lateinit var workManager: androidx.work.WorkManager + + @MockK + private lateinit var authStore: AuthStore + + @MockK + private lateinit var configRepository: ConfigRepository + + @MockK + private lateinit var eventSyncManager: EventSyncManager + + @MockK + private lateinit var eventSyncStateProcessor: EventSyncStateProcessor + + @MockK + private lateinit var observeImageSyncStatus: ObserveImageSyncStatusUseCase + + @MockK + private lateinit var shouldScheduleFirmwareUpdate: ShouldScheduleFirmwareUpdateUseCase + + @MockK + private lateinit var cleanupDeprecatedWorkers: CleanupDeprecatedWorkersUseCase + + @MockK + private lateinit var imageSyncTimestampProvider: ImageSyncTimestampProvider + + private val eventSyncStatusFlow = MutableSharedFlow() + private val imageSyncStatusFlow = MutableSharedFlow() + + @Before + fun setup() { + MockKAnnotations.init(this, relaxed = true) + every { workManager.getWorkInfosFlow(any()) } returns flowOf(emptyList()) + every { eventSyncStateProcessor.getLastSyncState() } returns eventSyncStatusFlow + every { observeImageSyncStatus.invoke() } returns imageSyncStatusFlow + } + + @Test + fun `returns default SyncStatus before upstream flows emit`() = runTest { + val expected = SyncStatus( + eventSyncState = EventSyncState( + syncId = "", + progress = null, + total = null, + upSyncWorkersInfo = emptyList(), + downSyncWorkersInfo = emptyList(), + reporterStates = emptyList(), + lastSyncTime = null, + ), + imageSyncStatus = ImageSyncStatus( + isSyncing = false, + progress = null, + lastUpdateTimeMillis = -1L, + ), + ) + val orchestrator = createOrchestrator( + appScope = backgroundScope, + dispatcher = StandardTestDispatcher(testScheduler), + ) + + val resultFlow = orchestrator.observeSyncState() + + assertThat(resultFlow.value).isEqualTo(expected) + } + + @Test + fun `combines latest event and image states into SyncStatus`() = runTest { + val event = EventSyncState( + syncId = "sync-1", + progress = 1, + total = 10, + upSyncWorkersInfo = emptyList(), + downSyncWorkersInfo = emptyList(), + reporterStates = emptyList(), + lastSyncTime = null, + ) + val image = ImageSyncStatus( + isSyncing = true, + progress = 2 to 5, + lastUpdateTimeMillis = 123L, + ) + val orchestrator = createOrchestrator( + appScope = backgroundScope, + dispatcher = StandardTestDispatcher(testScheduler), + ) + + val resultFlow = orchestrator.observeSyncState() + + runCurrent() // ensure upstream flows are collected before emitting + eventSyncStatusFlow.emit(event) + imageSyncStatusFlow.emit(image) + runCurrent() + + assertThat(resultFlow.value).isEqualTo(SyncStatus(event, image)) + } + + @Test + fun `returns the same shared StateFlow across invocations`() = runTest { + val orchestrator = createOrchestrator( + appScope = backgroundScope, + dispatcher = StandardTestDispatcher(testScheduler), + ) + + val flow1 = orchestrator.observeSyncState() + val flow2 = orchestrator.observeSyncState() + + assertThat(flow1).isSameInstanceAs(flow2) + verify(exactly = 1) { eventSyncStateProcessor.getLastSyncState() } + verify(exactly = 1) { observeImageSyncStatus.invoke() } + } + + private fun createOrchestrator( + appScope: CoroutineScope, + dispatcher: CoroutineDispatcher, + ) = SyncOrchestratorImpl( + workManager = workManager, + authStore = authStore, + configRepository = configRepository, + eventSyncManager = eventSyncManager, + eventSyncStateProcessor = eventSyncStateProcessor, + observeImageSyncStatus = observeImageSyncStatus, + shouldScheduleFirmwareUpdate = shouldScheduleFirmwareUpdate, + cleanupDeprecatedWorkers = cleanupDeprecatedWorkers, + imageSyncTimestampProvider = imageSyncTimestampProvider, + appScope = appScope, + ioDispatcher = dispatcher, + ) +} diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/SyncResponseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/SyncResponseTest.kt deleted file mode 100644 index baa57fa94d..0000000000 --- a/infra/sync/src/test/java/com/simprints/infra/sync/SyncResponseTest.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.simprints.infra.sync - -import com.google.common.truth.Truth.assertThat -import io.mockk.mockk -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.junit.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class SyncResponseTest { - @Test - fun `await completes when syncCommandJob completes normally`() = runTest { - val syncCommandJob = Job() - val response = SyncResponse( - syncCommandJob = syncCommandJob, - syncStatusFlow = MutableStateFlow(mockk(relaxed = true)), - ) - val awaitDeferred = backgroundScope.async { response.await() } - - runCurrent() - assertThat(awaitDeferred.isCompleted).isFalse() - - syncCommandJob.complete() - runCurrent() - - awaitDeferred.await() - assertThat(awaitDeferred.isCompleted).isTrue() - } - - @Test - fun `await rethrows failure from syncCommandJob`() = runTest { - val syncCommandJob = Job() - val response = SyncResponse( - syncCommandJob = syncCommandJob, - syncStatusFlow = MutableStateFlow(mockk(relaxed = true)), - ) - val expected = IllegalStateException("ExceptionMessage") - - launch { syncCommandJob.completeExceptionally(expected) } - - val thrown = try { - response.await() - null - } catch (throwable: Throwable) { - throwable - } - assertThat(thrown).isNotNull() - assertThat(thrown).isInstanceOf(IllegalStateException::class.java) - assertThat(thrown!!.message).isEqualTo("ExceptionMessage") - assertThat(thrown === expected || thrown.cause === expected).isTrue() - } - - @Test - fun `await throws CancellationException when syncCommandJob is cancelled`() = runTest { - val syncCommandJob = Job() - val response = SyncResponse( - syncCommandJob = syncCommandJob, - syncStatusFlow = MutableStateFlow(mockk(relaxed = true)), - ) - - launch { syncCommandJob.cancel() } - - val thrown = try { - response.await() - null - } catch (throwable: Throwable) { - throwable - } - assertThat(thrown).isInstanceOf(CancellationException::class.java) - } -} diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/LogoutUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/LogoutUseCaseTest.kt index e672395deb..9fe80e8541 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/LogoutUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/LogoutUseCaseTest.kt @@ -1,28 +1,19 @@ package com.simprints.infra.sync.config.usecase -import com.google.common.truth.Truth.assertThat import com.simprints.infra.authlogic.AuthManager -import com.simprints.infra.sync.SyncAction -import com.simprints.infra.sync.SyncCommand -import com.simprints.infra.sync.SyncCommands +import com.simprints.infra.sync.ScheduleCommand import com.simprints.infra.sync.SyncOrchestrator -import com.simprints.infra.sync.SyncTarget -import com.simprints.infra.sync.usecase.SyncUseCase import io.mockk.MockKAnnotations import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK -import io.mockk.mockk -import io.mockk.slot import io.mockk.verify +import kotlinx.coroutines.Job import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test class LogoutUseCaseTest { - @MockK - private lateinit var sync: SyncUseCase - @MockK private lateinit var syncOrchestrator: SyncOrchestrator @@ -30,16 +21,14 @@ class LogoutUseCaseTest { private lateinit var authManager: AuthManager private lateinit var useCase: LogoutUseCase - private val syncCommandSlot = slot() @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) - every { sync(capture(syncCommandSlot)) } returns mockk() + every { syncOrchestrator.executeSchedulingCommand(any()) } returns Job().apply { complete() } useCase = LogoutUseCase( syncOrchestrator = syncOrchestrator, - sync = sync, authManager = authManager, ) } @@ -48,12 +37,7 @@ class LogoutUseCaseTest { fun `Fully logs out when called`() = runTest { useCase.invoke() - val command = syncCommandSlot.captured as SyncCommands.ExecutableSyncCommand - assertThat(command.target).isEqualTo(SyncTarget.SCHEDULE_EVERYTHING) - assertThat(command.action).isEqualTo(SyncAction.STOP) - assertThat(command).isEqualTo(SyncCommands.ScheduleOf.Everything.stop()) - - verify { sync(SyncCommands.ScheduleOf.Everything.stop()) } + verify { syncOrchestrator.executeSchedulingCommand(ScheduleCommand.Everything.unschedule()) } coVerify { syncOrchestrator.deleteEventSyncInfo() authManager.signOut() diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCaseTest.kt index 87253fcee0..b3893e661f 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/RescheduleWorkersIfConfigChangedUseCaseTest.kt @@ -1,21 +1,18 @@ package com.simprints.infra.sync.config.usecase import com.google.common.truth.Truth.assertThat -import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.SyncResponse +import com.simprints.infra.sync.ScheduleCommand +import com.simprints.infra.sync.SyncOrchestrator import com.simprints.infra.sync.config.testtools.projectConfiguration import com.simprints.infra.sync.config.testtools.simprintsUpSyncConfigurationConfiguration import com.simprints.infra.sync.config.testtools.synchronizationConfiguration -import com.simprints.infra.sync.usecase.SyncUseCase import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK -import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Before @@ -24,16 +21,16 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class RescheduleWorkersIfConfigChangedUseCaseTest { @MockK - private lateinit var sync: SyncUseCase + private lateinit var syncOrchestrator: SyncOrchestrator private lateinit var useCase: RescheduleWorkersIfConfigChangedUseCase @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) - every { sync(any()) } returns noopSyncResponse() + every { syncOrchestrator.executeSchedulingCommand(any()) } returns Job().apply { complete() } - useCase = RescheduleWorkersIfConfigChangedUseCase(sync) + useCase = RescheduleWorkersIfConfigChangedUseCase(syncOrchestrator) } @Test @@ -59,16 +56,13 @@ class RescheduleWorkersIfConfigChangedUseCaseTest { ), ) - verify(exactly = 0) { sync(any()) } + verify(exactly = 0) { syncOrchestrator.executeSchedulingCommand(any()) } } @Test fun `should reschedule image upload when unmetered connection flag changes`() = runTest { val syncCommandJob = Job() - every { sync(any()) } returns SyncResponse( - syncCommandJob = syncCommandJob, - syncStatusFlow = MutableStateFlow(mockk(relaxed = true)), - ) + every { syncOrchestrator.executeSchedulingCommand(any()) } returns syncCommandJob val useCaseJob = async { useCase( @@ -100,12 +94,7 @@ class RescheduleWorkersIfConfigChangedUseCaseTest { runCurrent() useCaseJob.await() - verify { sync(SyncCommands.ScheduleOf.Images.start()) } + verify { syncOrchestrator.executeSchedulingCommand(ScheduleCommand.Images.reschedule()) } assertThat(useCaseJob.isCompleted).isTrue() } - - private fun noopSyncResponse() = SyncResponse( - syncCommandJob = Job().apply { complete() }, - syncStatusFlow = MutableStateFlow(mockk(relaxed = true)), - ) } diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt index da87aee366..13cf221500 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/config/usecase/ResetLocalRecordsIfConfigChangedUseCaseTest.kt @@ -6,20 +6,14 @@ import com.simprints.infra.config.store.models.DownSynchronizationConfiguration import com.simprints.infra.config.store.models.Frequency import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository import com.simprints.infra.eventsync.EventSyncManager -import com.simprints.infra.sync.SyncAction -import com.simprints.infra.sync.SyncCommand -import com.simprints.infra.sync.SyncCommandPayload -import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.SyncResponse -import com.simprints.infra.sync.SyncTarget +import com.simprints.infra.sync.ScheduleCommand +import com.simprints.infra.sync.SyncOrchestrator import com.simprints.infra.sync.config.testtools.projectConfiguration import com.simprints.infra.sync.config.testtools.synchronizationConfiguration -import com.simprints.infra.sync.usecase.SyncUseCase import io.mockk.* import io.mockk.impl.annotations.MockK import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Before @@ -28,7 +22,7 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class ResetLocalRecordsIfConfigChangedUseCaseTest { @MockK - private lateinit var sync: SyncUseCase + private lateinit var syncOrchestrator: SyncOrchestrator @MockK private lateinit var eventSyncManager: EventSyncManager @@ -37,17 +31,17 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { private lateinit var enrolmentRecordRepository: EnrolmentRecordRepository private lateinit var useCase: ResetLocalRecordsIfConfigChangedUseCase - private val syncCommandSlot = slot() + private val scheduleCommandSlot = slot() @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) - every { sync(capture(syncCommandSlot)) } returns noopSyncResponse() + every { syncOrchestrator.executeSchedulingCommand(capture(scheduleCommandSlot)) } returns noopJob() useCase = ResetLocalRecordsIfConfigChangedUseCase( eventSyncManager = eventSyncManager, enrolmentRecordRepository = enrolmentRecordRepository, - sync = sync, + syncOrchestrator = syncOrchestrator, ) } @@ -74,7 +68,7 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ), ) - verify(exactly = 0) { sync(any()) } + verify(exactly = 0) { syncOrchestrator.executeSchedulingCommand(any()) } coVerify(exactly = 0) { eventSyncManager.resetDownSyncInfo() enrolmentRecordRepository.deleteAll() @@ -104,16 +98,13 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ), ) - verify { sync(any()) } - val command = syncCommandSlot.captured as SyncCommands.ExecutableSyncCommand - assertThat(command.target) - .isEqualTo(SyncTarget.SCHEDULE_EVENTS) - assertThat(command.action) - .isEqualTo(SyncAction.RESTART) - assertThat((command.payload as SyncCommandPayload.WithDelay).withDelay) - .isFalse() + verify { syncOrchestrator.executeSchedulingCommand(any()) } + val command = scheduleCommandSlot.captured as ScheduleCommand.EventsCommand + assertThat(command.action).isEqualTo(ScheduleCommand.Action.RESCHEDULE) + assertThat(command.withDelay).isFalse() + assertThat(command.blockWhileUnscheduled).isNotNull() - command.blockToRunWhileStopped?.invoke() + command.blockWhileUnscheduled?.invoke() runCurrent() coVerify { eventSyncManager.resetDownSyncInfo() @@ -144,14 +135,12 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ), ) - verify { sync(any()) } - val command = syncCommandSlot.captured as SyncCommands.ExecutableSyncCommand - assertThat(command.target) - .isEqualTo(SyncTarget.SCHEDULE_EVENTS) - assertThat(command.action) - .isEqualTo(SyncAction.RESTART) + verify { syncOrchestrator.executeSchedulingCommand(any()) } + val command = scheduleCommandSlot.captured as ScheduleCommand.EventsCommand + assertThat(command.action).isEqualTo(ScheduleCommand.Action.RESCHEDULE) + assertThat(command.blockWhileUnscheduled).isNotNull() - command.blockToRunWhileStopped?.invoke() + command.blockWhileUnscheduled?.invoke() runCurrent() coVerify { eventSyncManager.resetDownSyncInfo() @@ -182,14 +171,12 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ), ) - verify { sync(any()) } - val command = syncCommandSlot.captured as SyncCommands.ExecutableSyncCommand - assertThat(command.target) - .isEqualTo(SyncTarget.SCHEDULE_EVENTS) - assertThat(command.action) - .isEqualTo(SyncAction.RESTART) + verify { syncOrchestrator.executeSchedulingCommand(any()) } + val command = scheduleCommandSlot.captured as ScheduleCommand.EventsCommand + assertThat(command.action).isEqualTo(ScheduleCommand.Action.RESCHEDULE) + assertThat(command.blockWhileUnscheduled).isNotNull() - command.blockToRunWhileStopped?.invoke() + command.blockWhileUnscheduled?.invoke() runCurrent() coVerify { eventSyncManager.resetDownSyncInfo() @@ -220,14 +207,12 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ), ) - verify { sync(any()) } - val command = syncCommandSlot.captured as SyncCommands.ExecutableSyncCommand - assertThat(command.target) - .isEqualTo(SyncTarget.SCHEDULE_EVENTS) - assertThat(command.action) - .isEqualTo(SyncAction.RESTART) + verify { syncOrchestrator.executeSchedulingCommand(any()) } + val command = scheduleCommandSlot.captured as ScheduleCommand.EventsCommand + assertThat(command.action).isEqualTo(ScheduleCommand.Action.RESCHEDULE) + assertThat(command.blockWhileUnscheduled).isNotNull() - command.blockToRunWhileStopped?.invoke() + command.blockWhileUnscheduled?.invoke() runCurrent() coVerify { eventSyncManager.resetDownSyncInfo() @@ -258,7 +243,7 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ), ) - verify(exactly = 0) { sync(any()) } + verify(exactly = 0) { syncOrchestrator.executeSchedulingCommand(any()) } coVerify(exactly = 0) { eventSyncManager.resetDownSyncInfo() enrolmentRecordRepository.deleteAll() @@ -288,7 +273,7 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ), ) - verify(exactly = 0) { sync(any()) } + verify(exactly = 0) { syncOrchestrator.executeSchedulingCommand(any()) } coVerify(exactly = 0) { eventSyncManager.resetDownSyncInfo() enrolmentRecordRepository.deleteAll() @@ -318,7 +303,7 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ), ) - verify(exactly = 0) { sync(any()) } + verify(exactly = 0) { syncOrchestrator.executeSchedulingCommand(any()) } coVerify(exactly = 0) { eventSyncManager.resetDownSyncInfo() enrolmentRecordRepository.deleteAll() @@ -348,15 +333,12 @@ class ResetLocalRecordsIfConfigChangedUseCaseTest { ), ) - verify(exactly = 0) { sync(any()) } + verify(exactly = 0) { syncOrchestrator.executeSchedulingCommand(any()) } coVerify(exactly = 0) { eventSyncManager.resetDownSyncInfo() enrolmentRecordRepository.deleteAll() } } - private fun noopSyncResponse() = SyncResponse( - syncCommandJob = Job().apply { complete() }, - syncStatusFlow = MutableStateFlow(mockk(relaxed = true)), - ) + private fun noopJob(): Job = Job().apply { complete() } } diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/extensions/JobExtTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/extensions/JobExtTest.kt new file mode 100644 index 0000000000..804284fcd7 --- /dev/null +++ b/infra/sync/src/test/java/com/simprints/infra/sync/extensions/JobExtTest.kt @@ -0,0 +1,61 @@ +package com.simprints.infra.sync.extensions + +import com.google.common.truth.Truth +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class JobExtTest { + @Test + fun `await completes when job completes normally`() = runTest { + val job = Job() + val awaitDeferred = backgroundScope.async { job.await() } + + runCurrent() + Truth.assertThat(awaitDeferred.isCompleted).isFalse() + + job.complete() + runCurrent() + awaitDeferred.await() + Truth.assertThat(awaitDeferred.isCompleted).isTrue() + } + + @Test + fun `await rethrows failure from job`() = runTest { + val job = Job() + val expected = IllegalStateException("ExceptionMessage") + + launch { job.completeExceptionally(expected) } + + val thrown = try { + job.await() + null + } catch (throwable: Throwable) { + throwable + } + Truth.assertThat(thrown).isNotNull() + Truth.assertThat(thrown).isInstanceOf(IllegalStateException::class.java) + Truth.assertThat(thrown!!.message).isEqualTo("ExceptionMessage") + Truth.assertThat(thrown === expected || thrown.cause === expected).isTrue() + } + + @Test + fun `await throws CancellationException when job is cancelled`() = runTest { + val job = Job() + launch { job.cancel() } + + val thrown = try { + job.await() + null + } catch (throwable: Throwable) { + throwable + } + Truth.assertThat(thrown).isInstanceOf(CancellationException::class.java) + } +} diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt deleted file mode 100644 index 70cabf8943..0000000000 --- a/infra/sync/src/test/java/com/simprints/infra/sync/usecase/SyncUseCaseTest.kt +++ /dev/null @@ -1,302 +0,0 @@ -package com.simprints.infra.sync.usecase - -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import com.google.common.truth.Truth.assertThat -import com.simprints.infra.eventsync.status.models.EventSyncState -import com.simprints.infra.eventsync.sync.EventSyncStateProcessor -import com.simprints.infra.sync.ImageSyncStatus -import com.simprints.infra.sync.SyncCommands -import com.simprints.infra.sync.SyncStatus -import com.simprints.infra.sync.usecase.internal.ExecuteSyncCommandUseCase -import com.simprints.infra.sync.usecase.internal.ObserveImageSyncStatusUseCase -import io.mockk.MockKAnnotations -import io.mockk.every -import io.mockk.impl.annotations.MockK -import io.mockk.verify -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.test.runCurrent -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class SyncUseCaseTest { - @get:Rule - val rule = InstantTaskExecutorRule() - - @MockK - private lateinit var eventSyncStateProcessor: EventSyncStateProcessor - - @MockK - private lateinit var imageSync: ObserveImageSyncStatusUseCase - - @MockK - private lateinit var executeSyncCommand: ExecuteSyncCommandUseCase - - private val eventSyncStatusFlow = MutableSharedFlow() - private val imageSyncStatusFlow = MutableSharedFlow() - - @Before - fun setup() { - MockKAnnotations.init(this, relaxed = true) - every { eventSyncStateProcessor.getLastSyncState() } returns eventSyncStatusFlow - every { imageSync.invoke() } returns imageSyncStatusFlow - every { executeSyncCommand.invoke(any(), any()) } returns Job().apply { complete() } - } - - @Test - fun `returns default SyncStatus before upstream flows emit`() = runTest { - val expected = SyncStatus( - eventSyncState = EventSyncState( - syncId = "", - progress = null, - total = null, - upSyncWorkersInfo = emptyList(), - downSyncWorkersInfo = emptyList(), - reporterStates = emptyList(), - lastSyncTime = null, - ), - imageSyncStatus = ImageSyncStatus( - isSyncing = false, - progress = null, - lastUpdateTimeMillis = -1L, - ), - ) - val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, executeSyncCommand, appScope = backgroundScope) - - val resultFlow = useCase(SyncCommands.ObserveOnly).syncStatusFlow - - assertThat(resultFlow.value).isEqualTo(expected) - } - - @Test - fun `combines latest event and image states into SyncStatus`() = runTest { - val event = EventSyncState( - syncId = "sync-1", - progress = 1, - total = 10, - upSyncWorkersInfo = emptyList(), - downSyncWorkersInfo = emptyList(), - reporterStates = emptyList(), - lastSyncTime = null, - ) - val image = ImageSyncStatus( - isSyncing = true, - progress = 2 to 5, - lastUpdateTimeMillis = 123L, - ) - val expected = SyncStatus(event, image) - val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, executeSyncCommand, appScope = backgroundScope) - - val resultFlow = useCase(SyncCommands.ObserveOnly).syncStatusFlow - - runCurrent() // ensure upstream flows are collected before emitting - eventSyncStatusFlow.emit(event) - imageSyncStatusFlow.emit(image) - runCurrent() - - assertThat(resultFlow.value).isEqualTo(expected) - } - - @Test - fun `updates SyncStatus when event emits even if image never emits`() = runTest { - val event = EventSyncState( - syncId = "sync-1", - progress = 1, - total = 10, - upSyncWorkersInfo = emptyList(), - downSyncWorkersInfo = emptyList(), - reporterStates = emptyList(), - lastSyncTime = null, - ) - val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, executeSyncCommand, appScope = backgroundScope) - - val resultFlow = useCase(SyncCommands.ObserveOnly).syncStatusFlow - - runCurrent() - val expected = with(resultFlow.value) { - copy(eventSyncState = event) - } - eventSyncStatusFlow.emit(event) - runCurrent() - - assertThat(resultFlow.value).isEqualTo(expected) - } - - @Test - fun `updates SyncStatus when image emits even if event never emits`() = runTest { - val image = ImageSyncStatus( - isSyncing = true, - progress = 2 to 5, - lastUpdateTimeMillis = 123L, - ) - val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, executeSyncCommand, appScope = backgroundScope) - - val resultFlow = useCase(SyncCommands.ObserveOnly).syncStatusFlow - - runCurrent() - val expected = with(resultFlow.value) { - copy(imageSyncStatus = image) - } - imageSyncStatusFlow.emit(image) - runCurrent() - - assertThat(resultFlow.value).isEqualTo(expected) - } - - @Test - fun `updates SyncStatus when event sync state changes`() = runTest { - val event1 = EventSyncState( - syncId = "sync-1", - progress = 1, - total = 10, - upSyncWorkersInfo = emptyList(), - downSyncWorkersInfo = emptyList(), - reporterStates = emptyList(), - lastSyncTime = null, - ) - val event2 = event1.copy( - progress = 5, - ) - val image = ImageSyncStatus( - isSyncing = true, - progress = 2 to 5, - lastUpdateTimeMillis = 123L, - ) - val expected1 = SyncStatus(event1, image) - val expected2 = SyncStatus(event2, image) - val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, executeSyncCommand, appScope = backgroundScope) - - val resultFlow = useCase(SyncCommands.ObserveOnly).syncStatusFlow - - runCurrent() - eventSyncStatusFlow.emit(event1) - imageSyncStatusFlow.emit(image) - runCurrent() - - assertThat(resultFlow.value).isEqualTo(expected1) - - eventSyncStatusFlow.emit(event2) - runCurrent() - - assertThat(resultFlow.value).isEqualTo(expected2) - } - - @Test - fun `updates SyncStatus when image sync status changes`() = runTest { - val event = EventSyncState( - syncId = "sync-1", - progress = 1, - total = 10, - upSyncWorkersInfo = emptyList(), - downSyncWorkersInfo = emptyList(), - reporterStates = emptyList(), - lastSyncTime = null, - ) - val image1 = ImageSyncStatus( - isSyncing = true, - progress = 2 to 5, - lastUpdateTimeMillis = 123L, - ) - val image2 = image1.copy( - isSyncing = false, - ) - val expected1 = SyncStatus(event, image1) - val expected2 = SyncStatus(event, image2) - val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, executeSyncCommand, appScope = backgroundScope) - - val resultFlow = useCase(SyncCommands.ObserveOnly).syncStatusFlow - - runCurrent() - eventSyncStatusFlow.emit(event) - imageSyncStatusFlow.emit(image1) - runCurrent() - - assertThat(resultFlow.value).isEqualTo(expected1) - - imageSyncStatusFlow.emit(image2) - runCurrent() - - assertThat(resultFlow.value).isEqualTo(expected2) - } - - @Test - fun `returns the same shared StateFlow across invocations`() = runTest { - val event = EventSyncState( - syncId = "sync-1", - progress = 1, - total = 10, - upSyncWorkersInfo = emptyList(), - downSyncWorkersInfo = emptyList(), - reporterStates = emptyList(), - lastSyncTime = null, - ) - val image1 = ImageSyncStatus( - isSyncing = true, - progress = 2 to 5, - lastUpdateTimeMillis = 123L, - ) - val image2 = image1.copy( - isSyncing = false, - ) - val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, executeSyncCommand, appScope = backgroundScope) - - val resultFlow1 = useCase(SyncCommands.ObserveOnly).syncStatusFlow - - runCurrent() - eventSyncStatusFlow.emit(event) - imageSyncStatusFlow.emit(image1) - runCurrent() - - imageSyncStatusFlow.emit(image2) - runCurrent() - - val resultFlow2 = useCase(SyncCommands.ObserveOnly).syncStatusFlow - - assertThat(resultFlow1).isSameInstanceAs(resultFlow2) - verify(exactly = 1) { eventSyncStateProcessor.getLastSyncState() } - verify(exactly = 1) { imageSync() } - } - - @Test - fun `does not execute sync command for observe-only`() = runTest { - val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, executeSyncCommand, appScope = backgroundScope) - - val response = useCase(SyncCommands.ObserveOnly) - - assertThat(response.syncCommandJob.isCompleted).isTrue() - verify(exactly = 0) { executeSyncCommand.invoke(any(), any()) } - } - - @Test - fun `executes executable sync command and returns its job`() = runTest { - val expectedJob = Job().apply { complete() } - val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, executeSyncCommand, appScope = backgroundScope) - val command = SyncCommands.ScheduleOf.Everything.restart() as SyncCommands.ExecutableSyncCommand - every { executeSyncCommand.invoke(command, backgroundScope) } returns expectedJob - - val response = useCase(command) - - assertThat(response.syncCommandJob).isSameInstanceAs(expectedJob) - verify(exactly = 1) { executeSyncCommand.invoke(command, backgroundScope) } - } - - @Test - fun `executes executable sync command using provided commandScope`() = runTest { - val expectedJob = Job().apply { complete() } - val customScope = CoroutineScope(backgroundScope.coroutineContext + Job()) - val useCase = SyncUseCase(eventSyncStateProcessor, imageSync, executeSyncCommand, appScope = backgroundScope) - val command = SyncCommands.ScheduleOf.Everything.restart() as SyncCommands.ExecutableSyncCommand - - every { executeSyncCommand.invoke(command, customScope) } returns expectedJob - - val response = useCase(command, commandScope = customScope) - - assertThat(response.syncCommandJob).isSameInstanceAs(expectedJob) - verify(exactly = 1) { executeSyncCommand.invoke(command, customScope) } - } -}