From 9ceeaf5049bba65d6dd34580df90dd852316a79c Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Mon, 8 Dec 2025 09:54:57 +0200 Subject: [PATCH 1/4] MS-1148 Simplify ticker helper --- .../java/com/simprints/core/CoreModule.kt | 6 ---- .../com/simprints/core/tools/time/Ticker.kt | 15 ++++++++-- .../simprints/core/tools/time/TickerImpl.kt | 19 ------------- .../time/{TickerImplTest.kt => TickerTest.kt} | 28 +++++++++---------- 4 files changed, 26 insertions(+), 42 deletions(-) delete mode 100644 infra/core/src/main/java/com/simprints/core/tools/time/TickerImpl.kt rename infra/core/src/test/java/com/simprints/core/tools/time/{TickerImplTest.kt => TickerTest.kt} (66%) diff --git a/infra/core/src/main/java/com/simprints/core/CoreModule.kt b/infra/core/src/main/java/com/simprints/core/CoreModule.kt index 9d854f381f..d2dc20408f 100644 --- a/infra/core/src/main/java/com/simprints/core/CoreModule.kt +++ b/infra/core/src/main/java/com/simprints/core/CoreModule.kt @@ -9,8 +9,6 @@ import com.simprints.core.tools.extentions.packageVersionName import com.simprints.core.tools.json.JsonHelper import com.simprints.core.tools.time.KronosTimeHelperImpl import com.simprints.core.tools.time.TimeHelper -import com.simprints.core.tools.time.Ticker -import com.simprints.core.tools.time.TickerImpl import com.simprints.core.tools.utils.EncodingUtils import com.simprints.core.tools.utils.EncodingUtilsImpl import com.simprints.core.tools.utils.SimNetworkUtils @@ -47,10 +45,6 @@ object CoreModule { ), ) - @Provides - @Singleton - fun provideTicker(): Ticker = TickerImpl() - @Provides @Singleton fun provideSimNetworkUtils( diff --git a/infra/core/src/main/java/com/simprints/core/tools/time/Ticker.kt b/infra/core/src/main/java/com/simprints/core/tools/time/Ticker.kt index 0a2802a16c..7bd6ff99e6 100644 --- a/infra/core/src/main/java/com/simprints/core/tools/time/Ticker.kt +++ b/infra/core/src/main/java/com/simprints/core/tools/time/Ticker.kt @@ -1,9 +1,20 @@ package com.simprints.core.tools.time import androidx.annotation.Keep +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration @Keep -interface Ticker { - fun observeTickOncePerMinute(): Flow +@Singleton +class Ticker @Inject constructor() { + fun observeTicks(interval: Duration): Flow = flow { + while (true) { + emit(Unit) + delay(interval) + } + } } diff --git a/infra/core/src/main/java/com/simprints/core/tools/time/TickerImpl.kt b/infra/core/src/main/java/com/simprints/core/tools/time/TickerImpl.kt deleted file mode 100644 index 7fb8b4eea5..0000000000 --- a/infra/core/src/main/java/com/simprints/core/tools/time/TickerImpl.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.simprints.core.tools.time - -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import javax.inject.Inject - -class TickerImpl @Inject constructor() : Ticker { - override fun observeTickOncePerMinute(): Flow = flow { - while (true) { - emit(Unit) - delay(ONE_MINUTE_IN_MILLIS) - } - } - - private companion object { - private const val ONE_MINUTE_IN_MILLIS = 60 * 1000L - } -} diff --git a/infra/core/src/test/java/com/simprints/core/tools/time/TickerImplTest.kt b/infra/core/src/test/java/com/simprints/core/tools/time/TickerTest.kt similarity index 66% rename from infra/core/src/test/java/com/simprints/core/tools/time/TickerImplTest.kt rename to infra/core/src/test/java/com/simprints/core/tools/time/TickerTest.kt index 0287fa191d..9b89877fcc 100644 --- a/infra/core/src/test/java/com/simprints/core/tools/time/TickerImplTest.kt +++ b/infra/core/src/test/java/com/simprints/core/tools/time/TickerTest.kt @@ -1,8 +1,7 @@ package com.simprints.core.tools.time -import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.* import com.simprints.testtools.common.coroutines.TestCoroutineRule -import io.mockk.MockKAnnotations import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.drop @@ -14,24 +13,23 @@ import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test +import kotlin.time.Duration.Companion.minutes -class TickerImplTest { +class TickerTest { @get:Rule val testCoroutineRule = TestCoroutineRule() - private lateinit var tickerImpl: TickerImpl + private lateinit var ticker: Ticker @Before fun setUp() { - MockKAnnotations.init(this, relaxed = true) - - tickerImpl = TickerImpl() + ticker = Ticker() } @Test - fun testObserveTickOncePerMinute_emitsImmediately() = runTest { - val result = tickerImpl - .observeTickOncePerMinute() + fun testObserveTicks_emitsImmediately() = runTest { + val result = ticker + .observeTicks(1.minutes) .take(1) .toList() @@ -40,9 +38,9 @@ class TickerImplTest { } @Test - fun testObserveTickOncePerMinute_emitsMultipleTimes() = runTest { - val result = tickerImpl - .observeTickOncePerMinute() + fun testObserveTicks_emitsMultipleTimes() = runTest { + val result = ticker + .observeTicks(1.minutes) .take(3) .toList() @@ -51,8 +49,8 @@ class TickerImplTest { @OptIn(ExperimentalCoroutinesApi::class) @Test - fun testObserveTickOncePerMinute_waitsForCorrectTime() = runTest { - val flow = tickerImpl.observeTickOncePerMinute() + fun testObserveTicks_waitsForCorrectTime() = runTest { + val flow = ticker.observeTicks(1.minutes) // 1st tick immediately assertThat(flow.first()).isEqualTo(Unit) From 8722ef107fabc7820903c01685a21c8f929b7982 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Mon, 8 Dec 2025 11:47:47 +0200 Subject: [PATCH 2/4] MS-1148 Extract configuration observation out of the full sync state use case --- .../ObserveConfigurationChangesUseCase.kt | 62 ++++ .../usecase/ObserveSyncInfoUseCase.kt | 147 ++++------ .../ObserveConfigurationChangesUseCaseTest.kt | 106 +++++++ .../usecase/ObserveSyncInfoUseCaseTest.kt | 275 ++++++------------ .../lifecycle/AppForegroundStateTracker.kt | 11 +- .../core/tools/extentions/Flow.ext.kt | 26 -- .../AppForegroundStateTrackerTest.kt | 2 +- .../core/tools/extentions/FlowExtTest.kt | 21 +- 8 files changed, 322 insertions(+), 328 deletions(-) create mode 100644 feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveConfigurationChangesUseCase.kt create mode 100644 feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveConfigurationChangesUseCaseTest.kt diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveConfigurationChangesUseCase.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveConfigurationChangesUseCase.kt new file mode 100644 index 0000000000..0d1d72ed74 --- /dev/null +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveConfigurationChangesUseCase.kt @@ -0,0 +1,62 @@ +package com.simprints.feature.dashboard.settings.syncinfo.usecase + +import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.feature.dashboard.settings.syncinfo.modulecount.ModuleCount +import com.simprints.infra.config.store.models.ProjectConfiguration +import com.simprints.infra.config.store.models.ProjectState +import com.simprints.infra.config.store.models.TokenKeyType +import com.simprints.infra.config.store.tokenization.TokenizationProcessor +import com.simprints.infra.config.sync.ConfigManager +import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository +import com.simprints.infra.enrolment.records.repository.domain.models.SubjectQuery +import kotlinx.coroutines.flow.combine +import javax.inject.Inject + +internal class ObserveConfigurationChangesUseCase @Inject constructor( + private val configManager: ConfigManager, + private val tokenizationProcessor: TokenizationProcessor, + private val enrolmentRecordRepository: EnrolmentRecordRepository, +) { + operator fun invoke() = combine( + configManager.observeIsProjectRefreshing(), + configManager.observeProjectConfiguration(), + configManager.observeDeviceConfiguration(), + ) { isRefreshing, projectConfig, deviceConfig -> + val project = configManager.getProject() + + val moduleCounts = if (project != null) { + deviceConfig.selectedModules.map { moduleName -> + ModuleCount( + name = when (moduleName) { + is TokenizableString.Raw -> moduleName + + is TokenizableString.Tokenized -> tokenizationProcessor.decrypt( + encrypted = moduleName, + tokenKeyType = TokenKeyType.ModuleId, + project, + ) + }.value, + count = enrolmentRecordRepository.count( + SubjectQuery(projectId = project.id, moduleId = moduleName), + ), + ) + } + } else { + emptyList() + } + + ConfigurationState( + isRefreshing = isRefreshing, + isProjectRunning = project?.state == ProjectState.RUNNING, + selectedModules = moduleCounts, + projectConfig = projectConfig, + ) + } +} + +internal data class ConfigurationState( + val isRefreshing: Boolean, + val isProjectRunning: Boolean, + val selectedModules: List, + val projectConfig: ProjectConfiguration, +) 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 c56fdcf8b4..f98d1ec8af 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 @@ -2,10 +2,7 @@ package com.simprints.feature.dashboard.settings.syncinfo.usecase import androidx.lifecycle.asFlow import com.simprints.core.DispatcherIO -import com.simprints.core.DispatcherMain -import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.core.lifecycle.AppForegroundStateTracker -import com.simprints.core.tools.extentions.combine9 import com.simprints.core.tools.extentions.onChange import com.simprints.core.tools.time.Ticker import com.simprints.core.tools.time.TimeHelper @@ -18,17 +15,12 @@ import com.simprints.feature.dashboard.settings.syncinfo.SyncInfoProgressPart import com.simprints.feature.dashboard.settings.syncinfo.SyncInfoSectionImages import com.simprints.feature.dashboard.settings.syncinfo.SyncInfoSectionModules import com.simprints.feature.dashboard.settings.syncinfo.SyncInfoSectionRecords -import com.simprints.feature.dashboard.settings.syncinfo.modulecount.ModuleCount import com.simprints.infra.authstore.AuthStore -import com.simprints.infra.config.store.models.ProjectState -import com.simprints.infra.config.store.models.TokenKeyType import com.simprints.infra.config.store.models.canSyncDataToSimprints import com.simprints.infra.config.store.models.isCommCareEventDownSyncAllowed import com.simprints.infra.config.store.models.isModuleSelectionAvailable import com.simprints.infra.config.store.models.isSampleUploadEnabledInProject import com.simprints.infra.config.store.models.isSimprintsEventDownSyncAllowed -import com.simprints.infra.config.store.tokenization.TokenizationProcessor -import com.simprints.infra.config.sync.ConfigManager import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository import com.simprints.infra.enrolment.records.repository.domain.models.SubjectQuery import com.simprints.infra.events.event.domain.models.EventType @@ -41,75 +33,83 @@ import com.simprints.infra.sync.SyncOrchestrator import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.withTimeout import javax.inject.Inject import kotlin.math.roundToInt +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes internal class ObserveSyncInfoUseCase @Inject constructor( - private val configManager: ConfigManager, private val connectivityTracker: ConnectivityTracker, private val enrolmentRecordRepository: EnrolmentRecordRepository, private val authStore: AuthStore, private val imageRepository: ImageRepository, private val eventSyncManager: EventSyncManager, syncOrchestrator: SyncOrchestrator, - private val tokenizationProcessor: TokenizationProcessor, private val timeHelper: TimeHelper, private val ticker: Ticker, private val appForegroundStateTracker: AppForegroundStateTracker, private val commCarePermissionChecker: CommCarePermissionChecker, - @DispatcherMain private val mainDispatcher: CoroutineDispatcher, + private val observeConfigurationFlow: ObserveConfigurationChangesUseCase, @DispatcherIO private val ioDispatcher: CoroutineDispatcher, ) { - private val eventSyncStateFlow = - eventSyncManager - .getLastSyncState( - useDefaultValue = true, // otherwise value not guaranteed - ).asFlow() - private val imageSyncStatusFlow = - syncOrchestrator.observeImageSyncStatus() - - operator fun invoke(isPreLogoutUpSync: Boolean = false): Flow = combine9( + private val eventSyncStateFlow = eventSyncManager + .getLastSyncState(useDefaultValue = true) // otherwise value not guaranteed + .asFlow() + + private val imageSyncStatusFlow = syncOrchestrator.observeImageSyncStatus() + + // Since we are not using distinctUntilChanged any emission from combined flows will trigger the main flow as well + private fun combinedRefreshSignals() = combine( connectivityTracker.observeIsConnected().asFlow(), - authStore.observeSignedInProjectId().map(String::isNotEmpty), - configManager.observeIsProjectRefreshing(), + appForegroundStateTracker.observeAppInForeground().filter { it }, // only when going to foreground + ticker.observeTicks(1.minutes), + ) { isOnline, _, _ -> isOnline } + + operator fun invoke(isPreLogoutUpSync: Boolean = false): Flow = combine( + combinedRefreshSignals(), + authStore.observeSignedInProjectId(), eventSyncStateFlow, imageSyncStatusFlow, - configManager.observeProjectConfiguration(), - configManager.observeDeviceConfiguration(), - appForegroundStateTracker - .observeAppInForeground() - .filter { - it // only when going to foreground - }.flowOn(mainDispatcher), // runs in main thread by design - ticker.observeTickOncePerMinute(), - ) { isOnline, isLoggedIn, isRefreshing, eventSyncState, imageSyncStatus, projectConfig, deviceConfig, _, _ -> + observeConfigurationFlow(), + ) { isOnline, projectId, eventSyncState, imageSyncStatus, (isRefreshing, isProjectRunning, moduleCounts, projectConfig) -> val currentEvents = eventSyncState.progress?.coerceAtLeast(0) ?: 0 val totalEvents = eventSyncState.total?.takeIf { it >= 1 } ?: 0 val currentImages = imageSyncStatus.progress?.first?.coerceAtLeast(0) ?: 0 val totalImages = imageSyncStatus.progress?.second?.takeIf { it >= 1 } ?: 0 val eventsNormalizedProgress = when { - isPreLogoutUpSync && eventSyncState.isSyncCompleted() && totalImages > 0 -> - (0.5f + 0.5f * currentImages / totalImages).coerceIn(0.5f, 1f) // combined progress 2nd half - images + isPreLogoutUpSync && eventSyncState.isSyncCompleted() && totalImages > 0 -> { + (0.5f + 0.5f * currentImages / totalImages).coerceIn(0.5f, 1f) + } + + // combined progress 2nd half - images + + isPreLogoutUpSync && eventSyncState.isSyncInProgress() && totalEvents > 0 -> { + (0.5f * currentEvents / totalEvents).coerceIn(0f, 0.5f) + } - isPreLogoutUpSync && eventSyncState.isSyncInProgress() && totalEvents > 0 -> - (0.5f * currentEvents / totalEvents).coerceIn(0f, 0.5f) // combined progress 1st half - events + // combined progress 1st half - events - eventSyncState.isSyncInProgress() && totalEvents > 0 -> + eventSyncState.isSyncInProgress() && totalEvents > 0 -> { (currentEvents.toFloat() / totalEvents).coerceIn(0f, 1f) + } - eventSyncState.isSyncConnecting() || eventSyncState.isThereNotSyncHistory() -> 0f - else -> 1f + eventSyncState.isSyncConnecting() || eventSyncState.isThereNotSyncHistory() -> { + 0f + } + + else -> { + 1f + } } val imagesNormalizedProgress = when { - imageSyncStatus.isSyncing && totalImages > 0 -> - (currentImages.toFloat() / totalImages).coerceIn(0f, 1f) - + imageSyncStatus.isSyncing && totalImages > 0 -> (currentImages.toFloat() / totalImages).coerceIn(0f, 1f) else -> 1f } @@ -117,7 +117,7 @@ internal class ObserveSyncInfoUseCase @Inject constructor( if (imageSyncStatus.isSyncing) { null } else { - imageRepository.getNumberOfImagesToUpload(projectId = authStore.signedInProjectId) + imageRepository.getNumberOfImagesToUpload(projectId = projectId) } val eventSyncProgressPart = SyncInfoProgressPart( @@ -160,14 +160,12 @@ internal class ObserveSyncInfoUseCase @Inject constructor( } val eventLastSyncTimestamp = eventSyncManager.getLastSyncTime() ?: Timestamp(-1) - val imageLastSyncTimestamp = imageSyncStatus.lastUpdateTimeMillis?.let { - Timestamp(it) - } ?: Timestamp(-1) + val imageLastSyncTimestamp = Timestamp(imageSyncStatus.lastUpdateTimeMillis ?: -1) val isReLoginRequired = eventSyncState.isSyncFailedBecauseReloginRequired() val isModuleSelectionRequired = - !isPreLogoutUpSync && projectConfig.isModuleSelectionAvailable() && deviceConfig.selectedModules.isEmpty() + !isPreLogoutUpSync && projectConfig.isModuleSelectionAvailable() && moduleCounts.isEmpty() val isCommCareSyncExpected = !isPreLogoutUpSync && projectConfig.isCommCareEventDownSyncAllowed() @@ -206,24 +204,23 @@ internal class ObserveSyncInfoUseCase @Inject constructor( (eventSyncVisibleState == OnStandby || eventSyncVisibleState == Error) && ((!isPreLogoutUpSync && isDownSyncPossible) || isEventUpSyncPossible) - val projectId = authStore.signedInProjectId - val recordsTotal = when { isEventSyncInProgress -> null - projectId.isBlank() -> null // without project ID, repository access attempts will throw an exception + + projectId.isBlank() -> null + + // without project ID, repository access attempts will throw an exception else -> enrolmentRecordRepository.count(SubjectQuery(projectId)) } val recordsToUpload = when { isEventSyncInProgress -> null - else -> - eventSyncManager - .countEventsToUpload( - listOf(EventType.ENROLMENT_V2, EventType.ENROLMENT_V4), - ).firstOrNull() ?: 0 + else -> eventSyncManager.countEventsToUpload(listOf(EventType.ENROLMENT_V2, EventType.ENROLMENT_V4)).firstOrNull() ?: 0 } val recordsToDownload = when { isEventSyncInProgress -> null + isPreLogoutUpSync -> null + projectConfig.isSimprintsEventDownSyncAllowed() -> try { withTimeout(COUNT_EVENTS_TIMEOUT_MILLIS) { countEventsToDownloadWithCaching() @@ -235,27 +232,6 @@ internal class ObserveSyncInfoUseCase @Inject constructor( else -> DownSyncCounts(0, isLowerBound = false) } - val project = configManager.getProject() - val isProjectRunning = project?.state == ProjectState.RUNNING - val moduleCounts = if (project != null) { - deviceConfig.selectedModules.map { moduleName -> - ModuleCount( - name = when (moduleName) { - is TokenizableString.Raw -> moduleName - is TokenizableString.Tokenized -> tokenizationProcessor.decrypt( - encrypted = moduleName, - tokenKeyType = TokenKeyType.ModuleId, - project, - ) - }.value, - count = enrolmentRecordRepository.count( - SubjectQuery(projectId = projectId, moduleId = moduleName), - ), - ) - } - } else { - emptyList() - } val modulesCountTotal = SyncInfoModuleCount( isTotal = true, name = "", @@ -315,7 +291,7 @@ internal class ObserveSyncInfoUseCase @Inject constructor( ) val syncInfo = SyncInfo( - isLoggedIn = isLoggedIn, + isLoggedIn = projectId.isNotEmpty(), isConfigurationLoadingProgressBarVisible = isRefreshing, isLoginPromptSectionVisible = isReLoginRequired && !isPreLogoutUpSync, isImageSyncSectionVisible = projectConfig.isSampleUploadEnabledInProject(), @@ -323,12 +299,10 @@ internal class ObserveSyncInfoUseCase @Inject constructor( syncInfoSectionImages = syncInfoSectionImages, syncInfoSectionModules = syncInfoSectionModules, ) - return@combine9 syncInfo - }.onRecordSyncComplete { - delay(timeMillis = SYNC_COMPLETION_HOLD_MILLIS) - }.onImageSyncComplete { - delay(timeMillis = SYNC_COMPLETION_HOLD_MILLIS) - }.flowOn(ioDispatcher) // upstream flows mostly do IO work + return@combine syncInfo + }.onRecordSyncComplete { delay(timeMillis = SYNC_COMPLETION_HOLD_MILLIS) } + .onImageSyncComplete { delay(timeMillis = SYNC_COMPLETION_HOLD_MILLIS) } + .flowOn(ioDispatcher) // upstream flows mostly do IO work // sync info change detection helpers @@ -354,11 +328,8 @@ internal class ObserveSyncInfoUseCase @Inject constructor( private suspend fun countEventsToDownloadWithCaching(): DownSyncCounts { val timeNowMs = timeHelper.now().ms cachedEventCountToDownload - ?.takeIf { - timeNowMs - cachedEventCountToDownloadTimestamp < COUNT_EVENTS_CACHE_LIFESPAN_MILLIS - }?.let { - return it - } + ?.takeIf { timeNowMs - cachedEventCountToDownloadTimestamp < COUNT_EVENTS_CACHE_LIFESPAN_MILLIS } + ?.let { return it } cachedEventCountToDownloadTimestamp = timeNowMs return eventSyncManager.countEventsToDownload().also { cachedEventCountToDownload = it diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveConfigurationChangesUseCaseTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveConfigurationChangesUseCaseTest.kt new file mode 100644 index 0000000000..e29716398f --- /dev/null +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveConfigurationChangesUseCaseTest.kt @@ -0,0 +1,106 @@ +package com.simprints.feature.dashboard.settings.syncinfo.usecase + +import com.google.common.truth.Truth.* +import com.simprints.core.domain.tokenization.asTokenizableEncrypted +import com.simprints.core.domain.tokenization.asTokenizableRaw +import com.simprints.feature.dashboard.settings.syncinfo.modulecount.ModuleCount +import com.simprints.infra.config.store.models.DeviceConfiguration +import com.simprints.infra.config.store.models.Project +import com.simprints.infra.config.store.models.ProjectConfiguration +import com.simprints.infra.config.store.models.ProjectState +import com.simprints.infra.config.store.tokenization.TokenizationProcessor +import com.simprints.infra.config.sync.ConfigManager +import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository +import io.mockk.* +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class ObserveConfigurationChangesUseCaseTest { + @MockK + private lateinit var configManager: ConfigManager + + @MockK + private lateinit var tokenizationProcessor: TokenizationProcessor + + @MockK + private lateinit var enrolmentRepository: EnrolmentRecordRepository + + @MockK + private lateinit var project: Project + + @MockK + private lateinit var projectConfiguration: ProjectConfiguration + + @MockK + private lateinit var deviceConfiguration: DeviceConfiguration + + private lateinit var useCase: ObserveConfigurationChangesUseCase + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + + useCase = ObserveConfigurationChangesUseCase( + configManager = configManager, + tokenizationProcessor = tokenizationProcessor, + enrolmentRecordRepository = enrolmentRepository, + ) + } + + @Test + fun `returns combined state`() = runTest { + coEvery { configManager.getProject() } returns null + every { configManager.observeIsProjectRefreshing() } returns flowOf(true) + every { configManager.observeProjectConfiguration() } returns flowOf(projectConfiguration) + every { configManager.observeDeviceConfiguration() } returns flowOf(deviceConfiguration) + + val result = useCase().first() + + assertThat(result.isRefreshing).isTrue() + assertThat(result.isProjectRunning).isFalse() + } + + @Test + fun `returns combined state on multiple emissions of combined flow`() = runTest { + coEvery { configManager.getProject() } returns null + every { configManager.observeIsProjectRefreshing() } returns flowOf(true, false) + every { configManager.observeProjectConfiguration() } returns flowOf(projectConfiguration) + every { configManager.observeDeviceConfiguration() } returns flowOf(deviceConfiguration) + + val result = useCase().toList() + + assertThat(result.first().isRefreshing).isTrue() + assertThat(result.last().isRefreshing).isFalse() + } + + @Test + fun `invoke untokenized list of modules`() = runTest { + coEvery { configManager.getProject() } returns project + every { project.id } returns "projectId" + every { project.state } returns ProjectState.RUNNING + every { deviceConfiguration.selectedModules } returns listOf( + "moduleRaw".asTokenizableRaw(), + "moduleToken".asTokenizableEncrypted(), + ) + every { + tokenizationProcessor.decrypt(any(), any(), any()) + } returns "moduleUntokenized".asTokenizableRaw() + coEvery { enrolmentRepository.count(any(), any()) } returnsMany listOf(1, 2) + + every { configManager.observeIsProjectRefreshing() } returns flowOf(true) + every { configManager.observeProjectConfiguration() } returns flowOf(projectConfiguration) + every { configManager.observeDeviceConfiguration() } returns flowOf(deviceConfiguration) + + val result = useCase().first() + + assertThat(result.selectedModules).containsExactly( + ModuleCount("moduleRaw", 1), + ModuleCount("moduleUntokenized", 2), + ) + } +} 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 9a55f73a5d..ddf75b49ac 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 @@ -11,23 +11,19 @@ import com.simprints.core.tools.time.Ticker import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.time.Timestamp import com.simprints.feature.dashboard.settings.syncinfo.SyncInfoModuleCount +import com.simprints.feature.dashboard.settings.syncinfo.modulecount.ModuleCount import com.simprints.infra.authstore.AuthStore import com.simprints.infra.config.store.models.DeviceConfiguration import com.simprints.infra.config.store.models.DownSynchronizationConfiguration import com.simprints.infra.config.store.models.GeneralConfiguration -import com.simprints.infra.config.store.models.Project import com.simprints.infra.config.store.models.ProjectConfiguration -import com.simprints.infra.config.store.models.ProjectState import com.simprints.infra.config.store.models.SynchronizationConfiguration -import com.simprints.infra.config.store.models.TokenKeyType import com.simprints.infra.config.store.models.UpSynchronizationConfiguration import com.simprints.infra.config.store.models.canSyncDataToSimprints import com.simprints.infra.config.store.models.isCommCareEventDownSyncAllowed import com.simprints.infra.config.store.models.isModuleSelectionAvailable import com.simprints.infra.config.store.models.isSampleUploadEnabledInProject import com.simprints.infra.config.store.models.isSimprintsEventDownSyncAllowed -import com.simprints.infra.config.store.tokenization.TokenizationProcessor -import com.simprints.infra.config.sync.ConfigManager import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository import com.simprints.infra.eventsync.EventSyncManager import com.simprints.infra.eventsync.permission.CommCarePermissionChecker @@ -55,18 +51,17 @@ class ObserveSyncInfoUseCaseTest { @get:Rule val testCoroutineRule = TestCoroutineRule() - private val configManager = mockk() private val connectivityTracker = mockk() private val enrolmentRecordRepository = mockk() private val authStore = mockk() private val imageRepository = mockk() private val eventSyncManager = mockk() private val syncOrchestrator = mockk() - private val tokenizationProcessor = mockk() private val timeHelper = mockk() private val ticker = mockk() private val appForegroundStateTracker = mockk() private val commCarePermissionChecker = mockk() + private val observeConfigurationFlow = mockk() private lateinit var useCase: ObserveSyncInfoUseCase @@ -94,12 +89,7 @@ class ObserveSyncInfoUseCaseTest { } every { synchronization } returns createMockSynchronizationConfiguration() } - private val mockDeviceConfiguration = mockk(relaxed = true) { - every { selectedModules } returns emptyList() - } - private val mockProject = mockk(relaxed = true) { - every { state } returns ProjectState.RUNNING - } + private val mockEventSyncState = mockk(relaxed = true) { every { isSyncCompleted() } returns false every { isSyncInProgress() } returns false @@ -132,21 +122,12 @@ class ObserveSyncInfoUseCaseTest { } private fun setupDefaultMocks() { - every { authStore.signedInProjectId } returns TEST_PROJECT_ID - every { authStore.signedInUserId } returns TokenizableString.Raw(TEST_USER_ID) every { authStore.observeSignedInProjectId() } returns MutableStateFlow(TEST_PROJECT_ID) val connectivityLiveData = MutableLiveData(true) every { connectivityTracker.observeIsConnected() } returns connectivityLiveData every { connectivityLiveData.asFlow() } returns flowOf(true) - every { configManager.observeIsProjectRefreshing() } returns MutableStateFlow(false) - every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfiguration) - every { configManager.observeDeviceConfiguration() } returns MutableStateFlow(mockDeviceConfiguration) - coEvery { configManager.getProjectConfiguration() } returns mockProjectConfiguration - coEvery { configManager.getDeviceConfiguration() } returns mockDeviceConfiguration - coEvery { configManager.getProject() } returns mockProject - val eventSyncLiveData = MutableLiveData(mockEventSyncState) every { eventSyncManager.getLastSyncState() } returns eventSyncLiveData every { eventSyncManager.getLastSyncState(any()) } returns eventSyncLiveData @@ -164,15 +145,15 @@ class ObserveSyncInfoUseCaseTest { coEvery { imageRepository.getNumberOfImagesToUpload(any()) } returns 0 coEvery { enrolmentRecordRepository.count(any()) } returns 0 - every { ticker.observeTickOncePerMinute() } returns MutableStateFlow(Unit) + every { ticker.observeTicks(any()) } returns MutableStateFlow(Unit) every { timeHelper.now() } returns TEST_TIMESTAMP every { timeHelper.msBetweenNowAndTime(any()) } returns 0L every { timeHelper.readableBetweenNowAndTime(any()) } returns "0 minutes ago" - every { tokenizationProcessor.decrypt(any(), any(), any()) } returns TokenizableString.Raw("decrypted_module") - every { appForegroundStateTracker.observeAppInForeground() } returns flowOf(true) + every { observeConfigurationFlow.invoke() } returns flowOf(createConfigurationState()) + every { any().isModuleSelectionAvailable() } returns false every { any().isSimprintsEventDownSyncAllowed() } returns true every { any().isCommCareEventDownSyncAllowed() } returns false @@ -182,20 +163,18 @@ class ObserveSyncInfoUseCaseTest { private fun createUseCase() { useCase = ObserveSyncInfoUseCase( - configManager = configManager, connectivityTracker = connectivityTracker, enrolmentRecordRepository = enrolmentRecordRepository, authStore = authStore, imageRepository = imageRepository, eventSyncManager = eventSyncManager, syncOrchestrator = syncOrchestrator, - tokenizationProcessor = tokenizationProcessor, timeHelper = timeHelper, ticker = ticker, appForegroundStateTracker = appForegroundStateTracker, commCarePermissionChecker = commCarePermissionChecker, + observeConfigurationFlow = observeConfigurationFlow, ioDispatcher = testCoroutineRule.testCoroutineDispatcher, - mainDispatcher = testCoroutineRule.testCoroutineDispatcher, ) } @@ -214,7 +193,7 @@ class ObserveSyncInfoUseCaseTest { @Test fun `should show configuration loading when project is refreshing`() = runTest { - every { configManager.observeIsProjectRefreshing() } returns MutableStateFlow(true) + every { observeConfigurationFlow.invoke() } returns flowOf(createConfigurationState(isRefreshing = true)) createUseCase() val result = useCase().first() @@ -224,6 +203,7 @@ class ObserveSyncInfoUseCaseTest { @Test fun `should show re-login prompt when sync failed due to authentication required`() = runTest { + every { observeConfigurationFlow.invoke() } returns flowOf(createConfigurationState()) val mockFailedEventSyncState = mockk(relaxed = true) { every { isSyncFailedBecauseReloginRequired() } returns true } @@ -251,34 +231,8 @@ class ObserveSyncInfoUseCaseTest { } @Test - fun `should handle paused project state correctly in sync info`() = runTest { - val mockPausedProject = mockk { - every { state } returns ProjectState.PROJECT_PAUSED - } - coEvery { configManager.getProject() } returns mockPausedProject - createUseCase() - - val result = useCase().first() - - assertThat(result.syncInfoSectionRecords.isCounterRecordsToDownloadVisible).isFalse() - } - - @Test - fun `should handle ending project state correctly in sync info`() = runTest { - val mockEndingProject = mockk { - every { state } returns ProjectState.PROJECT_ENDING - } - coEvery { configManager.getProject() } returns mockEndingProject - createUseCase() - - val result = useCase().first() - - assertThat(result.syncInfoSectionRecords.isCounterRecordsToDownloadVisible).isFalse() - } - - @Test - fun `should handle missing project state correctly in sync info`() = runTest { - coEvery { configManager.getProject() } returns null + fun `should handle non-running project state correctly in sync info`() = runTest { + every { observeConfigurationFlow.invoke() } returns flowOf(createConfigurationState(isProjectRunning = false)) createUseCase() val result = useCase().first() @@ -404,14 +358,13 @@ class ObserveSyncInfoUseCaseTest { } every { synchronization } returns createMockSynchronizationConfiguration() } - val mockDeviceConfigWithModules = mockk { - every { selectedModules } returns listOf(TokenizableString.Raw(TEST_MODULE_NAME)) - } - every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithModules) - every { configManager.observeDeviceConfiguration() } returns MutableStateFlow(mockDeviceConfigWithModules) - coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithModules - coEvery { configManager.getDeviceConfiguration() } returns mockDeviceConfigWithModules - coEvery { enrolmentRecordRepository.count(any()) } returns 50 + every { observeConfigurationFlow.invoke() } returns flowOf( + createConfigurationState( + selectedModules = listOf(ModuleCount(TEST_MODULE_NAME, 50)), + projectConfig = mockProjectConfigWithModules, + ), + ) + every { mockProjectConfigWithModules.isModuleSelectionAvailable() } returns true createUseCase() @@ -652,14 +605,16 @@ class ObserveSyncInfoUseCaseTest { } every { synchronization } returns createMockSynchronizationConfiguration() } - val mockDeviceConfigWithModules = mockk { - every { selectedModules } returns listOf(TokenizableString.Raw("module_1"), TokenizableString.Raw("module_2")) - } - every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithModules) - every { configManager.observeDeviceConfiguration() } returns MutableStateFlow(mockDeviceConfigWithModules) - coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithModules - coEvery { configManager.getDeviceConfiguration() } returns mockDeviceConfigWithModules - coEvery { enrolmentRecordRepository.count(any()) } returnsMany listOf(10, 15, 25) // records total, module_1, module_2 + every { observeConfigurationFlow.invoke() } returns flowOf( + createConfigurationState( + selectedModules = listOf( + ModuleCount("module_1", 15), + ModuleCount("module_2", 25), + ), + projectConfig = mockProjectConfigWithModules, + ), + ) + every { mockProjectConfigWithModules.isModuleSelectionAvailable() } returns true createUseCase() @@ -688,14 +643,10 @@ class ObserveSyncInfoUseCaseTest { } every { synchronization } returns createMockSynchronizationConfiguration() } - val mockDeviceConfigWithoutModules = mockk { - every { selectedModules } returns emptyList() - } - every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithoutModules) - every { configManager.observeDeviceConfiguration() } returns MutableStateFlow(mockDeviceConfigWithoutModules) - coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithoutModules - coEvery { configManager.getDeviceConfiguration() } returns mockDeviceConfigWithoutModules + every { observeConfigurationFlow.invoke() } returns + flowOf(createConfigurationState(projectConfig = mockProjectConfigWithoutModules)) every { mockProjectConfigWithoutModules.isModuleSelectionAvailable() } returns false + createUseCase() val result = useCase().first() @@ -715,13 +666,12 @@ class ObserveSyncInfoUseCaseTest { val mockIdleEventSyncState = mockk(relaxed = true) { every { isSyncInProgress() } returns false } - + every { observeConfigurationFlow.invoke() } returns flowOf(createConfigurationState(projectConfig = mockProjectConfigWithDownSync)) every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) - every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithDownSync) - coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithDownSync coEvery { eventSyncManager.countEventsToDownload() } returns DownSyncCounts(42, isLowerBound = false) every { mockProjectConfigWithDownSync.isSimprintsEventDownSyncAllowed() } returns true every { mockProjectConfigWithDownSync.isModuleSelectionAvailable() } returns false + createUseCase() val result = useCase().first() @@ -755,10 +705,8 @@ class ObserveSyncInfoUseCaseTest { val mockIdleEventSyncState = mockk(relaxed = true) { every { isSyncInProgress() } returns false } - + every { observeConfigurationFlow.invoke() } returns flowOf(createConfigurationState(projectConfig = mockProjectConfigWithDownSync)) every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) - every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithDownSync) - coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithDownSync coEvery { eventSyncManager.countEventsToDownload() } throws Exception("Timeout") every { mockProjectConfigWithDownSync.isSimprintsEventDownSyncAllowed() } returns true every { mockProjectConfigWithDownSync.isModuleSelectionAvailable() } returns false @@ -780,9 +728,8 @@ class ObserveSyncInfoUseCaseTest { val mockIdleEventSyncState = mockk(relaxed = true) { every { isSyncInProgress() } returns false } + every { observeConfigurationFlow.invoke() } returns flowOf(createConfigurationState(projectConfig = mockProjectConfigWithDownSync)) every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) - every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithDownSync) - coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithDownSync coEvery { eventSyncManager.countEventsToDownload() } throws RuntimeException("Network error") every { mockProjectConfigWithDownSync.isSimprintsEventDownSyncAllowed() } returns true every { mockProjectConfigWithDownSync.isModuleSelectionAvailable() } returns false @@ -874,15 +821,15 @@ class ObserveSyncInfoUseCaseTest { @Test fun `should handle changes in project refreshing stream`() = runTest { - val refreshingFlow = MutableStateFlow(false) // started non refreshing - every { configManager.observeIsProjectRefreshing() } returns refreshingFlow + val refreshingFlow = MutableStateFlow(createConfigurationState(isRefreshing = false)) // started non refreshing + every { observeConfigurationFlow.invoke() } returns refreshingFlow createUseCase() val notRefreshingResult = useCase().first() assertThat(notRefreshingResult.isConfigurationLoadingProgressBarVisible).isFalse() - refreshingFlow.value = true // changed to refreshing + refreshingFlow.value = createConfigurationState(isRefreshing = true) // changed to refreshing val refreshingResult = useCase().first() @@ -944,12 +891,12 @@ class ObserveSyncInfoUseCaseTest { @Test fun `should handle changes in project config stream`() = runTest { - val projectConfigFlow = MutableStateFlow(mockProjectConfiguration) - every { configManager.observeProjectConfiguration() } returns projectConfigFlow // started without modules + val projectConfigFlow = MutableStateFlow(createConfigurationState(projectConfig = mockProjectConfiguration)) + every { observeConfigurationFlow.invoke() } returns projectConfigFlow + createUseCase() val initialResult = useCase().first() - assertThat(initialResult.syncInfoSectionModules.isSectionAvailable).isFalse() val mockConfigWithModules = mockk { @@ -959,7 +906,7 @@ class ObserveSyncInfoUseCaseTest { every { synchronization } returns createMockSynchronizationConfiguration() } every { mockConfigWithModules.isModuleSelectionAvailable() } returns true - projectConfigFlow.value = mockConfigWithModules // now with modules + projectConfigFlow.value = createConfigurationState(projectConfig = mockConfigWithModules) // now with modules val moduleConfigResult = useCase().first() @@ -968,27 +915,26 @@ class ObserveSyncInfoUseCaseTest { @Test fun `should handle changes in device config stream`() = runTest { - every { configManager.observeProjectConfiguration() } returns flowOf( - mockk { - every { general } returns mockk { - every { modalities } returns emptyList() - } - every { synchronization } returns mockk(relaxed = true) { - every { up } returns mockk(relaxed = true) { - every { coSync } returns - mockk(relaxed = true) { - every { kind } returns UpSynchronizationConfiguration.UpSynchronizationKind.NONE - } - } + val projectConfig = mockk { + every { general } returns mockk { + every { modalities } returns emptyList() + } + every { synchronization } returns mockk(relaxed = true) { + every { up } returns mockk(relaxed = true) { + every { coSync } returns + mockk(relaxed = true) { + every { kind } returns UpSynchronizationConfiguration.UpSynchronizationKind.NONE + } } - }, - ) + } + } val deviceConfigFlow = MutableStateFlow( - mockk(relaxed = true) { - every { selectedModules } returns emptyList() - }, - ) // started without selected modules - every { configManager.observeDeviceConfiguration() } returns deviceConfigFlow + createConfigurationState( + selectedModules = emptyList(), // started without selected modules + projectConfig = projectConfig, + ), + ) + every { observeConfigurationFlow.invoke() } returns deviceConfigFlow createUseCase() val noModulesResult = useCase().first() @@ -996,10 +942,11 @@ class ObserveSyncInfoUseCaseTest { assertThat(noModulesResult.syncInfoSectionModules.moduleCounts).isEmpty() deviceConfigFlow.emit( - mockk(relaxed = true) { - every { selectedModules } returns listOf(TokenizableString.Raw(TEST_MODULE_NAME)) - }, - ) // now with selected modules + createConfigurationState( + selectedModules = listOf(ModuleCount(TEST_MODULE_NAME, 0)), // now with selected modules + projectConfig = projectConfig, + ), + ) val withModulesResult = useCase().first() @@ -1017,7 +964,7 @@ class ObserveSyncInfoUseCaseTest { every { timeHelper.readableBetweenNowAndTime(any()) } returnsMany listOf("0 minutes ago", "1 minute ago") // MutableStateFlow of Unit won't emit another (identical) Unit, so we'll count minutes and map to Units val timePaceFlow = MutableStateFlow(0) - every { ticker.observeTickOncePerMinute() } returns timePaceFlow.map { } + every { ticker.observeTicks(any()) } returns timePaceFlow.map { } createUseCase() val initialResult = useCase().first() @@ -1332,17 +1279,15 @@ class ObserveSyncInfoUseCaseTest { } every { synchronization } returns createMockSynchronizationConfiguration() } - val mockEmptyDeviceConfig = mockk { - every { selectedModules } returns emptyList() - } val mockIdleEventSyncState = mockk(relaxed = true) { every { isSyncFailed() } returns false every { isSyncInProgress() } returns false } - every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigRequiringModules) - every { configManager.observeDeviceConfiguration() } returns MutableStateFlow(mockEmptyDeviceConfig) - coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigRequiringModules - coEvery { configManager.getDeviceConfiguration() } returns mockEmptyDeviceConfig + + every { observeConfigurationFlow.invoke() } returns flowOf( + createConfigurationState(projectConfig = mockProjectConfigRequiringModules), + ) + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) every { mockProjectConfigRequiringModules.isModuleSelectionAvailable() } returns true @@ -1407,7 +1352,14 @@ class ObserveSyncInfoUseCaseTest { every { commCare } returns mockk() } } + every { isCommCareEventDownSyncAllowed() } returns true } + every { observeConfigurationFlow.invoke() } returns flowOf( + createConfigurationState( + projectConfig = mockProjectConfigWithCommCareDownSync, + ), + ) + val mockNormalEventSyncState = mockk(relaxed = true) { every { isSyncFailedBecauseCommCarePermissionIsMissing() } returns false every { isSyncRunning() } returns false @@ -1415,11 +1367,8 @@ class ObserveSyncInfoUseCaseTest { every { isSyncFailed() } returns false every { isSyncInProgress() } returns false } - every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithCommCareDownSync) - coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithCommCareDownSync every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockNormalEventSyncState) every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(false) - every { mockProjectConfigWithCommCareDownSync.isCommCareEventDownSyncAllowed() } returns true createUseCase() val result = useCase().first() @@ -1429,67 +1378,6 @@ class ObserveSyncInfoUseCaseTest { assertThat(result.syncInfoSectionRecords.isInstructionDefaultVisible).isTrue() } - // Module tokenization tests - - @Test - fun `should correctly decrypt tokenized module names`() = runTest { - val tokenizedModule = TokenizableString.Tokenized("encrypted_module_name") - val mockProjectConfigWithModules = mockk { - every { general } returns mockk { - every { modalities } returns listOf(Modality.FINGERPRINT) - } - every { synchronization } returns createMockSynchronizationConfiguration() - } - val mockDeviceConfigWithTokenizedModules = mockk { - every { selectedModules } returns listOf(tokenizedModule) - } - every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithModules) - every { configManager.observeDeviceConfiguration() } returns MutableStateFlow(mockDeviceConfigWithTokenizedModules) - coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithModules - coEvery { configManager.getDeviceConfiguration() } returns mockDeviceConfigWithTokenizedModules - coEvery { enrolmentRecordRepository.count(any()) } returnsMany listOf(10, 10) // total, and the module - every { - tokenizationProcessor.decrypt(tokenizedModule, TokenKeyType.ModuleId, any()) - } returns TokenizableString.Raw("decrypted_module") - every { mockProjectConfigWithModules.isModuleSelectionAvailable() } returns true - createUseCase() - - val result = useCase().first() - - assertThat(result.syncInfoSectionModules.isSectionAvailable).isTrue() - assertThat(result.syncInfoSectionModules.moduleCounts).hasSize(2) // total + the module - assertThat(result.syncInfoSectionModules.moduleCounts[1].name).isEqualTo("decrypted_module") - verify { tokenizationProcessor.decrypt(tokenizedModule, TokenKeyType.ModuleId, any()) } - } - - @Test - fun `should correctly handle raw module names`() = runTest { - val rawModule = TokenizableString.Raw("raw_module_name") - val mockProjectConfigWithModules = mockk { - every { general } returns mockk { - every { modalities } returns listOf(Modality.FINGERPRINT) - } - every { synchronization } returns createMockSynchronizationConfiguration() - } - val mockDeviceConfigWithRawModules = mockk { - every { selectedModules } returns listOf(rawModule) - } - every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithModules) - every { configManager.observeDeviceConfiguration() } returns MutableStateFlow(mockDeviceConfigWithRawModules) - coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithModules - coEvery { configManager.getDeviceConfiguration() } returns mockDeviceConfigWithRawModules - coEvery { enrolmentRecordRepository.count(any()) } returnsMany listOf(10, 10) // total, and the module - every { mockProjectConfigWithModules.isModuleSelectionAvailable() } returns true - createUseCase() - - val result = useCase().first() - - assertThat(result.syncInfoSectionModules.isSectionAvailable).isTrue() - assertThat(result.syncInfoSectionModules.moduleCounts).hasSize(2) // total + the module - assertThat(result.syncInfoSectionModules.moduleCounts[1].name).isEqualTo("raw_module_name") - verify(exactly = 0) { tokenizationProcessor.decrypt(any(), any(), any()) } - } - @Test fun `should show CommCare permission missing when does not have permission`() = runTest { val mockNormalEventSyncState = mockk(relaxed = true) { @@ -1522,4 +1410,11 @@ class ObserveSyncInfoUseCaseTest { assertThat(result.syncInfoSectionRecords.isSyncButtonEnabled).isTrue() assertThat(result.syncInfoSectionRecords.isInstructionDefaultVisible).isTrue() } + + private fun createConfigurationState( + isRefreshing: Boolean = false, + isProjectRunning: Boolean = true, + selectedModules: List = emptyList(), + projectConfig: ProjectConfiguration = mockProjectConfiguration, + ) = ConfigurationState(isRefreshing, isProjectRunning, selectedModules, projectConfig) } diff --git a/infra/core/src/main/java/com/simprints/core/lifecycle/AppForegroundStateTracker.kt b/infra/core/src/main/java/com/simprints/core/lifecycle/AppForegroundStateTracker.kt index 92971af26c..39d70957df 100644 --- a/infra/core/src/main/java/com/simprints/core/lifecycle/AppForegroundStateTracker.kt +++ b/infra/core/src/main/java/com/simprints/core/lifecycle/AppForegroundStateTracker.kt @@ -3,14 +3,19 @@ package com.simprints.core.lifecycle import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner +import com.simprints.core.DispatcherMain +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.flowOn import javax.inject.Inject import javax.inject.Singleton @Singleton -class AppForegroundStateTracker @Inject constructor() { +class AppForegroundStateTracker @Inject constructor( + @param:DispatcherMain private val mainDispatcher: CoroutineDispatcher, +) { fun observeAppInForeground(): Flow = callbackFlow { val lifecycleObserver = object : DefaultLifecycleObserver { override fun onResume(owner: LifecycleOwner) { @@ -21,10 +26,10 @@ class AppForegroundStateTracker @Inject constructor() { trySend(false) } } - val lifecycle = ProcessLifecycleOwner.Companion.get().lifecycle + val lifecycle = ProcessLifecycleOwner.get().lifecycle lifecycle.addObserver(lifecycleObserver) awaitClose { lifecycle.removeObserver(lifecycleObserver) } - } + }.flowOn(mainDispatcher) // runs in main thread by design } diff --git a/infra/core/src/main/java/com/simprints/core/tools/extentions/Flow.ext.kt b/infra/core/src/main/java/com/simprints/core/tools/extentions/Flow.ext.kt index 41f2e063a3..a6f1c4631d 100644 --- a/infra/core/src/main/java/com/simprints/core/tools/extentions/Flow.ext.kt +++ b/infra/core/src/main/java/com/simprints/core/tools/extentions/Flow.ext.kt @@ -1,36 +1,10 @@ package com.simprints.core.tools.extentions import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.scan -fun combine9( - flow1: Flow, - flow2: Flow, - flow3: Flow, - flow4: Flow, - flow5: Flow, - flow6: Flow, - flow7: Flow, - flow8: Flow, - flow9: Flow, - transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9) -> R, -): Flow = combine(flow1, flow2, flow3, flow4, flow5, flow6, flow7, flow8, flow9) { args: Array<*> -> - transform( - args[0] as T1, - args[1] as T2, - args[2] as T3, - args[3] as T4, - args[4] as T5, - args[5] as T6, - args[6] as T7, - args[7] as T8, - args[8] as T9, - ) -} - fun Flow.onChange( comparator: (T, T) -> Boolean, action: suspend (T) -> Unit, diff --git a/infra/core/src/test/java/com/simprints/core/lifecycle/AppForegroundStateTrackerTest.kt b/infra/core/src/test/java/com/simprints/core/lifecycle/AppForegroundStateTrackerTest.kt index 3e6ae72a5c..4011f7b52e 100644 --- a/infra/core/src/test/java/com/simprints/core/lifecycle/AppForegroundStateTrackerTest.kt +++ b/infra/core/src/test/java/com/simprints/core/lifecycle/AppForegroundStateTrackerTest.kt @@ -41,7 +41,7 @@ class AppForegroundStateTrackerTest { every { lifecycle.addObserver(any()) } returns Unit every { lifecycle.removeObserver(any()) } returns Unit - foregroundStateTracker = AppForegroundStateTracker() + foregroundStateTracker = AppForegroundStateTracker(testCoroutineRule.testCoroutineDispatcher) } @Test diff --git a/infra/core/src/test/java/com/simprints/core/tools/extentions/FlowExtTest.kt b/infra/core/src/test/java/com/simprints/core/tools/extentions/FlowExtTest.kt index 279ef3c537..61e345cf9f 100644 --- a/infra/core/src/test/java/com/simprints/core/tools/extentions/FlowExtTest.kt +++ b/infra/core/src/test/java/com/simprints/core/tools/extentions/FlowExtTest.kt @@ -1,31 +1,12 @@ package com.simprints.core.tools.extentions -import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.* import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest import org.junit.Test class FlowExtTest { - @Test - fun `combine9 combines 9 flows`() = runTest { - val flow1 = flowOf(1) - val flow2 = flowOf(2) - val flow3 = flowOf(3) - val flow4 = flowOf(4) - val flow5 = flowOf(5) - val flow6 = flowOf(6) - val flow7 = flowOf(7) - val flow8 = flowOf(8) - val flow9 = flowOf(9) - - val result = combine9(flow1, flow2, flow3, flow4, flow5, flow6, flow7, flow8, flow9) { t1, t2, t3, t4, t5, t6, t7, t8, t9 -> - t1 + t2 + t3 + t4 + t5 + t6 + t7 + t8 + t9 - }.toList() - - assertThat(result).isEqualTo(listOf(1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9)) - } - @Test fun `onChange triggers action when comparator returns true`() = runTest { val flow = flowOf(1, 2, 2, 3) From fc8c2a300daa43c0400256bac4ae6ec913c2af5b Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Mon, 8 Dec 2025 14:30:30 +0200 Subject: [PATCH 3/4] MS-1148 Remove redundant Intermediate module count model --- .../dashboard/settings/syncinfo/SyncInfo.kt | 26 ++++++++----------- .../settings/syncinfo/SyncInfoFragment.kt | 16 ++---------- .../modulecount/ModuleCountViewHolder.kt | 16 ++++++------ .../usecase/ObserveSyncInfoUseCase.kt | 21 +++++---------- .../usecase/ObserveSyncInfoUseCaseTest.kt | 16 ++++-------- 5 files changed, 33 insertions(+), 62 deletions(-) diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfo.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfo.kt index 22e7765840..cd222bf23c 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfo.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfo.kt @@ -1,6 +1,8 @@ package com.simprints.feature.dashboard.settings.syncinfo -data class SyncInfo( +import com.simprints.feature.dashboard.settings.syncinfo.modulecount.ModuleCount + +internal data class SyncInfo( val isLoggedIn: Boolean = true, val isConfigurationLoadingProgressBarVisible: Boolean = false, val isLoginPromptSectionVisible: Boolean = false, @@ -10,7 +12,7 @@ data class SyncInfo( val syncInfoSectionModules: SyncInfoSectionModules = SyncInfoSectionModules(), ) -data class SyncInfoSectionRecords( +internal data class SyncInfoSectionRecords( // counters val counterTotalRecords: String = "", val counterRecordsToUpload: String = "", @@ -40,13 +42,13 @@ data class SyncInfoSectionRecords( val footerLastSyncMinutesAgo: String = "", ) -data class SyncInfoError( +internal data class SyncInfoError( val isBackendMaintenance: Boolean = false, val backendMaintenanceEstimatedOutage: Long = -1, val isTooManyRequests: Boolean = false, ) -data class SyncInfoSectionImages( +internal data class SyncInfoSectionImages( // counters val counterImagesToUpload: String = "", // instructions @@ -62,12 +64,12 @@ data class SyncInfoSectionImages( val footerLastSyncMinutesAgo: String = "", ) -data class SyncInfoProgress( +internal data class SyncInfoProgress( val progressParts: List = listOf(), val progressBarPercentage: Int = 0, ) -data class SyncInfoProgressPart( +internal data class SyncInfoProgressPart( val isPending: Boolean = true, val isDone: Boolean = false, val areNumbersVisible: Boolean = false, @@ -75,18 +77,12 @@ data class SyncInfoProgressPart( val totalNumber: Int = 0, ) -data class SyncInfoSectionModules( +internal data class SyncInfoSectionModules( val isSectionAvailable: Boolean = false, - val moduleCounts: List = emptyList(), -) - -data class SyncInfoModuleCount( - val isTotal: Boolean = false, - val name: String, - val count: String = "", + val moduleCounts: List = emptyList(), ) -enum class LogoutActionReason { +internal enum class LogoutActionReason { USER_ACTION, PROJECT_ENDING_OR_DEVICE_COMPROMISED, } diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoFragment.kt index f54e60d795..ff1cef847b 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoFragment.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoFragment.kt @@ -25,7 +25,6 @@ import com.simprints.feature.dashboard.R import com.simprints.feature.dashboard.databinding.FragmentSyncInfoBinding import com.simprints.feature.dashboard.requestlogin.LogoutReason import com.simprints.feature.dashboard.requestlogin.RequestLoginFragmentArgs -import com.simprints.feature.dashboard.settings.syncinfo.modulecount.ModuleCount import com.simprints.feature.dashboard.settings.syncinfo.modulecount.ModuleCountAdapter import com.simprints.feature.dashboard.view.ConfigurableSyncInfoFragmentContainer import com.simprints.feature.login.LoginContract @@ -340,24 +339,13 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { binding.layoutModuleSelection.isGone = !isModuleSectionVisible binding.selectedModulesView.isGone = !config.isSyncInfoModuleListVisible - val moduleCountsForAdapter = modules.moduleCounts.map { syncInfoModuleCount -> - ModuleCount( - name = if (syncInfoModuleCount.isTotal) { - getString(IDR.string.sync_info_total_records) - } else { - syncInfoModuleCount.name - }, - count = syncInfoModuleCount.count.toIntOrNull() ?: 0, - ) - } - - moduleCountAdapter.submitList(moduleCountsForAdapter) + moduleCountAdapter.submitList(modules.moduleCounts) // RecyclerView height fix (wrong height may be caused by ConstraintLayout in parent views) viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { val itemHeight = resources.getDimensionPixelSize(R.dimen.module_item_height) - val itemCount = moduleCountsForAdapter.size.coerceAtMost(MAX_MODULE_LIST_HEIGHT_ITEMS) + val itemCount = modules.moduleCounts.size.coerceAtMost(MAX_MODULE_LIST_HEIGHT_ITEMS) binding.selectedModulesView.apply { layoutParams = layoutParams.apply { height = itemHeight * itemCount diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/modulecount/ModuleCountViewHolder.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/modulecount/ModuleCountViewHolder.kt index 450b093f32..bbc4b5db4e 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/modulecount/ModuleCountViewHolder.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/modulecount/ModuleCountViewHolder.kt @@ -6,6 +6,7 @@ import android.widget.ImageView import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import com.simprints.feature.dashboard.R +import com.simprints.infra.resources.R as IDR internal class ModuleCountViewHolder( itemView: View, @@ -18,14 +19,13 @@ internal class ModuleCountViewHolder( moduleCount: ModuleCount, isFirstElementForTotalCount: Boolean, ) { - moduleItemIcon.setImageResource( - if (isFirstElementForTotalCount) { - R.drawable.ic_global - } else { - R.drawable.ic_module - }, - ) - moduleNameText.text = moduleCount.name + if (isFirstElementForTotalCount) { + moduleItemIcon.setImageResource(R.drawable.ic_global) + moduleNameText.setText(IDR.string.sync_info_total_records) + } else { + moduleItemIcon.setImageResource(R.drawable.ic_module) + moduleNameText.text = moduleCount.name + } moduleCountText.text = moduleCount.count.toString() if (isFirstElementForTotalCount) { 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 f98d1ec8af..4e6d5f71cb 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 @@ -232,22 +232,9 @@ internal class ObserveSyncInfoUseCase @Inject constructor( else -> DownSyncCounts(0, isLowerBound = false) } - val modulesCountTotal = SyncInfoModuleCount( - isTotal = true, - name = "", - count = moduleCounts.sumOf { it.count }.toString(), - ) val syncInfoSectionModules = SyncInfoSectionModules( isSectionAvailable = projectConfig.isModuleSelectionAvailable(), - moduleCounts = listOfNotNull( - modulesCountTotal.takeIf { moduleCounts.isNotEmpty() }, - ) + moduleCounts.map { moduleCount -> - SyncInfoModuleCount( - isTotal = false, - name = moduleCount.name, - count = moduleCount.count.toString(), - ) - }, + moduleCounts = moduleCounts.prependTotalModuleCount(), ) val syncInfoSectionRecords = SyncInfoSectionRecords( @@ -304,6 +291,12 @@ internal class ObserveSyncInfoUseCase @Inject constructor( .onImageSyncComplete { delay(timeMillis = SYNC_COMPLETION_HOLD_MILLIS) } .flowOn(ioDispatcher) // upstream flows mostly do IO work + private fun List.prependTotalModuleCount(): List = if (isEmpty()) { + emptyList() + } else { + listOf(ModuleCount(name = "", count = sumOf { it.count })) + this + } + // sync info change detection helpers private fun Flow.onRecordSyncComplete(action: suspend (SyncInfo) -> Unit) = onChange( 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 ddf75b49ac..eb3eeda168 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 @@ -5,15 +5,12 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.asFlow import com.google.common.truth.Truth.* import com.simprints.core.domain.common.Modality -import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.core.lifecycle.AppForegroundStateTracker import com.simprints.core.tools.time.Ticker import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.time.Timestamp -import com.simprints.feature.dashboard.settings.syncinfo.SyncInfoModuleCount import com.simprints.feature.dashboard.settings.syncinfo.modulecount.ModuleCount import com.simprints.infra.authstore.AuthStore -import com.simprints.infra.config.store.models.DeviceConfiguration import com.simprints.infra.config.store.models.DownSynchronizationConfiguration import com.simprints.infra.config.store.models.GeneralConfiguration import com.simprints.infra.config.store.models.ProjectConfiguration @@ -44,7 +41,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test -class ObserveSyncInfoUseCaseTest { +internal class ObserveSyncInfoUseCaseTest { @get:Rule val rule = InstantTaskExecutorRule() @@ -67,7 +64,6 @@ class ObserveSyncInfoUseCaseTest { private companion object { const val TEST_PROJECT_ID = "test_project_id" - const val TEST_USER_ID = "test_user_id" const val TEST_MODULE_NAME = "test_module" val TEST_TIMESTAMP = Timestamp(1000L) @@ -372,8 +368,7 @@ class ObserveSyncInfoUseCaseTest { assertThat(result.syncInfoSectionModules.isSectionAvailable).isTrue() assertThat(result.syncInfoSectionModules.moduleCounts).hasSize(2) // total + module - assertThat(result.syncInfoSectionModules.moduleCounts[0].isTotal).isTrue() - assertThat(result.syncInfoSectionModules.moduleCounts[0].count).isEqualTo("50") + assertThat(result.syncInfoSectionModules.moduleCounts[0].count).isEqualTo(50) } // Progress calculation tests @@ -623,15 +618,14 @@ class ObserveSyncInfoUseCaseTest { assertThat(result.syncInfoSectionModules.isSectionAvailable).isTrue() assertThat(result.syncInfoSectionModules.moduleCounts).hasSize(3) // sum of modules + the 2 modules // sum of modules - assertThat(result.syncInfoSectionModules.moduleCounts[0].isTotal).isTrue() - assertThat(result.syncInfoSectionModules.moduleCounts[0].count).isEqualTo("40") + assertThat(result.syncInfoSectionModules.moduleCounts[0].count).isEqualTo(40) // module_1 assertThat(result.syncInfoSectionModules.moduleCounts[1]).isEqualTo( - SyncInfoModuleCount(isTotal = false, name = "module_1", count = "15"), + ModuleCount(name = "module_1", count = 15), ) // module_2 assertThat(result.syncInfoSectionModules.moduleCounts[2]).isEqualTo( - SyncInfoModuleCount(isTotal = false, name = "module_2", count = "25"), + ModuleCount(name = "module_2", count = 25), ) } From bbef1caa10d706fa1cf6df231ac6a073fd8395b6 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Mon, 8 Dec 2025 15:28:53 +0200 Subject: [PATCH 4/4] MS-1148 Minor cosmetic code cleanup to marginally improve readability --- .../usecase/ObserveSyncInfoUseCase.kt | 122 ++++++++---------- .../usecase/ObserveSyncInfoUseCaseTest.kt | 2 +- 2 files changed, 56 insertions(+), 68 deletions(-) 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 4e6d5f71cb..dbe17e054c 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 @@ -1,7 +1,7 @@ package com.simprints.feature.dashboard.settings.syncinfo.usecase import androidx.lifecycle.asFlow -import com.simprints.core.DispatcherIO +import com.simprints.core.DispatcherBG import com.simprints.core.lifecycle.AppForegroundStateTracker import com.simprints.core.tools.extentions.onChange import com.simprints.core.tools.time.Ticker @@ -9,12 +9,12 @@ import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.time.Timestamp import com.simprints.feature.dashboard.settings.syncinfo.SyncInfo import com.simprints.feature.dashboard.settings.syncinfo.SyncInfoError -import com.simprints.feature.dashboard.settings.syncinfo.SyncInfoModuleCount import com.simprints.feature.dashboard.settings.syncinfo.SyncInfoProgress import com.simprints.feature.dashboard.settings.syncinfo.SyncInfoProgressPart import com.simprints.feature.dashboard.settings.syncinfo.SyncInfoSectionImages import com.simprints.feature.dashboard.settings.syncinfo.SyncInfoSectionModules import com.simprints.feature.dashboard.settings.syncinfo.SyncInfoSectionRecords +import com.simprints.feature.dashboard.settings.syncinfo.modulecount.ModuleCount import com.simprints.infra.authstore.AuthStore import com.simprints.infra.config.store.models.canSyncDataToSimprints import com.simprints.infra.config.store.models.isCommCareEventDownSyncAllowed @@ -34,14 +34,12 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.withTimeout import javax.inject.Inject import kotlin.math.roundToInt -import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes internal class ObserveSyncInfoUseCase @Inject constructor( @@ -56,7 +54,7 @@ internal class ObserveSyncInfoUseCase @Inject constructor( private val appForegroundStateTracker: AppForegroundStateTracker, private val commCarePermissionChecker: CommCarePermissionChecker, private val observeConfigurationFlow: ObserveConfigurationChangesUseCase, - @DispatcherIO private val ioDispatcher: CoroutineDispatcher, + @param:DispatcherBG private val dispatcher: CoroutineDispatcher, ) { private val eventSyncStateFlow = eventSyncManager .getLastSyncState(useDefaultValue = true) // otherwise value not guaranteed @@ -79,46 +77,36 @@ internal class ObserveSyncInfoUseCase @Inject constructor( observeConfigurationFlow(), ) { isOnline, projectId, eventSyncState, imageSyncStatus, (isRefreshing, isProjectRunning, moduleCounts, projectConfig) -> val currentEvents = eventSyncState.progress?.coerceAtLeast(0) ?: 0 - val totalEvents = eventSyncState.total?.takeIf { it >= 1 } ?: 0 + val totalEvents = eventSyncState.total?.coerceAtLeast(0) ?: 0 val currentImages = imageSyncStatus.progress?.first?.coerceAtLeast(0) ?: 0 val totalImages = imageSyncStatus.progress?.second?.takeIf { it >= 1 } ?: 0 - val eventsNormalizedProgress = when { - isPreLogoutUpSync && eventSyncState.isSyncCompleted() && totalImages > 0 -> { - (0.5f + 0.5f * currentImages / totalImages).coerceIn(0.5f, 1f) - } + val eventProgressProportion = calculateProportion(currentEvents, totalEvents) + val imageProgressProportion = calculateProportion(currentImages, totalImages) - // combined progress 2nd half - images - - isPreLogoutUpSync && eventSyncState.isSyncInProgress() && totalEvents > 0 -> { - (0.5f * currentEvents / totalEvents).coerceIn(0f, 0.5f) - } + val eventsNormalizedProgress = when { + // Combined progressbar in pre-logout screen, event sync done => updating images part in [0.5;1] range + isPreLogoutUpSync && eventSyncState.isSyncCompleted() && totalImages > 0 -> (0.5f + imageProgressProportion / 2) - // combined progress 1st half - events + // Combined progressbar in pre-logout screen, event sync in progress => updating events part in [0;0.5] range + isPreLogoutUpSync && eventSyncState.isSyncInProgress() && totalEvents > 0 -> eventProgressProportion / 2 - eventSyncState.isSyncInProgress() && totalEvents > 0 -> { - (currentEvents.toFloat() / totalEvents).coerceIn(0f, 1f) - } + // Showing only event sync progress + eventSyncState.isSyncInProgress() && totalEvents > 0 -> eventProgressProportion - eventSyncState.isSyncConnecting() || eventSyncState.isThereNotSyncHistory() -> { - 0f - } + // Sync hasn't started + eventSyncState.isSyncConnecting() || eventSyncState.isThereNotSyncHistory() -> 0f - else -> { - 1f - } - } - val imagesNormalizedProgress = when { - imageSyncStatus.isSyncing && totalImages > 0 -> (currentImages.toFloat() / totalImages).coerceIn(0f, 1f) + // Sync done else -> 1f } + val imagesNormalizedProgress = if (imageSyncStatus.isSyncing && totalImages > 0) imageProgressProportion else 1f - val imagesToUpload = - if (imageSyncStatus.isSyncing) { - null - } else { - imageRepository.getNumberOfImagesToUpload(projectId = projectId) - } + val imagesToUpload = if (imageSyncStatus.isSyncing) { + null + } else { + imageRepository.getNumberOfImagesToUpload(projectId = projectId) + } val eventSyncProgressPart = SyncInfoProgressPart( isPending = eventSyncState.isSyncConnecting() || eventSyncState.isThereNotSyncHistory(), @@ -135,9 +123,8 @@ internal class ObserveSyncInfoUseCase @Inject constructor( totalNumber = totalImages, ) - val isEventSyncInProgress = - eventSyncState.isSyncInProgress() || - (isPreLogoutUpSync && imageSyncStatus.isSyncing) // if combined with images + val isEventSyncInProgress = eventSyncState.isSyncInProgress() || + (isPreLogoutUpSync && imageSyncStatus.isSyncing) // if combined with images val eventSyncProgress = if (isEventSyncInProgress) { SyncInfoProgress( progressParts = if (isPreLogoutUpSync) { @@ -189,10 +176,9 @@ internal class ObserveSyncInfoUseCase @Inject constructor( else -> OnStandby } - val isEventUpSyncPossible = - projectConfig.canSyncDataToSimprints() && isOnline + val isEventUpSyncPossible = isOnline && projectConfig.canSyncDataToSimprints() val isDownSyncPossible = - (projectConfig.isSimprintsEventDownSyncAllowed() && isOnline && !isReLoginRequired) || + (isOnline && !isReLoginRequired && projectConfig.isSimprintsEventDownSyncAllowed()) || ( projectConfig.isCommCareEventDownSyncAllowed() && ( @@ -200,14 +186,11 @@ internal class ObserveSyncInfoUseCase @Inject constructor( commCarePermissionChecker.hasCommCarePermissions() ) ) - val isSyncButtonEnabled = - (eventSyncVisibleState == OnStandby || eventSyncVisibleState == Error) && - ((!isPreLogoutUpSync && isDownSyncPossible) || isEventUpSyncPossible) + val isSyncButtonEnabled = ((!isPreLogoutUpSync && isDownSyncPossible) || isEventUpSyncPossible) && + (eventSyncVisibleState == OnStandby || eventSyncVisibleState == Error) val recordsTotal = when { - isEventSyncInProgress -> null - - projectId.isBlank() -> null + isEventSyncInProgress || projectId.isBlank() -> null // without project ID, repository access attempts will throw an exception else -> enrolmentRecordRepository.count(SubjectQuery(projectId)) @@ -217,20 +200,10 @@ internal class ObserveSyncInfoUseCase @Inject constructor( else -> eventSyncManager.countEventsToUpload(listOf(EventType.ENROLMENT_V2, EventType.ENROLMENT_V4)).firstOrNull() ?: 0 } val recordsToDownload = when { - isEventSyncInProgress -> null - - isPreLogoutUpSync -> null - - projectConfig.isSimprintsEventDownSyncAllowed() -> try { - withTimeout(COUNT_EVENTS_TIMEOUT_MILLIS) { - countEventsToDownloadWithCaching() - } - } catch (_: Throwable) { - DownSyncCounts(0, isLowerBound = false) - } - + isEventSyncInProgress || isPreLogoutUpSync -> null + projectConfig.isSimprintsEventDownSyncAllowed() -> countEventsToDownloadWithCaching() else -> DownSyncCounts(0, isLowerBound = false) - } + }?.let { "${it.count}${if (it.isLowerBound) "+" else ""}" }.orEmpty() val syncInfoSectionModules = SyncInfoSectionModules( isSectionAvailable = projectConfig.isModuleSelectionAvailable(), @@ -241,7 +214,7 @@ internal class ObserveSyncInfoUseCase @Inject constructor( counterTotalRecords = recordsTotal?.toString().orEmpty(), counterRecordsToUpload = recordsToUpload?.toString().orEmpty(), isCounterRecordsToDownloadVisible = !isPreLogoutUpSync && isProjectRunning, - counterRecordsToDownload = recordsToDownload?.let { "${it.count}${if (it.isLowerBound) "+" else ""}" }.orEmpty(), + counterRecordsToDownload = recordsToDownload, isCounterImagesToUploadVisible = isPreLogoutUpSync, counterImagesToUpload = imagesToUpload?.toString().orEmpty(), isInstructionDefaultVisible = eventSyncVisibleState == OnStandby && !isPreLogoutUpSync, @@ -289,7 +262,12 @@ internal class ObserveSyncInfoUseCase @Inject constructor( return@combine syncInfo }.onRecordSyncComplete { delay(timeMillis = SYNC_COMPLETION_HOLD_MILLIS) } .onImageSyncComplete { delay(timeMillis = SYNC_COMPLETION_HOLD_MILLIS) } - .flowOn(ioDispatcher) // upstream flows mostly do IO work + .flowOn(dispatcher) // upstream flows do a lot of computation + + private fun calculateProportion( + current: Int, + total: Int, + ): Float = if (total == 0) 0f else (current.toFloat() / total).coerceIn(0f, 1f) private fun List.prependTotalModuleCount(): List = if (isEmpty()) { emptyList() @@ -320,13 +298,23 @@ internal class ObserveSyncInfoUseCase @Inject constructor( private suspend fun countEventsToDownloadWithCaching(): DownSyncCounts { val timeNowMs = timeHelper.now().ms - cachedEventCountToDownload - ?.takeIf { timeNowMs - cachedEventCountToDownloadTimestamp < COUNT_EVENTS_CACHE_LIFESPAN_MILLIS } - ?.let { return it } - cachedEventCountToDownloadTimestamp = timeNowMs - return eventSyncManager.countEventsToDownload().also { - cachedEventCountToDownload = it + val timeSinceLastDownload = timeNowMs - cachedEventCountToDownloadTimestamp + val cached = cachedEventCountToDownload // for smart-cast + + if (cached == null || timeSinceLastDownload > COUNT_EVENTS_CACHE_LIFESPAN_MILLIS) { + val result = try { + withTimeout(COUNT_EVENTS_TIMEOUT_MILLIS) { + eventSyncManager.countEventsToDownload() + } + } catch (_: Throwable) { + DownSyncCounts(0, isLowerBound = false) + } + cachedEventCountToDownload = result + cachedEventCountToDownloadTimestamp = timeNowMs + return result } + + return cached } private companion object { 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 eb3eeda168..c97c8e836a 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 @@ -170,7 +170,7 @@ internal class ObserveSyncInfoUseCaseTest { appForegroundStateTracker = appForegroundStateTracker, commCarePermissionChecker = commCarePermissionChecker, observeConfigurationFlow = observeConfigurationFlow, - ioDispatcher = testCoroutineRule.testCoroutineDispatcher, + dispatcher = testCoroutineRule.testCoroutineDispatcher, ) }