From 4bfc066b8aa2acf4b6aac6dca69fb5c75ab4e40e Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 24 Jul 2025 11:12:40 +0100 Subject: [PATCH 01/45] MS-939 Data layer preparation: data flow watchers --- .../simprints/infra/authstore/AuthStore.kt | 3 + .../infra/authstore/AuthStoreImpl.kt | 3 + .../infra/authstore/domain/LoginInfoStore.kt | 11 ++ .../infra/authstore/AuthStoreImplTest.kt | 37 ++++++ .../authstore/domain/LoginInfoStoreTest.kt | 50 ++++++++ .../infra/config/store/ConfigRepository.kt | 2 + .../config/store/ConfigRepositoryImpl.kt | 2 + .../store/local/ConfigLocalDataSource.kt | 2 + .../store/local/ConfigLocalDataSourceImpl.kt | 35 ++++-- .../config/store/ConfigRepositoryImplTest.kt | 27 ++++- .../local/ConfigLocalDataSourceImplTest.kt | 28 +++++ .../infra/config/sync/ConfigManager.kt | 25 +++- .../infra/config/sync/ConfigManagerTest.kt | 68 ++++++++++- .../core/tools/time/KronosTimeHelperImpl.kt | 14 +++ .../simprints/core/tools/time/TimeHelper.kt | 3 + .../tools/time/KronosTimeHelperImplTest.kt | 51 ++++++++ .../simprints/infra/sync/ImageSyncStatus.kt | 7 ++ .../infra/sync/ImageSyncTimestampProvider.kt | 33 +++++ .../simprints/infra/sync/SyncOrchestrator.kt | 6 + .../infra/sync/SyncOrchestratorImpl.kt | 49 ++++++++ .../sync/ImageSyncTimestampProviderTest.kt | 113 ++++++++++++++++++ 21 files changed, 549 insertions(+), 20 deletions(-) create mode 100644 infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncStatus.kt create mode 100644 infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncTimestampProvider.kt create mode 100644 infra/sync/src/test/java/com/simprints/infra/sync/ImageSyncTimestampProviderTest.kt diff --git a/infra/auth-store/src/main/java/com/simprints/infra/authstore/AuthStore.kt b/infra/auth-store/src/main/java/com/simprints/infra/authstore/AuthStore.kt index b589c41237..48c94da859 100644 --- a/infra/auth-store/src/main/java/com/simprints/infra/authstore/AuthStore.kt +++ b/infra/auth-store/src/main/java/com/simprints/infra/authstore/AuthStore.kt @@ -5,6 +5,7 @@ import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.infra.authstore.domain.models.Token import com.simprints.infra.network.SimNetwork import com.simprints.infra.network.SimRemoteInterface +import kotlinx.coroutines.flow.StateFlow import kotlin.reflect.KClass interface AuthStore { @@ -13,6 +14,8 @@ interface AuthStore { fun isProjectIdSignedIn(possibleProjectId: String): Boolean + fun watchSignedInProjectId(): StateFlow + fun cleanCredentials() suspend fun storeFirebaseToken(token: Token) diff --git a/infra/auth-store/src/main/java/com/simprints/infra/authstore/AuthStoreImpl.kt b/infra/auth-store/src/main/java/com/simprints/infra/authstore/AuthStoreImpl.kt index 7ca286a39d..d8a766b510 100644 --- a/infra/auth-store/src/main/java/com/simprints/infra/authstore/AuthStoreImpl.kt +++ b/infra/auth-store/src/main/java/com/simprints/infra/authstore/AuthStoreImpl.kt @@ -8,6 +8,7 @@ import com.simprints.infra.authstore.domain.models.Token import com.simprints.infra.authstore.network.SimApiClientFactory import com.simprints.infra.network.SimNetwork import com.simprints.infra.network.SimRemoteInterface +import kotlinx.coroutines.flow.StateFlow import javax.inject.Inject import kotlin.reflect.KClass @@ -28,6 +29,8 @@ internal class AuthStoreImpl @Inject constructor( loginInfoStore.signedInProjectId = value } + override fun watchSignedInProjectId(): StateFlow = loginInfoStore.watchSignedInProjectId() + override fun isProjectIdSignedIn(possibleProjectId: String): Boolean = loginInfoStore.isProjectIdSignedIn(possibleProjectId) override fun cleanCredentials() { diff --git a/infra/auth-store/src/main/java/com/simprints/infra/authstore/domain/LoginInfoStore.kt b/infra/auth-store/src/main/java/com/simprints/infra/authstore/domain/LoginInfoStore.kt index c7783e6fb3..da39a6b93b 100644 --- a/infra/auth-store/src/main/java/com/simprints/infra/authstore/domain/LoginInfoStore.kt +++ b/infra/auth-store/src/main/java/com/simprints/infra/authstore/domain/LoginInfoStore.kt @@ -7,6 +7,9 @@ import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.core.domain.tokenization.isTokenized import com.simprints.infra.security.SecurityManager import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import javax.inject.Inject import javax.inject.Singleton @@ -75,13 +78,20 @@ internal class LoginInfoStore @Inject constructor( } } + private val signedInProjectIdFlow: MutableStateFlow = MutableStateFlow( + getSecurePrefs().getString(PROJECT_ID, "").orEmpty() + ) + var signedInProjectId: String = "" get() = getSecurePrefs().getString(PROJECT_ID, "").orEmpty() set(value) { field = value getSecurePrefs().edit { putString(PROJECT_ID, field) } + signedInProjectIdFlow.tryEmit(value) } + fun watchSignedInProjectId(): StateFlow = signedInProjectIdFlow.asStateFlow() + // Core Firebase Project details. We store them to initialize the core Firebase project. var coreFirebaseProjectId: String = "" get() = securePrefs.getString(CORE_FIREBASE_PROJECT_ID, "").orEmpty() @@ -118,6 +128,7 @@ internal class LoginInfoStore @Inject constructor( fun cleanCredentials() { securePrefs.clearValues() prefs.clearValues() + signedInProjectIdFlow.tryEmit("") } fun clearCachedTokenClaims() { diff --git a/infra/auth-store/src/test/java/com/simprints/infra/authstore/AuthStoreImplTest.kt b/infra/auth-store/src/test/java/com/simprints/infra/authstore/AuthStoreImplTest.kt index b69f21316c..ef4229e2f1 100644 --- a/infra/auth-store/src/test/java/com/simprints/infra/authstore/AuthStoreImplTest.kt +++ b/infra/auth-store/src/test/java/com/simprints/infra/authstore/AuthStoreImplTest.kt @@ -11,6 +11,8 @@ import com.simprints.infra.authstore.network.SimApiClientFactory import com.simprints.infra.network.SimNetwork import com.simprints.infra.network.SimRemoteInterface import io.mockk.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Test @@ -129,6 +131,41 @@ class AuthStoreImplTest { assertThat(receivedClient).isEqualTo(SIM_API_CLIENT) } + @Test + fun `watchSignedInProjectId should return flow with initial project id value`() = runTest { + val expectedFlow = MutableStateFlow("initial-project-id") + every { loginInfoStore.watchSignedInProjectId() } returns expectedFlow + + val flow = loginManagerManagerImpl.watchSignedInProjectId() + val initialValue = flow.first() + + assertThat(initialValue).isEqualTo("initial-project-id") + verify(exactly = 1) { loginInfoStore.watchSignedInProjectId() } + } + + @Test + fun `watchSignedInProjectId should return flow with empty string when project id is empty`() = runTest { + val expectedFlow = MutableStateFlow("") + every { loginInfoStore.watchSignedInProjectId() } returns expectedFlow + + val flow = loginManagerManagerImpl.watchSignedInProjectId() + val initialValue = flow.first() + + assertThat(initialValue).isEqualTo("") + verify(exactly = 1) { loginInfoStore.watchSignedInProjectId() } + } + + @Test + fun `watchSignedInProjectId should listen to the logged in project id values`() = runTest { + val expectedValues = MutableStateFlow("project1").apply { emit("project2") } + every { loginInfoStore.watchSignedInProjectId() } returns expectedValues + + val receivedFlow = loginManagerManagerImpl.watchSignedInProjectId() + + assertThat(receivedFlow).isEqualTo(expectedValues) + verify(exactly = 1) { loginInfoStore.watchSignedInProjectId() } + } + companion object { private const val PROJECT_ID = "projectId" private val USER_ID = "userId".asTokenizableRaw() diff --git a/infra/auth-store/src/test/java/com/simprints/infra/authstore/domain/LoginInfoStoreTest.kt b/infra/auth-store/src/test/java/com/simprints/infra/authstore/domain/LoginInfoStoreTest.kt index 5f31157ad5..f88c5e4ba2 100644 --- a/infra/auth-store/src/test/java/com/simprints/infra/authstore/domain/LoginInfoStoreTest.kt +++ b/infra/auth-store/src/test/java/com/simprints/infra/authstore/domain/LoginInfoStoreTest.kt @@ -10,6 +10,8 @@ import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.verify +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test @@ -270,4 +272,52 @@ class LoginInfoStoreTest { verify(exactly = 4) { secureEditor.remove(any()) } } + + @Test + fun `watchSignedInProjectId should return flow with initial project id value`() = runTest { + loginInfoStoreImpl.signedInProjectId = "initial-project-id" + + val flow = loginInfoStoreImpl.watchSignedInProjectId() + val initialValue = flow.first() + + assertThat(initialValue).isEqualTo("initial-project-id") + } + + @Test + fun `watchSignedInProjectId should return flow with empty string when project id is empty`() = runTest { + loginInfoStoreImpl.signedInProjectId = "" + + val flow = loginInfoStoreImpl.watchSignedInProjectId() + val initialValue = flow.first() + + assertThat(initialValue).isEqualTo("") + } + + @Test + fun `watchSignedInProjectId should emit new values when signedInProjectId is updated`() = runTest { + val flow = loginInfoStoreImpl.watchSignedInProjectId() + loginInfoStoreImpl.signedInProjectId = "initial-project-id" + val initialValue = flow.first() + + assertThat(initialValue).isEqualTo("initial-project-id") + + loginInfoStoreImpl.signedInProjectId = "updated-project-id" + + val updatedValue = flow.first() + assertThat(updatedValue).isEqualTo("updated-project-id") + } + + @Test + fun `watchSignedInProjectId should emit empty string when credentials are cleared`() = runTest { + loginInfoStoreImpl.signedInProjectId = "project-id" + val flow = loginInfoStoreImpl.watchSignedInProjectId() + val initialValue = flow.first() + + assertThat(initialValue).isEqualTo("project-id") + + loginInfoStoreImpl.cleanCredentials() + + val clearedValue = flow.first() + assertThat(clearedValue).isEqualTo("") + } } diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigRepository.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigRepository.kt index 0c763c2b79..190f284150 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigRepository.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigRepository.kt @@ -21,6 +21,8 @@ interface ConfigRepository { suspend fun getDeviceConfiguration(): DeviceConfiguration + fun watchDeviceConfiguration(): Flow + suspend fun updateDeviceConfiguration(update: suspend (t: DeviceConfiguration) -> DeviceConfiguration) suspend fun clearData() diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigRepositoryImpl.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigRepositoryImpl.kt index f21dc8b214..ff3564ca1b 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigRepositoryImpl.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigRepositoryImpl.kt @@ -71,6 +71,8 @@ internal class ConfigRepositoryImpl @Inject constructor( override suspend fun getDeviceConfiguration(): DeviceConfiguration = localDataSource.getDeviceConfiguration() + override fun watchDeviceConfiguration(): Flow = localDataSource.watchDeviceConfiguration() + override suspend fun updateDeviceConfiguration(update: suspend (t: DeviceConfiguration) -> DeviceConfiguration) = localDataSource.updateDeviceConfiguration(update) diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/ConfigLocalDataSource.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/ConfigLocalDataSource.kt index e466839466..bd3facab32 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/ConfigLocalDataSource.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/ConfigLocalDataSource.kt @@ -22,6 +22,8 @@ internal interface ConfigLocalDataSource { suspend fun getDeviceConfiguration(): DeviceConfiguration + fun watchDeviceConfiguration(): Flow + suspend fun updateDeviceConfiguration(update: suspend (t: DeviceConfiguration) -> DeviceConfiguration) suspend fun clearDeviceConfiguration() diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/ConfigLocalDataSourceImpl.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/ConfigLocalDataSourceImpl.kt index 633ab8e1f3..17c785c90c 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/ConfigLocalDataSourceImpl.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/ConfigLocalDataSourceImpl.kt @@ -75,26 +75,35 @@ internal class ConfigLocalDataSourceImpl @Inject constructor( override suspend fun getProjectConfiguration(): ProjectConfiguration = configDataStore.data.first().toDomain() - override fun watchProjectConfiguration(): Flow = configDataStore.data.map(ProtoProjectConfiguration::toDomain) + override fun watchProjectConfiguration(): Flow = + configDataStore.data.map(ProtoProjectConfiguration::toDomain) override suspend fun clearProjectConfiguration() { configDataStore.updateData { it.toBuilder().clear().build() } } - override suspend fun getDeviceConfiguration(): DeviceConfiguration { - val config = deviceConfigDataStore.data.first().toDomain() - val tokenizedModules = config.selectedModules.map { moduleId -> - when (moduleId) { - is TokenizableString.Raw -> tokenizationProcessor.encrypt( - decrypted = moduleId, - tokenKeyType = TokenKeyType.ModuleId, - project = getProject(), - ) - is TokenizableString.Tokenized -> moduleId + override suspend fun getDeviceConfiguration(): DeviceConfiguration = + deviceConfigDataStore.data.first().toDomain().apply { + selectedModules = selectedModules.mapToTokenizedModuleIds() + } + + override fun watchDeviceConfiguration(): Flow = + deviceConfigDataStore.data.map(ProtoDeviceConfiguration::toDomain).map { config -> + config.apply { + selectedModules = selectedModules.mapToTokenizedModuleIds() } } - config.selectedModules = tokenizedModules - return config + + private suspend fun List.mapToTokenizedModuleIds() = map { moduleId -> + when (moduleId) { + is TokenizableString.Raw -> tokenizationProcessor.encrypt( + decrypted = moduleId, + tokenKeyType = TokenKeyType.ModuleId, + project = getProject(), + ) + + is TokenizableString.Tokenized -> moduleId + } } override suspend fun updateDeviceConfiguration(update: suspend (t: DeviceConfiguration) -> DeviceConfiguration) { diff --git a/infra/config-store/src/test/java/com/simprints/infra/config/store/ConfigRepositoryImplTest.kt b/infra/config-store/src/test/java/com/simprints/infra/config/store/ConfigRepositoryImplTest.kt index 2386621f05..db6edbc314 100644 --- a/infra/config-store/src/test/java/com/simprints/infra/config/store/ConfigRepositoryImplTest.kt +++ b/infra/config-store/src/test/java/com/simprints/infra/config/store/ConfigRepositoryImplTest.kt @@ -18,6 +18,7 @@ import com.simprints.infra.config.store.testtools.project import com.simprints.infra.config.store.testtools.projectConfiguration import com.simprints.infra.config.store.tokenization.TokenizationProcessor import com.simprints.infra.logging.Simber +import com.simprints.core.domain.tokenization.asTokenizableEncrypted import com.simprints.infra.network.SimNetwork import com.simprints.infra.network.exceptions.BackendMaintenanceException import com.simprints.testtools.common.syntax.assertThrows @@ -31,6 +32,7 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.verify +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.toList import kotlinx.coroutines.test.runTest import org.junit.After @@ -251,7 +253,7 @@ class ConfigRepositoryImplTest { val config1 = projectConfiguration.copy(projectId = "project1") val config2 = projectConfiguration.copy(projectId = "project2") - coEvery { localDataSource.watchProjectConfiguration() } returns kotlinx.coroutines.flow.flow { + coEvery { localDataSource.watchProjectConfiguration() } returns flow { emit(config1) emit(config2) } @@ -312,4 +314,27 @@ class ConfigRepositoryImplTest { verify(exactly = 0) { Simber.i(any(), any()) } } + + @Test + fun `watchDeviceConfiguration should track values from the local data source`() = runTest { + val config1 = deviceConfiguration.copy(selectedModules = emptyList()) + val config2 = deviceConfiguration.copy( + selectedModules = listOf( + "module1".asTokenizableEncrypted(), + "module2".asTokenizableEncrypted(), + ) + ) + + coEvery { localDataSource.watchDeviceConfiguration() } returns flow { + emit(config1) + emit(config2) + } + + val emittedConfigs = configServiceImpl.watchDeviceConfiguration().toList() + + assertThat(emittedConfigs).hasSize(2) + assertThat(emittedConfigs[0]).isEqualTo(config1) + assertThat(emittedConfigs[1]).isEqualTo(config2) + coVerify(exactly = 1) { localDataSource.watchDeviceConfiguration() } + } } diff --git a/infra/config-store/src/test/java/com/simprints/infra/config/store/local/ConfigLocalDataSourceImplTest.kt b/infra/config-store/src/test/java/com/simprints/infra/config/store/local/ConfigLocalDataSourceImplTest.kt index 68378448af..c6d72dd9eb 100644 --- a/infra/config-store/src/test/java/com/simprints/infra/config/store/local/ConfigLocalDataSourceImplTest.kt +++ b/infra/config-store/src/test/java/com/simprints/infra/config/store/local/ConfigLocalDataSourceImplTest.kt @@ -23,6 +23,7 @@ import com.simprints.infra.config.store.testtools.synchronizationConfiguration import com.simprints.infra.config.store.tokenization.TokenizationProcessor import com.simprints.testtools.common.syntax.assertThrows import io.mockk.mockk +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest @@ -322,4 +323,31 @@ class ConfigLocalDataSourceImplTest { assertThat(emittedConfigs[2]).isEqualTo(config4) job.cancel() } + + @Test + fun `watchDeviceConfiguration should emit updated values when configuration changes`() = runTest { + configLocalDataSourceImpl.saveProject(project) + + val config1 = DeviceConfiguration("en", listOf(), "instruction1") + val config2 = DeviceConfiguration("fr", listOf("module1".asTokenizableEncrypted()), "instruction2") + + configLocalDataSourceImpl.updateDeviceConfiguration { config1 } + + val result1 = configLocalDataSourceImpl.watchDeviceConfiguration().first() + + assertThat(result1).isEqualTo(config1) + + configLocalDataSourceImpl.updateDeviceConfiguration { config2 } + + val result2 = configLocalDataSourceImpl.watchDeviceConfiguration().first() + + assertThat(result2).isEqualTo(config2) + } + + @Test + fun `watchDeviceConfiguration should emit default configuration initially`() = runTest { + val result = configLocalDataSourceImpl.watchDeviceConfiguration().first() + + assertThat(result).isEqualTo(ConfigLocalDataSourceImpl.defaultDeviceConfiguration.toDomain()) + } } diff --git a/infra/config-sync/src/main/java/com/simprints/infra/config/sync/ConfigManager.kt b/infra/config-sync/src/main/java/com/simprints/infra/config/sync/ConfigManager.kt index 52115149c3..e8f2d307dd 100644 --- a/infra/config-sync/src/main/java/com/simprints/infra/config/sync/ConfigManager.kt +++ b/infra/config-sync/src/main/java/com/simprints/infra/config/sync/ConfigManager.kt @@ -10,6 +10,8 @@ import com.simprints.infra.config.store.models.ProjectWithConfig import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository import com.simprints.infra.enrolment.records.repository.local.migration.RealmToRoomMigrationScheduler import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.onStart import javax.inject.Inject @@ -19,10 +21,19 @@ class ConfigManager @Inject constructor( private val configSyncCache: ConfigSyncCache, private val realmToRoomMigrationScheduler: RealmToRoomMigrationScheduler, ) { - suspend fun refreshProject(projectId: String): ProjectWithConfig = configRepository.refreshProject(projectId).also { - enrolmentRecordRepository.tokenizeExistingRecords(it.project) - configSyncCache.saveUpdateTime() - realmToRoomMigrationScheduler.scheduleMigrationWorkerIfNeeded() + private val ifProjectRefreshingFlow: MutableStateFlow = MutableStateFlow(false) + + suspend fun refreshProject(projectId: String): ProjectWithConfig { + ifProjectRefreshingFlow.tryEmit(true) + try { + return configRepository.refreshProject(projectId).also { + enrolmentRecordRepository.tokenizeExistingRecords(it.project) + configSyncCache.saveUpdateTime() + realmToRoomMigrationScheduler.scheduleMigrationWorkerIfNeeded() + } + } finally { + ifProjectRefreshingFlow.tryEmit(false) + } } suspend fun getProject(projectId: String): Project = try { @@ -49,12 +60,18 @@ class ConfigManager @Inject constructor( } } + fun watchIfProjectRefreshing(): Flow = ifProjectRefreshingFlow.asStateFlow() + fun watchProjectConfiguration(): Flow = configRepository .watchProjectConfiguration() .onStart { getProjectConfiguration() } // to invoke download if empty suspend fun getDeviceConfiguration(): DeviceConfiguration = configRepository.getDeviceConfiguration() + fun watchDeviceConfiguration(): Flow = configRepository + .watchDeviceConfiguration() + .onStart { getDeviceConfiguration() } + suspend fun updateDeviceConfiguration(update: suspend (t: DeviceConfiguration) -> DeviceConfiguration) = configRepository.updateDeviceConfiguration(update) diff --git a/infra/config-sync/src/test/java/com/simprints/infra/config/sync/ConfigManagerTest.kt b/infra/config-sync/src/test/java/com/simprints/infra/config/sync/ConfigManagerTest.kt index 118bc34f2a..8836acc6c9 100644 --- a/infra/config-sync/src/test/java/com/simprints/infra/config/sync/ConfigManagerTest.kt +++ b/infra/config-sync/src/test/java/com/simprints/infra/config/sync/ConfigManagerTest.kt @@ -10,7 +10,13 @@ import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepositor import com.simprints.infra.enrolment.records.repository.local.migration.RealmToRoomMigrationScheduler import io.mockk.* import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test @@ -147,7 +153,7 @@ class ConfigManagerTest { val config1 = projectConfiguration.copy(projectId = "project1") val config2 = projectConfiguration.copy(projectId = "project2") - coEvery { configRepository.watchProjectConfiguration() } returns kotlinx.coroutines.flow.flow { + coEvery { configRepository.watchProjectConfiguration() } returns flow { emit(config1) emit(config2) } @@ -161,7 +167,7 @@ class ConfigManagerTest { @Test fun `watchProjectConfiguration should call getProjectConfiguration on start to invoke download if config empty`() = runTest { - coEvery { configRepository.watchProjectConfiguration() } returns kotlinx.coroutines.flow.flow { + coEvery { configRepository.watchProjectConfiguration() } returns flow { emit(projectConfiguration) } @@ -172,4 +178,62 @@ class ConfigManagerTest { assertThat(emittedConfigs).hasSize(1) assertThat(emittedConfigs[0]).isEqualTo(projectConfiguration) } + + @Test + fun `watchIfProjectRefreshing should initially emit false`() = runTest { + val isRefreshing = configManager.watchIfProjectRefreshing().first() + assertThat(isRefreshing).isFalse() + } + + @Test + fun `watchIfProjectRefreshing should emit false after refreshProject completes`() = runTest { + coEvery { configRepository.refreshProject(PROJECT_ID) } returns projectWithConfig + configManager.refreshProject(PROJECT_ID) + + val isRefreshing = configManager.watchIfProjectRefreshing().first() + + assertThat(isRefreshing).isFalse() + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `watchIfProjectRefreshing should emit true during refreshProject and false when done`() = runTest { + coEvery { configRepository.refreshProject(PROJECT_ID) } coAnswers { + delay(1000) + projectWithConfig + } + + assertThat(configManager.watchIfProjectRefreshing().first()).isFalse() // before + + launch { configManager.refreshProject(PROJECT_ID) } + advanceTimeBy(500) + + assertThat(configManager.watchIfProjectRefreshing().first()).isTrue() // during + + advanceTimeBy(1000) + + assertThat(configManager.watchIfProjectRefreshing().first()).isFalse() // after + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `watchIfProjectRefreshing should emit false even when refreshProject fails`() = runTest { + coEvery { configRepository.refreshProject(PROJECT_ID) } coAnswers { + delay(500) + throw Exception("Test exception") + } + + assertThat(configManager.watchIfProjectRefreshing().first()).isFalse() // before + + launch { + try { + configManager.refreshProject(PROJECT_ID) + } catch (e: Exception) { + // Expected + } + } + advanceTimeBy(1000) + + assertThat(configManager.watchIfProjectRefreshing().first()).isFalse() // after failure + } } diff --git a/infra/core/src/main/java/com/simprints/core/tools/time/KronosTimeHelperImpl.kt b/infra/core/src/main/java/com/simprints/core/tools/time/KronosTimeHelperImpl.kt index 67073f2ae9..4daf6faceb 100644 --- a/infra/core/src/main/java/com/simprints/core/tools/time/KronosTimeHelperImpl.kt +++ b/infra/core/src/main/java/com/simprints/core/tools/time/KronosTimeHelperImpl.kt @@ -4,6 +4,9 @@ import android.text.format.DateUtils.FORMAT_SHOW_DATE import android.text.format.DateUtils.MINUTE_IN_MILLIS import android.text.format.DateUtils.getRelativeTimeSpanString import com.lyft.kronos.KronosClock +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow import java.text.DateFormat import java.util.Calendar import java.util.Date @@ -59,4 +62,15 @@ class KronosTimeHelperImpl @Inject constructor( timeInMillis } + + override fun watchOncePerMinute(): 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/main/java/com/simprints/core/tools/time/TimeHelper.kt b/infra/core/src/main/java/com/simprints/core/tools/time/TimeHelper.kt index 75bf735a6a..bd4d6014e1 100644 --- a/infra/core/src/main/java/com/simprints/core/tools/time/TimeHelper.kt +++ b/infra/core/src/main/java/com/simprints/core/tools/time/TimeHelper.kt @@ -1,6 +1,7 @@ package com.simprints.core.tools.time import androidx.annotation.Keep +import kotlinx.coroutines.flow.Flow @Keep interface TimeHelper { @@ -17,4 +18,6 @@ interface TimeHelper { fun todayInMillis(): Long fun tomorrowInMillis(): Long + + fun watchOncePerMinute(): Flow } diff --git a/infra/core/src/test/java/com/simprints/core/tools/time/KronosTimeHelperImplTest.kt b/infra/core/src/test/java/com/simprints/core/tools/time/KronosTimeHelperImplTest.kt index 8254d89661..faa6afba16 100644 --- a/infra/core/src/test/java/com/simprints/core/tools/time/KronosTimeHelperImplTest.kt +++ b/infra/core/src/test/java/com/simprints/core/tools/time/KronosTimeHelperImplTest.kt @@ -3,15 +3,28 @@ package com.simprints.core.tools.time import com.google.common.truth.Truth.assertThat import com.lyft.kronos.KronosClock import com.lyft.kronos.KronosTime +import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Ignore +import org.junit.Rule import org.junit.Test class KronosTimeHelperImplTest { + @get:Rule + val testCoroutineRule = TestCoroutineRule() + @MockK private lateinit var kronosClock: KronosClock @@ -79,6 +92,44 @@ class KronosTimeHelperImplTest { assertThat(result).isEqualTo(TIMESTAMP_TOMORROW) } + @Test + fun testWatchOncePerMinute_emitsImmediately() = runTest { + val result = timeHelperImpl.watchOncePerMinute() + .take(1) + .toList() + + assertThat(result).hasSize(1) + assertThat(result[0]).isEqualTo(Unit) + } + + @Test + fun testWatchOncePerMinute_emitsMultipleTimes() = runTest { + val result = timeHelperImpl.watchOncePerMinute() + .take(3) + .toList() + + assertThat(result).hasSize(3) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun testWatchOncePerMinute_waitsForCorrectTime() = runTest { + val flow = timeHelperImpl.watchOncePerMinute() + + // 1st tick immediately + assertThat(flow.first()).isEqualTo(Unit) + + // no next tick earlier than in a minute + val deferred = async { flow.drop(1).first() } + advanceTimeBy(59_000L) + assertThat(deferred.isCompleted).isFalse() + + // next tick in a full minute + advanceTimeBy(1_000L) + deferred.await() + assertThat(deferred.isCompleted).isTrue() + } + companion object { // Random date at random time private const val TIMESTAMP = 1_542_537_183_000L diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncStatus.kt b/infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncStatus.kt new file mode 100644 index 0000000000..69cfbbd1c5 --- /dev/null +++ b/infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncStatus.kt @@ -0,0 +1,7 @@ +package com.simprints.infra.sync + +data class ImageSyncStatus( + val isSyncing: Boolean, + val progress: Pair?, + val secondsSinceLastUpdate: Long?, +) diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncTimestampProvider.kt b/infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncTimestampProvider.kt new file mode 100644 index 0000000000..cf127aa494 --- /dev/null +++ b/infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncTimestampProvider.kt @@ -0,0 +1,33 @@ +package com.simprints.infra.sync + +import androidx.core.content.edit +import com.simprints.core.tools.time.TimeHelper +import com.simprints.infra.security.SecurityManager +import javax.inject.Inject + +class ImageSyncTimestampProvider @Inject constructor( + securityManager: SecurityManager, + private val timeHelper: TimeHelper, +) { + private val securePrefs = securityManager.buildEncryptedSharedPreferences(SECURE_PREF_FILE_NAME) + + fun saveImageSyncCompletionTimestampNow() { + securePrefs.edit { putLong(IMAGE_SYNC_COMPLETION_TIME_MILLIS, timeHelper.now().ms) } + } + + fun getSecondsSinceLastImageSync(): Long? = + securePrefs.getLong(IMAGE_SYNC_COMPLETION_TIME_MILLIS, 0).takeIf { + securePrefs.contains(IMAGE_SYNC_COMPLETION_TIME_MILLIS) + }?.let { lastSyncTimestamp -> + (timeHelper.now().ms - lastSyncTimestamp) / 1000 + } + + fun clearTimestamp() { + securePrefs.edit { clear() } + } + + companion object { + private const val SECURE_PREF_FILE_NAME = "93e98bc1-5b25-4805-94f6-f55ce0400747" + private const val IMAGE_SYNC_COMPLETION_TIME_MILLIS = "IMAGE_SYNC_COMPLETION_TIME_MILLIS" + } +} 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 57d67bff67..e2c26bc0f0 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 @@ -21,6 +21,12 @@ interface SyncOrchestrator { fun stopEventSync() + fun startImageSync() + + fun stopImageSync() + + fun observeImageSyncStatus(): Flow + /** * Fully reschedule the background worker. * Should be used in when the configuration that affects scheduling has changed. 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 ba22289a12..35c1f0d3e5 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 @@ -28,6 +28,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton @@ -40,6 +41,7 @@ internal class SyncOrchestratorImpl @Inject constructor( private val eventSyncManager: EventSyncManager, private val shouldScheduleFirmwareUpdate: ShouldScheduleFirmwareUpdateUseCase, private val cleanupDeprecatedWorkers: CleanupDeprecatedWorkersUseCase, + private val imageSyncTimestampProvider: ImageSyncTimestampProvider, @AppScope private val appScope: CoroutineScope, ) : SyncOrchestrator { init { @@ -137,6 +139,52 @@ internal class SyncOrchestratorImpl @Inject constructor( 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 fun observeImageSyncStatus(): Flow { + return workManager + .getWorkInfosFlow(WorkQuery.fromUniqueWorkNames(SyncConstants.FILE_UP_SYNC_WORK_NAME)) + .associateWithIfSyncing() + .map { (workInfos, isSyncing) -> + val secondsSinceLastUpdate = imageSyncTimestampProvider.getSecondsSinceLastImageSync() + val currentIndex = workInfos.firstOrNull()?.progress + ?.getInt(SyncConstants.PROGRESS_CURRENT, 0)?.coerceAtLeast(0) ?: 0 + val totalCount = workInfos.firstOrNull()?.progress + ?.getInt(SyncConstants.PROGRESS_MAX, 0)?.takeIf { it >= 1 } + val progress = totalCount?.let { currentIndex to totalCount } + ImageSyncStatus(isSyncing, progress, secondsSinceLastUpdate) + } + } + + private fun Flow>.associateWithIfSyncing() = transformLatest { workInfos -> + val isJustUpdated = imageSyncTimestampProvider.getSecondsSinceLastImageSync() == 0L + when { + workInfos.any { + it.state == WorkInfo.State.RUNNING + } -> { + emit(workInfos to true) + } + + workInfos.any { + it.state == WorkInfo.State.SUCCEEDED + } && isJustUpdated -> { + emit(workInfos to true) // at least for a moment, in case if RUNNING was missed + emit(workInfos to false) + } + + else -> { + emit(workInfos to false) + } + } + } + override suspend fun rescheduleImageUpSync() { workManager.schedulePeriodicWorker( SyncConstants.FILE_UP_SYNC_WORK_NAME, @@ -163,6 +211,7 @@ internal class SyncOrchestratorImpl @Inject constructor( override suspend fun deleteEventSyncInfo() { eventSyncManager.deleteSyncInfo() workManager.pruneWork() + imageSyncTimestampProvider.clearTimestamp() } override fun cleanupWorkers() { diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/ImageSyncTimestampProviderTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/ImageSyncTimestampProviderTest.kt new file mode 100644 index 0000000000..cadc57be1d --- /dev/null +++ b/infra/sync/src/test/java/com/simprints/infra/sync/ImageSyncTimestampProviderTest.kt @@ -0,0 +1,113 @@ +package com.simprints.infra.sync + +import android.content.SharedPreferences +import com.google.common.truth.Truth.assertThat +import com.simprints.core.tools.time.TimeHelper +import com.simprints.core.tools.time.Timestamp +import com.simprints.infra.security.SecurityManager +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.verify +import org.junit.Before +import org.junit.Test + +class ImageSyncTimestampProviderTest { + + @MockK + private lateinit var securityManager: SecurityManager + + @MockK + private lateinit var timeHelper: TimeHelper + + @MockK + private lateinit var sharedPreferences: SharedPreferences + + @MockK + private lateinit var editor: SharedPreferences.Editor + + private lateinit var imageSyncTimestampProvider: ImageSyncTimestampProvider + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + + every { securityManager.buildEncryptedSharedPreferences(any()) } returns sharedPreferences + every { sharedPreferences.edit() } returns editor + every { editor.putLong(any(), any()) } returns editor + every { editor.clear() } returns editor + + imageSyncTimestampProvider = ImageSyncTimestampProvider( + securityManager = securityManager, + timeHelper = timeHelper, + ) + } + + @Test + fun `saveImageSyncCompletionTimestampNow saves current timestamp to secure preferences`() { + val currentTime = 1234567890L + every { timeHelper.now() } returns Timestamp(currentTime) + + imageSyncTimestampProvider.saveImageSyncCompletionTimestampNow() + + verify { + sharedPreferences.edit() + editor.putLong("IMAGE_SYNC_COMPLETION_TIME_MILLIS", currentTime) + } + } + + @Test + fun `getSecondsSinceLastImageSync returns null when no timestamp exists`() { + every { sharedPreferences.contains("IMAGE_SYNC_COMPLETION_TIME_MILLIS") } returns false + every { sharedPreferences.getLong("IMAGE_SYNC_COMPLETION_TIME_MILLIS", 0) } returns 0 + + val result = imageSyncTimestampProvider.getSecondsSinceLastImageSync() + + assertThat(result).isNull() + } + + @Test + fun `getSecondsSinceLastImageSync returns null when timestamp is zero`() { + every { sharedPreferences.contains("IMAGE_SYNC_COMPLETION_TIME_MILLIS") } returns false + every { sharedPreferences.getLong("IMAGE_SYNC_COMPLETION_TIME_MILLIS", 0) } returns 0 + + val result = imageSyncTimestampProvider.getSecondsSinceLastImageSync() + + assertThat(result).isNull() + } + + @Test + fun `getSecondsSinceLastImageSync returns correct seconds when timestamp exists`() { + val lastSyncTimeMillis = 1000000L + val currentTimeMillis = 1005000L // 5 seconds later + val expectedSeconds = 5L + + every { sharedPreferences.contains("IMAGE_SYNC_COMPLETION_TIME_MILLIS") } returns true + every { sharedPreferences.getLong("IMAGE_SYNC_COMPLETION_TIME_MILLIS", 0) } returns lastSyncTimeMillis + every { timeHelper.now() } returns Timestamp(currentTimeMillis) + + val result = imageSyncTimestampProvider.getSecondsSinceLastImageSync() + + assertThat(result).isEqualTo(expectedSeconds) + } + + @Test + fun `clearTimestamp clears all timestamp preferences`() { + imageSyncTimestampProvider.clearTimestamp() + + val result = imageSyncTimestampProvider.getSecondsSinceLastImageSync() + + assertThat(result).isNull() + verify { + sharedPreferences.edit() + editor.clear() + } + } + + @Test + fun `provider uses correct secure preference file name`() { + verify { + securityManager.buildEncryptedSharedPreferences("93e98bc1-5b25-4805-94f6-f55ce0400747") + } + } +} From bbe33d2e1271885a387b8bf6d6975f81d8219912 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 24 Jul 2025 11:14:05 +0100 Subject: [PATCH 02/45] MS-939 Data layer preparation: EventSyncManager to allow default/cached values needed for new UI/UX --- .../infra/eventsync/EventSyncManager.kt | 4 +- .../infra/eventsync/EventSyncManagerImpl.kt | 28 +++++++++- .../infra/eventsync/EventSyncManagerTest.kt | 56 ++++++++++++++++++- 3 files changed, 82 insertions(+), 6 deletions(-) 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 9288ccb834..d379b9108e 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 @@ -16,13 +16,13 @@ interface EventSyncManager { suspend fun getLastSyncTime(): Timestamp? - fun getLastSyncState(): LiveData + fun getLastSyncState(useDefaultValue: Boolean = false): LiveData suspend fun countEventsToUpload(): Flow suspend fun countEventsToUpload(types: List): Flow - suspend fun countEventsToDownload(): DownSyncCounts + suspend fun countEventsToDownload(maxCacheAgeMillis: Long = 0): DownSyncCounts suspend fun downSyncSubject( projectId: String, diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManagerImpl.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManagerImpl.kt index e3ea7842bd..73bd0723c2 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManagerImpl.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManagerImpl.kt @@ -1,6 +1,7 @@ package com.simprints.infra.eventsync import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData import com.simprints.core.DispatcherIO import com.simprints.core.domain.tokenization.values import com.simprints.core.tools.time.TimeHelper @@ -46,7 +47,15 @@ internal class EventSyncManagerImpl @Inject constructor( ) : EventSyncManager { override suspend fun getLastSyncTime(): Timestamp? = eventSyncCache.readLastSuccessfulSyncTime() - override fun getLastSyncState(): LiveData = eventSyncStateProcessor.getLastSyncState() + override fun getLastSyncState(useDefaultValue: Boolean): LiveData = + MediatorLiveData().apply { + if (useDefaultValue) { + value = EventSyncState(syncId = "", null, null, emptyList(), emptyList(), emptyList()) + } + addSource(eventSyncStateProcessor.getLastSyncState()) { lastSyncState -> + value = lastSyncState + } + } override fun getPeriodicWorkTags(): List = listOf( MASTER_SYNC_SCHEDULERS, @@ -68,7 +77,18 @@ internal class EventSyncManagerImpl @Inject constructor( types.map { eventRepository.observeEventCount(it) }, ) { it.sum() } - override suspend fun countEventsToDownload(): DownSyncCounts { + private var cachedEventCountToDownload: DownSyncCounts? = null + private var cachedEventCountToDownloadTimestamp: Long = 0 + + override suspend fun countEventsToDownload(maxCacheAgeMillis: Long): DownSyncCounts { + val timeNowMs = timeHelper.now().ms + cachedEventCountToDownload?.takeIf { + timeNowMs - cachedEventCountToDownloadTimestamp < maxCacheAgeMillis + }?.let { + return it + }.also { + cachedEventCountToDownloadTimestamp = timeNowMs + } val projectConfig = configRepository.getProjectConfiguration() val deviceConfig = configRepository.getDeviceConfiguration() @@ -85,7 +105,9 @@ internal class EventSyncManagerImpl @Inject constructor( return DownSyncCounts( count = counts.sumOf { it.count }, isLowerBound = counts.any { it.isLowerBound }, - ) + ).also { + cachedEventCountToDownload = it + } } override suspend fun downSyncSubject( diff --git a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/EventSyncManagerTest.kt b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/EventSyncManagerTest.kt index 27c8b429ff..0eb287ec90 100644 --- a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/EventSyncManagerTest.kt +++ b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/EventSyncManagerTest.kt @@ -1,5 +1,6 @@ package com.simprints.infra.eventsync +import androidx.lifecycle.MutableLiveData import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.simprints.core.domain.common.Partitioning @@ -17,6 +18,7 @@ import com.simprints.infra.events.sampledata.SampleDefaults.DEFAULT_PROJECT_ID import com.simprints.infra.eventsync.event.remote.EventRemoteDataSource import com.simprints.infra.eventsync.status.down.EventDownSyncScopeRepository import com.simprints.infra.eventsync.status.models.DownSyncCounts +import com.simprints.infra.eventsync.status.models.EventSyncState import com.simprints.infra.eventsync.status.up.EventUpSyncScopeRepository import com.simprints.infra.eventsync.sync.EventSyncStateProcessor import com.simprints.infra.eventsync.sync.common.EventSyncCache @@ -116,6 +118,25 @@ internal class EventSyncManagerTest { verify { eventSyncStateProcessor.getLastSyncState() } } + @Test + fun `getLastSyncState with useDefaultValue true should return an immediate default value`() = runTest { + every { eventSyncStateProcessor.getLastSyncState() } returns MutableLiveData(null) + val defaultValue = EventSyncState(syncId = "", null, null, emptyList(), emptyList(), emptyList()) + + val result = eventSyncManagerImpl.getLastSyncState(true).value + + assertThat(result).isEqualTo(defaultValue) + } + + @Test + fun `getLastSyncState with useDefaultValue false and no data emission should return null value`() = runTest { + every { eventSyncStateProcessor.getLastSyncState() } returns MutableLiveData(null) + + val result = eventSyncManagerImpl.getLastSyncState(false).value + + assertThat(result).isEqualTo(null) + } + @Test fun `countEventsToUpload without types should call event repo`() = runTest { eventSyncManagerImpl.countEventsToUpload().toList() @@ -131,7 +152,7 @@ internal class EventSyncManagerTest { } @Test - fun `getDownSyncCounts correctly counts sync events`() = runTest { + fun `countEventsToDownload correctly counts sync events`() = runTest { coEvery { eventDownSyncScopeRepository.getDownSyncScope(any(), any(), any()) } returns SampleSyncScopes.modulesDownSyncScope @@ -149,6 +170,39 @@ internal class EventSyncManagerTest { assertThat(result).isEqualTo(DownSyncCounts(26, isLowerBound = true)) } + @Test + fun `countEventsToDownload uses cache when within max age`() = runTest { + every { timeHelper.now() } returnsMany listOf(Timestamp(1000), Timestamp(5000)) + coEvery { + eventDownSyncScopeRepository.getDownSyncScope(any(), any(), any()) + } returns SampleSyncScopes.modulesDownSyncScope + coEvery { eventRemoteDataSource.count(any()) } returns EventCount(10, false) + coEvery { configRepository.getDeviceConfiguration() } returns mockk { + every { selectedModules } returns listOf(DEFAULT_MODULE_ID) + } + + eventSyncManagerImpl.countEventsToDownload(2000) // remote fetch + eventSyncManagerImpl.countEventsToDownload(2000) // cache hit + + coVerify(exactly = 2) { eventDownSyncScopeRepository.getDownSyncScope(any(), any(), any()) } + } + + @Test + fun `countEventsToDownload bypasses cache when exceeds max age`() = runTest { + every { timeHelper.now() } returnsMany listOf(Timestamp(1000), Timestamp(2000)) + coEvery { + eventDownSyncScopeRepository.getDownSyncScope(any(), any(), any()) + } returns SampleSyncScopes.modulesDownSyncScope + coEvery { configRepository.getDeviceConfiguration() } returns mockk { + every { selectedModules } returns listOf(DEFAULT_MODULE_ID) + } + + eventSyncManagerImpl.countEventsToDownload(2000) // remote fetch + eventSyncManagerImpl.countEventsToDownload(2000) // remote fetch + + coVerify(exactly = 1) { eventDownSyncScopeRepository.getDownSyncScope(any(), any(), any()) } + } + @Test fun `downSync should call down sync helper`() = runTest { coEvery { eventRepository.createEventScope(any()) } returns eventScope From 4689cdac41547651ac4604d6e05959c3ba7a6a5d Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 24 Jul 2025 11:15:40 +0100 Subject: [PATCH 03/45] MS-939 Data layer preparation: EventSyncMasterWorker to allow disabling down-sync when needed for new UI/UX --- .../sync/master/EventSyncMasterWorker.kt | 4 +- .../simprints/infra/sync/SyncOrchestrator.kt | 2 +- .../infra/sync/SyncOrchestratorImpl.kt | 3 +- .../infra/sync/SyncOrchestratorImplTest.kt | 120 +++++++++++++++++- 4 files changed, 125 insertions(+), 4 deletions(-) diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/master/EventSyncMasterWorker.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/master/EventSyncMasterWorker.kt index ca0529b29f..4962d3d884 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/master/EventSyncMasterWorker.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/master/EventSyncMasterWorker.kt @@ -96,7 +96,8 @@ class EventSyncMasterWorker @AssistedInject internal constructor( ).also { Simber.d("Scheduled ${it.size} up workers", tag = tag) } } - if (configuration.isEventDownSyncAllowed()) { + val isDownSyncAllowedInWorker = inputData.getBoolean(IS_DOWN_SYNC_ALLOWED, true) + if (configuration.isEventDownSyncAllowed() && isDownSyncAllowedInWorker) { eventRepository.createEventScope( EventScopeType.DOWN_SYNC, downSyncWorkerScopeId, @@ -161,5 +162,6 @@ class EventSyncMasterWorker @AssistedInject internal constructor( companion object { const val OUTPUT_LAST_SYNC_ID = "OUTPUT_LAST_SYNC_ID" + const val IS_DOWN_SYNC_ALLOWED = "IS_DOWN_SYNC_ALLOWED" } } 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 e2c26bc0f0..0f171a4c0d 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 @@ -17,7 +17,7 @@ interface SyncOrchestrator { fun cancelEventSync() - fun startEventSync() + fun startEventSync(isDownSyncAllowed: Boolean = true) fun stopEventSync() 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 35c1f0d3e5..bcc6195bbb 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 @@ -126,10 +126,11 @@ internal class SyncOrchestratorImpl @Inject constructor( stopEventSync() } - override fun startEventSync() { + override fun startEventSync(isDownSyncAllowed: Boolean) { workManager.startWorker( SyncConstants.EVENT_SYNC_WORK_NAME_ONE_TIME, tags = eventSyncManager.getOneTimeWorkTags(), + inputData = workDataOf(EventSyncMasterWorker.IS_DOWN_SYNC_ALLOWED to isDownSyncAllowed), ) } 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 fcbeb8ced1..1903f9e6fa 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,5 +1,6 @@ package com.simprints.infra.sync +import androidx.work.Data import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequest @@ -10,6 +11,7 @@ import com.google.common.util.concurrent.ListenableFuture import com.simprints.infra.authstore.AuthStore import com.simprints.infra.config.sync.ConfigManager 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 @@ -18,6 +20,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.SyncConstants.PROJECT_SYNC_WORK_NAME_ONE_TIME +import com.simprints.infra.sync.SyncConstants.PROGRESS_CURRENT +import com.simprints.infra.sync.SyncConstants.PROGRESS_MAX 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 @@ -33,6 +37,8 @@ import io.mockk.verify import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.count +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Before @@ -62,6 +68,9 @@ class SyncOrchestratorImplTest { @MockK private lateinit var cleanupDeprecatedWorkers: CleanupDeprecatedWorkersUseCase + @MockK + private lateinit var imageSyncTimestampProvider: ImageSyncTimestampProvider + private lateinit var syncOrchestrator: SyncOrchestratorImpl @Before @@ -261,6 +270,23 @@ class SyncOrchestratorImplTest { } } + @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" @@ -268,6 +294,7 @@ class SyncOrchestratorImplTest { syncOrchestrator.cancelEventSync() verify { + workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME) workManager.cancelUniqueWork(EVENT_SYNC_WORK_NAME_ONE_TIME) workManager.cancelAllWorkByTag("syncWorkers") } @@ -286,6 +313,81 @@ class SyncOrchestratorImplTest { } } + @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 `observe image sync status returns syncing when worker is running`() = runTest { + val workInfoFlow = flowOf(createWorkInfo(WorkInfo.State.RUNNING)) + every { workManager.getWorkInfosFlow(any()) } returns workInfoFlow + every { imageSyncTimestampProvider.getSecondsSinceLastImageSync() } returns 30L + + val status = syncOrchestrator.observeImageSyncStatus().first() + + assertThat(status.isSyncing).isTrue() + assertThat(status.secondsSinceLastUpdate).isEqualTo(30L) + } + + @Test + fun `observe image sync status returns not syncing when worker is cancelled`() = runTest { + val workInfoFlow = flowOf(createWorkInfo(WorkInfo.State.CANCELLED)) + every { workManager.getWorkInfosFlow(any()) } returns workInfoFlow + every { imageSyncTimestampProvider.getSecondsSinceLastImageSync() } returns 120L + + val status = syncOrchestrator.observeImageSyncStatus().first() + + assertThat(status.isSyncing).isFalse() + assertThat(status.secondsSinceLastUpdate).isEqualTo(120L) + } + + @Test + fun `observe image sync status includes progress when available`() = runTest { + val workInfo1 = createWorkInfoWithProgress(WorkInfo.State.RUNNING, current = 5, max = 10) + val workInfo2 = createWorkInfoWithProgress(WorkInfo.State.RUNNING) + val workInfoFlow = flowOf(workInfo1, workInfo2) + every { workManager.getWorkInfosFlow(any()) } returns workInfoFlow + every { imageSyncTimestampProvider.getSecondsSinceLastImageSync() } returns 0L + + val status1 = syncOrchestrator.observeImageSyncStatus().first() + assertThat(status1.progress).isEqualTo(5 to 10) + + val status2 = syncOrchestrator.observeImageSyncStatus().drop(1).first() + assertThat(status2.progress).isEqualTo(null) + } + + @Test + fun `observe image sync status returns syncing momentarily when worker succeeds quickly`() = runTest { + val workInfoFlow = flowOf(createWorkInfo(WorkInfo.State.SUCCEEDED)) + every { workManager.getWorkInfosFlow(any()) } returns workInfoFlow + every { imageSyncTimestampProvider.getSecondsSinceLastImageSync() } returns 0L + + val status1 = syncOrchestrator.observeImageSyncStatus().first() + assertThat(status1.isSyncing).isTrue() + + val status2 = syncOrchestrator.observeImageSyncStatus().drop(1).first() + assertThat(status2.isSyncing).isFalse() + } + @Test fun `schedules record upload`() = runTest { syncOrchestrator.uploadEnrolmentRecords(INSTRUCTION_ID, listOf(SUBJECT_ID)) @@ -311,7 +413,10 @@ class SyncOrchestratorImplTest { @Test fun `delegates sync info deletion`() = runTest { syncOrchestrator.deleteEventSyncInfo() - coVerify { eventSyncManager.deleteSyncInfo() } + coVerify { + eventSyncManager.deleteSyncInfo() + imageSyncTimestampProvider.clearTimestamp() + } } @Test @@ -363,6 +468,7 @@ class SyncOrchestratorImplTest { eventSyncManager, shouldScheduleFirmwareUpdate, cleanupDeprecatedWorkers, + imageSyncTimestampProvider, CoroutineScope(testCoroutineRule.testCoroutineDispatcher), ) @@ -372,6 +478,18 @@ class SyncOrchestratorImplTest { WorkInfo(UUID.randomUUID(), state, emptySet()), ) + private fun createWorkInfoWithProgress(state: WorkInfo.State, current: Int? = null, max: Int? = null): List { + val workInfo = mockk { + every { this@mockk.state } returns state + every { progress } returns Data.Builder() + .apply { + current?.let { putInt(PROGRESS_CURRENT, current) } + max?.let { putInt(PROGRESS_MAX, max) } + }.build() + } + return listOf(workInfo) + } + companion object { private const val INSTRUCTION_ID = "id" private const val SUBJECT_ID = "subjectId" From 12c9f93cd6db194b841b510a5eda28586fce9fbe Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 24 Jul 2025 11:17:09 +0100 Subject: [PATCH 04/45] MS-939 Data layer preparation: sync progress callbacks needed for new UI/UX --- .../simprints/infra/images/ImageRepository.kt | 6 +- .../infra/images/ImageRepositoryImpl.kt | 6 +- .../infra/images/remote/SampleUploader.kt | 2 +- .../firestore/FirestoreSampleUploader.kt | 6 +- .../signedurl/SignedUrlSampleUploader.kt | 7 +- .../infra/images/ImageRepositoryImplTest.kt | 32 ++++++++- .../firestore/FirestoreSampleUploaderTest.kt | 18 +++++ .../signedurl/SignedUrlSampleUploaderTest.kt | 17 +++++ .../com/simprints/infra/sync/SyncConstants.kt | 3 + .../infra/sync/files/FileUpSyncWorker.kt | 19 ++++- .../infra/sync/files/FileUpSyncWorkerTest.kt | 71 +++++++++++++++++-- 11 files changed, 172 insertions(+), 15 deletions(-) diff --git a/infra/images/src/main/java/com/simprints/infra/images/ImageRepository.kt b/infra/images/src/main/java/com/simprints/infra/images/ImageRepository.kt index 3f422b03c4..cb97ddd851 100644 --- a/infra/images/src/main/java/com/simprints/infra/images/ImageRepository.kt +++ b/infra/images/src/main/java/com/simprints/infra/images/ImageRepository.kt @@ -31,9 +31,13 @@ interface ImageRepository { /** * Uploads all images stored locally for the project and deletes if the upload has been successful * + * @param progressCallback optional callback to report current and max item counts of progress * @return true if all images have been successfully uploaded and deleted from the device */ - suspend fun uploadStoredImagesAndDelete(projectId: String): Boolean + suspend fun uploadStoredImagesAndDelete( + projectId: String, + progressCallback: (suspend (Int, Int) -> Unit)? = null + ): Boolean /** * Deletes all images stored on the device diff --git a/infra/images/src/main/java/com/simprints/infra/images/ImageRepositoryImpl.kt b/infra/images/src/main/java/com/simprints/infra/images/ImageRepositoryImpl.kt index 73f87648d2..6a65d08e00 100644 --- a/infra/images/src/main/java/com/simprints/infra/images/ImageRepositoryImpl.kt +++ b/infra/images/src/main/java/com/simprints/infra/images/ImageRepositoryImpl.kt @@ -52,7 +52,11 @@ internal class ImageRepositoryImpl @Inject internal constructor( override suspend fun getNumberOfImagesToUpload(projectId: String): Int = localDataSource.listImages(projectId).count() - override suspend fun uploadStoredImagesAndDelete(projectId: String): Boolean = getSampleUploader().uploadAllSamples(projectId) + override suspend fun uploadStoredImagesAndDelete( + projectId: String, + progressCallback: (suspend (Int, Int) -> Unit)?, + ): Boolean = + getSampleUploader().uploadAllSamples(projectId, progressCallback) override suspend fun deleteStoredImages() { metadataStore.deleteAllMetadata() diff --git a/infra/images/src/main/java/com/simprints/infra/images/remote/SampleUploader.kt b/infra/images/src/main/java/com/simprints/infra/images/remote/SampleUploader.kt index 0e8b138d04..dae0844a29 100644 --- a/infra/images/src/main/java/com/simprints/infra/images/remote/SampleUploader.kt +++ b/infra/images/src/main/java/com/simprints/infra/images/remote/SampleUploader.kt @@ -8,5 +8,5 @@ internal interface SampleUploader { * Uploads all locally stored samples. * On successful upload, the file and the associated metadata are deleted. */ - suspend fun uploadAllSamples(projectId: String): Boolean + suspend fun uploadAllSamples(projectId: String, progressCallback: (suspend (Int, Int) -> Unit)? = null): Boolean } diff --git a/infra/images/src/main/java/com/simprints/infra/images/remote/firestore/FirestoreSampleUploader.kt b/infra/images/src/main/java/com/simprints/infra/images/remote/firestore/FirestoreSampleUploader.kt index 7de80828c0..5defb34d38 100644 --- a/infra/images/src/main/java/com/simprints/infra/images/remote/firestore/FirestoreSampleUploader.kt +++ b/infra/images/src/main/java/com/simprints/infra/images/remote/firestore/FirestoreSampleUploader.kt @@ -22,7 +22,7 @@ internal class FirestoreSampleUploader @Inject constructor( private val localDataSource: ImageLocalDataSource, private val metadataStore: ImageMetadataStore, ) : SampleUploader { - override suspend fun uploadAllSamples(projectId: String): Boolean { + override suspend fun uploadAllSamples(projectId: String, progressCallback: (suspend (Int, Int) -> Unit)?): Boolean { val firebaseApp = authStore.getLegacyAppFallback() if (firebaseApp.options.projectId.isNullOrBlank()) { Simber.i("Firebase projectId is null", tag = SAMPLE_UPLOAD) @@ -36,8 +36,10 @@ internal class FirestoreSampleUploader @Inject constructor( .getInstance(firebaseApp, bucketUrl) .reference - localDataSource.listImages(projectId).forEach { imageRef -> + val sampleReferences = localDataSource.listImages(projectId) + sampleReferences.forEachIndexed { index, imageRef -> Simber.i("Reading sample file: ${imageRef.relativePath.parts.last()}", tag = SAMPLE_UPLOAD) + progressCallback?.invoke(index, sampleReferences.size) try { localDataSource.decryptImage(imageRef)?.let { stream -> val metadata = metadataStore.getMetadata(imageRef.relativePath) diff --git a/infra/images/src/main/java/com/simprints/infra/images/remote/signedurl/SignedUrlSampleUploader.kt b/infra/images/src/main/java/com/simprints/infra/images/remote/signedurl/SignedUrlSampleUploader.kt index 8ea3e5eed9..41b8c379e9 100644 --- a/infra/images/src/main/java/com/simprints/infra/images/remote/signedurl/SignedUrlSampleUploader.kt +++ b/infra/images/src/main/java/com/simprints/infra/images/remote/signedurl/SignedUrlSampleUploader.kt @@ -25,14 +25,16 @@ internal class SignedUrlSampleUploader @Inject constructor( private val uploadSampleWithTracking: UploadSampleWithTrackingUseCase, private val fetchUploadUrlsPerSample: FetchUploadUrlsPerSampleUseCase, ) : SampleUploader { - override suspend fun uploadAllSamples(projectId: String): Boolean { + override suspend fun uploadAllSamples(projectId: String, progressCallback: (suspend (Int, Int) -> Unit)?): Boolean { var allImagesUploaded = true val batchSize = getBatchSize() val urlRequestScope = eventRepository.createEventScope(type = EventScopeType.SAMPLE_UP_SYNC) Simber.i("Starting image upload in batches of $batchSize (Scope ID: ${urlRequestScope.id}") - val sampleReferenceBatches = localDataSource + val sampleReferences = localDataSource .listImages(projectId) + var sampleIndex = 0 + val sampleReferenceBatches = sampleReferences // Preparing the file for upload requires reading each of them to calculate md5 and size, // therefore splitting the list into batches before preparing allows to avoid some work in // cases where there are large amounts of files and the coroutine is being interrupted, @@ -76,6 +78,7 @@ internal class SignedUrlSampleUploader @Inject constructor( break } Simber.i("Uploading ${sample.sampleId}") + progressCallback?.invoke(sampleIndex++, sampleReferences.size) val url = sampleIdToUrlMap[sample.sampleId] if (url == null) { diff --git a/infra/images/src/test/java/com/simprints/infra/images/ImageRepositoryImplTest.kt b/infra/images/src/test/java/com/simprints/infra/images/ImageRepositoryImplTest.kt index e63c8f76f6..ec105357cc 100644 --- a/infra/images/src/test/java/com/simprints/infra/images/ImageRepositoryImplTest.kt +++ b/infra/images/src/test/java/com/simprints/infra/images/ImageRepositoryImplTest.kt @@ -43,7 +43,7 @@ internal class ImageRepositoryImplTest { MockKAnnotations.init(this, relaxed = true) every { samplePathConverter.create(any(), any(), any(), any()) } returns Path(VALID_PATH) - coEvery { sampleUploader.uploadAllSamples(any()) } returns true + coEvery { sampleUploader.uploadAllSamples(any(), any()) } returns true coEvery { getUploaderUseCase.invoke() } returns sampleUploader repository = ImageRepositoryImpl( @@ -117,7 +117,35 @@ internal class ImageRepositoryImplTest { val successful = repository.uploadStoredImagesAndDelete(PROJECT_ID) assertThat(successful).isTrue() - coVerify { sampleUploader.uploadAllSamples(any()) } + coVerify { sampleUploader.uploadAllSamples(any(), any()) } + } + + @Test + fun `delegates sample upload to uploader with progress callback`() = runTest { + val progressCallback: suspend (Int, Int) -> Unit = mockk(relaxed = true) + val successful = repository.uploadStoredImagesAndDelete(PROJECT_ID, progressCallback) + + assertThat(successful).isTrue() + coVerify { sampleUploader.uploadAllSamples(PROJECT_ID, progressCallback) } + } + + @Test + fun `progress callback receives correct values`() = runTest { + var (receivedCurrent, receivedTotal) = -1 to -1 + val progressCallback: suspend (Int, Int) -> Unit = { current, total -> + receivedCurrent = current + receivedTotal = total + } + coEvery { sampleUploader.uploadAllSamples(any(), any()) } coAnswers { + val callback = secondArg Unit>() + callback(3, 10) + true + } + + repository.uploadStoredImagesAndDelete(PROJECT_ID, progressCallback) + + assertThat(receivedCurrent).isEqualTo(3) + assertThat(receivedTotal).isEqualTo(10) } @Test diff --git a/infra/images/src/test/java/com/simprints/infra/images/remote/firestore/FirestoreSampleUploaderTest.kt b/infra/images/src/test/java/com/simprints/infra/images/remote/firestore/FirestoreSampleUploaderTest.kt index 1bb2ab638e..d1a8f1f144 100644 --- a/infra/images/src/test/java/com/simprints/infra/images/remote/firestore/FirestoreSampleUploaderTest.kt +++ b/infra/images/src/test/java/com/simprints/infra/images/remote/firestore/FirestoreSampleUploaderTest.kt @@ -138,6 +138,24 @@ class FirestoreSampleUploaderTest { assertThat(remoteDataSource.uploadAllSamples(PROJECT_ID)).isFalse() } + @Test + fun `progress callback receives correct index counter values during upload`() = runTest { + setupProjectConfig() + setupStorageMock() + configureLocalImageFiles(numberOfValidFiles = 3) + val progressUpdates = mutableListOf>() + val progressCallback: suspend (Int, Int) -> Unit = { current, total -> + progressUpdates.add(current to total) + } + + assertThat(remoteDataSource.uploadAllSamples(PROJECT_ID, progressCallback)).isTrue() + + assertThat(progressUpdates).hasSize(3) + assertThat(progressUpdates[0]).isEqualTo(0 to 3) + assertThat(progressUpdates[1]).isEqualTo(1 to 3) + assertThat(progressUpdates[2]).isEqualTo(2 to 3) + } + private fun setupProjectConfig() { coEvery { configManager.getProject(any()).imageBucket } returns "gs://`simprints-dev.appspot.com" every { authStore.getLegacyAppFallback().options.projectId } returns "projectId" diff --git a/infra/images/src/test/java/com/simprints/infra/images/remote/signedurl/SignedUrlSampleUploaderTest.kt b/infra/images/src/test/java/com/simprints/infra/images/remote/signedurl/SignedUrlSampleUploaderTest.kt index 5842a952e5..6292be777b 100644 --- a/infra/images/src/test/java/com/simprints/infra/images/remote/signedurl/SignedUrlSampleUploaderTest.kt +++ b/infra/images/src/test/java/com/simprints/infra/images/remote/signedurl/SignedUrlSampleUploaderTest.kt @@ -220,6 +220,23 @@ internal class SignedUrlSampleUploaderTest { } } + @Test + fun `progress callback receives correct index counter values during upload`() = runTest { + val progressValues = mutableListOf>() + val progressCallback: suspend (Int, Int) -> Unit = { current, total -> + progressValues.add(current to total) + } + mockBatchSize(1) + coEvery { localDataSource.listImages(any()) } returns List(3) { mockImageRef("${SAMPLE_ID}_$it") } + + signedUrlSampleUploader.uploadAllSamples(PROJECT_ID, progressCallback) + + assertThat(progressValues).hasSize(3) + assertThat(progressValues[0]).isEqualTo(0 to 3) + assertThat(progressValues[1]).isEqualTo(1 to 3) + assertThat(progressValues[2]).isEqualTo(2 to 3) + } + private fun mockBatchSize(batchSize: Int) { coEvery { configRepository diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/SyncConstants.kt b/infra/sync/src/main/java/com/simprints/infra/sync/SyncConstants.kt index a600ff2825..cf716cebb3 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/SyncConstants.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/SyncConstants.kt @@ -27,4 +27,7 @@ internal object SyncConstants { const val EVENT_SYNC_WORK_NAME = "event-sync-work" const val EVENT_SYNC_WORK_NAME_ONE_TIME = "event-sync-work-one-time" const val EVENT_SYNC_WORKER_INTERVAL = BuildConfig.EVENT_SYNC_WORKER_INTERVAL_MINUTES + + const val PROGRESS_CURRENT = "progress_current" + const val PROGRESS_MAX = "progress_max" } diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/files/FileUpSyncWorker.kt b/infra/sync/src/main/java/com/simprints/infra/sync/files/FileUpSyncWorker.kt index 64a4829a93..fdd44a8dac 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/files/FileUpSyncWorker.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/files/FileUpSyncWorker.kt @@ -3,11 +3,14 @@ package com.simprints.infra.sync.files import android.content.Context import androidx.hilt.work.HiltWorker import androidx.work.WorkerParameters +import androidx.work.workDataOf import com.simprints.core.DispatcherBG import com.simprints.core.workers.SimCoroutineWorker import com.simprints.fingerprint.infra.imagedistortionconfig.ImageDistortionConfigRepo import com.simprints.infra.authstore.AuthStore import com.simprints.infra.images.ImageRepository +import com.simprints.infra.sync.SyncConstants +import com.simprints.infra.sync.ImageSyncTimestampProvider import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineDispatcher @@ -23,6 +26,7 @@ internal class FileUpSyncWorker @AssistedInject constructor( private val imageRepository: ImageRepository, private val imageDistortionConfigRepo: ImageDistortionConfigRepo, private val authStore: AuthStore, + private val imageSyncTimestampProvider: ImageSyncTimestampProvider, @DispatcherBG private val dispatcher: CoroutineDispatcher, ) : SimCoroutineWorker(context, params) { override val tag: String = "FileUpSyncWorker" @@ -32,7 +36,20 @@ internal class FileUpSyncWorker @AssistedInject constructor( try { when { !imageDistortionConfigRepo.uploadPendingConfigs() -> retry() - imageRepository.uploadStoredImagesAndDelete(authStore.signedInProjectId) -> success() + imageRepository.uploadStoredImagesAndDelete( + authStore.signedInProjectId, + progressCallback = { currentIndex, max -> + setProgress( + workDataOf( + SyncConstants.PROGRESS_CURRENT to currentIndex, + SyncConstants.PROGRESS_MAX to max, + ) + ) + } + ) -> success().also { + imageSyncTimestampProvider.saveImageSyncCompletionTimestampNow() + } + else -> retry() } } catch (ex: Exception) { diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/files/FileUpSyncWorkerTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/files/FileUpSyncWorkerTest.kt index 3090aa6a9c..bc7ae66401 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/files/FileUpSyncWorkerTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/files/FileUpSyncWorkerTest.kt @@ -5,6 +5,8 @@ import com.google.common.truth.Truth import com.simprints.fingerprint.infra.imagedistortionconfig.ImageDistortionConfigRepo import com.simprints.infra.authstore.AuthStore import com.simprints.infra.images.ImageRepository +import com.simprints.infra.sync.ImageSyncTimestampProvider +import com.simprints.infra.sync.SyncConstants import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.* import io.mockk.coEvery @@ -33,11 +35,14 @@ class FileUpSyncWorkerTest { @MockK private lateinit var authStore: AuthStore + @MockK + private lateinit var imageSyncTimestampProvider: ImageSyncTimestampProvider + private lateinit var fileUpSyncWorker: FileUpSyncWorker @Before fun setUp() { - MockKAnnotations.init(this) + MockKAnnotations.init(this, relaxUnitFun = true) every { authStore.signedInProjectId } returns PROJECT_ID fileUpSyncWorker = FileUpSyncWorker( @@ -46,6 +51,7 @@ class FileUpSyncWorkerTest { imageRepository, imageDistortionConfigRepo, authStore, + imageSyncTimestampProvider, testCoroutineRule.testCoroutineDispatcher, ) } @@ -62,13 +68,14 @@ class FileUpSyncWorkerTest { Truth.assertThat(Result.retry()).isEqualTo(result) coVerify(exactly = 1) { imageDistortionConfigRepo.uploadPendingConfigs() } coVerify(exactly = 0) { imageRepository.uploadStoredImagesAndDelete(any()) } + coVerify(exactly = 0) { imageSyncTimestampProvider.saveImageSyncCompletionTimestampNow() } } @Test fun `doWork returns success when uploadStoredImagesAndDelete succeeds`() = runBlocking { // Given coEvery { imageDistortionConfigRepo.uploadPendingConfigs() } returns true - coEvery { imageRepository.uploadStoredImagesAndDelete(PROJECT_ID) } returns true + coEvery { imageRepository.uploadStoredImagesAndDelete(PROJECT_ID, any()) } returns true // When val result = fileUpSyncWorker.doWork() @@ -76,14 +83,15 @@ class FileUpSyncWorkerTest { // Then Truth.assertThat(Result.success()).isEqualTo(result) coVerify(exactly = 1) { imageDistortionConfigRepo.uploadPendingConfigs() } - coVerify(exactly = 1) { imageRepository.uploadStoredImagesAndDelete(PROJECT_ID) } + coVerify(exactly = 1) { imageRepository.uploadStoredImagesAndDelete(PROJECT_ID, any()) } + coVerify(exactly = 1) { imageSyncTimestampProvider.saveImageSyncCompletionTimestampNow() } } @Test fun `doWork returns retry when uploadStoredImagesAndDelete fails`() = runBlocking { // Given coEvery { imageDistortionConfigRepo.uploadPendingConfigs() } returns true - coEvery { imageRepository.uploadStoredImagesAndDelete(PROJECT_ID) } returns false + coEvery { imageRepository.uploadStoredImagesAndDelete(PROJECT_ID, any()) } returns false // When val result = fileUpSyncWorker.doWork() @@ -91,7 +99,8 @@ class FileUpSyncWorkerTest { // Then Truth.assertThat(Result.retry()).isEqualTo(result) coVerify(exactly = 1) { imageDistortionConfigRepo.uploadPendingConfigs() } - coVerify(exactly = 1) { imageRepository.uploadStoredImagesAndDelete(PROJECT_ID) } + coVerify(exactly = 1) { imageRepository.uploadStoredImagesAndDelete(PROJECT_ID, any()) } + coVerify(exactly = 0) { imageSyncTimestampProvider.saveImageSyncCompletionTimestampNow() } } @Test @@ -106,5 +115,57 @@ class FileUpSyncWorkerTest { Truth.assertThat(Result.retry()).isEqualTo(result) coVerify(exactly = 1) { imageDistortionConfigRepo.uploadPendingConfigs() } coVerify(exactly = 0) { imageRepository.uploadStoredImagesAndDelete(any()) } + coVerify(exactly = 0) { imageSyncTimestampProvider.saveImageSyncCompletionTimestampNow() } + } + + @Test + fun `doWork calls progress callback during image upload`() = runBlocking { + // Given + coEvery { imageDistortionConfigRepo.uploadPendingConfigs() } returns true + var progressCallbackReceived: (suspend (Int, Int) -> Unit)? = null + coEvery { imageRepository.uploadStoredImagesAndDelete(PROJECT_ID, any()) } coAnswers { + progressCallbackReceived = secondArg Unit>() + true + } + + // When + fileUpSyncWorker.doWork() + + // Then + coVerify(exactly = 1) { imageRepository.uploadStoredImagesAndDelete(PROJECT_ID, any()) } + Truth.assertThat(progressCallbackReceived).isNotNull() + } + + @Test + fun `doWork progress callback receives correct progress values`() = runBlocking { + // Given + coEvery { imageDistortionConfigRepo.uploadPendingConfigs() } returns true + val progressValues = mutableListOf>() + coEvery { imageRepository.uploadStoredImagesAndDelete(PROJECT_ID, any()) } coAnswers { + val progressCallback = secondArg Unit>() + progressCallback(2, 10) + progressCallback(5, 10) + progressCallback(10, 10) + true + } + val mockWorker = spyk(fileUpSyncWorker) { + coEvery { setProgress(any()) } coAnswers { + val workData = firstArg() + val current = workData.getInt(SyncConstants.PROGRESS_CURRENT, -1) + val max = workData.getInt(SyncConstants.PROGRESS_MAX, -1) + progressValues.add(current to max) + } + } + + // When + mockWorker.doWork() + + // Then + coVerify(exactly = 1) { imageRepository.uploadStoredImagesAndDelete(PROJECT_ID, any()) } + Truth.assertThat(progressValues).containsExactly( + 2 to 10, + 5 to 10, + 10 to 10, + ).inOrder() } } From ced2db7dba0a3e58a941c52e7340b0513bea4ccc Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 24 Jul 2025 11:18:09 +0100 Subject: [PATCH 05/45] MS-939 Data layer preparation: project configuration extension functions needed for new UI/UX --- .../store/models/ProjectConfiguration.kt | 17 ++ .../store/models/ProjectConfigurationTest.kt | 187 ++++++++++++++++++ 2 files changed, 204 insertions(+) diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ProjectConfiguration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ProjectConfiguration.kt index de6e8d9aaf..8978928cd1 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ProjectConfiguration.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ProjectConfiguration.kt @@ -73,3 +73,20 @@ fun ProjectConfiguration.sortedUniqueAgeGroups(): List { fun ProjectConfiguration.isAgeRestricted() = allowedAgeRanges().any { !it.isEmpty() } fun ProjectConfiguration.experimental(): ExperimentalProjectConfiguration = ExperimentalProjectConfiguration(custom) + +// module sync + +fun ProjectConfiguration.isProjectWithModuleSync(): Boolean = + synchronization.down.simprints.partitionType == DownSynchronizationConfiguration.PartitionType.MODULE + +fun ProjectConfiguration.isProjectWithPeriodicallyUpSync(): Boolean = + synchronization.up.simprints.frequency == Frequency.ONLY_PERIODICALLY_UP_SYNC + +fun ProjectConfiguration.isModuleSelectionAvailable(): Boolean = + isProjectWithModuleSync() && isProjectWithPeriodicallyUpSync() + +fun ProjectConfiguration.areModuleOptionsEmpty(): Boolean = + synchronization.down.simprints.moduleOptions.isEmpty() + +fun ProjectConfiguration.isMissingModulesToChooseFrom(): Boolean = + isProjectWithModuleSync() && areModuleOptionsEmpty() diff --git a/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ProjectConfigurationTest.kt b/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ProjectConfigurationTest.kt index 7deecd49d8..7ce6a172ac 100644 --- a/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ProjectConfigurationTest.kt +++ b/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ProjectConfigurationTest.kt @@ -7,6 +7,7 @@ import com.simprints.infra.config.store.models.UpSynchronizationConfiguration.Up import com.simprints.infra.config.store.models.UpSynchronizationConfiguration.UpSynchronizationKind.NONE import com.simprints.infra.config.store.models.UpSynchronizationConfiguration.UpSynchronizationKind.ONLY_ANALYTICS import com.simprints.infra.config.store.models.UpSynchronizationConfiguration.UpSynchronizationKind.ONLY_BIOMETRICS +import com.simprints.core.domain.tokenization.asTokenizableEncrypted import com.simprints.infra.config.store.testtools.faceConfiguration import com.simprints.infra.config.store.testtools.faceSdkConfiguration import com.simprints.infra.config.store.testtools.fingerprintConfiguration @@ -463,4 +464,190 @@ class ProjectConfigurationTest { assertThat(result).isEqualTo(expected) } + + @Test + fun `isProjectWithModuleSync should return true when partition type is MODULE`() { + val config = projectConfiguration.copy( + synchronization = synchronizationConfiguration.copy( + down = synchronizationConfiguration.down.copy( + simprints = simprintsDownSyncConfigurationConfiguration.copy( + partitionType = DownSynchronizationConfiguration.PartitionType.MODULE, + ), + ), + ), + ) + assertThat(config.isProjectWithModuleSync()).isTrue() + } + + @Test + fun `isProjectWithModuleSync should return false when partition type is not MODULE`() { + val config = projectConfiguration.copy( + synchronization = synchronizationConfiguration.copy( + down = synchronizationConfiguration.down.copy( + simprints = simprintsDownSyncConfigurationConfiguration.copy( + partitionType = DownSynchronizationConfiguration.PartitionType.PROJECT, + ), + ), + ), + ) + assertThat(config.isProjectWithModuleSync()).isFalse() + } + + @Test + fun `isProjectWithPeriodicallyUpSync should return true when frequency is ONLY_PERIODICALLY_UP_SYNC`() { + val config = projectConfiguration.copy( + synchronization = synchronizationConfiguration.copy( + up = synchronizationConfiguration.up.copy( + simprints = simprintsUpSyncConfigurationConfiguration.copy( + frequency = Frequency.ONLY_PERIODICALLY_UP_SYNC, + ), + ), + ), + ) + assertThat(config.isProjectWithPeriodicallyUpSync()).isTrue() + } + + @Test + fun `isProjectWithPeriodicallyUpSync should return false when frequency is not ONLY_PERIODICALLY_UP_SYNC`() { + val config = projectConfiguration.copy( + synchronization = synchronizationConfiguration.copy( + up = synchronizationConfiguration.up.copy( + simprints = simprintsUpSyncConfigurationConfiguration.copy( + frequency = Frequency.PERIODICALLY, + ), + ), + ), + ) + assertThat(config.isProjectWithPeriodicallyUpSync()).isFalse() + } + + @Test + fun `isModuleSelectionAvailable should return true when project has MODULE and ONLY_PERIODICALLY_UP_SYNC`() { + val config = projectConfiguration.copy( + synchronization = synchronizationConfiguration.copy( + down = synchronizationConfiguration.down.copy( + simprints = simprintsDownSyncConfigurationConfiguration.copy( + partitionType = DownSynchronizationConfiguration.PartitionType.MODULE, + ), + ), + up = synchronizationConfiguration.up.copy( + simprints = simprintsUpSyncConfigurationConfiguration.copy( + frequency = Frequency.ONLY_PERIODICALLY_UP_SYNC, + ), + ), + ), + ) + assertThat(config.isModuleSelectionAvailable()).isTrue() + } + + @Test + fun `isModuleSelectionAvailable should return false when partition type is not MODULE`() { + val config = projectConfiguration.copy( + synchronization = synchronizationConfiguration.copy( + down = synchronizationConfiguration.down.copy( + simprints = simprintsDownSyncConfigurationConfiguration.copy( + partitionType = DownSynchronizationConfiguration.PartitionType.PROJECT, + ), + ), + up = synchronizationConfiguration.up.copy( + simprints = simprintsUpSyncConfigurationConfiguration.copy( + frequency = Frequency.ONLY_PERIODICALLY_UP_SYNC, + ), + ), + ), + ) + assertThat(config.isModuleSelectionAvailable()).isFalse() + } + + @Test + fun `isModuleSelectionAvailable should return false when frequency is not ONLY_PERIODICALLY_UP_SYNC`() { + val config = projectConfiguration.copy( + synchronization = synchronizationConfiguration.copy( + down = synchronizationConfiguration.down.copy( + simprints = simprintsDownSyncConfigurationConfiguration.copy( + partitionType = DownSynchronizationConfiguration.PartitionType.MODULE, + ), + ), + up = synchronizationConfiguration.up.copy( + simprints = simprintsUpSyncConfigurationConfiguration.copy( + frequency = Frequency.PERIODICALLY, + ), + ), + ), + ) + assertThat(config.isModuleSelectionAvailable()).isFalse() + } + + @Test + fun `areModuleOptionsEmpty should return true when moduleOptions is empty`() { + val config = projectConfiguration.copy( + synchronization = synchronizationConfiguration.copy( + down = synchronizationConfiguration.down.copy( + simprints = simprintsDownSyncConfigurationConfiguration.copy( + moduleOptions = emptyList(), + ), + ), + ), + ) + assertThat(config.areModuleOptionsEmpty()).isTrue() + } + + @Test + fun `areModuleOptionsEmpty should return false when moduleOptions is not empty`() { + val config = projectConfiguration.copy( + synchronization = synchronizationConfiguration.copy( + down = synchronizationConfiguration.down.copy( + simprints = simprintsDownSyncConfigurationConfiguration.copy( + moduleOptions = listOf("module1".asTokenizableEncrypted()), + ), + ), + ), + ) + assertThat(config.areModuleOptionsEmpty()).isFalse() + } + + @Test + fun `isMissingModulesToChooseFrom should return true when project has module sync and empty module options`() { + val config = projectConfiguration.copy( + synchronization = synchronizationConfiguration.copy( + down = synchronizationConfiguration.down.copy( + simprints = simprintsDownSyncConfigurationConfiguration.copy( + partitionType = DownSynchronizationConfiguration.PartitionType.MODULE, + moduleOptions = emptyList(), + ), + ), + ), + ) + assertThat(config.isMissingModulesToChooseFrom()).isTrue() + } + + @Test + fun `isMissingModulesToChooseFrom should return false when project has module sync but non-empty module options`() { + val config = projectConfiguration.copy( + synchronization = synchronizationConfiguration.copy( + down = synchronizationConfiguration.down.copy( + simprints = simprintsDownSyncConfigurationConfiguration.copy( + partitionType = DownSynchronizationConfiguration.PartitionType.MODULE, + moduleOptions = listOf("module1".asTokenizableEncrypted()), + ), + ), + ), + ) + assertThat(config.isMissingModulesToChooseFrom()).isFalse() + } + + @Test + fun `isMissingModulesToChooseFrom should return false when project has non-module sync and empty module options`() { + val config = projectConfiguration.copy( + synchronization = synchronizationConfiguration.copy( + down = synchronizationConfiguration.down.copy( + simprints = simprintsDownSyncConfigurationConfiguration.copy( + partitionType = DownSynchronizationConfiguration.PartitionType.PROJECT, + moduleOptions = listOf(), + ), + ), + ), + ) + assertThat(config.isMissingModulesToChooseFrom()).isFalse() + } } From bef19b0f45aa964eb66adeb452027d4a85c4e1dd Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 24 Jul 2025 11:20:38 +0100 Subject: [PATCH 06/45] MS-939 Configurable container for XML layouts that will contain the new UI/UX --- .../syncinfo/SyncInfoFragmentConfig.kt | 12 +++++++ .../ConfigurableSyncInfoFragmentContainer.kt | 33 +++++++++++++++++++ .../dashboard/src/main/res/values/attrs.xml | 13 ++++++++ 3 files changed, 58 insertions(+) create mode 100644 feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoFragmentConfig.kt create mode 100644 feature/dashboard/src/main/java/com/simprints/feature/dashboard/view/ConfigurableSyncInfoFragmentContainer.kt create mode 100644 feature/dashboard/src/main/res/values/attrs.xml diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoFragmentConfig.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoFragmentConfig.kt new file mode 100644 index 0000000000..f2a7c487d2 --- /dev/null +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoFragmentConfig.kt @@ -0,0 +1,12 @@ +package com.simprints.feature.dashboard.settings.syncinfo + +data class SyncInfoFragmentConfig( + val isSyncInfoToolbarVisible: Boolean = true, + val isSyncInfoStatusHeaderVisible: Boolean = false, + val isSyncInfoStatusHeaderSettingsButtonVisible: Boolean = false, + val areSyncInfoSectionHeadersVisible: Boolean = true, + val isSyncInfoImageSyncVisible: Boolean = true, + val isSyncInfoRecordsImagesCombined: Boolean = false, + val isSyncInfoLogoutOnComplete: Boolean = false, + val isSyncInfoModuleListVisible: Boolean = true, +) diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/view/ConfigurableSyncInfoFragmentContainer.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/view/ConfigurableSyncInfoFragmentContainer.kt new file mode 100644 index 0000000000..a9ac98d58c --- /dev/null +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/view/ConfigurableSyncInfoFragmentContainer.kt @@ -0,0 +1,33 @@ +package com.simprints.feature.dashboard.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.FrameLayout +import androidx.core.content.withStyledAttributes +import com.simprints.feature.dashboard.R +import com.simprints.feature.dashboard.settings.syncinfo.SyncInfoFragmentConfig + +class ConfigurableSyncInfoFragmentContainer @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + val syncInfoFragmentConfig: SyncInfoFragmentConfig? = attrs?.let { + var config: SyncInfoFragmentConfig? = null + context.withStyledAttributes(attrs, R.styleable.FragmentContainerView) { + config = SyncInfoFragmentConfig( + isSyncInfoToolbarVisible = getBoolean(R.styleable.FragmentContainerView_isSyncInfoToolbarVisible, true), + isSyncInfoStatusHeaderVisible = getBoolean(R.styleable.FragmentContainerView_isSyncInfoStatusHeaderVisible, false), + isSyncInfoStatusHeaderSettingsButtonVisible = getBoolean(R.styleable.FragmentContainerView_isSyncInfoStatusHeaderSettingsButtonVisible, false), + areSyncInfoSectionHeadersVisible = getBoolean(R.styleable.FragmentContainerView_areSyncInfoSectionHeadersVisible, true), + isSyncInfoImageSyncVisible = getBoolean(R.styleable.FragmentContainerView_isSyncInfoImageSyncVisible, true), + isSyncInfoRecordsImagesCombined = getBoolean(R.styleable.FragmentContainerView_isSyncInfoRecordsImagesCombined, false), + isSyncInfoLogoutOnComplete = getBoolean(R.styleable.FragmentContainerView_isSyncInfoLogoutOnComplete, false), + isSyncInfoModuleListVisible = getBoolean(R.styleable.FragmentContainerView_isSyncInfoModuleListVisible, true) + ) + } + config + } + +} diff --git a/feature/dashboard/src/main/res/values/attrs.xml b/feature/dashboard/src/main/res/values/attrs.xml new file mode 100644 index 0000000000..672695d0ee --- /dev/null +++ b/feature/dashboard/src/main/res/values/attrs.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + From 3964cfd837ba71fa9e63f4d627798a32b9411637 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 24 Jul 2025 11:23:02 +0100 Subject: [PATCH 07/45] MS-939 Module list item view update for the new UI/UX --- .../modulecount/ModuleCountViewHolder.kt | 9 +++++ .../src/main/res/drawable/ic_global.xml | 5 +++ .../src/main/res/drawable/ic_module.xml | 5 +++ .../src/main/res/layout/item_module_count.xml | 39 +++++++++++++------ .../dashboard/src/main/res/values/dimens.xml | 1 + 5 files changed, 47 insertions(+), 12 deletions(-) create mode 100644 feature/dashboard/src/main/res/drawable/ic_global.xml create mode 100644 feature/dashboard/src/main/res/drawable/ic_module.xml 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 d371130d67..05ea99c23f 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 @@ -2,6 +2,7 @@ package com.simprints.feature.dashboard.settings.syncinfo.modulecount import android.graphics.Color import android.view.View +import android.widget.ImageView import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import com.simprints.feature.dashboard.R @@ -9,6 +10,7 @@ import com.simprints.feature.dashboard.R internal class ModuleCountViewHolder( itemView: View, ) : RecyclerView.ViewHolder(itemView) { + private val moduleItemIcon: ImageView = itemView.findViewById(R.id.moduleItemIcon) private val moduleNameText: TextView = itemView.findViewById(R.id.moduleNameText) private val moduleCountText: TextView = itemView.findViewById(R.id.moduleCountText) @@ -16,6 +18,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 moduleCountText.text = moduleCount.count.toString() diff --git a/feature/dashboard/src/main/res/drawable/ic_global.xml b/feature/dashboard/src/main/res/drawable/ic_global.xml new file mode 100644 index 0000000000..7ac1a9f7ed --- /dev/null +++ b/feature/dashboard/src/main/res/drawable/ic_global.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/feature/dashboard/src/main/res/drawable/ic_module.xml b/feature/dashboard/src/main/res/drawable/ic_module.xml new file mode 100644 index 0000000000..c2537bb5ed --- /dev/null +++ b/feature/dashboard/src/main/res/drawable/ic_module.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/feature/dashboard/src/main/res/layout/item_module_count.xml b/feature/dashboard/src/main/res/layout/item_module_count.xml index 3e2d139b14..dfbf89b42f 100644 --- a/feature/dashboard/src/main/res/layout/item_module_count.xml +++ b/feature/dashboard/src/main/res/layout/item_module_count.xml @@ -3,25 +3,40 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" - android:orientation="horizontal"> + xmlns:app="http://schemas.android.com/apk/res-auto" + android:orientation="horizontal" + android:paddingVertical="8dp"> + + + android:layout_gravity="start" + android:layout_weight="0.85" + android:padding="4dp" + tools:text="Module1" /> + style="@style/Text.Body1.Secondary" + android:layout_width="0dp" + android:layout_height="32dp" + android:layout_gravity="center" + android:layout_marginEnd="7dp" + android:layout_weight="0.15" + android:gravity="end|center_vertical" + tools:text="39" + android:textSize="16sp" /> + diff --git a/feature/dashboard/src/main/res/values/dimens.xml b/feature/dashboard/src/main/res/values/dimens.xml index 1cf39d5b4d..265dfcca87 100644 --- a/feature/dashboard/src/main/res/values/dimens.xml +++ b/feature/dashboard/src/main/res/values/dimens.xml @@ -3,4 +3,5 @@ 16dp 0.7 0.3 + 48dp From 2bc38f0cf7474334d2b94a3febddef3582384ef9 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 24 Jul 2025 11:25:23 +0100 Subject: [PATCH 08/45] MS-939 Kotlin Flow extension functions what will be used in the ViewModel of the new sync UI/UX. These follow the idea of the functions in the Kotlin's standard library. --- .../core/tools/extentions/Flow.ext.kt | 47 ++++++++ .../core/tools/extentions/FlowExtTest.kt | 107 ++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 infra/core/src/main/java/com/simprints/core/tools/extentions/Flow.ext.kt create mode 100644 infra/core/src/test/java/com/simprints/core/tools/extentions/FlowExtTest.kt 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 new file mode 100644 index 0000000000..bda61aff51 --- /dev/null +++ b/infra/core/src/main/java/com/simprints/core/tools/extentions/Flow.ext.kt @@ -0,0 +1,47 @@ +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 combine8( + flow1: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + flow8: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8) -> R +): Flow = combine(flow1, flow2, flow3, flow4, flow5, flow6, flow7, flow8) { 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, + ) +} + +fun Flow.onChange(comparator: (T, T) -> Boolean, action: suspend (T) -> Unit) = + windowed(2, partial = true).map { window -> + val previousOrCurrent = window.first() + val current = window.last() + if (comparator(previousOrCurrent, current)) { + action(current) + } + current + } + +fun Flow.windowed(size: Int, partial: Boolean = false): Flow> = + scan(emptyList()) { acc, value -> + (acc + value).takeLast(size) + }.drop( + if (partial) 1 else size + ) 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 new file mode 100644 index 0000000000..b5b1cebff2 --- /dev/null +++ b/infra/core/src/test/java/com/simprints/core/tools/extentions/FlowExtTest.kt @@ -0,0 +1,107 @@ +package com.simprints.core.tools.extentions + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class FlowExtTest { + @Test + fun `combine8 combines 8 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 result = combine8(flow1, flow2, flow3, flow4, flow5, flow6, flow7, flow8) { t1, t2, t3, t4, t5, t6, t7, t8 -> + t1 + t2 + t3 + t4 + t5 + t6 + t7 + t8 + }.toList() + + assertThat(result).isEqualTo(listOf(1 + 2 + 3 + 4 + 5 + 6 + 7 + 8)) + } + + @Test + fun `onChange triggers action when comparator returns true`() = runTest { + val flow = flowOf(1, 2, 2, 3) + val triggeredValues = mutableListOf() + + val result = flow.onChange({ prev, curr -> prev != curr }) { value -> + triggeredValues.add(value) + }.toList() + + assertThat(result).isEqualTo(listOf(1, 2, 2, 3)) + assertThat(triggeredValues).isEqualTo(listOf(2, 3)) + } + + @Test + fun `onChange does not trigger action when comparator returns false`() = runTest { + val flow = flowOf(1, 1, 1) + val triggeredValues = mutableListOf() + + val result = flow.onChange({ prev, curr -> prev != curr }) { value -> + triggeredValues.add(value) + }.toList() + + assertThat(result).isEqualTo(listOf(1, 1, 1)) + assertThat(triggeredValues).isEmpty() + } + + @Test + fun `windowed creates correct windows with partial=false`() = runTest { + val flow = flowOf(1, 2, 3, 4, 5) + + val result = flow.windowed(3, partial = false).toList() + + assertThat(result).isEqualTo( + listOf( + listOf(1, 2, 3), + listOf(2, 3, 4), + listOf(3, 4, 5), + ) + ) + } + + @Test + fun `windowed creates correct windows with partial=true`() = runTest { + val flow = flowOf(1, 2, 3, 4, 5) + + val result = flow.windowed(3, partial = true).toList() + + assertThat(result).isEqualTo( + listOf( + listOf(1), + listOf(1, 2), + listOf(1, 2, 3), + listOf(2, 3, 4), + listOf(3, 4, 5), + ) + ) + } + + @Test + fun `windowed handles single element flow`() = runTest { + val flow = flowOf(1) + + val resultPartial = flow.windowed(3, partial = true).toList() + val resultNonPartial = flow.windowed(3, partial = false).toList() + + assertThat(resultPartial).isEqualTo(listOf(listOf(1))) + assertThat(resultNonPartial).isEmpty() + } + + @Test + fun `windowed handles empty flow`() = runTest { + val flow = flowOf() + + val resultPartial = flow.windowed(3, partial = true).toList() + val resultNonPartial = flow.windowed(3, partial = false).toList() + + assertThat(resultPartial).isEmpty() + assertThat(resultNonPartial).isEmpty() + } +} From 6707857e287e69b9e586c8bd02f0b3aaf798575f Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 24 Jul 2025 11:26:15 +0100 Subject: [PATCH 09/45] MS-939 Unified model for the UI state of the upcoming new sync UI/UX --- .../dashboard/settings/syncinfo/SyncInfo.kt | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfo.kt 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 new file mode 100644 index 0000000000..a40f8e185b --- /dev/null +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfo.kt @@ -0,0 +1,93 @@ +package com.simprints.feature.dashboard.settings.syncinfo + +data class SyncInfo( + val isLoggedIn: Boolean = true, + val isConfigurationLoadingProgressBarVisible: Boolean = false, + val isLoginPromptSectionVisible: Boolean = false, + val syncInfoSectionRecords: SyncInfoSectionRecords = SyncInfoSectionRecords(), + val syncInfoSectionImages: SyncInfoSectionImages = SyncInfoSectionImages(), + val syncInfoSectionModules: SyncInfoSectionModules = SyncInfoSectionModules(), +) + +data class SyncInfoSectionRecords( + // counters + val counterTotalRecords: String = "", + val counterRecordsToUpload: String = "", + val isCounterRecordsToDownloadVisible: Boolean = true, + val counterRecordsToDownload: String = "", + val isCounterImagesToUploadVisible: Boolean = false, // images may be combined with the records + val counterImagesToUpload: String = "", + + // instructions + val isInstructionDefaultVisible: Boolean = false, + val isInstructionNoModulesVisible: Boolean = false, + val isInstructionOfflineVisible: Boolean = false, + val isInstructionErrorVisible: Boolean = false, + val instructionPopupErrorInfo: SyncInfoError = SyncInfoError(), + + // progress text & progress bar + val isProgressVisible: Boolean = false, + val progress: SyncInfoProgress = SyncInfoProgress(), + + // sync button + val isSyncButtonVisible: Boolean = false, + val isSyncButtonEnabled: Boolean = false, + val isSyncButtonForRetry: Boolean = false, + + // footer + val isFooterSyncInProgressVisible: Boolean = true, + val isFooterReadyToLogOutVisible: Boolean = false, + val isFooterSyncIncompleteVisible: Boolean = false, + val isFooterLastSyncTimeVisible: Boolean = false, + val footerLastSyncMinutesAgo: Int = -1, +) + +data class SyncInfoError( + val isBackendMaintenance: Boolean = false, + val backendMaintenanceEstimatedOutage: Long = -1, + val isTooManyRequests: Boolean = false, +) + +data class SyncInfoSectionImages( + // counters + val counterImagesToUpload: String = "", + + // instructions + val isInstructionDefaultVisible: Boolean = false, + val isInstructionOfflineVisible: Boolean = false, + + // progress text & progress bar + val isProgressVisible: Boolean = false, + val progress: SyncInfoProgress = SyncInfoProgress(), + + // sync button + val isSyncButtonEnabled: Boolean = false, + + // footer + val isFooterLastSyncTimeVisible: Boolean = false, + val footerLastSyncMinutesAgo: Int = -1, +) + +data class SyncInfoProgress( + val progressParts: List = listOf(), + val progressBarPercentage: Int = 0, +) + +data class SyncInfoProgressPart( + val isPending: Boolean = true, + val isDone: Boolean = false, + val areNumbersVisible: Boolean = false, + val currentNumber: Int = 0, + val totalNumber: Int = 0, +) + +data class SyncInfoSectionModules( + val isSectionAvailable: Boolean = false, + val moduleCounts: List = emptyList(), +) + +data class SyncInfoModuleCount( + val isTotal: Boolean = false, + val name: String, + val count: String = "", +) From 094c05b9da5dd49cc571bbb70a47ce26138f6849 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 24 Jul 2025 11:28:43 +0100 Subject: [PATCH 10/45] MS-939 New unified sync UI/UX in settings, dashboard and logout screen --- .../dashboard/logout/LogoutSyncViewModel.kt | 35 + .../logout/sync/LogoutSyncFragment.kt | 74 +- .../settings/syncinfo/SyncInfoFragment.kt | 439 +++++-- .../settings/syncinfo/SyncInfoViewModel.kt | 502 +++++--- .../src/main/res/drawable/ic_down_sync.xml | 5 + .../src/main/res/drawable/ic_images.xml | 5 + .../src/main/res/drawable/ic_list.xml | 5 + .../src/main/res/drawable/ic_records.xml | 5 + .../src/main/res/drawable/ic_up_sync.xml | 5 + .../main/res/layout/fragment_logout_sync.xml | 48 +- .../src/main/res/layout/fragment_main.xml | 38 +- .../main/res/layout/fragment_sync_info.xml | 1014 ++++++++++++----- .../main/res/navigation/graph_dashboard.xml | 3 + .../logout/LogoutSyncViewModelTest.kt | 165 ++- .../logout/sync/LogoutSyncFragmentTest.kt | 443 +------ .../dashboard/main/MainFragmentTest.kt | 5 - .../src/main/res/values-am-rET/strings.xml | 61 +- .../src/main/res/values-am/strings.xml | 61 +- .../src/main/res/values-bn/strings.xml | 61 +- .../src/main/res/values-fr/strings.xml | 61 +- .../src/main/res/values-hi/strings.xml | 61 +- .../src/main/res/values-om/strings.xml | 55 +- .../resources/src/main/res/values/strings.xml | 97 +- 23 files changed, 1913 insertions(+), 1335 deletions(-) create mode 100644 feature/dashboard/src/main/res/drawable/ic_down_sync.xml create mode 100644 feature/dashboard/src/main/res/drawable/ic_images.xml create mode 100644 feature/dashboard/src/main/res/drawable/ic_list.xml create mode 100644 feature/dashboard/src/main/res/drawable/ic_records.xml create mode 100644 feature/dashboard/src/main/res/drawable/ic_up_sync.xml 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 abe3d6961f..b1c0f5297d 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 @@ -2,20 +2,51 @@ package com.simprints.feature.dashboard.logout import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow +import androidx.lifecycle.asLiveData import androidx.lifecycle.liveData import androidx.lifecycle.viewModelScope import com.simprints.core.livedata.LiveDataEventWithContent import com.simprints.feature.dashboard.logout.usecase.LogoutUseCase +import com.simprints.infra.authstore.AuthStore import com.simprints.infra.config.store.models.SettingsPasswordConfig +import com.simprints.infra.config.store.models.isModuleSelectionAvailable import com.simprints.infra.config.sync.ConfigManager +import com.simprints.infra.eventsync.EventSyncManager +import com.simprints.infra.sync.SyncOrchestrator import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map import javax.inject.Inject @HiltViewModel internal class LogoutSyncViewModel @Inject constructor( private val configManager: ConfigManager, + eventSyncManager: EventSyncManager, + syncOrchestrator: SyncOrchestrator, + authStore: AuthStore, private val logoutUseCase: LogoutUseCase, ) : ViewModel() { + + val logoutEventLiveData: LiveData = + authStore.watchSignedInProjectId().filter { projectId -> + projectId.isEmpty() + }.distinctUntilChanged().map { /* Unit on every "true" */ }.asLiveData() + + val isLogoutWithoutSyncVisibleLiveData: LiveData = combine( + eventSyncManager.getLastSyncState(useDefaultValue = true).asFlow(), + syncOrchestrator.observeImageSyncStatus(), + configManager.watchProjectConfiguration(), + configManager.watchDeviceConfiguration(), + ) { eventSyncState, imageSyncStatus, projectConfig, deviceConfig -> + val isModuleSelectionRequired = + projectConfig.isModuleSelectionAvailable() && deviceConfig.selectedModules.isEmpty() + !eventSyncState.isSyncCompleted() || imageSyncStatus.isSyncing || isModuleSelectionRequired + }.debounce(timeoutMillis = ANTI_JITTER_DELAY_MILLIS).asLiveData() + val settingsLocked: LiveData> get() = liveData(context = viewModelScope.coroutineContext) { emit(LiveDataEventWithContent(configManager.getProjectConfiguration().general.settingsPassword)) @@ -24,4 +55,8 @@ internal class LogoutSyncViewModel @Inject constructor( fun logout() { logoutUseCase() } + + private companion object { + private const val ANTI_JITTER_DELAY_MILLIS = 1000L + } } diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/sync/LogoutSyncFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/sync/LogoutSyncFragment.kt index 369a60c57a..fdb2971906 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/sync/LogoutSyncFragment.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/sync/LogoutSyncFragment.kt @@ -1,31 +1,24 @@ package com.simprints.feature.dashboard.logout.sync -import android.content.Intent import android.os.Bundle -import android.provider.Settings import android.view.View -import androidx.core.view.isInvisible -import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController -import com.simprints.core.livedata.LiveDataEventWithContentObserver import com.simprints.feature.dashboard.R import com.simprints.feature.dashboard.databinding.FragmentLogoutSyncBinding import com.simprints.feature.dashboard.logout.LogoutSyncViewModel -import com.simprints.feature.dashboard.main.sync.SyncViewModel -import com.simprints.feature.dashboard.views.SyncCardState -import com.simprints.feature.login.LoginContract -import com.simprints.feature.login.LoginResult -import com.simprints.infra.uibase.navigation.handleResult import com.simprints.infra.uibase.navigation.navigateSafely import com.simprints.infra.uibase.viewbinding.viewBinding import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch @AndroidEntryPoint class LogoutSyncFragment : Fragment(R.layout.fragment_logout_sync) { - private val logoutSyncViewModel by viewModels() - private val syncViewModel by viewModels() + private val viewModel by viewModels() private val binding by viewBinding(FragmentLogoutSyncBinding::bind) override fun onViewCreated( @@ -35,25 +28,9 @@ class LogoutSyncFragment : Fragment(R.layout.fragment_logout_sync) { super.onViewCreated(view, savedInstanceState) initViews() observeLiveData() - - findNavController().handleResult( - viewLifecycleOwner, - R.id.logOutSyncFragment, - LoginContract.DESTINATION, - ) { result -> syncViewModel.handleLoginResult(result) } } private fun initViews() = with(binding) { - logoutSyncCard.onSyncButtonClick = { syncViewModel.sync() } - logoutSyncCard.onOfflineButtonClick = - { startActivity(Intent(Settings.ACTION_WIRELESS_SETTINGS)) } - logoutSyncCard.onSelectNoModulesButtonClick = { - findNavController().navigateSafely( - this@LogoutSyncFragment, - LogoutSyncFragmentDirections.actionLogoutSyncFragmentToModuleSelectionFragment(), - ) - } - logoutSyncCard.onLoginButtonClick = { syncViewModel.login() } logoutSyncToolbar.setNavigationOnClickListener { findNavController().popBackStack() } @@ -63,38 +40,23 @@ class LogoutSyncFragment : Fragment(R.layout.fragment_logout_sync) { LogoutSyncFragmentDirections.actionLogoutSyncFragmentToLogoutSyncDeclineFragment(), ) } - logoutButton.setOnClickListener { - logoutSyncViewModel.logout() - findNavController().navigateSafely( - this@LogoutSyncFragment, - LogoutSyncFragmentDirections.actionLogoutSyncFragmentToRequestLoginFragment(), - ) - } } private fun observeLiveData() = with(binding) { - syncViewModel.syncCardLiveData.observe(viewLifecycleOwner) { state -> - val isLogoutButtonVisible = isLogoutButtonVisible(state) - logoutSyncCard.render(state) - logoutButton.isVisible = isLogoutButtonVisible - logoutWithoutSyncButton.isVisible = isLogoutButtonVisible.not() - logoutSyncInfo.isInvisible = isLogoutButtonVisible + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + viewModel.isLogoutWithoutSyncVisibleLiveData.observe(viewLifecycleOwner) { isLogoutWithoutSyncVisible -> + logoutSyncInfo.visibility = if (isLogoutWithoutSyncVisible) View.VISIBLE else View.INVISIBLE + logoutWithoutSyncButton.visibility = if (isLogoutWithoutSyncVisible) View.VISIBLE else View.INVISIBLE + } + } + } + viewModel.logoutEventLiveData.observe(viewLifecycleOwner) { + findNavController().navigateSafely( + this@LogoutSyncFragment, + R.id.action_logoutSyncFragment_to_requestLoginFragment, + ) } - syncViewModel.loginRequestedEventLiveData.observe( - viewLifecycleOwner, - LiveDataEventWithContentObserver { loginArgs -> - findNavController().navigateSafely( - this@LogoutSyncFragment, - R.id.action_logOutSyncFragment_to_login, - loginArgs, - ) - }, - ) } - /** - * Helper function that calculates whether the 'proceed to log out' button should be visible. - * The button should be visible only when synchronization is complete - */ - private fun isLogoutButtonVisible(state: SyncCardState) = state is SyncCardState.SyncComplete } 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 897e680f3d..375a76634d 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 @@ -1,40 +1,54 @@ package com.simprints.feature.dashboard.settings.syncinfo +import android.animation.ObjectAnimator +import android.content.Intent +import android.content.res.ColorStateList import android.os.Bundle +import android.provider.Settings +import android.view.LayoutInflater import android.view.View -import android.widget.ProgressBar +import android.view.ViewGroup +import android.view.animation.AccelerateDecelerateInterpolator import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController -import com.simprints.core.livedata.LiveDataEventWithContentObserver +import kotlinx.coroutines.launch +import com.simprints.core.tools.utils.TimeUtils import com.simprints.feature.dashboard.R import com.simprints.feature.dashboard.databinding.FragmentSyncInfoBinding 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 -import com.simprints.feature.login.LoginResult -import com.simprints.infra.config.store.models.ProjectConfiguration -import com.simprints.infra.config.store.models.SynchronizationConfiguration -import com.simprints.infra.config.store.models.canSyncDataToSimprints -import com.simprints.infra.config.store.models.isEventDownSyncAllowed import com.simprints.infra.uibase.view.applySystemBarInsets import com.simprints.infra.uibase.navigation.handleResult -import com.simprints.infra.uibase.navigation.navigateSafely +import com.simprints.infra.uibase.navigation.toBundle import com.simprints.infra.uibase.viewbinding.viewBinding import dagger.hilt.android.AndroidEntryPoint import com.simprints.infra.resources.R as IDR @AndroidEntryPoint internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { - companion object { - private const val TOTAL_RECORDS_INDEX = 0 - } - private val viewModel: SyncInfoViewModel by viewModels() private val binding by viewBinding(FragmentSyncInfoBinding::bind) private val moduleCountAdapter by lazy { ModuleCountAdapter() } + private var syncInfoConfig: SyncInfoFragmentConfig = SyncInfoFragmentConfig() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + syncInfoConfig = + (container?.parent as? ConfigurableSyncInfoFragmentContainer)?.syncInfoFragmentConfig + ?: SyncInfoFragmentConfig() + viewModel.isPreLogoutUpSync = syncInfoConfig.isSyncInfoLogoutOnComplete + return super.onCreateView(inflater, container, savedInstanceState) + } + override fun onViewCreated( view: View, savedInstanceState: Bundle?, @@ -43,151 +57,350 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { applySystemBarInsets(view) binding.selectedModulesView.adapter = moduleCountAdapter + setupClickListeners() observeUI() - findNavController().handleResult( + findNavController().handleResult( viewLifecycleOwner, - R.id.syncInfoFragment, + getCurrentDestinationId(), LoginContract.DESTINATION, - ) { result -> viewModel.handleLoginResult(result) } + viewModel::handleLoginResult, + ) } private fun setupClickListeners() { - binding.moduleSelectionButton.setOnClickListener { - findNavController().navigateSafely(this, SyncInfoFragmentDirections.actionSyncInfoFragmentToModuleSelectionFragment()) + binding.buttonSelectModules.setOnClickListener { + findNavController().navigate(R.id.moduleSelectionFragment) } - binding.syncInfoToolbar.setNavigationOnClickListener { - findNavController().popBackStack() + binding.textEventSyncInstructionsNoModules.setOnClickListener { + findNavController().navigate(R.id.moduleSelectionFragment) } - binding.syncInfoToolbar.setOnMenuItemClickListener { - viewModel.refreshInformation() - true + binding.syncSettingsButton.setOnClickListener { + findNavController().navigate(R.id.syncInfoFragment) } - binding.syncButton.setOnClickListener { - viewModel.forceSync() - updateSyncButton(isSyncInProgress = true) + binding.syncInfoToolbar.setNavigationOnClickListener { + findNavController().popBackStack() } binding.syncReloginRequiredLoginButton.setOnClickListener { - viewModel.login() + viewModel.requestNavigationToLogin() } - } - - private fun observeUI() { - viewModel.configuration.observe(viewLifecycleOwner) { - enableModuleSelectionButtonAndTabsIfNecessary(it.synchronization) - setupRecordsCountCards(it) + binding.buttonSyncRecordsNow.setOnClickListener { + viewModel.forceEventSync() } - viewModel.recordsInLocal.observe(viewLifecycleOwner) { - binding.totalRecordsCount.text = it?.toString() ?: "" - setProgressBar(it, binding.totalRecordsCount, binding.totalRecordsProgress) + binding.buttonSyncImagesNow.setOnClickListener { + viewModel.toggleImageSync() } - - viewModel.recordsToUpSync.observe(viewLifecycleOwner) { - binding.recordsToUploadCount.text = it?.toString() ?: "" - setProgressBar(it, binding.recordsToUploadCount, binding.recordsToUploadProgress) + binding.textEventSyncInstructionsOffline.setOnClickListener { + startActivity(Intent(Settings.ACTION_WIRELESS_SETTINGS)) } - - viewModel.imagesToUpload.observe(viewLifecycleOwner) { - binding.imagesToUploadCount.text = it?.toString() ?: "" - setProgressBar(it, binding.imagesToUploadCount, binding.imagesToUploadProgress) + binding.textImageSyncInstructionsOffline.setOnClickListener { + startActivity(Intent(Settings.ACTION_WIRELESS_SETTINGS)) } + binding.textEventSyncInstructionsDefault.showInfoPopupOnClick(getString(IDR.string.sync_info_details_event_sync_default)) + binding.textImageSyncInstructionsDefault.showInfoPopupOnClick(getString(IDR.string.sync_info_details_image_sync_default)) + binding.textModuleSyncInstructions.showInfoPopupOnClick(getString(IDR.string.sync_info_details_module_selection)) + } - viewModel.recordsToDownSync.observe(viewLifecycleOwner) { - binding.recordsToDownloadCount.text = it?.let { - if (it.isLowerBound) "${it.count}+" else "${it.count}" - } ?: "" - setProgressBar(it?.count, binding.recordsToDownloadCount, binding.recordsToDownloadProgress) + private fun View.showInfoPopupOnClick(message: String) { + setOnClickListener { + AlertDialog.Builder(requireContext()) + .setMessage(message) + .setPositiveButton(IDR.string.sync_info_details_ok) { di, _ -> di.dismiss() } + .create() + .show() } + } - viewModel.moduleCounts.observe(viewLifecycleOwner) { - updateModuleCounts(it) - } - viewModel.lastSyncState.observe(viewLifecycleOwner) { - viewModel.fetchSyncInformationIfNeeded(it) - val isRunning = it.isSyncRunning() - updateSyncButton(isRunning) - } - viewModel.isSyncAvailable.observe(viewLifecycleOwner) { - binding.syncButton.isEnabled = it + private fun observeUI() { + renderSyncInfo(SyncInfo(), syncInfoConfig) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + renderSyncInfo(SyncInfo(), syncInfoConfig) + viewModel.syncInfoLiveData.observe(viewLifecycleOwner) { syncInfo -> + renderSyncInfo(syncInfo, syncInfoConfig) + } + } } - viewModel.isReloginRequired.observe(viewLifecycleOwner) { reloginRequired -> - if (reloginRequired) { - binding.syncReloginRequiredSection.visibility = View.VISIBLE - binding.syncButton.visibility = View.GONE - } else { - binding.syncReloginRequiredSection.visibility = View.GONE - binding.syncButton.visibility = View.VISIBLE + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + viewModel.logoutEventLiveData.observe(viewLifecycleOwner) { logoutIfNotNull -> + logoutIfNotNull?.let { + viewModel.logout() + } + } } } - viewModel.loginRequestedEventLiveData.observe( + + viewModel.loginNavigationEventLiveData.observe( viewLifecycleOwner, - LiveDataEventWithContentObserver { loginArgs -> - findNavController().navigateSafely( - this, - R.id.action_syncInfoFragment_to_login, - loginArgs, - ) + { loginParams -> + findNavController().navigate(com.simprints.feature.login.R.id.graph_login, loginParams.toBundle()) }, ) } - private fun updateSyncButton(isSyncInProgress: Boolean) { - binding.syncButton.text = getString( - if (isSyncInProgress) { - IDR.string.dashboard_sync_info_sync_in_progress - } else { - IDR.string.dashboard_sync_info_sync_now_button - }, + private fun renderSyncInfo(syncInfo: SyncInfo, config: SyncInfoFragmentConfig) { + // App toolbar + binding.appBarLayout.visibility = if (config.isSyncInfoToolbarVisible) View.VISIBLE else View.GONE + + // Config loading progress bar + binding.progressConfigRefresh.visibility = if (syncInfo.isConfigurationLoadingProgressBarVisible) View.VISIBLE else View.INVISIBLE + + // Sync info header + binding.syncStatusHeader.visibility = if (config.isSyncInfoStatusHeaderVisible) View.VISIBLE else View.GONE + binding.syncSettingsButton.visibility = if (config.isSyncInfoStatusHeaderSettingsButtonVisible) View.VISIBLE else View.GONE + + // Section separators + binding.headerRecordSync.visibility = if (config.areSyncInfoSectionHeadersVisible) View.VISIBLE else View.GONE + binding.sectionDivider1.visibility = if (config.areSyncInfoSectionHeadersVisible) View.VISIBLE else View.GONE + binding.headerImageSync.visibility = if (config.areSyncInfoSectionHeadersVisible) View.VISIBLE else View.GONE + binding.sectionDivider2.visibility = if (config.areSyncInfoSectionHeadersVisible) View.VISIBLE else View.GONE + binding.headerModuleSelection.visibility = if (config.areSyncInfoSectionHeadersVisible) View.VISIBLE else View.GONE + binding.sectionFooter.visibility = if (config.areSyncInfoSectionHeadersVisible) View.GONE else View.VISIBLE + + // Re-login section + binding.syncReLoginRequiredSection.visibility = if (syncInfo.isLoginPromptSectionVisible) View.VISIBLE else View.GONE + + // Records section + renderRecordsSection(syncInfo.syncInfoSectionRecords, config) + + // Images section + binding.layoutImagesSync.visibility = if (config.isSyncInfoImageSyncVisible) View.VISIBLE else View.GONE + renderImagesSection(syncInfo.syncInfoSectionImages) + + // Modules section + renderModulesSection(syncInfo.syncInfoSectionModules, config) + } + + private fun renderRecordsSection(records: SyncInfoSectionRecords, config: SyncInfoFragmentConfig) { + // Counter - total records + binding.totalRecordsCount.visibility = if (records.counterTotalRecords.isBlank()) View.GONE else View.VISIBLE + binding.totalRecordsCount.text = records.counterTotalRecords + binding.totalRecordsProgress.visibility = if (records.counterTotalRecords.isBlank()) View.VISIBLE else View.GONE + + // Counter - records to upload + binding.layoutRecordsToDownload.visibility = if (records.isCounterRecordsToDownloadVisible) View.VISIBLE else View.GONE + binding.recordsToUploadCount.visibility = if (records.counterRecordsToUpload.isBlank()) View.GONE else View.VISIBLE + binding.recordsToUploadCount.text = records.counterRecordsToUpload + binding.recordsToUploadProgress.visibility = if (records.counterRecordsToUpload.isBlank()) View.VISIBLE else View.GONE + + // Counter - records to download + binding.recordsToDownloadCount.visibility = if (records.counterRecordsToDownload.isBlank()) View.GONE else View.VISIBLE + binding.recordsToDownloadCount.text = records.counterRecordsToDownload + binding.recordsToDownloadProgress.visibility = if (records.counterRecordsToDownload.isBlank()) View.VISIBLE else View.GONE + + // Counter - images to upload (may be combined with records) + binding.layoutComboImageCounter.visibility = if (config.isSyncInfoRecordsImagesCombined) View.VISIBLE else View.GONE + binding.comboImagesToUploadCount.visibility = if (records.counterImagesToUpload.isBlank()) View.GONE else View.VISIBLE + binding.comboImagesToUploadCount.text = records.counterImagesToUpload + binding.comboImagesToUploadProgress.visibility = if (records.counterImagesToUpload.isBlank()) View.VISIBLE else View.GONE + + // Instructions + binding.textEventSyncInstructionsDefault.visibility = if (records.isInstructionDefaultVisible) View.VISIBLE else View.GONE + binding.textEventSyncInstructionsOffline.visibility = if (records.isInstructionOfflineVisible) View.VISIBLE else View.GONE + binding.textEventSyncInstructionsNoModules.visibility = if (records.isInstructionNoModulesVisible) View.VISIBLE else View.GONE + binding.textEventSyncInstructionsError.visibility = if (records.isInstructionErrorVisible) View.VISIBLE else View.GONE + records.instructionPopupErrorInfo.configureErrorPopup() + + // Progress + binding.layoutEventSyncProgress.visibility = if (records.isProgressVisible) View.VISIBLE else View.INVISIBLE + renderProgress( + records.progress, + binding.eventSyncProgressBar, + binding.textEventSyncProgress, + IDR.string.sync_info_item_record_or_event, + IDR.string.sync_info_item_image, + ) + binding.eventSyncProgressBar.setPulseAnimation(isEnabled = records.isProgressVisible) + + // Sync button + val isSyncButtonVisible = !config.isSyncInfoLogoutOnComplete || records.isSyncButtonVisible + binding.buttonSyncRecordsNow.visibility = if (isSyncButtonVisible) View.VISIBLE else View.GONE + binding.buttonSyncRecordsNow.isEnabled = records.isSyncButtonEnabled + binding.buttonSyncRecordsNow.text = getString( + when { + records.isSyncButtonForRetry -> IDR.string.sync_info_button_try_again + records.isProgressVisible -> IDR.string.sync_info_button_records_syncing + else -> IDR.string.sync_info_button_sync_records + } ) + + // Footer + val isFooterSyncInProgressVisible = config.isSyncInfoLogoutOnComplete && records.isFooterSyncInProgressVisible + binding.textFooterRecordSyncInProgress.visibility = if (isFooterSyncInProgressVisible) View.VISIBLE else View.GONE + binding.textFooterRecordLoggingOut.visibility = if (records.isFooterReadyToLogOutVisible) View.VISIBLE else View.GONE + binding.textFooterRecordSyncIncomplete.visibility = if (records.isFooterSyncIncompleteVisible) View.VISIBLE else View.GONE + binding.textFooterRecordLastSyncedWhen.visibility = if (records.isFooterLastSyncTimeVisible) View.VISIBLE else View.GONE + binding.textFooterRecordLastSyncedWhen.text = formatLastSyncTime(records.footerLastSyncMinutesAgo) } - private fun enableModuleSelectionButtonAndTabsIfNecessary(synchronizationConfiguration: SynchronizationConfiguration) { - if (viewModel.isModuleSyncAndModuleIdOptionsNotEmpty(synchronizationConfiguration)) { - binding.moduleSelectionButton.visibility = View.VISIBLE - binding.modulesTabHost.visibility = View.VISIBLE - } else { - binding.moduleSelectionButton.visibility = View.GONE - binding.modulesTabHost.visibility = View.GONE - } + private fun SyncInfoError.configureErrorPopup() { + binding.textEventSyncInstructionsError.showInfoPopupOnClick( + when { + isTooManyRequests -> getString( + IDR.string.sync_info_details_too_many_modules + ) + + isBackendMaintenance && backendMaintenanceEstimatedOutage > 0 -> getString( + IDR.string.error_backend_maintenance_with_time_message, + TimeUtils.getFormattedEstimatedOutage(backendMaintenanceEstimatedOutage), + ) + + isBackendMaintenance -> getString( + IDR.string.error_backend_maintenance_message + ) + + else -> getString( + IDR.string.sync_info_details_error + ) + } + ) + } + + private fun renderImagesSection(images: SyncInfoSectionImages) { + // Counter - images to upload + binding.imagesToUploadCount.visibility = if (images.counterImagesToUpload.isBlank()) View.GONE else View.VISIBLE + binding.imagesToUploadCount.text = images.counterImagesToUpload + binding.imagesToUploadProgress.visibility = if (images.counterImagesToUpload.isBlank()) View.VISIBLE else View.GONE + + // Handle instruction visibility + binding.textImageSyncInstructionsDefault.visibility = if (images.isInstructionDefaultVisible) View.VISIBLE else View.GONE + binding.textImageSyncInstructionsOffline.visibility = if (images.isInstructionOfflineVisible) View.VISIBLE else View.GONE + + // Progress + binding.layoutImageSyncProgress.visibility = if (images.isProgressVisible) View.VISIBLE else View.INVISIBLE + renderProgress(images.progress, binding.imageSyncProgressBar, binding.textImageSyncProgress, IDR.string.sync_info_item_image) + binding.imageSyncProgressBar.setPulseAnimation(isEnabled = images.isProgressVisible) + + // Sync button + binding.buttonSyncImagesNow.isEnabled = images.isSyncButtonEnabled + binding.buttonSyncImagesNow.text = getString( + when { + images.isProgressVisible -> IDR.string.sync_info_button_images_sync_stop + else -> IDR.string.sync_info_button_sync_images + } + ) + binding.buttonSyncImagesNow.backgroundTintList = ColorStateList( + arrayOf( + intArrayOf(android.R.attr.state_enabled), // enabled + intArrayOf(-android.R.attr.state_enabled) // disabled + ), + intArrayOf( + ContextCompat.getColor( + requireContext(), + if (images.isProgressVisible) { + IDR.color.simprints_red_dark + } else { + IDR.color.simprints_orange + } + ), + ContextCompat.getColor(requireContext(), IDR.color.simprints_grey_disabled), + ), + ) + + // Footer + binding.textFooterImageLastSyncedWhen.visibility = if (images.isFooterLastSyncTimeVisible) View.VISIBLE else View.INVISIBLE + binding.textFooterImageLastSyncedWhen.text = formatLastSyncTime(images.footerLastSyncMinutesAgo) } - private fun setupRecordsCountCards(configuration: ProjectConfiguration) { - if (!configuration.isEventDownSyncAllowed()) { - binding.recordsToDownloadCardView.visibility = View.GONE + private fun renderModulesSection(modules: SyncInfoSectionModules, config: SyncInfoFragmentConfig) { + val isModuleSectionVisible = + modules.isSectionAvailable && (config.isSyncInfoModuleListVisible || modules.moduleCounts.isEmpty()) + binding.layoutModuleSelection.visibility = if (isModuleSectionVisible) View.VISIBLE else View.GONE + binding.selectedModulesView.visibility = if (config.isSyncInfoModuleListVisible) View.VISIBLE else View.GONE + + 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 + ) } - if (!configuration.canSyncDataToSimprints()) { - binding.recordsToUploadCardView.visibility = View.GONE - binding.imagesToUploadCardView.visibility = View.GONE + moduleCountAdapter.submitList(moduleCountsForAdapter) + + // RecyclerView height fix (wrong height may be caused by ConstraintLayout in parent views) + binding.selectedModulesView.post { + val itemHeight = resources.getDimensionPixelSize(R.dimen.module_item_height) + val itemCount = moduleCountsForAdapter.size.coerceAtMost(MAX_MODULE_LIST_HEIGHT_ITEMS) + binding.selectedModulesView.apply { + layoutParams = layoutParams.apply { + height = itemHeight * itemCount + } + } } } - private fun setProgressBar( - value: Int?, - tv: TextView, - pb: ProgressBar, + private fun renderProgress( + progress: SyncInfoProgress, + progressBar: com.google.android.material.progressindicator.LinearProgressIndicator, + textView: TextView, + vararg itemNameResIDs: Int, ) { - if (value == null) { - pb.visibility = View.VISIBLE - tv.visibility = View.GONE - } else { - pb.visibility = View.GONE - tv.visibility = View.VISIBLE - } + progressBar.progress = progress.progressBarPercentage + val progressText = progress.progressParts + .mapIndexed { index, (isPending, isDone, areNumbersVisible, currentNumber, totalNumber) -> + val itemName = getString(itemNameResIDs.getOrNull(index) ?: IDR.string.sync_info_item_default) + when { + isPending -> getString(IDR.string.sync_info_progress_pending, itemName) + isDone -> getString(IDR.string.sync_info_progress_complete, itemName) + !areNumbersVisible -> getString(IDR.string.sync_info_progress_ongoing_no_counters, itemName) + else -> getString(IDR.string.sync_info_progress_ongoing, itemName, currentNumber, totalNumber) + } + }.joinToString(separator = "\n") + + textView.text = progressText } - private fun updateModuleCounts(moduleCounts: List) { - val moduleCountsArray = ArrayList().apply { - addAll(moduleCounts) + private fun formatLastSyncTime(minutesAgo: Int): String = + when { + minutesAgo < 0 -> getString(IDR.string.sync_info_footer_time_none) + minutesAgo == 0 -> getString(IDR.string.sync_info_footer_time_now) + minutesAgo == 1 -> getString(IDR.string.sync_info_footer_time_1_minute) + minutesAgo < 60 -> getString(IDR.string.sync_info_footer_time_minutes, minutesAgo) + minutesAgo < 2 * 60 -> getString(IDR.string.sync_info_footer_time_1_hour) + minutesAgo < 24 * 60 -> getString(IDR.string.sync_info_footer_time_hours, minutesAgo / 60) + minutesAgo < 2 * 24 * 60 -> getString(IDR.string.sync_info_footer_time_1_day) + else -> getString(IDR.string.sync_info_footer_time_days, minutesAgo / 60 / 24) } - val totalRecordsEntry = ModuleCount( - getString(IDR.string.dashboard_sync_info_total_records), - moduleCounts.sumOf { it.count }, - ) - moduleCountsArray.add(TOTAL_RECORDS_INDEX, totalRecordsEntry) + private fun View.setPulseAnimation(isEnabled: Boolean) { + (tag as? ObjectAnimator?)?.run { + cancel() + tag = null + } + if (!isEnabled) return + val progressBarPulseAnimator = ObjectAnimator.ofFloat( + this, + View.ALPHA, + PULSE_ANIMATION_ALPHA_FULL, PULSE_ANIMATION_ALPHA_INTERMEDIATE, PULSE_ANIMATION_ALPHA_MIN, + ).apply { + duration = PULSE_ANIMATION_DURATION_MILLIS + repeatCount = ObjectAnimator.INFINITE + repeatMode = ObjectAnimator.REVERSE + interpolator = AccelerateDecelerateInterpolator() + start() + } + tag = progressBarPulseAnimator + } - moduleCountAdapter.submitList(moduleCountsArray) + private fun getCurrentDestinationId() = + parentFragment?.takeIf { !syncInfoConfig.isSyncInfoToolbarVisible }?.id // parent if this isn't standalone + ?: id + + private companion object { + private const val PULSE_ANIMATION_ALPHA_FULL = 1.0f + private const val PULSE_ANIMATION_ALPHA_INTERMEDIATE = 0.9f + private const val PULSE_ANIMATION_ALPHA_MIN = 0.6f + + private const val PULSE_ANIMATION_DURATION_MILLIS = 2000L + + private const val MAX_MODULE_LIST_HEIGHT_ITEMS = 5 } + } 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 e683d67b50..396ed3e563 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 @@ -1,23 +1,25 @@ package com.simprints.feature.dashboard.settings.syncinfo -import android.os.Bundle import androidx.lifecycle.LiveData -import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow +import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import com.simprints.core.domain.tokenization.TokenizableString -import com.simprints.core.livedata.LiveDataEventWithContent -import com.simprints.core.livedata.send +import com.simprints.core.tools.extentions.combine8 +import com.simprints.core.tools.extentions.onChange +import com.simprints.core.tools.time.TimeHelper +import com.simprints.feature.dashboard.logout.usecase.LogoutUseCase import com.simprints.feature.dashboard.settings.syncinfo.modulecount.ModuleCount -import com.simprints.feature.login.LoginContract +import com.simprints.feature.login.LoginParams import com.simprints.feature.login.LoginResult import com.simprints.infra.authstore.AuthStore -import com.simprints.infra.config.store.models.DownSynchronizationConfiguration -import com.simprints.infra.config.store.models.ProjectConfiguration -import com.simprints.infra.config.store.models.SynchronizationConfiguration +import com.simprints.infra.config.store.models.ProjectState import com.simprints.infra.config.store.models.TokenKeyType import com.simprints.infra.config.store.models.isEventDownSyncAllowed +import com.simprints.infra.config.store.models.isMissingModulesToChooseFrom +import com.simprints.infra.config.store.models.isModuleSelectionAvailable import com.simprints.infra.config.store.tokenization.TokenizationProcessor import com.simprints.infra.config.sync.ConfigManager import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository @@ -25,19 +27,24 @@ import com.simprints.infra.enrolment.records.repository.domain.models.SubjectQue import com.simprints.infra.events.event.domain.models.EventType import com.simprints.infra.eventsync.EventSyncManager import com.simprints.infra.eventsync.status.models.DownSyncCounts -import com.simprints.infra.eventsync.status.models.EventSyncState import com.simprints.infra.images.ImageRepository -import com.simprints.infra.logging.LoggingConstants.CrashReportTag.SYNC -import com.simprints.infra.logging.Simber import com.simprints.infra.network.ConnectivityTracker import com.simprints.infra.recent.user.activity.RecentUserActivityManager import com.simprints.infra.sync.SyncOrchestrator -import com.simprints.infra.uibase.navigation.toBundle import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout import javax.inject.Inject +import kotlin.math.roundToInt @HiltViewModel internal class SyncInfoViewModel @Inject constructor( @@ -50,193 +57,358 @@ internal class SyncInfoViewModel @Inject constructor( private val syncOrchestrator: SyncOrchestrator, private val tokenizationProcessor: TokenizationProcessor, private val recentUserActivityManager: RecentUserActivityManager, + private val timeHelper: TimeHelper, + private val logoutUseCase: LogoutUseCase, ) : ViewModel() { - val recordsInLocal: LiveData - get() = _recordsInLocal - private val _recordsInLocal = MutableLiveData(null) - - val recordsToUpSync: LiveData - get() = _recordsToUpSync - private val _recordsToUpSync = MutableLiveData(null) - - val imagesToUpload: LiveData - get() = _imagesToUpload - private val _imagesToUpload = MutableLiveData(null) - - val recordsToDownSync: LiveData - get() = _recordsToDownSync - private val _recordsToDownSync = MutableLiveData(null) - - val moduleCounts: LiveData> - get() = _moduleCounts - private val _moduleCounts = MutableLiveData>() - - val configuration: LiveData - get() = _configuration - private val _configuration = MutableLiveData() - - private val isConnected: LiveData = connectivityTracker.observeIsConnected() - - val lastSyncState = eventSyncManager.getLastSyncState() - private var lastKnownEventSyncState: EventSyncState? = null - - val isSyncAvailable: LiveData - get() = _isSyncAvailable - private val _isSyncAvailable = MediatorLiveData() - - val isReloginRequired: LiveData - get() = _isReloginRequired - private val _isReloginRequired = MediatorLiveData() - - val loginRequestedEventLiveData: LiveData> - get() = _loginRequestedEventLiveData - private val _loginRequestedEventLiveData = MutableLiveData>() - - init { - _isSyncAvailable.addSource(lastSyncState) { lastSyncStateValue -> - _isSyncAvailable.postValue( - emitSyncAvailable( - isSyncRunning = lastSyncStateValue?.isSyncRunning(), - isConnected = isConnected.value, - syncConfiguration = configuration.value?.synchronization, - ), + var isPreLogoutUpSync = false + + val loginNavigationEventLiveData: LiveData + get() = _loginNavigationEventLiveData + private val _loginNavigationEventLiveData = MutableLiveData() + + private val eventSyncStateFlow = + eventSyncManager.getLastSyncState(useDefaultValue = true /* otherwise value not guaranteed */).asFlow() + private val imageSyncStatusFlow = + syncOrchestrator.observeImageSyncStatus() + + val logoutEventLiveData: LiveData = combine( + eventSyncStateFlow, + imageSyncStatusFlow, + ) { eventSyncState, imageSyncStatus -> + val isReadyToLogOut = + isPreLogoutUpSync && eventSyncState.isSyncCompleted() && !imageSyncStatus.isSyncing + && !configManager.isModuleSelectionRequired() + return@combine isReadyToLogOut + }.debounce(LOGOUT_DELAY_MILLIS).transformLatest { isReadyToLogOut -> + if (isReadyToLogOut) { + // "flick" the logout event to prevent persistence while not observed, and to avoid unexpected logout later + emit(Unit) + emit(null) + } + }.asLiveData() + + val syncInfoLiveData: LiveData = combine8( + connectivityTracker.observeIsConnected().asFlow(), + authStore.watchSignedInProjectId().map(String::isNotEmpty), + configManager.watchIfProjectRefreshing(), + eventSyncStateFlow, + imageSyncStatusFlow, + configManager.watchProjectConfiguration(), + configManager.watchDeviceConfiguration(), + timeHelper.watchOncePerMinute(), + ) { isConnected, isLoggedIn, isRefreshing, eventSyncState, imageSyncStatus, projectConfig, deviceConfig, _ -> + + 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.isSyncInProgress() && totalEvents > 0 -> + (0.5f * currentEvents / totalEvents).coerceIn(0f, 0.5f) // combined progress 1st half - events + + eventSyncState.isSyncInProgress() && totalEvents > 0 -> + (currentEvents.toFloat() / totalEvents).coerceIn(0f, 1f) + + eventSyncState.isSyncConnecting() || eventSyncState.isThereNotSyncHistory() -> 0f + else -> 1f + } + val imagesNormalizedProgress = when { + imageSyncStatus.isSyncing && totalImages > 0 -> + (currentImages.toFloat() / totalImages).coerceIn(0f, 1f) + + else -> 1f + } + + val imagesToUpload = + if (imageSyncStatus.isSyncing) { + null + } else { + imageRepository.getNumberOfImagesToUpload(projectId = authStore.signedInProjectId) + } + + val eventSyncProgressPart = SyncInfoProgressPart( + isPending = eventSyncState.isSyncConnecting() || eventSyncState.isThereNotSyncHistory(), + isDone = eventSyncState.isSyncCompleted(), + areNumbersVisible = eventSyncState.isSyncInProgress() && totalEvents > 0, + currentNumber = currentEvents, + totalNumber = totalEvents, + ) + val imageSyncProgressPart = SyncInfoProgressPart( + isPending = eventSyncState.isSyncInProgress() && !imageSyncStatus.isSyncing, + isDone = !eventSyncState.isSyncInProgress() && !imageSyncStatus.isSyncing && imagesToUpload == 0, + areNumbersVisible = imageSyncStatus.isSyncing && totalImages > 0, + currentNumber = currentImages, + totalNumber = totalImages, + ) + + val isEventSyncInProgress = + eventSyncState.isSyncInProgress() + || (isPreLogoutUpSync && imageSyncStatus.isSyncing) // if combined with images + val eventSyncProgress = if (isEventSyncInProgress) { + SyncInfoProgress( + progressParts = if (isPreLogoutUpSync) { + listOf(eventSyncProgressPart, imageSyncProgressPart) + } else { + listOf(eventSyncProgressPart) + }, + progressBarPercentage = (eventsNormalizedProgress * 100).roundToInt(), ) + } else { + SyncInfoProgress() } - _isSyncAvailable.addSource(isConnected) { isConnectedValue -> - _isSyncAvailable.postValue( - emitSyncAvailable( - isSyncRunning = lastSyncState.value?.isSyncRunning(), - isConnected = isConnectedValue, - syncConfiguration = configuration.value?.synchronization, - ), + val imageSyncProgress = if (imageSyncStatus.isSyncing) { + SyncInfoProgress( + progressParts = listOf(imageSyncProgressPart), + progressBarPercentage = (imagesNormalizedProgress * 100).roundToInt(), ) + } else { + SyncInfoProgress() + } + + val eventLastSyncMinutes = eventSyncManager.getLastSyncTime()?.run { + (timeHelper.now().ms - ms) / 60 / 1000 + }?.toInt() ?: -1 + val imageLastSyncMinutes = imageSyncStatus.secondsSinceLastUpdate?.let { + (it / 60).toInt() + } ?: -1 + + val isReLoginRequired = eventSyncState.isSyncFailedBecauseReloginRequired() + + val isModuleSelectionRequired = + projectConfig.isModuleSelectionAvailable() && deviceConfig.selectedModules.isEmpty() + val isEventSyncAvailable = + !isReLoginRequired && isConnected && !eventSyncState.isSyncRunning() && !projectConfig.isMissingModulesToChooseFrom() + && !isModuleSelectionRequired + + val projectId = authStore.signedInProjectId + + val recordsTotal = when { + isEventSyncInProgress -> null + else -> enrolmentRecordRepository.count(SubjectQuery(projectId)) } - _isSyncAvailable.addSource(_configuration) { config -> - _isSyncAvailable.postValue( - emitSyncAvailable( - isSyncRunning = lastSyncState.value?.isSyncRunning(), - isConnected = isConnected.value, - syncConfiguration = config.synchronization, + val recordsToUpload = when { + isEventSyncInProgress -> null + else -> eventSyncManager.countEventsToUpload( + listOf(EventType.ENROLMENT_V2, EventType.ENROLMENT_V4) + ).firstOrNull() ?: 0 + } + val recordsToDownload = when { + isEventSyncInProgress -> null + isPreLogoutUpSync -> null + projectConfig.isEventDownSyncAllowed() -> try { + withTimeout(COUNT_EVENTS_TIMEOUT_MILLIS) { + eventSyncManager.countEventsToDownload(maxCacheAgeMillis = COUNT_EVENTS_TIMEOUT_MILLIS) + } + } catch (t: Throwable) { + DownSyncCounts(0, isLowerBound = false) + } + + else -> DownSyncCounts(0, isLowerBound = false) + } + + val project = configManager.getProject(projectId) + val isProjectEnding = + project.state == ProjectState.PROJECT_ENDING + val moduleCounts = 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), ), ) } - _isReloginRequired.addSource(lastSyncState) { lastSyncStateValue -> - _isReloginRequired.postValue(lastSyncStateValue.isSyncFailedBecauseReloginRequired()) - } - viewModelScope.launch { getRecordsToUpSync() } - } + 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(), + ) + } + ) - fun refreshInformation() { - _recordsInLocal.postValue(null) - _recordsToDownSync.postValue(null) - _imagesToUpload.postValue(null) - _moduleCounts.postValue(listOf()) - load() - } + val syncInfoSectionRecords = SyncInfoSectionRecords( + counterTotalRecords = recordsTotal?.toString() ?: "", + counterRecordsToUpload = recordsToUpload?.toString() ?: "", + isCounterRecordsToDownloadVisible = !isPreLogoutUpSync && !isProjectEnding, + counterRecordsToDownload = recordsToDownload?.let { "${it.count}${if (it.isLowerBound) "+" else ""}" } ?: "", + isCounterImagesToUploadVisible = isPreLogoutUpSync, + counterImagesToUpload = imagesToUpload?.toString() ?: "", + isInstructionDefaultVisible = !isModuleSelectionRequired && isConnected && !eventSyncState.isSyncFailed() + && !eventSyncState.isSyncInProgress() && !isPreLogoutUpSync, + isInstructionNoModulesVisible = isConnected && isModuleSelectionRequired && !isEventSyncInProgress, + isInstructionOfflineVisible = !isConnected, + isInstructionErrorVisible = isConnected && eventSyncState.isSyncFailed(), + instructionPopupErrorInfo = SyncInfoError( + isBackendMaintenance = eventSyncState.isSyncFailedBecauseBackendMaintenance(), + backendMaintenanceEstimatedOutage = eventSyncState.getEstimatedBackendMaintenanceOutage() ?: -1, + isTooManyRequests = eventSyncState.isSyncFailedBecauseTooManyRequests() + ), + isProgressVisible = isEventSyncInProgress, + progress = eventSyncProgress, + isSyncButtonVisible = !isPreLogoutUpSync || eventSyncState.isSyncFailed(), + isSyncButtonEnabled = isEventSyncAvailable, + isSyncButtonForRetry = eventSyncState.isSyncFailed(), + isFooterSyncInProgressVisible = isPreLogoutUpSync && isEventSyncInProgress, + isFooterReadyToLogOutVisible = isPreLogoutUpSync && eventSyncState.isSyncCompleted() && !imageSyncStatus.isSyncing + && !isModuleSelectionRequired, + isFooterSyncIncompleteVisible = isPreLogoutUpSync && eventSyncState.isSyncFailed(), + isFooterLastSyncTimeVisible = !isPreLogoutUpSync && !eventSyncState.isSyncInProgress() && eventLastSyncMinutes >= 0, + footerLastSyncMinutesAgo = eventLastSyncMinutes, + ) + + val syncInfoSectionImages = SyncInfoSectionImages( + counterImagesToUpload = imagesToUpload?.toString() ?: "", + isInstructionDefaultVisible = !imageSyncStatus.isSyncing && isConnected, + isInstructionOfflineVisible = !isConnected, + isProgressVisible = imageSyncStatus.isSyncing, + progress = imageSyncProgress, + isSyncButtonEnabled = isConnected && !isReLoginRequired, + isFooterLastSyncTimeVisible = !imageSyncStatus.isSyncing && imageLastSyncMinutes >= 0, + footerLastSyncMinutesAgo = imageLastSyncMinutes, + ) - fun forceSync() { - syncOrchestrator.startEventSync() - // There is a delay between starting sync and lastSyncState - // reporting it so this prevents starting multiple syncs by accident - _isSyncAvailable.postValue(false) + val syncInfo = SyncInfo( + isLoggedIn, + isConfigurationLoadingProgressBarVisible = isRefreshing, + isLoginPromptSectionVisible = isReLoginRequired && !isPreLogoutUpSync, + syncInfoSectionRecords, + syncInfoSectionImages, + syncInfoSectionModules, + ) + return@combine8 syncInfo + }.onStart { + startInitialSyncIfRequired() + syncImagesAfterEventsWhenRequired() + }.onRecordSyncComplete { + delay(timeMillis = SYNC_COMPLETION_HOLD_MILLIS) + }.onImageSyncComplete { + delay(timeMillis = SYNC_COMPLETION_HOLD_MILLIS) + }.asLiveData() + + fun forceEventSync() { + viewModelScope.launch { + syncOrchestrator.stopEventSync() + val isDownSyncAllowed = + !isPreLogoutUpSync && configManager.getProject(authStore.signedInProjectId).state != ProjectState.PROJECT_ENDING + syncOrchestrator.startEventSync(isDownSyncAllowed) + } } - /** - * Calls fetchSyncInformation() when all workers are done. - * To determine this EventSyncState is checked to have all workers in Succeeded state. - * Also, to avoid consecutive calls with the same EventSyncState the last one is saved - * and compared with new one before evaluating it. - */ - fun fetchSyncInformationIfNeeded(eventSyncState: EventSyncState) { - if (eventSyncState != lastKnownEventSyncState) { - if (eventSyncState.isSyncCompleted() && eventSyncState.isSyncReporterCompleted()) { - load() + fun toggleImageSync() { + viewModelScope.launch { + val isImageSyncing = imageSyncStatusFlow.firstOrNull()?.isSyncing == true + if (isImageSyncing) { + syncOrchestrator.stopImageSync() + } else { + syncOrchestrator.startImageSync() } - - lastKnownEventSyncState = eventSyncState } } - fun login() { + fun logout() { + logoutUseCase() + } + + fun requestNavigationToLogin() { viewModelScope.launch { - val loginArgs = LoginContract.getParams( - authStore.signedInProjectId, - authStore.signedInUserId ?: recentUserActivityManager.getRecentUserActivity().lastUserUsed, + _loginNavigationEventLiveData.postValue( + LoginParams( + projectId = authStore.signedInProjectId, + userId = authStore.signedInUserId ?: recentUserActivityManager.getRecentUserActivity().lastUserUsed, + ) ) - _loginRequestedEventLiveData.send(loginArgs.toBundle()) } } fun handleLoginResult(result: LoginResult) { if (result.isSuccess) { - forceSync() + forceEventSync() } } - private fun load() = viewModelScope.launch { - val projectId = authStore.signedInProjectId - awaitAll( - async { _configuration.postValue(configManager.getProjectConfiguration()) }, - async { _recordsInLocal.postValue(getRecordsInLocal(projectId)) }, - async { _recordsToDownSync.postValue(fetchRecordsToCreateAndDeleteCount()) }, - async { _imagesToUpload.postValue(imageRepository.getNumberOfImagesToUpload(projectId)) }, - async { _moduleCounts.postValue(getModuleCounts(projectId)) }, - ) - } + // sync info change detection helpers - private fun emitSyncAvailable( - isSyncRunning: Boolean?, - isConnected: Boolean?, - syncConfiguration: SynchronizationConfiguration? = configuration.value?.synchronization, - ) = isConnected == true && - isSyncRunning == false && - syncConfiguration?.let { - !isModuleSync(it.down) || - isModuleSyncAndModuleIdOptionsNotEmpty( - it, - ) - } == true + private fun Flow.onRecordSyncComplete(action: suspend (SyncInfo) -> Unit) = + onChange( + comparator = { previous, current -> + previous.syncInfoSectionRecords.isProgressVisible && !current.syncInfoSectionRecords.isProgressVisible + }, + action, + ) - private fun isModuleSync(syncConfiguration: DownSynchronizationConfiguration) = - syncConfiguration.simprints.partitionType == DownSynchronizationConfiguration.PartitionType.MODULE + private fun Flow.onImageSyncComplete(action: suspend (SyncInfo) -> Unit) = + onChange( + comparator = { previous, current -> + previous.syncInfoSectionImages.isProgressVisible && !current.syncInfoSectionImages.isProgressVisible + }, + action, + ) - fun isModuleSyncAndModuleIdOptionsNotEmpty(synchronizationConfiguration: SynchronizationConfiguration) = - synchronizationConfiguration.down.let { it.simprints.moduleOptions.isNotEmpty() && isModuleSync(it) } - private suspend fun getRecordsInLocal(projectId: String): Int = enrolmentRecordRepository.count(SubjectQuery(projectId = projectId)) + // initial actions - private suspend fun getRecordsToUpSync() = eventSyncManager - .countEventsToUpload(listOf(EventType.ENROLMENT_V2, EventType.ENROLMENT_V4)) - .collect { _recordsToUpSync.postValue(it) } + private fun startInitialSyncIfRequired() { + viewModelScope.launch { + val isRunning = eventSyncManager.getLastSyncState().value?.isSyncRunning() ?: false + val lastUpdate = eventSyncManager.getLastSyncTime() - private suspend fun fetchRecordsToCreateAndDeleteCount(): DownSyncCounts = - if (configManager.getProjectConfiguration().isEventDownSyncAllowed()) { - fetchAndUpdateRecordsToDownSyncAndDeleteCount() - } else { - DownSyncCounts(0, isLowerBound = false) + val isForceEventSync = when { + configManager.isModuleSelectionRequired() -> false + isPreLogoutUpSync -> true + isRunning -> false + lastUpdate == null -> true + timeHelper.msBetweenNowAndTime(lastUpdate) > RE_SYNC_TIMEOUT_MILLIS -> true + else -> false + } + if (isForceEventSync) { + forceEventSync() + } } - - private suspend fun fetchAndUpdateRecordsToDownSyncAndDeleteCount(): DownSyncCounts = try { - eventSyncManager.countEventsToDownload() - } catch (t: Throwable) { - Simber.i("Could not count events for download", t, tag = SYNC) - DownSyncCounts(0, isLowerBound = false) } - private suspend fun getModuleCounts(projectId: String): List = - configManager.getDeviceConfiguration().selectedModules.map { moduleName -> - val count = enrolmentRecordRepository.count( - SubjectQuery(projectId = projectId, moduleId = moduleName), - ) - val decryptedName = when (moduleName) { - is TokenizableString.Raw -> moduleName - is TokenizableString.Tokenized -> tokenizationProcessor.decrypt( - encrypted = moduleName, - tokenKeyType = TokenKeyType.ModuleId, - project = configManager.getProject(projectId), - ) + private fun syncImagesAfterEventsWhenRequired() { + viewModelScope.launch { + if (isPreLogoutUpSync) { + eventSyncStateFlow + .map { it.isSyncCompleted() } + .distinctUntilChanged() + .collect { isEventSyncCompleted -> + if (isEventSyncCompleted && !configManager.isModuleSelectionRequired()) { + syncOrchestrator.startImageSync() + } + } } - return@map ModuleCount(name = decryptedName.value, count = count) } + } + + private suspend fun ConfigManager.isModuleSelectionRequired() = + getProjectConfiguration().isModuleSelectionAvailable() && getDeviceConfiguration().selectedModules.isEmpty() + + private companion object { + private const val RE_SYNC_TIMEOUT_MILLIS = 5 * 60 * 1000L + private const val SYNC_COMPLETION_HOLD_MILLIS = 1000L + private const val LOGOUT_DELAY_MILLIS = 3000L + private const val COUNT_EVENTS_TIMEOUT_MILLIS = 10 * 1000L + } } diff --git a/feature/dashboard/src/main/res/drawable/ic_down_sync.xml b/feature/dashboard/src/main/res/drawable/ic_down_sync.xml new file mode 100644 index 0000000000..ee43431e29 --- /dev/null +++ b/feature/dashboard/src/main/res/drawable/ic_down_sync.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/feature/dashboard/src/main/res/drawable/ic_images.xml b/feature/dashboard/src/main/res/drawable/ic_images.xml new file mode 100644 index 0000000000..e63e11d3be --- /dev/null +++ b/feature/dashboard/src/main/res/drawable/ic_images.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/feature/dashboard/src/main/res/drawable/ic_list.xml b/feature/dashboard/src/main/res/drawable/ic_list.xml new file mode 100644 index 0000000000..9558379f93 --- /dev/null +++ b/feature/dashboard/src/main/res/drawable/ic_list.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/feature/dashboard/src/main/res/drawable/ic_records.xml b/feature/dashboard/src/main/res/drawable/ic_records.xml new file mode 100644 index 0000000000..01132b8a79 --- /dev/null +++ b/feature/dashboard/src/main/res/drawable/ic_records.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/feature/dashboard/src/main/res/drawable/ic_up_sync.xml b/feature/dashboard/src/main/res/drawable/ic_up_sync.xml new file mode 100644 index 0000000000..39d578e74c --- /dev/null +++ b/feature/dashboard/src/main/res/drawable/ic_up_sync.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/feature/dashboard/src/main/res/layout/fragment_logout_sync.xml b/feature/dashboard/src/main/res/layout/fragment_logout_sync.xml index 6672edd2c5..84922071d9 100644 --- a/feature/dashboard/src/main/res/layout/fragment_logout_sync.xml +++ b/feature/dashboard/src/main/res/layout/fragment_logout_sync.xml @@ -28,6 +28,8 @@ android:layout_width="0dp" android:layout_height="0dp" android:fillViewport="true" + app:layout_constraintWidth_max="500dp" + app:layout_constraintHorizontal_bias="0.5" app:layout_constraintBottom_toTopOf="@+id/logoutWithoutSyncButton" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" @@ -47,30 +49,42 @@ android:text="@string/dashboard_logout_confirmation_sync_info" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintTop_toTopOf="parent" + android:visibility="invisible" + tools:visibility="visible"/> - + app:layout_constraintTop_toBottomOf="@+id/logout_sync_info"> + + + + + + + + - diff --git a/feature/dashboard/src/main/res/layout/fragment_main.xml b/feature/dashboard/src/main/res/layout/fragment_main.xml index 413ec3f483..db6d7c5ff8 100644 --- a/feature/dashboard/src/main/res/layout/fragment_main.xml +++ b/feature/dashboard/src/main/res/layout/fragment_main.xml @@ -4,6 +4,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:background="?attr/colorPrimary" android:theme="@style/Theme.Simprints"> - + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/vertical_spacing_dashboard_cards"> + + + + + + + + - + android:layout_height="match_parent" + android:background="@color/simprints_off_white" + android:orientation="vertical"> - + android:layout_height="wrap_content"> - - - - - - - + android:layout_height="?android:attr/actionBarSize" + app:navigationContentDescription="back" + app:navigationIcon="?android:attr/homeAsUpIndicator" + app:title="@string/sync_info_title" /> - + - - - - - + - - - - + + app:layout_constraintWidth_max="500dp" + app:layout_constraintHorizontal_bias="0.5" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent" > - - - - - + android:background="@color/simprints_white"> - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - + android:orientation="vertical" + android:padding="8dp" + android:background="@color/simprints_white"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + - - - - - - - + android:padding="8dp" + android:background="@color/simprints_white" + android:orientation="vertical"> + + + + + + + + + + + + + - + - - - - + - + - - - - - - - - - - - - - - - - - - - - diff --git a/feature/dashboard/src/main/res/navigation/graph_dashboard.xml b/feature/dashboard/src/main/res/navigation/graph_dashboard.xml index 534238df29..5ad73c5e46 100644 --- a/feature/dashboard/src/main/res/navigation/graph_dashboard.xml +++ b/feature/dashboard/src/main/res/navigation/graph_dashboard.xml @@ -55,6 +55,9 @@ + 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 f39857cd17..6884ad5dac 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 @@ -1,18 +1,31 @@ package com.simprints.feature.dashboard.logout import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.LiveData +import androidx.lifecycle.asFlow import com.google.common.truth.Truth.assertThat import com.simprints.feature.dashboard.logout.usecase.LogoutUseCase +import com.simprints.infra.authstore.AuthStore +import com.simprints.infra.config.store.models.DeviceConfiguration +import com.simprints.infra.config.store.models.ProjectConfiguration import com.simprints.infra.config.store.models.SettingsPasswordConfig +import com.simprints.infra.config.store.models.isModuleSelectionAvailable import com.simprints.infra.config.sync.ConfigManager +import com.simprints.infra.eventsync.EventSyncManager +import com.simprints.infra.eventsync.status.models.EventSyncState +import com.simprints.infra.sync.ImageSyncStatus +import com.simprints.infra.sync.SyncOrchestrator import com.simprints.testtools.common.coroutines.TestCoroutineRule import com.simprints.testtools.common.livedata.getOrAwaitValue import io.mockk.MockKAnnotations import io.mockk.coEvery -import io.mockk.coVerify +import io.mockk.verify import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk +import io.mockk.mockkStatic +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf import org.junit.Before import org.junit.Rule import org.junit.Test @@ -24,6 +37,15 @@ internal class LogoutSyncViewModelTest { @MockK lateinit var configManager: ConfigManager + @MockK + lateinit var eventSyncManager: EventSyncManager + + @MockK + lateinit var syncOrchestrator: SyncOrchestrator + + @MockK + lateinit var authStore: AuthStore + @get:Rule val rule = InstantTaskExecutorRule() @@ -33,18 +55,17 @@ internal class LogoutSyncViewModelTest { @Before fun setup() { MockKAnnotations.init(this, relaxed = true) + // Setup default behavior for logoutUseCase + every { logoutUseCase() } returns Unit } @Test fun `should logout correctly`() { - val viewModel = LogoutSyncViewModel( - configManager = configManager, - logoutUseCase = logoutUseCase, - ) + val viewModel = createViewModel() viewModel.logout() - coVerify(exactly = 1) { logoutUseCase.invoke() } + verify(exactly = 1) { logoutUseCase() } } @Test @@ -55,11 +76,135 @@ internal class LogoutSyncViewModelTest { every { settingsPassword } returns config } } - val viewModel = LogoutSyncViewModel( - configManager = configManager, - logoutUseCase = logoutUseCase, - ) + val viewModel = createViewModel() val resultConfig = viewModel.settingsLocked.getOrAwaitValue() assertThat(resultConfig.peekContent()).isEqualTo(config) } + + @Test + fun `logoutEventLiveData should emit momentarily when user is signed out`() { + every { authStore.watchSignedInProjectId() } returns MutableStateFlow("") + + val viewModel = createViewModel() + + val result = viewModel.logoutEventLiveData.getOrAwaitValue() + assertThat(result).isEqualTo(Unit) + } + + @Test + fun `logoutEventLiveData should not emit when user is signed in`() { + every { authStore.watchSignedInProjectId() } returns MutableStateFlow("userId123") + + val viewModel = createViewModel() + + assertThat(viewModel.logoutEventLiveData.value).isNull() + } + + @Test + fun `isLogoutWithoutSyncVisibleLiveData should return true when sync is not completed`() { + val eventSyncState = mockk { + every { isSyncCompleted() } returns false + } + val imageSyncStatus = ImageSyncStatus(isSyncing = false, progress = null, secondsSinceLastUpdate = null) + val projectConfig = mockk(relaxed = true) + val deviceConfig = mockk { + every { selectedModules } returns listOf(mockk()) + } + + mockProjectConfigExtension(projectConfig, isModuleSelectionAvailable = false) + setupSyncMocks(eventSyncState, imageSyncStatus, projectConfig, deviceConfig) + + val viewModel = createViewModel() + + val result = viewModel.isLogoutWithoutSyncVisibleLiveData.getOrAwaitValue() + assertThat(result).isTrue() + } + + @Test + fun `isLogoutWithoutSyncVisibleLiveData should return true when image sync is running`() { + val eventSyncState = mockk { + every { isSyncCompleted() } returns true + } + val imageSyncStatus = ImageSyncStatus(isSyncing = true, progress = null, secondsSinceLastUpdate = null) + val projectConfig = mockk(relaxed = true) + val deviceConfig = mockk { + every { selectedModules } returns listOf(mockk()) + } + + mockProjectConfigExtension(projectConfig, isModuleSelectionAvailable = false) + setupSyncMocks(eventSyncState, imageSyncStatus, projectConfig, deviceConfig) + + val viewModel = createViewModel() + + val result = viewModel.isLogoutWithoutSyncVisibleLiveData.getOrAwaitValue() + assertThat(result).isTrue() + } + + @Test + fun `isLogoutWithoutSyncVisibleLiveData should return true when module selection is required`() { + val eventSyncState = mockk { + every { isSyncCompleted() } returns true + } + val imageSyncStatus = ImageSyncStatus(isSyncing = false, progress = null, secondsSinceLastUpdate = null) + val projectConfig = mockk(relaxed = true) + val deviceConfig = mockk { + every { selectedModules } returns emptyList() + } + + mockProjectConfigExtension(projectConfig, isModuleSelectionAvailable = true) + setupSyncMocks(eventSyncState, imageSyncStatus, projectConfig, deviceConfig) + + val viewModel = createViewModel() + + val result = viewModel.isLogoutWithoutSyncVisibleLiveData.getOrAwaitValue() + assertThat(result).isTrue() + } + + @Test + fun `isLogoutWithoutSyncVisibleLiveData should return false when conditions for logout are met`() { + val eventSyncState = mockk { + every { isSyncCompleted() } returns true + } + val imageSyncStatus = ImageSyncStatus(isSyncing = false, progress = null, secondsSinceLastUpdate = null) + val projectConfig = mockk(relaxed = true) + val deviceConfig = mockk { + every { selectedModules } returns listOf(mockk()) + } + + mockProjectConfigExtension(projectConfig, isModuleSelectionAvailable = false) + setupSyncMocks(eventSyncState, imageSyncStatus, projectConfig, deviceConfig) + + val viewModel = createViewModel() + + val result = viewModel.isLogoutWithoutSyncVisibleLiveData.getOrAwaitValue() + assertThat(result).isFalse() + } + + private fun mockProjectConfigExtension(projectConfig: ProjectConfiguration, isModuleSelectionAvailable: Boolean) { + mockkStatic("com.simprints.infra.config.store.models.ProjectConfigurationKt") + every { projectConfig.isModuleSelectionAvailable() } returns isModuleSelectionAvailable + } + + private fun setupSyncMocks( + eventSyncState: EventSyncState, + imageSyncStatus: ImageSyncStatus, + projectConfig: ProjectConfiguration, + deviceConfig: DeviceConfiguration + ) { + mockkStatic("androidx.lifecycle.FlowLiveDataConversions") + val eventSyncLiveData = mockk>(relaxed = true) + every { eventSyncLiveData.asFlow() } returns flowOf(eventSyncState) + every { eventSyncManager.getLastSyncState(useDefaultValue = true) } returns eventSyncLiveData + every { syncOrchestrator.observeImageSyncStatus() } returns flowOf(imageSyncStatus) + every { configManager.watchProjectConfiguration() } returns flowOf(projectConfig) + every { configManager.watchDeviceConfiguration() } returns flowOf(deviceConfig) + } + + private fun createViewModel() = LogoutSyncViewModel( + configManager = configManager, + eventSyncManager = eventSyncManager, + syncOrchestrator = syncOrchestrator, + authStore = authStore, + logoutUseCase = logoutUseCase, + ) } diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/sync/LogoutSyncFragmentTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/sync/LogoutSyncFragmentTest.kt index 414bc5b807..60f06b8c63 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/sync/LogoutSyncFragmentTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/sync/LogoutSyncFragmentTest.kt @@ -2,19 +2,13 @@ package com.simprints.feature.dashboard.logout.sync import androidx.lifecycle.Observer import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.action.ViewActions.scrollTo import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertThat import com.simprints.feature.dashboard.R import com.simprints.feature.dashboard.logout.LogoutSyncViewModel -import com.simprints.feature.dashboard.main.sync.SyncViewModel -import com.simprints.feature.dashboard.views.SyncCardState import com.simprints.testtools.hilt.launchFragmentInHiltContainer import com.simprints.testtools.hilt.testNavController import dagger.hilt.android.testing.BindValue @@ -23,459 +17,64 @@ import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltTestApplication import io.mockk.every import io.mockk.mockk -import io.mockk.verify import org.hamcrest.core.IsNot.not import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.annotation.Config -import com.simprints.infra.resources.R as IDR @RunWith(AndroidJUnit4::class) @HiltAndroidTest @Config(application = HiltTestApplication::class) internal class LogoutSyncFragmentTest { - companion object { - private const val LAST_SYNC_TIME = "2022-10-10" - } @get:Rule var hiltRule = HiltAndroidRule(this) - @BindValue - @JvmField - internal val syncViewModel = mockk(relaxed = true) - @BindValue @JvmField internal val logoutSyncViewModel = mockk(relaxed = true) - private val context = InstrumentationRegistry.getInstrumentation().context private val navController = testNavController(R.navigation.graph_dashboard) @Test - fun `should not hide the sync card view if it can't sync to BFSID`() { - mockSyncToBFSIDAllowed(false) - launchFragmentInHiltContainer(navController = navController) - - onView(withId(R.id.logoutSyncCard)).check(matches(isDisplayed())) - } - - @Test - fun `should display the correct sync card view for the SyncDefault state`() { - mockSyncToBFSIDAllowed(true) - mockSyncCardLiveData(SyncCardState.SyncDefault(LAST_SYNC_TIME)) - - launchFragmentInHiltContainer(navController = navController) - - checkHiddenViews( - listOf( - R.id.sync_card_failed_message, - R.id.sync_card_select_no_modules, - R.id.sync_card_offline, - R.id.sync_card_progress, - R.id.sync_card_try_again, - R.id.logoutButton, - ), - ) - val lastSyncText = context.getString( - IDR.string.dashboard_sync_card_last_sync, - LAST_SYNC_TIME, - ) - onView(withId(R.id.sync_card_last_sync)) - .check(matches(withText(lastSyncText))) - onView(withId(R.id.sync_card_default_items_to_upload)).check( - matches(withText(context.getString(IDR.string.dashboard_sync_card_records_uploaded))), - ) - onView(withId(R.id.sync_card_default_state_sync_button)) - .check(matches(isDisplayed())) - .perform(scrollTo(), click()) - verify(exactly = 1) { syncViewModel.sync() } - } - - @Test - fun `should display the correct sync card view for the SyncPendingUpload state`() { - mockSyncToBFSIDAllowed(true) - mockSyncCardLiveData(SyncCardState.SyncPendingUpload(LAST_SYNC_TIME, 2)) - - launchFragmentInHiltContainer(navController = navController) - - checkHiddenViews( - listOf( - R.id.sync_card_failed_message, - R.id.sync_card_select_no_modules, - R.id.sync_card_offline, - R.id.sync_card_progress, - R.id.sync_card_try_again, - R.id.logoutButton, - ), - ) - val lastSyncText = context.getString( - IDR.string.dashboard_sync_card_last_sync, - LAST_SYNC_TIME, - ) - onView(withId(R.id.sync_card_last_sync)) - .check(matches(withText(lastSyncText))) - onView(withId(R.id.sync_card_default_items_to_upload)).check( - matches( - withText( - context.resources.getQuantityString( - com.simprints.infra.resources.R.plurals.dashboard_sync_card_records_to_upload, - 2, - 2, - ), - ), - ), - ) - onView(withId(R.id.sync_card_default_state_sync_button)) - .check(matches(isDisplayed())) - .perform(scrollTo(), click()) - verify(exactly = 1) { syncViewModel.sync() } - } - - @Test - fun `should display the correct sync card view for the SyncFailed state`() { - mockSyncToBFSIDAllowed(true) - mockSyncCardLiveData(SyncCardState.SyncFailed(LAST_SYNC_TIME)) - - launchFragmentInHiltContainer(navController = navController) - - checkHiddenViews( - listOf( - R.id.sync_card_default_state_sync_button, - R.id.sync_card_select_no_modules, - R.id.sync_card_offline, - R.id.sync_card_progress, - R.id.sync_card_try_again, - R.id.logoutButton, - ), - ) - - val lastSyncText = context.getString( - IDR.string.dashboard_sync_card_last_sync, - LAST_SYNC_TIME, - ) - onView(withId(R.id.sync_card_last_sync)) - .check(matches(withText(lastSyncText))) - onView(withId(R.id.sync_card_failed_message)) - .check(matches(withText(IDR.string.dashboard_sync_card_failed_message))) - } - - @Test - fun `should display the correct sync card view for the SyncFailedBackendMaintenance state without estimated outage`() { - mockSyncToBFSIDAllowed(true) - mockSyncCardLiveData(SyncCardState.SyncFailedBackendMaintenance(LAST_SYNC_TIME)) - - launchFragmentInHiltContainer(navController = navController) - - checkHiddenViews( - listOf( - R.id.sync_card_default_state_sync_button, - R.id.sync_card_select_no_modules, - R.id.sync_card_offline, - R.id.sync_card_progress, - R.id.sync_card_try_again, - R.id.logoutButton, - ), - ) - val lastSyncText = context.getString( - IDR.string.dashboard_sync_card_last_sync, - LAST_SYNC_TIME, - ) - onView(withId(R.id.sync_card_last_sync)) - .check(matches(withText(lastSyncText))) - onView(withId(R.id.sync_card_failed_message)) - .check(matches(withText(IDR.string.error_backend_maintenance_message))) - } - - @Test - fun `should display the correct sync card view for the SyncFailedBackendMaintenance state with estimated outage`() { - mockSyncToBFSIDAllowed(true) - mockSyncCardLiveData( - SyncCardState.SyncFailedBackendMaintenance( - LAST_SYNC_TIME, - 10L, - ), - ) - - launchFragmentInHiltContainer(navController = navController) - - checkHiddenViews( - listOf( - R.id.sync_card_default_state_sync_button, - R.id.sync_card_select_no_modules, - R.id.sync_card_offline, - R.id.sync_card_progress, - R.id.sync_card_try_again, - R.id.logoutButton, - ), - ) - - val lastSyncText = context.getString( - IDR.string.dashboard_sync_card_last_sync, - LAST_SYNC_TIME, - ) - onView(withId(R.id.sync_card_last_sync)) - .check(matches(withText(lastSyncText))) - val text = - context.getString( - IDR.string.error_backend_maintenance_with_time_message, - "10 seconds", - ) - onView(withId(R.id.sync_card_failed_message)) - .check(matches(withText(text))) - } - - @Test - fun `should display the correct sync card view for the SyncTooManyRequests state`() { - mockSyncToBFSIDAllowed(true) - mockSyncCardLiveData(SyncCardState.SyncTooManyRequests(LAST_SYNC_TIME)) - - launchFragmentInHiltContainer(navController = navController) - - checkHiddenViews( - listOf( - R.id.sync_card_default_state_sync_button, - R.id.sync_card_select_no_modules, - R.id.sync_card_offline, - R.id.sync_card_progress, - R.id.sync_card_try_again, - R.id.logoutButton, - ), - ) - - val lastSyncText = context.getString( - IDR.string.dashboard_sync_card_last_sync, - LAST_SYNC_TIME, - ) - onView(withId(R.id.sync_card_last_sync)) - .check(matches(withText(lastSyncText))) - onView(withId(R.id.sync_card_failed_message)) - .check(matches(withText(IDR.string.dashboard_sync_card_too_many_modules_message))) - } - - @Test - fun `should display the correct sync card view for the SyncTryAgain state`() { - mockSyncToBFSIDAllowed(true) - mockSyncCardLiveData(SyncCardState.SyncTryAgain(LAST_SYNC_TIME)) - - launchFragmentInHiltContainer(navController = navController) - - checkHiddenViews( - listOf( - R.id.sync_card_default_state_sync_button, - R.id.sync_card_failed_message, - R.id.sync_card_select_no_modules, - R.id.sync_card_offline, - R.id.sync_card_progress, - R.id.logoutButton, - ), - ) - - val lastSyncText = context.getString( - IDR.string.dashboard_sync_card_last_sync, - LAST_SYNC_TIME, - ) - onView(withId(R.id.sync_card_last_sync)) - .check(matches(withText(lastSyncText))) - onView(withId(R.id.sync_card_try_again_sync_button)) - .check(matches(isDisplayed())) - .perform(scrollTo(), click()) - verify(exactly = 1) { syncViewModel.sync() } - } - - @Test - fun `should display the correct sync card view for the SyncHasNoModules state`() { - mockSyncToBFSIDAllowed(true) - mockSyncCardLiveData(SyncCardState.SyncHasNoModules(LAST_SYNC_TIME)) - - val navController = testNavController(R.navigation.graph_dashboard, R.id.logOutSyncFragment) - - launchFragmentInHiltContainer(navController = navController) - - checkHiddenViews( - listOf( - R.id.sync_card_default_state_sync_button, - R.id.sync_card_failed_message, - R.id.sync_card_try_again, - R.id.sync_card_offline, - R.id.sync_card_progress, - R.id.logoutButton, - ), - ) - - val lastSyncText = context.getString( - IDR.string.dashboard_sync_card_last_sync, - LAST_SYNC_TIME, - ) - onView(withId(R.id.sync_card_last_sync)) - .check(matches(withText(lastSyncText))) - onView(withId(R.id.sync_card_select_no_modules_button)) - .check(matches(isDisplayed())) - .perform(click()) - assertThat(navController.currentDestination?.id) - .isEqualTo(R.id.moduleSelectionFragment) - } - - @Test - fun `should display the correct sync card view for the SyncProgress state`() { - mockSyncToBFSIDAllowed(true) - mockSyncCardLiveData(SyncCardState.SyncProgress(LAST_SYNC_TIME, 20, 40)) - - launchFragmentInHiltContainer(navController = navController) - - checkHiddenViews( - listOf( - R.id.sync_card_default_state_sync_button, - R.id.sync_card_failed_message, - R.id.sync_card_try_again, - R.id.sync_card_select_no_modules_button, - R.id.sync_card_offline, - R.id.logoutButton, - ), - ) - - val lastSyncText = context.getString( - IDR.string.dashboard_sync_card_last_sync, - LAST_SYNC_TIME, - ) - onView(withId(R.id.sync_card_last_sync)) - .check(matches(withText(lastSyncText))) - - onView(withId(R.id.sync_card_progress_sync_progress_bar)).check( - matches( - isDisplayed(), - ), - ) - - val text = context.getString(IDR.string.dashboard_sync_card_progress, "50%") - onView(withId(R.id.sync_card_progress_message)) - .check(matches(withText(text))) - } - - @Test - fun `should display the correct sync card view for the SyncConnecting state`() { - mockSyncToBFSIDAllowed(true) - mockSyncCardLiveData(SyncCardState.SyncConnecting(LAST_SYNC_TIME, 20, 40)) - - launchFragmentInHiltContainer(navController = navController) - - checkHiddenViews( - listOf( - R.id.sync_card_default_state_sync_button, - R.id.sync_card_failed_message, - R.id.sync_card_try_again, - R.id.sync_card_select_no_modules_button, - R.id.sync_card_offline, - R.id.logoutButton, - ), - ) - - val lastSyncText = context.getString( - IDR.string.dashboard_sync_card_last_sync, - LAST_SYNC_TIME, - ) - onView(withId(R.id.sync_card_last_sync)) - .check(matches(withText(lastSyncText))) - - onView(withId(R.id.sync_card_progress_sync_progress_bar)).check( - matches(isDisplayed()), - ) - - onView(withId(R.id.sync_card_progress_message)) - .check(matches(withText(IDR.string.dashboard_sync_card_connecting))) - } - - @Test - fun `should display the correct sync card view for the SyncComplete state`() { - mockSyncToBFSIDAllowed(true) - mockSyncCardLiveData(SyncCardState.SyncComplete(LAST_SYNC_TIME)) - - launchFragmentInHiltContainer(navController = navController) - - checkHiddenViews( - listOf( - R.id.sync_card_default_state_sync_button, - R.id.sync_card_failed_message, - R.id.sync_card_try_again, - R.id.sync_card_select_no_modules_button, - R.id.sync_card_offline, - ), - ) - - val lastSyncText = context.getString( - IDR.string.dashboard_sync_card_last_sync, - LAST_SYNC_TIME, - ) - onView(withId(R.id.sync_card_last_sync)) - .check(matches(withText(lastSyncText))) - - onView(withId(R.id.sync_card_progress_sync_progress_bar)).check( - matches(isDisplayed()), - ) - - onView(withId(R.id.sync_card_progress_message)) - .check(matches(withText(IDR.string.dashboard_sync_card_complete))) - - onView(withId(R.id.logoutButton)) - .perform(scrollTo()) - .check(matches(isDisplayed())) - } - - @Test - fun `should navigate to requestLoginFragment when logout button is pressed`() { - mockSyncToBFSIDAllowed(true) - mockSyncCardLiveData(SyncCardState.SyncComplete(LAST_SYNC_TIME)) - val navController = testNavController(R.navigation.graph_dashboard, R.id.logout_navigation) + fun `instant logout button and instructions are visible when ready to be seen`() { + every { logoutSyncViewModel.isLogoutWithoutSyncVisibleLiveData } returns mockk { + every { observe(any(), any()) } answers { + secondArg>().onChanged(true) + } + } launchFragmentInHiltContainer(navController = navController) - onView(withId(R.id.logoutButton)).perform(scrollTo(), click()) - assertThat(navController.currentDestination?.id) - .isEqualTo(R.id.requestLoginFragment) - } - - @Test - fun `logout button is not visible when records are not synchronized`() { - mockSyncToBFSIDAllowed(true) - mockSyncCardLiveData(SyncCardState.SyncProgress(LAST_SYNC_TIME, 20, 40)) - - launchFragmentInHiltContainer(navController = navController) - onView(withId(R.id.logoutButton)).check(matches(not(isDisplayed()))) onView(withId(R.id.logout_sync_info)).check(matches(isDisplayed())) onView(withId(R.id.logoutWithoutSyncButton)).check(matches(isDisplayed())) } @Test - fun `logout button is visible when records are synchronized`() { - mockSyncToBFSIDAllowed(true) - mockSyncCardLiveData(SyncCardState.SyncComplete(LAST_SYNC_TIME)) - + fun `instant logout button and instructions are not visible when not ready to be seen`() { + every { logoutSyncViewModel.isLogoutWithoutSyncVisibleLiveData } returns mockk { + every { observe(any(), any()) } answers { + secondArg>().onChanged(false) + } + } launchFragmentInHiltContainer(navController = navController) - onView(withId(R.id.logoutButton)).check(matches(isDisplayed())) + onView(withId(R.id.logout_sync_info)).check(matches(not(isDisplayed()))) onView(withId(R.id.logoutWithoutSyncButton)).check(matches(not(isDisplayed()))) } - private fun mockSyncCardLiveData(state: SyncCardState) { - every { syncViewModel.syncCardLiveData } returns mockk { - every { observe(any(), any()) } answers { - secondArg>().onChanged(state) - } - } - } - private fun mockSyncToBFSIDAllowed(allowed: Boolean) { - every { syncViewModel.syncToBFSIDAllowed } returns mockk { + @Test + fun `should navigate to requestLoginFragment when logout event received`() { + every { logoutSyncViewModel.logoutEventLiveData } returns mockk { every { observe(any(), any()) } answers { - secondArg>().onChanged(allowed) + secondArg>().onChanged(Unit) } } - } + val navController = testNavController(R.navigation.graph_dashboard, R.id.logout_navigation) + launchFragmentInHiltContainer(navController = navController) - private fun checkHiddenViews(views: List) { - views.forEach { - onView(withId(it)) - .check(matches(not(isDisplayed()))) - } + assertThat(navController.currentDestination?.id) + .isEqualTo(R.id.requestLoginFragment) } } diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/main/MainFragmentTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/main/MainFragmentTest.kt index 1e152fc69b..b5a6b984d0 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/main/MainFragmentTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/main/MainFragmentTest.kt @@ -13,7 +13,6 @@ import com.google.common.truth.Truth.assertThat import com.simprints.feature.dashboard.R import com.simprints.feature.dashboard.main.dailyactivity.DailyActivityViewModel import com.simprints.feature.dashboard.main.projectdetails.ProjectDetailsViewModel -import com.simprints.feature.dashboard.main.sync.SyncViewModel import com.simprints.testtools.hilt.launchFragmentInHiltContainer import com.simprints.testtools.hilt.testNavController import dagger.hilt.android.testing.BindValue @@ -48,10 +47,6 @@ class MainFragmentTest { @JvmField internal val dailyActivityViewModel = mockk(relaxed = true) - @BindValue - @JvmField - internal val syncViewModel = mockk(relaxed = true) - private val navController = testNavController(R.navigation.graph_dashboard, R.id.mainFragment) @Test diff --git a/infra/resources/src/main/res/values-am-rET/strings.xml b/infra/resources/src/main/res/values-am-rET/strings.xml index acdc769fb4..25931a6424 100644 --- a/infra/resources/src/main/res/values-am-rET/strings.xml +++ b/infra/resources/src/main/res/values-am-rET/strings.xml @@ -318,28 +318,28 @@ እስካነር ይጠቀሙ: %1$s አሁን ያለ ተጠቃሚ: %1$s - - መረጃውን መገናኘት - ሲንክ ማድረጉ ተጠናቋል - ሲንክ ማድረጉ አልተጠናቀቀም - በመገኛኘት ላይ ነው - በግንኙነት ላይ...%1$s - አሁን ግንኙነት ፍጠር - ሲንክ ማድረግ አልተቻለም እባክዎ የበላይ አካል ያግኙ - ሲንክ ለማድረግ ሞጁል ይምረጡ - ሞጁሎች - የስልኩ ሴቲንግ በመግባት የሞባይል ዳታ ያብሩ - ማስተካከያ - ብዙ ሞጁሎች ወርደዋል - ሲንክ ለማድረግ በቅድሚያ log in ይደረግ + + መረጃውን መገናኘት + ሲንክ ማድረጉ ተጠናቋል + ሲንክ ማድረጉ አልተጠናቀቀም + በመገኛኘት ላይ ነው + በግንኙነት ላይ...%1$s + አሁን ግንኙነት ፍጠር + ሲንክ ማድረግ አልተቻለም እባክዎ የበላይ አካል ያግኙ + ሲንክ ለማድረግ ሞጁል ይምረጡ + ሞጁሎች + የስልኩ ሴቲንግ በመግባት የሞባይል ዳታ ያብሩ + ማስተካከያ + ብዙ ሞጁሎች ወርደዋል + ሲንክ ለማድረግ በቅድሚያ log in ይደረግ - ያለፈው ግንኙነት: %1$s - ሁሉም መዝገቦች ተጭነዋል። - + ያለፈው ግንኙነት: %1$s + ሁሉም መዝገቦች ተጭነዋል። + %1$d ለመስቀል መዝገብ %1$d ለመስቀል መዝገቦች - እንደገና ይሞክሩ + እንደገና ይሞክሩ የየዕለት ተግባር: %1$s @@ -384,20 +384,17 @@ የድምጽ ማንቂያ ራስ-ሰር ቀረጻ - - ሲንክ የተደረገ መረጃ - ሞጅውሎችን ይምረጡ - የተመዘገበውን ለመጫን - ሪከርዶች ለማዉረድ ወይም ለመሰረዝ - የተመዘገበውን ለመሰረዝ - በስልኩ አጠቃላይ የተመዘገበ - የተመረጠ ሞጁል - ያልተመረጡ ሞጁሎች - አጠቃላይ የተመዘገበ - የሚጫኑ ምስሎች - አሁን ግንኙነት ፍጠር - በግንኙነት ላይ - የሲንኩን መረጃ በመውሰድ ላይ + + አጠቃላይ የተመዘገበ + ሲንክ የተደረገ መረጃ + ሞጅውሎችን ይምረጡ + የተመዘገበውን ለመጫን + ሪከርዶች ለማዉረድ ወይም ለመሰረዝ + የተመዘገበውን ለመሰረዝ + በስልኩ አጠቃላይ የተመዘገበ + የተመረጠ ሞጁል + የሚጫኑ ምስሎች + በግንኙነት ላይ የተሳሳተ ምርጫ፡ እባክዎት ካለው የምዕራፍ አማራጭ ውስጥ ቢያንስ አንድ ይምረጡ diff --git a/infra/resources/src/main/res/values-am/strings.xml b/infra/resources/src/main/res/values-am/strings.xml index 858e5766b8..7a56e25959 100644 --- a/infra/resources/src/main/res/values-am/strings.xml +++ b/infra/resources/src/main/res/values-am/strings.xml @@ -321,28 +321,28 @@ እስካነር ይጠቀሙ: %1$s አሁን ያለ ተጠቃሚ: %1$s - - መረጃውን መገናኘት - ሲንክ ማድረጉ ተጠናቋል - ሲንክ ማድረጉ አልተጠናቀቀም - በመገኛኘት ላይ ነው - በግንኙነት ላይ...%1$s - አሁን ግንኙነት ፍጠር - ሲንክ ማድረግ አልተቻለም እባክዎ የበላይ አካል ያግኙ - ሲንክ ለማድረግ ሞጁል ይምረጡ - ሞጁሎች - የስልኩ ሴቲንግ በመግባት የሞባይል ዳታ ያብሩ - ማስተካከያ - ከተፈቀደው ሞጁል በላይ ወርዷል - በቅድሚያ መግባት አለበት መረጃዉን ለመላክ + + መረጃውን መገናኘት + ሲንክ ማድረጉ ተጠናቋል + ሲንክ ማድረጉ አልተጠናቀቀም + በመገኛኘት ላይ ነው + በግንኙነት ላይ...%1$s + አሁን ግንኙነት ፍጠር + ሲንክ ማድረግ አልተቻለም እባክዎ የበላይ አካል ያግኙ + ሲንክ ለማድረግ ሞጁል ይምረጡ + ሞጁሎች + የስልኩ ሴቲንግ በመግባት የሞባይል ዳታ ያብሩ + ማስተካከያ + ከተፈቀደው ሞጁል በላይ ወርዷል + በቅድሚያ መግባት አለበት መረጃዉን ለመላክ - መጨረሻ ጊዜ ወደ ሰርቨር የተላከው : %1$s - ሁሉም መዝገቦች ተጭነዋል - + መጨረሻ ጊዜ ወደ ሰርቨር የተላከው : %1$s + ሁሉም መዝገቦች ተጭነዋል + %1$d ለመስቀል መዝገብ %1$d ለመስቀል መዝገቦች - እንደገና ይሞክሩ + እንደገና ይሞክሩ የየዕለት ተግባር: %1$s @@ -387,20 +387,17 @@ የድምጽ ማንቂያ ራስ-ሰር ቀረጻ - - ሲንክ የተደረገ መረጃ - ሞጅውሎችን ይምረጡ - የተመዘገበውን ለመጫን - ሪክርዶች ለማውረድ ወይም ለመሰረዝ - የተመዘገበውን ለመሰረዝ - በስልኩ አጠቃላይ የተመዘገበ - የተመረጡ ሞጁሎች - ያልተመረጡ ሞጁሎች - አጠቃላይ የተመዘገበ - የሚጫኑ ምስሎች - አሁን ግንኙነት ፍጠር - በግንኙነት ላይ - የሲንክ መረጃውን በመውሰድ ላይ + + አጠቃላይ የተመዘገበ + ሲንክ የተደረገ መረጃ + ሞጅውሎችን ይምረጡ + የተመዘገበውን ለመጫን + ሪክርዶች ለማውረድ ወይም ለመሰረዝ + የተመዘገበውን ለመሰረዝ + በስልኩ አጠቃላይ የተመዘገበ + የተመረጡ ሞጁሎች + የሚጫኑ ምስሎች + በግንኙነት ላይ የተሳሳተ ምርጫ፡ እባክዎት ካለው የምዕራፍ አማራጭ ውስጥ ቢያንስ አንድ ይምረጡ diff --git a/infra/resources/src/main/res/values-bn/strings.xml b/infra/resources/src/main/res/values-bn/strings.xml index 1e12b211b1..2603a5c484 100644 --- a/infra/resources/src/main/res/values-bn/strings.xml +++ b/infra/resources/src/main/res/values-bn/strings.xml @@ -318,28 +318,28 @@ স্ক্যানার ব্যবহার করা হয়েছে: %1$s বর্তমান ব্যবহারকারী: %1$s - - সিঙ্ক স্ট্যাটাস - সিঙ্ক হয়েছে - সিঙ্ক অসম্পূর্ণ - সংযুক্ত হচ্ছে - সিঙ্ক করা হচ্ছে… %1$s - সিঙ্ক করুন - সিঙ্ক অসফল হয়েছে - সিঙ্ক করতে মডিউল নির্বাচন করুন - মডিউল সমূহ - সেটিংস্‌ থেকে ইন্টারনেট সংযোগ চালু করুন - স্ক্যানার যোগ করুন - অনেক বেশি মডিউল ডাউনলোড করা হয়েছে - সিঙ্ক করতে পুনরায় লগইন করুন + + সিঙ্ক স্ট্যাটাস + সিঙ্ক হয়েছে + সিঙ্ক অসম্পূর্ণ + সংযুক্ত হচ্ছে + সিঙ্ক করা হচ্ছে… %1$s + সিঙ্ক করুন + সিঙ্ক অসফল হয়েছে + সিঙ্ক করতে মডিউল নির্বাচন করুন + মডিউল সমূহ + সেটিংস্‌ থেকে ইন্টারনেট সংযোগ চালু করুন + স্ক্যানার যোগ করুন + অনেক বেশি মডিউল ডাউনলোড করা হয়েছে + সিঙ্ক করতে পুনরায় লগইন করুন - সর্বশেষ সিঙ্ক: %1$s - সমস্ত রেকর্ড আপলোড হয়েছে - + সর্বশেষ সিঙ্ক: %1$s + সমস্ত রেকর্ড আপলোড হয়েছে + %1$dটি রেকর্ড আপলোড হওয়া বাকি %1$dটি রেকর্ড আপলোড হওয়া বাকি - আবার চেষ্টা করুন + আবার চেষ্টা করুন কার্যক্রম তথ্য: %1$s @@ -384,20 +384,17 @@ অডিও সতর্কতা অটো-ক্যাপচার - - তথ্য সিঙ্ক করুন - মডিউল নির্বাচন করুন - আপলোড এর জন্য প্রস্তুত রেকর্ড - ডাউনলোড বা মুছে ফেলার রেকর্ড - মুছে ফেলার জন্য প্রস্তুত রেকর্ড - ফোনে সংরক্ষিত সকল রেকর্ড - নির্বাচিত মডিউল - অনির্বাচিত মডিউল - সকল রেকর্ড - আপলোড বাকি - সিঙ্ক করুন - সিঙ্ক হচ্ছে… - সিঙ্ক তথ্য পুনরায় নিয়ে আসুন + + সকল রেকর্ড + তথ্য সিঙ্ক করুন + মডিউল নির্বাচন করুন + আপলোড এর জন্য প্রস্তুত রেকর্ড + ডাউনলোড বা মুছে ফেলার রেকর্ড + মুছে ফেলার জন্য প্রস্তুত রেকর্ড + ফোনে সংরক্ষিত সকল রেকর্ড + নির্বাচিত মডিউল + আপলোড বাকি + সিঙ্ক হচ্ছে… অবৈধ নির্বাচন। অন্তত একটি মডিউল নির্বাচন করুন। diff --git a/infra/resources/src/main/res/values-fr/strings.xml b/infra/resources/src/main/res/values-fr/strings.xml index ea67d87f88..5c13694971 100644 --- a/infra/resources/src/main/res/values-fr/strings.xml +++ b/infra/resources/src/main/res/values-fr/strings.xml @@ -319,29 +319,29 @@ Scanner utilisé %1$s Utilisateur actuel: %1$s - - Statut de la synchronisation - Synchronisation terminée - Synchronisation incomplète - Connexion - Synchronisation… %1$s - Synchroniser maintenant - La synchronisation a échoué. Veuillez contacter votre superviseur - Veuillez sélectionner les modules à synchroniser - Modules - Veuillez activer la connexion Internet dans les réglages - Réglages - Trop de modules ont été téléchargés. - Vous devez vous connecter pour synchroniser. + + Statut de la synchronisation + Synchronisation terminée + Synchronisation incomplète + Connexion + Synchronisation… %1$s + Synchroniser maintenant + La synchronisation a échoué. Veuillez contacter votre superviseur + Veuillez sélectionner les modules à synchroniser + Modules + Veuillez activer la connexion Internet dans les réglages + Réglages + Trop de modules ont été téléchargés. + Vous devez vous connecter pour synchroniser. - Dernière synchronisation : %1$s - Tous enregistrements téléchargés - + Dernière synchronisation : %1$s + Tous enregistrements téléchargés + %1$d enregistrement à envoyer %1$d enregistrements à envoyer %1$d enregistrements à envoyer - Réessayez + Réessayez Activité : %1$s @@ -389,20 +389,17 @@ Alerte audio Auto-capture - - Informations sur la synchronisation - Sélectionner les modules - Enregistrements à envoyer - Enregistrements à télécharger ou à supprimer - Enregistrements à supprimer - Nombre total d\'enregistrements sur l\'appareil - Modules sélectionnés - Modules non sélectionnés - Nombre total d\'enregistrements - Images à envoyer - Synchroniser maintenant - Synchronisation… - Re-récupération des informations de synchronisation + + Nombre total d\'enregistrements + Informations sur la synchronisation + Sélectionner les modules + Enregistrements à envoyer + Enregistrements à télécharger ou à supprimer + Enregistrements à supprimer + Nombre total d\'enregistrements sur l\'appareil + Modules sélectionnés + Images à envoyer + Synchronisation… Sélection invalide. Veuillez sélectionner au moins un module. diff --git a/infra/resources/src/main/res/values-hi/strings.xml b/infra/resources/src/main/res/values-hi/strings.xml index df3df45a37..b25e4d3c59 100644 --- a/infra/resources/src/main/res/values-hi/strings.xml +++ b/infra/resources/src/main/res/values-hi/strings.xml @@ -314,28 +314,28 @@ स्कैनर का उपयोग किया गया:%1$s वर्तमान उपयोगकर्ता: %1$s - - सिंक की स्थिति - सिंक सफल हुआ - सिंक असफल - कनेक्ट हो रहा है - सिंक हो रहा है......%1$s - अभी सिंक करें - सिंक असफल रहा। कृपया अपने सुपरवाईज़र से सम्पर्क करें - कृपया सिंक करने के लिए मॉड्यूल चुनें - मॉड्यूल्स - कृपया सेटिंग में जाकर इंटरनेट को चालू करें - सेटिंग - बहुत सारे मॉड्यूल डाउनलोड किए जा चुके हैं. - सिंक करने के लिए आपको फिर से लॉग इन करना होगा + + सिंक की स्थिति + सिंक सफल हुआ + सिंक असफल + कनेक्ट हो रहा है + सिंक हो रहा है......%1$s + अभी सिंक करें + सिंक असफल रहा। कृपया अपने सुपरवाईज़र से सम्पर्क करें + कृपया सिंक करने के लिए मॉड्यूल चुनें + मॉड्यूल्स + कृपया सेटिंग में जाकर इंटरनेट को चालू करें + सेटिंग + बहुत सारे मॉड्यूल डाउनलोड किए जा चुके हैं. + सिंक करने के लिए आपको फिर से लॉग इन करना होगा - अंतिम बार सिंक: %1$s - सभी रिकॉर्ड अपलोड किए गए - + अंतिम बार सिंक: %1$s + सभी रिकॉर्ड अपलोड किए गए + %1$d अपलोड करने हेतु रिकार्ड %1$dअपलोड करने हेतु रिकार्ड - पुनः प्रयास करें + पुनः प्रयास करें गतिविधि: %1$s @@ -380,20 +380,17 @@ ऑडियो अलर्ट ऑटो-कैप्चर - - सिंक सम्बंधित जानकारी - मॉड्यूल का चुनाव करें - रिकोर्ड्स अपलोड किए जाने हैं - डाउनलोड करने या हटाने के लिए रिकॉर्ड - रिकोर्ड्स डिलीट किए जाने हैं - डिवाइस में कुल रिकोर्ड्स - चुने गए मॉड्यूल - अचयनित मॉड्यूल्स - कुल रिकोर्ड्स - अपलोड करने के लिए चित्र - अभी सिंक करें - सिंक हो रहा है … - सिंक जानकारी पुनः प्राप्त करें + + कुल रिकोर्ड्स + सिंक सम्बंधित जानकारी + मॉड्यूल का चुनाव करें + रिकोर्ड्स अपलोड किए जाने हैं + डाउनलोड करने या हटाने के लिए रिकॉर्ड + रिकोर्ड्स डिलीट किए जाने हैं + डिवाइस में कुल रिकोर्ड्स + चुने गए मॉड्यूल + अपलोड करने के लिए चित्र + सिंक हो रहा है … अमान्य चुनाव। कृपया कम से कम एक मॉड्यूल चुनें diff --git a/infra/resources/src/main/res/values-om/strings.xml b/infra/resources/src/main/res/values-om/strings.xml index 576fe184b3..95f3c09178 100644 --- a/infra/resources/src/main/res/values-om/strings.xml +++ b/infra/resources/src/main/res/values-om/strings.xml @@ -260,21 +260,21 @@ Iskaanarii hojii irra oole: %1$s Fayyadamaa Mosaajii - - Haala siinkii - Siinkiin xumureera - Siinkiin hin xumuramne - Walqunnamaa jira - Siink gochaa jira…%1$s - Amma Siink godhi - Siinkiin hin milkoofne, supparvaayzara kee qunnami - Maaloo moojuula sinkii gootuu filadhu - Moojula - Maaloo gara mijeessaa internetii keetii deebi\'i - Mijeessaa - Yeroo dhumaaf siink kan tahe: %1$s - Galmeewwan hundi olkaa\'aman - Irra deebi\'ii yaali + + Haala siinkii + Siinkiin xumureera + Siinkiin hin xumuramne + Walqunnamaa jira + Siink gochaa jira…%1$s + Amma Siink godhi + Siinkiin hin milkoofne, supparvaayzara kee qunnami + Maaloo moojuula sinkii gootuu filadhu + Moojula + Maaloo gara mijeessaa internetii keetii deebi\'i + Mijeessaa + Yeroo dhumaaf siink kan tahe: %1$s + Galmeewwan hundi olkaa\'aman + Irra deebi\'ii yaali Hojii guyyaa guyyaa:%1$s @@ -309,19 +309,18 @@ Quba ashaaraan isaa fuudhuu barbaaddu filadhu Bal\'inaan siinkirrattii - - Odeffannoo Siinkii - Moojuliiwwan filadhu - Barreeffama olfe\'aa - Barreeffama gad-buussuf - Barreeffama haquuf - Barreeffama waliigalaa meeshaarra - Moojuula filatamee - Moojuulaa hin filatamne - Barreeffama waliigalaa - Suuraalee olkaa\'uuf - Amma Siink godhi - Siink gochaa jira + + Barreeffama waliigalaa + Odeffannoo Siinkii + Moojuliiwwan filadhu + Barreeffama olfe\'aa + Barreeffama gad-buussuf + Barreeffama haquuf + Barreeffama waliigalaa meeshaarra + Moojuula filatamee + Suuraalee olkaa\'uuf + Siink gochaa jira + Filannoo dogoggoraa. Maaloo, yoo xiqqaate moodula tokko filadhu Filannoo dogoggoraa. Maaloo, %1$d moojulii ol hin filatiin. diff --git a/infra/resources/src/main/res/values/strings.xml b/infra/resources/src/main/res/values/strings.xml index 7d95c15728..cf3b39ee58 100644 --- a/infra/resources/src/main/res/values/strings.xml +++ b/infra/resources/src/main/res/values/strings.xml @@ -316,28 +316,66 @@ Scanner used: %1$s Current user: %1$s - - Sync status - Sync complete - Sync incomplete - Connecting - Syncing… %1$s - Sync Now - Sync failed. Please contact your supervisor - Please select modules to sync - Modules - Please turn on internet connection in settings - Settings - Too many modules have been downloaded. - You need to log in again in order to sync - - Last sync: %1$s - All records uploaded - - %1$d record to upload - %1$d records to upload - - Try again + + Sync Information + + Sync status + Settings + + You need to log in again in order to sync + + Record Sync + Image Sync + Selected Modules + + Manually synchronise records by pressing this button. More info + Manually send images to the cloud by pressing this button. More info + Records from the selected modules will be synchronised. More info + Sync needs an internet connection. Connection settings + Sync needs modules selected. Select modules + Sync failed. More info + + Manually synchronise records by pressing this button. Your device will also regularly attempt to synchronise records in the background. + Manually send images to the cloud by pressing this button. Your device will also regularly attempt to send images in the background. Note that image syncing may require a strong internet connection. + Records from the selected modules will be synchronised to your device. Click below/above to select modules to synchronise to your device. + Sync failed. Please contact your supervisor + Too many modules have been downloaded. + OK + + Total records on device + Records to upload + Records to synchronise + Images to upload + + Sync records now + Syncing + Sync images now + Stop images syncing + Try again + Select modules + + %1$s sync pending… + %1$s sync complete + %1$s sync in progress… + %1$s sync: %2$d of %3$d… + + Item + Record & Event + Image + + Total records + + Sync in progress + Sync incomplete + Sync complete, logging you out… + + Last synced just now + Last synced 1 minute ago + Last synced %1$d minutes ago + Last synced 1 hour ago + Last synced %1$d hours ago + Last synced 1 day ago + Last synced %1$d days ago Activity: %1$s @@ -382,21 +420,6 @@ Audio Alert Auto-capture - - Sync Information - Select modules - Records to upload - Records to download or delete - Records to delete - Total records on device - Selected modules - Unselected modules - Total records - Images to upload - Sync now - Syncing… - Re-fetch Sync Info - Invalid Selection. Please select at least one module. Invalid Selection. Please select no more than %1$d modules. From 8ebb4eee239f8039420dd1aa187cb00aea4067c2 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 24 Jul 2025 11:29:38 +0100 Subject: [PATCH 11/45] MS-939 Cleanup of now unused old sync UI --- .../dashboard/main/sync/SyncFragment.kt | 89 ---- .../dashboard/main/sync/SyncViewModel.kt | 278 ---------- .../feature/dashboard/views/SyncCardState.kt | 59 -- .../feature/dashboard/views/SyncCardView.kt | 229 -------- .../layout/fragment_dashboard_card_sync.xml | 7 - .../src/main/res/layout/layout_card_sync.xml | 228 -------- .../src/main/res/menu/sync_info_menu.xml | 9 - .../dashboard/main/sync/SyncFragmentTest.kt | 435 --------------- .../dashboard/main/sync/SyncViewModelTest.kt | 503 ------------------ 9 files changed, 1837 deletions(-) delete mode 100644 feature/dashboard/src/main/java/com/simprints/feature/dashboard/main/sync/SyncFragment.kt delete mode 100644 feature/dashboard/src/main/java/com/simprints/feature/dashboard/main/sync/SyncViewModel.kt delete mode 100644 feature/dashboard/src/main/java/com/simprints/feature/dashboard/views/SyncCardState.kt delete mode 100644 feature/dashboard/src/main/java/com/simprints/feature/dashboard/views/SyncCardView.kt delete mode 100644 feature/dashboard/src/main/res/layout/fragment_dashboard_card_sync.xml delete mode 100644 feature/dashboard/src/main/res/layout/layout_card_sync.xml delete mode 100644 feature/dashboard/src/main/res/menu/sync_info_menu.xml delete mode 100644 feature/dashboard/src/test/java/com/simprints/feature/dashboard/main/sync/SyncFragmentTest.kt delete mode 100644 feature/dashboard/src/test/java/com/simprints/feature/dashboard/main/sync/SyncViewModelTest.kt diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/main/sync/SyncFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/main/sync/SyncFragment.kt deleted file mode 100644 index dba65dd255..0000000000 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/main/sync/SyncFragment.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.simprints.feature.dashboard.main.sync - -import android.content.Intent -import android.os.Bundle -import android.provider.Settings -import android.view.View -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.navigation.fragment.findNavController -import com.simprints.core.livedata.LiveDataEventWithContentObserver -import com.simprints.feature.dashboard.R -import com.simprints.feature.dashboard.databinding.FragmentDashboardCardSyncBinding -import com.simprints.feature.dashboard.main.MainFragmentDirections -import com.simprints.feature.dashboard.requestlogin.LogoutReason -import com.simprints.feature.dashboard.requestlogin.RequestLoginFragmentArgs -import com.simprints.feature.login.LoginContract -import com.simprints.feature.login.LoginResult -import com.simprints.infra.uibase.navigation.handleResult -import com.simprints.infra.uibase.navigation.navigateSafely -import com.simprints.infra.uibase.viewbinding.viewBinding -import dagger.hilt.android.AndroidEntryPoint -import com.simprints.infra.resources.R as IDR - -@AndroidEntryPoint -internal class SyncFragment : Fragment(R.layout.fragment_dashboard_card_sync) { - private val viewModel by viewModels() - private val binding by viewBinding(FragmentDashboardCardSyncBinding::bind) - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - super.onViewCreated(view, savedInstanceState) - initViews() - observeLiveData() - - findNavController().handleResult( - viewLifecycleOwner, - R.id.mainFragment, - LoginContract.DESTINATION, - ) { result -> viewModel.handleLoginResult(result) } - } - - private fun initViews() = with(binding.dashboardSyncCard) { - onSyncButtonClick = { viewModel.sync() } - onOfflineButtonClick = { startActivity(Intent(Settings.ACTION_WIRELESS_SETTINGS)) } - onSelectNoModulesButtonClick = { - findNavController().navigateSafely( - parentFragment, - MainFragmentDirections.actionMainFragmentToModuleSelectionFragment(), - ) - } - onLoginButtonClick = { viewModel.login() } - } - - private fun observeLiveData() { - viewModel.syncToBFSIDAllowed.observe(viewLifecycleOwner) { - if (it) { - binding.dashboardSyncCard.visibility = View.VISIBLE - } else { - binding.dashboardSyncCard.visibility = View.GONE - } - } - viewModel.syncCardLiveData.observe(viewLifecycleOwner) { - binding.dashboardSyncCard.render(state = it) - } - viewModel.signOutEventLiveData.observe(viewLifecycleOwner) { - val logoutReason = LogoutReason( - title = getString(IDR.string.dashboard_sync_project_ending_alert_title), - body = getString(IDR.string.dashboard_sync_project_ending_message), - ) - findNavController().navigateSafely( - parentFragment, - R.id.action_mainFragment_to_requestLoginFragment, - RequestLoginFragmentArgs(logoutReason = logoutReason).toBundle(), - ) - } - viewModel.loginRequestedEventLiveData.observe( - viewLifecycleOwner, - LiveDataEventWithContentObserver { loginArgs -> - findNavController().navigateSafely( - parentFragment, - R.id.action_mainFragment_to_login, - loginArgs, - ) - }, - ) - } -} diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/main/sync/SyncViewModel.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/main/sync/SyncViewModel.kt deleted file mode 100644 index 42f0b41723..0000000000 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/main/sync/SyncViewModel.kt +++ /dev/null @@ -1,278 +0,0 @@ -package com.simprints.feature.dashboard.main.sync - -import android.os.Bundle -import androidx.lifecycle.LiveData -import androidx.lifecycle.MediatorLiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.simprints.core.livedata.LiveDataEvent -import com.simprints.core.livedata.LiveDataEventWithContent -import com.simprints.core.livedata.send -import com.simprints.core.tools.time.TimeHelper -import com.simprints.core.tools.time.Timestamp -import com.simprints.feature.dashboard.logout.usecase.LogoutUseCase -import com.simprints.feature.dashboard.views.SyncCardState -import com.simprints.feature.dashboard.views.SyncCardState.SyncComplete -import com.simprints.feature.dashboard.views.SyncCardState.SyncConnecting -import com.simprints.feature.dashboard.views.SyncCardState.SyncDefault -import com.simprints.feature.dashboard.views.SyncCardState.SyncFailed -import com.simprints.feature.dashboard.views.SyncCardState.SyncFailedBackendMaintenance -import com.simprints.feature.dashboard.views.SyncCardState.SyncFailedReloginRequired -import com.simprints.feature.dashboard.views.SyncCardState.SyncHasNoModules -import com.simprints.feature.dashboard.views.SyncCardState.SyncOffline -import com.simprints.feature.dashboard.views.SyncCardState.SyncPendingUpload -import com.simprints.feature.dashboard.views.SyncCardState.SyncProgress -import com.simprints.feature.dashboard.views.SyncCardState.SyncTooManyRequests -import com.simprints.feature.dashboard.views.SyncCardState.SyncTryAgain -import com.simprints.feature.login.LoginContract -import com.simprints.feature.login.LoginResult -import com.simprints.infra.authstore.AuthStore -import com.simprints.infra.config.store.models.DownSynchronizationConfiguration -import com.simprints.infra.config.store.models.Frequency -import com.simprints.infra.config.store.models.ProjectState -import com.simprints.infra.config.store.models.canSyncDataToSimprints -import com.simprints.infra.config.store.models.isEventDownSyncAllowed -import com.simprints.infra.config.sync.ConfigManager -import com.simprints.infra.events.event.domain.models.EventType -import com.simprints.infra.eventsync.EventSyncManager -import com.simprints.infra.eventsync.status.models.EventSyncState -import com.simprints.infra.network.ConnectivityTracker -import com.simprints.infra.recent.user.activity.RecentUserActivityManager -import com.simprints.infra.sync.SyncOrchestrator -import com.simprints.infra.uibase.navigation.toBundle -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -internal class SyncViewModel @Inject constructor( - private val eventSyncManager: EventSyncManager, - private val syncOrchestrator: SyncOrchestrator, - private val connectivityTracker: ConnectivityTracker, - private val configManager: ConfigManager, - private val timeHelper: TimeHelper, - private val authStore: AuthStore, - private val logout: LogoutUseCase, - private val recentUserActivityManager: RecentUserActivityManager, -) : ViewModel() { - companion object { - private const val ONE_MINUTE = 1000 * 60L - private const val MAX_TIME_BEFORE_SYNC_AGAIN = 5 * ONE_MINUTE - } - - val syncToBFSIDAllowed: LiveData - get() = _syncToBFSIDAllowed - private val _syncToBFSIDAllowed = MutableLiveData() - - val syncCardLiveData: LiveData - get() = _syncCardLiveData - private val _syncCardLiveData = MediatorLiveData() - val signOutEventLiveData: LiveData - get() = _signOutEventLiveData - private val _signOutEventLiveData = MediatorLiveData() - - val loginRequestedEventLiveData: LiveData> - get() = _loginRequestedEventLiveData - private val _loginRequestedEventLiveData = MutableLiveData>() - - private val upSyncCountLiveData = MutableLiveData(0) - private val syncStateLiveData = eventSyncManager.getLastSyncState() - - private suspend fun lastTimeSyncSucceed(): String? = eventSyncManager - .getLastSyncTime() - ?.let { timeHelper.readableBetweenNowAndTime(it) } - - private var lastTimeSyncRun: Timestamp? = null - - init { - viewModelScope.launch { - _syncCardLiveData.postValue(SyncConnecting(lastTimeSyncSucceed(), 0, null)) - } - - // CORE-2638 - // When project is in ENDING state and all data is synchronized, the user must be logged out - _signOutEventLiveData.addSource(_syncCardLiveData) { cardState -> - viewModelScope.launch { - val isSyncComplete = cardState is SyncComplete - val isProjectEnding = try { - configManager.getProject(authStore.signedInProjectId).state == ProjectState.PROJECT_ENDING - } catch (e: Throwable) { - // When the device is compromised the project data will be deleted and - // attempting to access project state with result in exception. - // For user it is essentially the same as project ending. - true - } - - if (isSyncComplete && isProjectEnding) { - viewModelScope.launch { - logout() - _signOutEventLiveData.postValue(LiveDataEvent()) - } - } - } - } - - startInitialSyncIfRequired() - load() - } - - fun sync() { - _syncCardLiveData.postValue(SyncConnecting(null, 0, null)) - syncOrchestrator.startEventSync() - } - - fun login() { - viewModelScope.launch { - val loginArgs = LoginContract.getParams( - authStore.signedInProjectId, - authStore.signedInUserId - ?: recentUserActivityManager.getRecentUserActivity().lastUserUsed, - ) - _loginRequestedEventLiveData.send(loginArgs.toBundle()) - } - } - - fun handleLoginResult(result: LoginResult) { - if (result.isSuccess) { - sync() - } - } - - private fun startInitialSyncIfRequired() { - viewModelScope.launch { - val lastUpdate = lastTimeSyncRun ?: eventSyncManager.getLastSyncTime() - - val isRunning = syncStateLiveData.value?.isSyncRunning() ?: false - - if (!isRunning && (lastUpdate == null || timeHelper.msBetweenNowAndTime(lastUpdate) > MAX_TIME_BEFORE_SYNC_AGAIN)) { - sync() - } - } - } - - private fun load() = viewModelScope.launch { - _syncCardLiveData.addSource(connectivityTracker.observeIsConnected()) { - CoroutineScope(coroutineContext + SupervisorJob()).launch { - emitNewCardState( - it, - isModuleSelectionRequired(), - syncStateLiveData.value, - upSyncCountLiveData.value ?: 0, - ) - } - } - _syncCardLiveData.addSource(syncStateLiveData) { - CoroutineScope(coroutineContext + SupervisorJob()).launch { - emitNewCardState( - isConnected(), - isModuleSelectionRequired(), - it, - upSyncCountLiveData.value ?: 0, - ) - } - } - _syncCardLiveData.addSource(upSyncCountLiveData) { - CoroutineScope(coroutineContext + SupervisorJob()).launch { - emitNewCardState( - isConnected(), - isModuleSelectionRequired(), - syncStateLiveData.value, - it, - ) - } - } - configManager.getProjectConfiguration().also { configuration -> - _syncToBFSIDAllowed.postValue(configuration.canSyncDataToSimprints() || configuration.isEventDownSyncAllowed()) - } - eventSyncManager - .countEventsToUpload(listOf(EventType.ENROLMENT_V2, EventType.ENROLMENT_V4)) - .collect { upSyncCountLiveData.postValue(it) } - } - - private suspend fun emitNewCardState( - isConnected: Boolean, - isModuleSelectionRequired: Boolean, - syncState: EventSyncState?, - itemsToUpSync: Int, - ) { - val syncRunningAndInfoNotReadyYet = - syncState == null && _syncCardLiveData.value is SyncConnecting - val syncNotRunningAndInfoNotReadyYet = - syncState == null && _syncCardLiveData.value !is SyncConnecting - - when { - isModuleSelectionRequired -> SyncHasNoModules(lastTimeSyncSucceed()) - !isConnected -> SyncOffline(lastTimeSyncSucceed()) - syncRunningAndInfoNotReadyYet -> SyncConnecting(lastTimeSyncSucceed(), 0, null) - syncNotRunningAndInfoNotReadyYet -> SyncDefault(lastTimeSyncSucceed()) - syncState == null -> SyncDefault(null) // Useless after the 2 above - just to satisfy nullability in the else - else -> processRecentSyncState(syncState, itemsToUpSync) - }.let { - _syncCardLiveData.postValue(it) - if (syncState != null && syncState.isSyncRunning()) { - lastTimeSyncRun = timeHelper.now() - } - } - } - - private suspend fun processRecentSyncState( - syncState: EventSyncState, - itemsToUpSync: Int, - ): SyncCardState = when { - syncState.isThereNotSyncHistory() -> SyncDefault(lastTimeSyncSucceed()) - syncState.isSyncCompleted() -> { - if (itemsToUpSync == 0) { - SyncComplete(lastTimeSyncSucceed()) - } else { - SyncPendingUpload(lastTimeSyncSucceed(), itemsToUpSync) - } - } - - syncState.isSyncInProgress() -> SyncProgress( - lastTimeSyncSucceed(), - syncState.progress, - syncState.total, - ) - - syncState.isSyncConnecting() -> SyncConnecting( - lastTimeSyncSucceed(), - syncState.progress, - syncState.total, - ) - - syncState.isSyncFailedBecauseReloginRequired() -> SyncFailedReloginRequired( - lastTimeSyncSucceed(), - ) - - syncState.isSyncFailedBecauseTooManyRequests() -> SyncTooManyRequests( - lastTimeSyncSucceed(), - ) - - syncState.isSyncFailedBecauseCloudIntegration() -> SyncFailed(lastTimeSyncSucceed()) - syncState.isSyncFailedBecauseBackendMaintenance() -> SyncFailedBackendMaintenance( - lastTimeSyncSucceed(), - syncState.getEstimatedBackendMaintenanceOutage(), - ) - - syncState.isSyncFailed() -> SyncTryAgain(lastTimeSyncSucceed()) - else -> SyncProgress(lastTimeSyncSucceed(), syncState.progress, syncState.total) - } - - private suspend fun isModuleSelectionRequired() = isDownSyncAllowed() && isSelectedModulesEmpty() && isModuleSync() - - private suspend fun isDownSyncAllowed() = configManager - .getProjectConfiguration() - .synchronization.down.simprints.frequency != - Frequency.ONLY_PERIODICALLY_UP_SYNC - - private suspend fun isSelectedModulesEmpty() = configManager.getDeviceConfiguration().selectedModules.isEmpty() - - private suspend fun isModuleSync() = configManager - .getProjectConfiguration() - .synchronization.down.simprints.partitionType == DownSynchronizationConfiguration.PartitionType.MODULE - - private fun isConnected() = connectivityTracker.observeIsConnected().value ?: true -} diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/views/SyncCardState.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/views/SyncCardState.kt deleted file mode 100644 index b0c801ddbf..0000000000 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/views/SyncCardState.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.simprints.feature.dashboard.views - -internal sealed class SyncCardState( - open val lastTimeSyncSucceed: String?, -) { - data class SyncDefault( - override val lastTimeSyncSucceed: String?, - ) : SyncCardState(lastTimeSyncSucceed) - - data class SyncPendingUpload( - override val lastTimeSyncSucceed: String?, - val itemsToUpSync: Int, - ) : SyncCardState(lastTimeSyncSucceed) - - data class SyncProgress( - override val lastTimeSyncSucceed: String?, - val progress: Int?, - val total: Int?, - ) : SyncCardState(lastTimeSyncSucceed) - - data class SyncConnecting( - override val lastTimeSyncSucceed: String?, - val progress: Int?, - val total: Int?, - ) : SyncCardState(lastTimeSyncSucceed) - - data class SyncFailed( - override val lastTimeSyncSucceed: String?, - ) : SyncCardState(lastTimeSyncSucceed) - - data class SyncFailedReloginRequired( - override val lastTimeSyncSucceed: String?, - ) : SyncCardState(lastTimeSyncSucceed) - - data class SyncFailedBackendMaintenance( - override val lastTimeSyncSucceed: String?, - val estimatedOutage: Long? = null, - ) : SyncCardState(lastTimeSyncSucceed) - - data class SyncTooManyRequests( - override val lastTimeSyncSucceed: String?, - ) : SyncCardState(lastTimeSyncSucceed) - - data class SyncTryAgain( - override val lastTimeSyncSucceed: String?, - ) : SyncCardState(lastTimeSyncSucceed) - - data class SyncComplete( - override val lastTimeSyncSucceed: String?, - ) : SyncCardState(lastTimeSyncSucceed) - - data class SyncHasNoModules( - override val lastTimeSyncSucceed: String?, - ) : SyncCardState(lastTimeSyncSucceed) - - data class SyncOffline( - override val lastTimeSyncSucceed: String?, - ) : SyncCardState(lastTimeSyncSucceed) -} diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/views/SyncCardView.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/views/SyncCardView.kt deleted file mode 100644 index 0bcfa843f3..0000000000 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/views/SyncCardView.kt +++ /dev/null @@ -1,229 +0,0 @@ -package com.simprints.feature.dashboard.views - -import android.content.Context -import android.util.AttributeSet -import android.view.LayoutInflater -import android.view.View -import android.widget.ProgressBar -import androidx.core.graphics.BlendModeColorFilterCompat -import androidx.core.graphics.BlendModeCompat -import com.google.android.material.card.MaterialCardView -import com.simprints.core.tools.utils.TimeUtils -import com.simprints.feature.dashboard.databinding.LayoutCardSyncBinding -import com.simprints.infra.resources.R -import kotlin.math.min - -internal class SyncCardView : MaterialCardView { - constructor(context: Context) : super(context) - constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) - - constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( - context, - attrs, - defStyleAttr, - ) - - var onSyncButtonClick: () -> Unit = {} - var onSelectNoModulesButtonClick: () -> Unit = {} - var onOfflineButtonClick: () -> Unit = {} - var onLoginButtonClick: () -> Unit = {} - private val binding = LayoutCardSyncBinding.inflate(LayoutInflater.from(context), this) - - init { - hideAllViews() - } - - internal fun render(state: SyncCardState) { - hideAllViews() - when (state) { - is SyncCardState.SyncDefault -> prepareSyncDefaultStateView() - is SyncCardState.SyncPendingUpload -> prepareSyncDefaultStateView(state.itemsToUpSync) - is SyncCardState.SyncFailed -> prepareSyncFailedStateView() - is SyncCardState.SyncFailedReloginRequired -> prepareSyncFailedBecauseReloginRequired() - is SyncCardState.SyncFailedBackendMaintenance -> prepareSyncFailedBecauseBackendMaintenanceView(state) - is SyncCardState.SyncTooManyRequests -> prepareSyncTooManyRequestsView() - is SyncCardState.SyncTryAgain -> prepareTryAgainStateView() - is SyncCardState.SyncHasNoModules -> prepareNoModulesStateView() - is SyncCardState.SyncOffline -> prepareSyncOfflineView() - is SyncCardState.SyncProgress -> prepareProgressView(state) - is SyncCardState.SyncConnecting -> prepareSyncConnectingView(state) - is SyncCardState.SyncComplete -> prepareSyncCompleteView() - } - updateLastSyncTime(state.lastTimeSyncSucceed) - } - - private fun hideAllViews() { - binding.syncCardDefault.visibility = View.GONE - binding.syncCardFailedMessage.visibility = View.GONE - binding.syncCardSelectNoModules.visibility = View.GONE - binding.syncCardOffline.visibility = View.GONE - binding.syncCardProgress.visibility = View.GONE - binding.syncCardTryAgain.visibility = View.GONE - binding.syncCardReloginRequired.visibility = View.GONE - } - - private fun prepareSyncDefaultStateView(itemsToSync: Int = 0) { - binding.syncCardDefault.visibility = View.VISIBLE - binding.syncCardDefaultStateSyncButton.setOnClickListener { onSyncButtonClick() } - binding.syncCardDefaultItemsToUpload.text = if (itemsToSync <= 0) { - resources.getString(R.string.dashboard_sync_card_records_uploaded) - } else { - resources.getQuantityString( - R.plurals.dashboard_sync_card_records_to_upload, - itemsToSync, - itemsToSync, - ) - } - } - - private fun prepareSyncFailedStateView() { - binding.syncCardFailedMessage.visibility = View.VISIBLE - binding.syncCardFailedMessage.text = - resources.getString(R.string.dashboard_sync_card_failed_message) - } - - private fun prepareSyncFailedBecauseReloginRequired() { - binding.syncCardReloginRequired.visibility = View.VISIBLE - binding.syncCardReloginRequiredLoginButton.setOnClickListener { onLoginButtonClick() } - } - - private fun prepareSyncFailedBecauseBackendMaintenanceView(state: SyncCardState.SyncFailedBackendMaintenance) { - binding.syncCardFailedMessage.visibility = View.VISIBLE - binding.syncCardFailedMessage.text = - if (state.estimatedOutage != null && state.estimatedOutage != 0L) { - resources.getString( - R.string.error_backend_maintenance_with_time_message, - TimeUtils.getFormattedEstimatedOutage(state.estimatedOutage), - ) - } else { - resources.getString(R.string.error_backend_maintenance_message) - } - } - - private fun prepareSyncTooManyRequestsView() { - binding.syncCardFailedMessage.visibility = View.VISIBLE - binding.syncCardFailedMessage.text = - resources.getString(R.string.dashboard_sync_card_too_many_modules_message) - } - - private fun prepareTryAgainStateView() { - binding.syncCardTryAgain.visibility = View.VISIBLE - binding.syncCardTryAgainSyncButton.setOnClickListener { onSyncButtonClick() } - } - - private fun prepareNoModulesStateView() { - binding.syncCardSelectNoModules.visibility = View.VISIBLE - binding.syncCardSelectNoModulesButton.setOnClickListener { - onSelectNoModulesButtonClick() - } - } - - private fun prepareSyncOfflineView() { - binding.syncCardOffline.visibility = View.VISIBLE - binding.syncCardOfflineButton.setOnClickListener { onOfflineButtonClick() } - } - - private fun prepareProgressView(state: SyncCardState.SyncProgress) { - binding.syncCardProgress.visibility = View.VISIBLE - - val percentage = if (state.progress != null && state.total != null) { - "${calculatePercentage(state.progress, state.total)}%" - } else { - "" - } - binding.syncCardProgressMessage.text = resources.getString( - R.string.dashboard_sync_card_progress, - percentage, - ) - binding.syncCardProgressMessage.setTextColor(getDefaultGrayTextColor()) - - setProgress(state.progress, state.total, R.color.simprints_blue_dark) - } - - private fun prepareSyncConnectingView(state: SyncCardState.SyncConnecting) { - binding.syncCardProgress.visibility = View.VISIBLE - - binding.syncCardProgressMessage.text = - resources.getString(R.string.dashboard_sync_card_connecting) - binding.syncCardProgressMessage.setTextColor(getDefaultGrayTextColor()) - - setProgress(state.progress, state.total, R.color.simprints_blue_dark) - } - - private fun prepareSyncCompleteView() { - binding.syncCardProgress.visibility = View.VISIBLE - - binding.syncCardProgressMessage.text = - resources.getString(R.string.dashboard_sync_card_complete) - binding.syncCardProgressMessage.setTextColor(context?.getColorStateList(R.color.simprints_green_dark)) - - setProgress(100, 100, R.color.simprints_green_dark) - } - - private fun updateLastSyncTime(lastSync: String?) { - if (lastSync == null) { - binding.syncCardLastSync.visibility = View.GONE - } else { - binding.syncCardLastSync.visibility = View.VISIBLE - binding.syncCardLastSync.text = String.format( - resources.getString(R.string.dashboard_sync_card_last_sync), - lastSync, - ) - } - } - - private fun setProgress( - progress: Int?, - total: Int?, - color: Int, - ) { - with(binding.syncCardProgressSyncProgressBar) { - if (progress != null && total != null) { - setProgressBarIndeterminate(this, false) - this.progress = calculatePercentage(progress, total) - } else { - setProgressBarIndeterminate(this, true) - } - setProgressColor(color, this) - } - } - - private fun setProgressColor( - color: Int, - progressBar: ProgressBar, - ) { - context?.getColorStateList(color)?.defaultColor?.let { - progressBar.progressDrawable.colorFilter = - BlendModeColorFilterCompat.createBlendModeColorFilterCompat( - color, - BlendModeCompat.SRC_IN, - ) - progressBar.indeterminateDrawable.colorFilter = - BlendModeColorFilterCompat.createBlendModeColorFilterCompat( - color, - BlendModeCompat.SRC_IN, - ) - } - } - - private fun setProgressBarIndeterminate( - progressBar: ProgressBar, - value: Boolean, - ) { - // Setting it only when required otherwise it creates glitches - if (progressBar.isIndeterminate != value) { - progressBar.isIndeterminate = value - } - } - - private fun calculatePercentage( - progressValue: Int, - totalValue: Int, - ) = min((100 * (progressValue.toFloat() / totalValue.toFloat())).toInt(), 100) - - // I couldn't find a way to get from Android SDK the default text color (in line with the theme). - // So I change a color for a TextView, then I can't set back to the default. - // The card's title has always the same color - the default one. - // Hacky way to extract the color from the title and use for the other TextViews - private fun getDefaultGrayTextColor(): Int = binding.syncCardTitle.textColors.defaultColor -} diff --git a/feature/dashboard/src/main/res/layout/fragment_dashboard_card_sync.xml b/feature/dashboard/src/main/res/layout/fragment_dashboard_card_sync.xml deleted file mode 100644 index 23e41c9b3b..0000000000 --- a/feature/dashboard/src/main/res/layout/fragment_dashboard_card_sync.xml +++ /dev/null @@ -1,7 +0,0 @@ - - diff --git a/feature/dashboard/src/main/res/layout/layout_card_sync.xml b/feature/dashboard/src/main/res/layout/layout_card_sync.xml deleted file mode 100644 index 0b94c25346..0000000000 --- a/feature/dashboard/src/main/res/layout/layout_card_sync.xml +++ /dev/null @@ -1,228 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/feature/dashboard/src/main/res/menu/sync_info_menu.xml b/feature/dashboard/src/main/res/menu/sync_info_menu.xml deleted file mode 100644 index 892ef9b1de..0000000000 --- a/feature/dashboard/src/main/res/menu/sync_info_menu.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/main/sync/SyncFragmentTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/main/sync/SyncFragmentTest.kt deleted file mode 100644 index 637aed4584..0000000000 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/main/sync/SyncFragmentTest.kt +++ /dev/null @@ -1,435 +0,0 @@ -package com.simprints.feature.dashboard.main.sync - -import androidx.lifecycle.Observer -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.espresso.matcher.ViewMatchers.withText -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.simprints.feature.dashboard.R -import com.simprints.feature.dashboard.views.SyncCardState -import com.simprints.testtools.hilt.launchFragmentInHiltContainer -import com.simprints.testtools.hilt.testNavController -import dagger.hilt.android.testing.BindValue -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest -import dagger.hilt.android.testing.HiltTestApplication -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import org.hamcrest.core.IsNot.not -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.annotation.Config -import com.simprints.infra.resources.R as IDR - -@RunWith(AndroidJUnit4::class) -@HiltAndroidTest -@Config(application = HiltTestApplication::class) -class SyncFragmentTest { - companion object { - private const val LAST_SYNC_TIME = "2022-10-10" - } - - @get:Rule - var hiltRule = HiltAndroidRule(this) - - @BindValue - @JvmField - internal val viewModel = mockk(relaxed = true) - - private val context = InstrumentationRegistry.getInstrumentation().context - private val navController = testNavController(R.navigation.graph_dashboard, R.id.mainFragment) - - @Test - fun `should hide the sync card view if it can't sync to BFSID`() { - mockSyncToBFSIDAllowed(false) - - launchFragmentInHiltContainer(navController = navController) - - onView(withId(R.id.dashboardSyncCard)).check(matches(not(isDisplayed()))) - } - - @Test - fun `should display the sync card view if it can sync to BFSID`() { - mockSyncToBFSIDAllowed(true) - - launchFragmentInHiltContainer(navController = navController) - - onView(withId(R.id.dashboardSyncCard)).check(matches(isDisplayed())) - } - - @Test - fun `should display the correct sync card view for the SyncDefault state`() { - mockSyncToBFSIDAllowed(true) - mockSyncCardLiveData(SyncCardState.SyncDefault(LAST_SYNC_TIME)) - - launchFragmentInHiltContainer(navController = navController) - - checkHiddenViews( - listOf( - R.id.sync_card_failed_message, - R.id.sync_card_select_no_modules, - R.id.sync_card_offline, - R.id.sync_card_progress, - R.id.sync_card_try_again, - R.id.sync_card_relogin_required, - ), - ) - val lastSyncText = context.getString(IDR.string.dashboard_sync_card_last_sync, LAST_SYNC_TIME) - onView(withId(R.id.sync_card_last_sync)).check(matches(withText(lastSyncText))) - onView(withId(R.id.sync_card_default_items_to_upload)).check( - matches( - withText( - context.getString(IDR.string.dashboard_sync_card_records_uploaded), - ), - ), - ) - onView(withId(R.id.sync_card_default_state_sync_button)) - .check(matches(isDisplayed())) - .perform(click()) - verify(exactly = 1) { viewModel.sync() } - } - - @Test - fun `should display the correct sync card view for the SyncPendingUpload state`() { - mockSyncToBFSIDAllowed(true) - mockSyncCardLiveData(SyncCardState.SyncPendingUpload(LAST_SYNC_TIME, 2)) - - launchFragmentInHiltContainer(navController = navController) - - checkHiddenViews( - listOf( - R.id.sync_card_failed_message, - R.id.sync_card_select_no_modules, - R.id.sync_card_offline, - R.id.sync_card_progress, - R.id.sync_card_try_again, - R.id.sync_card_relogin_required, - ), - ) - val lastSyncText = context.getString(IDR.string.dashboard_sync_card_last_sync, LAST_SYNC_TIME) - onView(withId(R.id.sync_card_last_sync)).check(matches(withText(lastSyncText))) - onView(withId(R.id.sync_card_default_items_to_upload)).check( - matches( - withText( - context.resources.getQuantityString(IDR.plurals.dashboard_sync_card_records_to_upload, 2, 2), - ), - ), - ) - onView(withId(R.id.sync_card_default_state_sync_button)) - .check(matches(isDisplayed())) - .perform(click()) - verify(exactly = 1) { viewModel.sync() } - } - - @Test - fun `should display the correct sync card view for the SyncFailed state`() { - mockSyncToBFSIDAllowed(true) - mockSyncCardLiveData(SyncCardState.SyncFailed(LAST_SYNC_TIME)) - - launchFragmentInHiltContainer(navController = navController) - - checkHiddenViews( - listOf( - R.id.sync_card_default_state_sync_button, - R.id.sync_card_select_no_modules, - R.id.sync_card_offline, - R.id.sync_card_progress, - R.id.sync_card_try_again, - R.id.sync_card_relogin_required, - ), - ) - - val lastSyncText = context.getString(IDR.string.dashboard_sync_card_last_sync, LAST_SYNC_TIME) - onView(withId(R.id.sync_card_last_sync)).check(matches(withText(lastSyncText))) - onView(withId(R.id.sync_card_failed_message)).check(matches(withText(IDR.string.dashboard_sync_card_failed_message))) - } - - @Test - fun `should display the correct sync card view for the SyncFailedReloginRequired state`() { - mockSyncToBFSIDAllowed(true) - mockSyncCardLiveData(SyncCardState.SyncFailedReloginRequired(LAST_SYNC_TIME)) - - launchFragmentInHiltContainer(navController = navController) - - checkHiddenViews( - listOf( - R.id.sync_card_default_state_sync_button, - R.id.sync_card_select_no_modules, - R.id.sync_card_offline, - R.id.sync_card_progress, - R.id.sync_card_try_again, - ), - ) - - val lastSyncText = context.getString(IDR.string.dashboard_sync_card_last_sync, LAST_SYNC_TIME) - onView(withId(R.id.sync_card_last_sync)).check(matches(withText(lastSyncText))) - onView(withId(R.id.sync_card_relogin_required)).check(matches(isDisplayed())) - onView(withId(R.id.sync_card_relogin_required_login_button)) - .check(matches(isDisplayed())) - .perform(click()) - verify(exactly = 1) { viewModel.login() } - } - - @Test - fun `should display the correct sync card view for the SyncFailedBackendMaintenance state without estimated outage`() { - mockSyncToBFSIDAllowed(true) - mockSyncCardLiveData(SyncCardState.SyncFailedBackendMaintenance(LAST_SYNC_TIME)) - - launchFragmentInHiltContainer(navController = navController) - - checkHiddenViews( - listOf( - R.id.sync_card_default_state_sync_button, - R.id.sync_card_select_no_modules, - R.id.sync_card_offline, - R.id.sync_card_progress, - R.id.sync_card_try_again, - R.id.sync_card_relogin_required, - ), - ) - val lastSyncText = context.getString(IDR.string.dashboard_sync_card_last_sync, LAST_SYNC_TIME) - onView(withId(R.id.sync_card_last_sync)).check(matches(withText(lastSyncText))) - onView(withId(R.id.sync_card_failed_message)).check(matches(withText(IDR.string.error_backend_maintenance_message))) - } - - @Test - fun `should display the correct sync card view for the SyncFailedBackendMaintenance state with estimated outage`() { - mockSyncToBFSIDAllowed(true) - mockSyncCardLiveData( - SyncCardState.SyncFailedBackendMaintenance( - LAST_SYNC_TIME, - 10L, - ), - ) - - launchFragmentInHiltContainer(navController = navController) - - checkHiddenViews( - listOf( - R.id.sync_card_default_state_sync_button, - R.id.sync_card_select_no_modules, - R.id.sync_card_offline, - R.id.sync_card_progress, - R.id.sync_card_try_again, - R.id.sync_card_relogin_required, - ), - ) - - val lastSyncText = context.getString(IDR.string.dashboard_sync_card_last_sync, LAST_SYNC_TIME) - onView(withId(R.id.sync_card_last_sync)).check(matches(withText(lastSyncText))) - val text = - context.getString(IDR.string.error_backend_maintenance_with_time_message, "10 seconds") - onView(withId(R.id.sync_card_failed_message)).check(matches(withText(text))) - } - - @Test - fun `should display the correct sync card view for the SyncTooManyRequests state`() { - mockSyncToBFSIDAllowed(true) - mockSyncCardLiveData(SyncCardState.SyncTooManyRequests(LAST_SYNC_TIME)) - - launchFragmentInHiltContainer(navController = navController) - - checkHiddenViews( - listOf( - R.id.sync_card_default_state_sync_button, - R.id.sync_card_select_no_modules, - R.id.sync_card_offline, - R.id.sync_card_progress, - R.id.sync_card_try_again, - R.id.sync_card_relogin_required, - ), - ) - - val lastSyncText = context.getString(IDR.string.dashboard_sync_card_last_sync, LAST_SYNC_TIME) - onView(withId(R.id.sync_card_last_sync)).check(matches(withText(lastSyncText))) - onView(withId(R.id.sync_card_failed_message)).check(matches(withText(IDR.string.dashboard_sync_card_too_many_modules_message))) - } - - @Test - fun `should display the correct sync card view for the SyncTryAgain state`() { - mockSyncToBFSIDAllowed(true) - mockSyncCardLiveData(SyncCardState.SyncTryAgain(LAST_SYNC_TIME)) - - launchFragmentInHiltContainer(navController = navController) - - checkHiddenViews( - listOf( - R.id.sync_card_default_state_sync_button, - R.id.sync_card_failed_message, - R.id.sync_card_select_no_modules, - R.id.sync_card_offline, - R.id.sync_card_progress, - R.id.sync_card_relogin_required, - ), - ) - - val lastSyncText = context.getString(IDR.string.dashboard_sync_card_last_sync, LAST_SYNC_TIME) - onView(withId(R.id.sync_card_last_sync)).check(matches(withText(lastSyncText))) - onView(withId(R.id.sync_card_try_again_sync_button)) - .check(matches(isDisplayed())) - .perform(click()) - verify(exactly = 1) { viewModel.sync() } - } - - @Test - fun `should display the correct sync card view for the SyncHasNoModules state`() { - mockSyncToBFSIDAllowed(true) - mockSyncCardLiveData(SyncCardState.SyncHasNoModules(LAST_SYNC_TIME)) - - launchFragmentInHiltContainer(navController = navController) - - checkHiddenViews( - listOf( - R.id.sync_card_default_state_sync_button, - R.id.sync_card_failed_message, - R.id.sync_card_try_again, - R.id.sync_card_offline, - R.id.sync_card_progress, - R.id.sync_card_relogin_required, - ), - ) - - val lastSyncText = context.getString(IDR.string.dashboard_sync_card_last_sync, LAST_SYNC_TIME) - onView(withId(R.id.sync_card_last_sync)).check(matches(withText(lastSyncText))) - } - - @Test - fun `should display the correct sync card view for the SyncOffline state`() { - mockSyncToBFSIDAllowed(true) - mockSyncCardLiveData(SyncCardState.SyncOffline(LAST_SYNC_TIME)) - - launchFragmentInHiltContainer(navController = navController) - - checkHiddenViews( - listOf( - R.id.sync_card_default_state_sync_button, - R.id.sync_card_failed_message, - R.id.sync_card_try_again, - R.id.sync_card_select_no_modules_button, - R.id.sync_card_progress, - R.id.sync_card_relogin_required, - ), - ) - - val lastSyncText = context.getString(IDR.string.dashboard_sync_card_last_sync, LAST_SYNC_TIME) - onView(withId(R.id.sync_card_last_sync)).check(matches(withText(lastSyncText))) - onView(withId(R.id.sync_card_offline_button)) - .check(matches(isDisplayed())) - .perform(click()) - } - - @Test - fun `should display the correct sync card view for the SyncProgress state`() { - mockSyncToBFSIDAllowed(true) - mockSyncCardLiveData(SyncCardState.SyncProgress(LAST_SYNC_TIME, 20, 40)) - - launchFragmentInHiltContainer(navController = navController) - - checkHiddenViews( - listOf( - R.id.sync_card_default_state_sync_button, - R.id.sync_card_failed_message, - R.id.sync_card_try_again, - R.id.sync_card_select_no_modules_button, - R.id.sync_card_offline, - R.id.sync_card_relogin_required, - ), - ) - - val lastSyncText = context.getString(IDR.string.dashboard_sync_card_last_sync, LAST_SYNC_TIME) - onView(withId(R.id.sync_card_last_sync)).check(matches(withText(lastSyncText))) - - onView(withId(R.id.sync_card_progress_sync_progress_bar)).check( - matches( - isDisplayed(), - ), - ) - - val text = context.getString(IDR.string.dashboard_sync_card_progress, "50%") - onView(withId(R.id.sync_card_progress_message)).check(matches(withText(text))) - } - - @Test - fun `should display the correct sync card view for the SyncConnecting state`() { - mockSyncToBFSIDAllowed(true) - mockSyncCardLiveData(SyncCardState.SyncConnecting(LAST_SYNC_TIME, 20, 40)) - - launchFragmentInHiltContainer(navController = navController) - - checkHiddenViews( - listOf( - R.id.sync_card_default_state_sync_button, - R.id.sync_card_failed_message, - R.id.sync_card_try_again, - R.id.sync_card_select_no_modules_button, - R.id.sync_card_offline, - R.id.sync_card_relogin_required, - ), - ) - - val lastSyncText = context.getString(IDR.string.dashboard_sync_card_last_sync, LAST_SYNC_TIME) - onView(withId(R.id.sync_card_last_sync)).check(matches(withText(lastSyncText))) - - onView(withId(R.id.sync_card_progress_sync_progress_bar)).check( - matches(isDisplayed()), - ) - - onView(withId(R.id.sync_card_progress_message)).check(matches(withText(IDR.string.dashboard_sync_card_connecting))) - } - - @Test - fun `should display the correct sync card view for the SyncComplete state`() { - mockSyncToBFSIDAllowed(true) - mockSyncCardLiveData(SyncCardState.SyncComplete(LAST_SYNC_TIME)) - - launchFragmentInHiltContainer(navController = navController) - - checkHiddenViews( - listOf( - R.id.sync_card_default_state_sync_button, - R.id.sync_card_failed_message, - R.id.sync_card_try_again, - R.id.sync_card_select_no_modules_button, - R.id.sync_card_offline, - R.id.sync_card_relogin_required, - ), - ) - - val lastSyncText = context.getString(IDR.string.dashboard_sync_card_last_sync, LAST_SYNC_TIME) - onView(withId(R.id.sync_card_last_sync)).check(matches(withText(lastSyncText))) - - onView(withId(R.id.sync_card_progress_sync_progress_bar)).check( - matches(isDisplayed()), - ) - - onView(withId(R.id.sync_card_progress_message)).check(matches(withText(IDR.string.dashboard_sync_card_complete))) - } - - private fun mockSyncToBFSIDAllowed(allowed: Boolean) { - every { viewModel.syncToBFSIDAllowed } returns mockk { - every { observe(any(), any()) } answers { - secondArg>().onChanged(allowed) - } - } - } - - private fun mockSyncCardLiveData(state: SyncCardState) { - every { viewModel.syncCardLiveData } returns mockk { - every { observe(any(), any()) } answers { - secondArg>().onChanged(state) - } - } - } - - private fun checkHiddenViews(views: List) { - views.forEach { - onView(withId(it)).check(matches(not(isDisplayed()))) - } - } -} diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/main/sync/SyncViewModelTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/main/sync/SyncViewModelTest.kt deleted file mode 100644 index 5d803bb14f..0000000000 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/main/sync/SyncViewModelTest.kt +++ /dev/null @@ -1,503 +0,0 @@ -package com.simprints.feature.dashboard.main.sync - -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.lifecycle.MutableLiveData -import com.google.common.truth.Truth.assertThat -import com.simprints.core.domain.tokenization.asTokenizableEncrypted -import com.simprints.core.tools.time.TimeHelper -import com.simprints.feature.dashboard.logout.usecase.LogoutUseCase -import com.simprints.feature.dashboard.views.SyncCardState -import com.simprints.feature.dashboard.views.SyncCardState.SyncComplete -import com.simprints.feature.dashboard.views.SyncCardState.SyncConnecting -import com.simprints.feature.dashboard.views.SyncCardState.SyncDefault -import com.simprints.feature.dashboard.views.SyncCardState.SyncFailed -import com.simprints.feature.dashboard.views.SyncCardState.SyncFailedBackendMaintenance -import com.simprints.feature.dashboard.views.SyncCardState.SyncHasNoModules -import com.simprints.feature.dashboard.views.SyncCardState.SyncOffline -import com.simprints.feature.dashboard.views.SyncCardState.SyncPendingUpload -import com.simprints.feature.dashboard.views.SyncCardState.SyncProgress -import com.simprints.feature.dashboard.views.SyncCardState.SyncTooManyRequests -import com.simprints.feature.dashboard.views.SyncCardState.SyncTryAgain -import com.simprints.feature.login.LoginResult -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.Frequency -import com.simprints.infra.config.store.models.ProjectState -import com.simprints.infra.config.store.models.UpSynchronizationConfiguration -import com.simprints.infra.config.store.models.UpSynchronizationConfiguration.SimprintsUpSynchronizationConfiguration -import com.simprints.infra.config.store.models.UpSynchronizationConfiguration.UpSynchronizationKind.ALL -import com.simprints.infra.config.sync.ConfigManager -import com.simprints.infra.events.event.domain.models.EventType -import com.simprints.infra.eventsync.EventSyncManager -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.network.ConnectivityTracker -import com.simprints.infra.recent.user.activity.RecentUserActivityManager -import com.simprints.infra.sync.SyncOrchestrator -import com.simprints.testtools.common.coroutines.TestCoroutineRule -import com.simprints.testtools.common.livedata.getOrAwaitValue -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.flow.flowOf -import org.junit.Before -import org.junit.Rule -import org.junit.Test - -internal class SyncViewModelTest { - companion object { - private const val DATE = "2022-10-10" - private val deviceConfiguration = DeviceConfiguration( - language = "", - selectedModules = listOf("module 1".asTokenizableEncrypted()), - lastInstructionId = "", - ) - } - - @get:Rule - val rule = InstantTaskExecutorRule() - - @get:Rule - val testCoroutineRule = TestCoroutineRule() - - private val isConnected = MutableLiveData() - private val syncState = MutableLiveData() - - @MockK - lateinit var eventSyncManager: EventSyncManager - - @MockK - lateinit var syncOrchestrator: SyncOrchestrator - - @MockK - lateinit var connectivityTracker: ConnectivityTracker - - @MockK - lateinit var configManager: ConfigManager - - @MockK - lateinit var timeHelper: TimeHelper - - @MockK - lateinit var authStore: AuthStore - - @MockK - lateinit var logoutUseCase: LogoutUseCase - - @MockK - lateinit var recentUserActivityManager: RecentUserActivityManager - - @Before - fun setUp() { - MockKAnnotations.init(this, relaxed = true) - - every { eventSyncManager.getLastSyncState() } returns syncState - every { connectivityTracker.observeIsConnected() } returns isConnected - coEvery { configManager.getProjectConfiguration().synchronization } returns mockk { - every { up.simprints } returns SimprintsUpSynchronizationConfiguration( - kind = ALL, - batchSizes = UpSynchronizationConfiguration.UpSyncBatchSizes.default(), - imagesRequireUnmeteredConnection = false, - frequency = Frequency.PERIODICALLY_AND_ON_SESSION_START, - ) - every { down.simprints.frequency } returns Frequency.PERIODICALLY_AND_ON_SESSION_START - every { down.simprints.partitionType } returns DownSynchronizationConfiguration.PartitionType.MODULE - } - every { timeHelper.readableBetweenNowAndTime(any()) } returns DATE - every { authStore.signedInProjectId } returns "projectId" - } - - @Test - fun `should initialize the live data syncToBFSIDAllowed correctly`() { - syncState.postValue(EventSyncState("", 0, 0, listOf(), listOf(), listOf())) - isConnected.postValue(true) - - val viewModel = initViewModel() - - assertThat(viewModel.syncToBFSIDAllowed.value).isEqualTo(true) - } - - @Test - fun `should trigger an initial sync if the sync is not running and there is no last sync`() { - coEvery { eventSyncManager.getLastSyncTime() } returns null - syncState.value = null - isConnected.value = true - - val viewModel = initViewModel() - - verify(exactly = 1) { syncOrchestrator.startEventSync() } - assertThat(viewModel.syncCardLiveData.value).isEqualTo(SyncConnecting(null, 0, null)) - } - - @Test - fun `should trigger an initial sync if the sync is not running and there is a last sync that is longer than 5 minutes ago`() { - every { timeHelper.msBetweenNowAndTime(any()) } returns 6 * 60_0000 - syncState.value = null - isConnected.value = true - - val viewModel = initViewModel() - - verify(exactly = 1) { syncOrchestrator.startEventSync() } - assertThat(viewModel.syncCardLiveData.value).isEqualTo(SyncConnecting(null, 0, null)) - } - - @Test - fun `should not trigger an initial sync if the sync is running`() { - syncState.value = EventSyncState( - "", - 0, - 0, - listOf( - EventSyncState.SyncWorkerInfo( - EventSyncWorkerType.DOWNLOADER, - EventSyncWorkerState.Running, - ), - ), - listOf(), - listOf(), - ) - isConnected.value = true - - initViewModel() - - verify(exactly = 0) { syncOrchestrator.startEventSync() } - } - - @Test - fun `should post a SyncHasNoModules card state if the module selection is empty`() { - coEvery { configManager.getDeviceConfiguration() } returns DeviceConfiguration( - "", - listOf(), - "", - ) - isConnected.value = true - val syncCardLiveData = initViewModel().syncCardLiveData.getOrAwaitValue() - - assertThat(syncCardLiveData).isEqualTo(SyncHasNoModules(DATE)) - } - - @Test - fun `should post a SyncOffline card state if the device is not connected`() { - coEvery { configManager.getDeviceConfiguration() } returns deviceConfiguration - isConnected.value = false - val syncCardLiveData = initViewModel().syncCardLiveData.getOrAwaitValue() - - assertThat(syncCardLiveData).isEqualTo(SyncOffline(DATE)) - } - - @Test - fun `should post a SyncConnecting card state if the sync is running but not info are available`() { - coEvery { configManager.getDeviceConfiguration() } returns deviceConfiguration - isConnected.value = true - syncState.value = null - val syncCardLiveData = initViewModel().syncCardLiveData.getOrAwaitValue() - - assertThat(syncCardLiveData).isEqualTo(SyncConnecting(DATE, 0, null)) - } - - @Test - fun `should post a SyncDefault card state if there is no sync history`() { - coEvery { configManager.getDeviceConfiguration() } returns deviceConfiguration - isConnected.value = true - syncState.value = EventSyncState("", 0, 0, listOf(), listOf(), listOf()) - val syncCardLiveData = initViewModel().syncCardLiveData.getOrAwaitValue() - - assertThat(syncCardLiveData).isEqualTo(SyncDefault(DATE)) - } - - @Test - fun `should post a SyncComplete card state if the sync is completed`() { - coEvery { configManager.getDeviceConfiguration() } returns deviceConfiguration - isConnected.value = true - syncState.value = EventSyncState( - "", - 0, - 0, - listOf(), - listOf( - EventSyncState.SyncWorkerInfo( - EventSyncWorkerType.DOWNLOADER, - EventSyncWorkerState.Succeeded, - ), - ), - listOf(), - ) - val syncCardLiveData = initViewModel().syncCardLiveData.getOrAwaitValue() - - assertThat(syncCardLiveData).isEqualTo(SyncComplete(DATE)) - } - - @Test - fun `should post a SyncPendingUpload card state if there are records to upload`() { - coEvery { configManager.getDeviceConfiguration() } returns deviceConfiguration - coEvery { eventSyncManager.countEventsToUpload(any>()) }.returns(flowOf(2)) - - isConnected.value = true - syncState.value = EventSyncState( - "", - 0, - 0, - listOf(), - listOf( - EventSyncState.SyncWorkerInfo( - EventSyncWorkerType.DOWNLOADER, - EventSyncWorkerState.Succeeded, - ), - ), - listOf(), - ) - val syncCardLiveData = initViewModel().syncCardLiveData.getOrAwaitValue() - - assertThat(syncCardLiveData).isEqualTo(SyncPendingUpload(DATE, 2)) - } - - @Test - fun `should post a SyncProgress card state if the sync is in progress`() { - coEvery { configManager.getDeviceConfiguration() } returns deviceConfiguration - isConnected.value = true - syncState.value = EventSyncState( - "", - 10, - 40, - listOf(), - listOf( - EventSyncState.SyncWorkerInfo( - EventSyncWorkerType.DOWNLOADER, - EventSyncWorkerState.Running, - ), - ), - listOf(), - ) - val syncCardLiveData = initViewModel().syncCardLiveData.getOrAwaitValue() - - assertThat(syncCardLiveData).isEqualTo(SyncProgress(DATE, 10, 40)) - } - - @Test - fun `should post a SyncConnecting card state if the sync is enqueued`() { - coEvery { configManager.getDeviceConfiguration() } returns deviceConfiguration - isConnected.value = true - syncState.value = EventSyncState( - "", - 10, - 40, - listOf(), - listOf( - EventSyncState.SyncWorkerInfo( - EventSyncWorkerType.DOWNLOADER, - EventSyncWorkerState.Enqueued, - ), - ), - listOf(), - ) - val syncCardLiveData = initViewModel().syncCardLiveData.getOrAwaitValue() - - assertThat(syncCardLiveData).isEqualTo(SyncConnecting(DATE, 10, 40)) - } - - @Test - fun `should post a SyncTooManyRequests card state if there are too many sync requests`() { - coEvery { configManager.getDeviceConfiguration() } returns deviceConfiguration - isConnected.value = true - syncState.value = EventSyncState( - "", - 10, - 40, - listOf(), - listOf( - EventSyncState.SyncWorkerInfo( - EventSyncWorkerType.DOWNLOADER, - EventSyncWorkerState.Failed(failedBecauseTooManyRequest = true), - ), - ), - listOf(), - ) - val syncCardLiveData = initViewModel().syncCardLiveData.getOrAwaitValue() - - assertThat(syncCardLiveData).isEqualTo(SyncTooManyRequests(DATE)) - } - - @Test - fun `should post a SyncFailed card state if the sync fails because of cloud integration`() { - coEvery { configManager.getDeviceConfiguration() } returns deviceConfiguration - isConnected.value = true - syncState.value = EventSyncState( - "", - 10, - 40, - listOf(), - listOf( - EventSyncState.SyncWorkerInfo( - EventSyncWorkerType.DOWNLOADER, - EventSyncWorkerState.Failed(failedBecauseCloudIntegration = true), - ), - ), - listOf(), - ) - val syncCardLiveData = initViewModel().syncCardLiveData.getOrAwaitValue() - - assertThat(syncCardLiveData).isEqualTo(SyncFailed(DATE)) - } - - @Test - fun `should post a ReloginRequired card state if the sync fails with such problem`() { - coEvery { configManager.getDeviceConfiguration() } returns deviceConfiguration - isConnected.value = true - syncState.value = EventSyncState( - "", - 10, - 40, - listOf(), - listOf( - EventSyncState.SyncWorkerInfo( - EventSyncWorkerType.DOWNLOADER, - EventSyncWorkerState.Failed(failedBecauseReloginRequired = true), - ), - ), - listOf(), - ) - val syncCardLiveData = initViewModel().syncCardLiveData.getOrAwaitValue() - - assertThat(syncCardLiveData).isEqualTo(SyncCardState.SyncFailedReloginRequired(DATE)) - } - - @Test - fun `calling login() sends respective event to the view`() { - val viewModel = initViewModel() - - viewModel.login() - - val loginRequestedEvent = viewModel.loginRequestedEventLiveData.getOrAwaitValue() - assertThat(loginRequestedEvent).isNotNull() - } - - @Test - fun `calling handleLoginResult() triggers sync if result is success`() { - val viewModel = initViewModel() - - viewModel.handleLoginResult(LoginResult(true)) - - verify(exactly = 1) { syncOrchestrator.startEventSync() } - } - - @Test - fun `calling handleLoginResult() does not trigger sync if result is not success`() { - val viewModel = initViewModel() - - viewModel.handleLoginResult(LoginResult(false)) - - verify(exactly = 0) { syncOrchestrator.startEventSync() } - } - - @Test - fun `should post a SyncFailedBackendMaintenance card state if the sync fails because of cloud maintenance`() { - coEvery { configManager.getDeviceConfiguration() } returns deviceConfiguration - isConnected.value = true - syncState.value = EventSyncState( - "", - 10, - 40, - listOf(), - listOf( - EventSyncState.SyncWorkerInfo( - EventSyncWorkerType.DOWNLOADER, - EventSyncWorkerState.Failed(failedBecauseBackendMaintenance = true), - ), - ), - listOf(), - ) - val syncCardLiveData = initViewModel().syncCardLiveData.getOrAwaitValue() - - assertThat(syncCardLiveData).isEqualTo(SyncFailedBackendMaintenance(DATE)) - } - - @Test - fun `should post a SyncFailedBackendMaintenance with estimated outage card state if the sync fails because of cloud maintenance with outage`() { - coEvery { configManager.getDeviceConfiguration() } returns deviceConfiguration - isConnected.value = true - syncState.value = EventSyncState( - "", - 10, - 40, - listOf(), - listOf( - EventSyncState.SyncWorkerInfo( - EventSyncWorkerType.DOWNLOADER, - EventSyncWorkerState.Failed( - failedBecauseBackendMaintenance = true, - estimatedOutage = 30, - ), - ), - ), - listOf(), - ) - val syncCardLiveData = initViewModel().syncCardLiveData.getOrAwaitValue() - - assertThat(syncCardLiveData).isEqualTo(SyncFailedBackendMaintenance(DATE, 30)) - } - - @Test - fun `should post a SyncTryAgain card state if the sync fails because of another thing`() { - coEvery { configManager.getDeviceConfiguration() } returns deviceConfiguration - isConnected.value = true - syncState.value = EventSyncState( - "", - 10, - 40, - listOf(), - listOf( - EventSyncState.SyncWorkerInfo( - EventSyncWorkerType.DOWNLOADER, - EventSyncWorkerState.Failed(), - ), - ), - listOf(), - ) - val syncCardLiveData = initViewModel().syncCardLiveData.getOrAwaitValue() - - assertThat(syncCardLiveData).isEqualTo(SyncTryAgain(DATE)) - } - - @Test - fun `should logout when project is ending and sync is complete`() { - coEvery { configManager.getDeviceConfiguration() } returns deviceConfiguration - coEvery { configManager.getProject(any()).state } returns ProjectState.PROJECT_ENDING - isConnected.value = true - syncState.value = EventSyncState( - "", - 0, - 0, - listOf(), - listOf( - EventSyncState.SyncWorkerInfo( - EventSyncWorkerType.DOWNLOADER, - EventSyncWorkerState.Succeeded, - ), - ), - listOf(), - ) - val viewModel = initViewModel() - viewModel.syncCardLiveData.getOrAwaitValue() - val signOutEvent = viewModel.signOutEventLiveData.getOrAwaitValue() - - assertThat(signOutEvent).isNotNull() - coVerify(exactly = 1) { logoutUseCase.invoke() } - } - - private fun initViewModel(): SyncViewModel = SyncViewModel( - eventSyncManager = eventSyncManager, - syncOrchestrator = syncOrchestrator, - connectivityTracker = connectivityTracker, - configManager = configManager, - timeHelper = timeHelper, - authStore = authStore, - logout = logoutUseCase, - recentUserActivityManager = recentUserActivityManager, - ) -} From 3eb98d836bb2e911e3debcbbb5b1177e6c7c7a4c Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 24 Jul 2025 11:30:04 +0100 Subject: [PATCH 12/45] MS-939 Tests for ViewModel of the new unified sync UI/UX --- .../syncinfo/SyncInfoViewModelTest.kt | 1845 +++++++++++++---- 1 file changed, 1471 insertions(+), 374 deletions(-) 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 66e317a3f8..50a40f85b2 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 @@ -2,47 +2,56 @@ package com.simprints.feature.dashboard.settings.syncinfo import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.MutableLiveData -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 androidx.lifecycle.Observer +import androidx.lifecycle.asFlow +import com.google.common.truth.Truth.assertThat +import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.core.tools.time.TimeHelper +import com.simprints.core.tools.time.Timestamp +import com.simprints.feature.dashboard.logout.usecase.LogoutUseCase import com.simprints.feature.login.LoginResult import com.simprints.infra.authstore.AuthStore -import com.simprints.infra.config.store.models.DownSynchronizationConfiguration -import com.simprints.infra.config.store.models.Frequency +import com.simprints.infra.config.store.models.DeviceConfiguration +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.SynchronizationConfiguration +import com.simprints.infra.config.store.models.ProjectState import com.simprints.infra.config.store.models.TokenKeyType +import com.simprints.infra.config.store.models.isEventDownSyncAllowed +import com.simprints.infra.config.store.models.isModuleSelectionAvailable +import com.simprints.infra.config.store.models.isMissingModulesToChooseFrom 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 import com.simprints.infra.eventsync.EventSyncManager import com.simprints.infra.eventsync.status.models.DownSyncCounts 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.images.ImageRepository import com.simprints.infra.network.ConnectivityTracker import com.simprints.infra.recent.user.activity.RecentUserActivityManager import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.ImageSyncStatus import com.simprints.testtools.common.coroutines.TestCoroutineRule import com.simprints.testtools.common.livedata.getOrAwaitValue -import com.simprints.testtools.common.livedata.getOrAwaitValues -import io.mockk.* -import io.mockk.impl.annotations.MockK +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test class SyncInfoViewModelTest { - companion object { - private const val PROJECT_ID = "projectId" - } @get:Rule val rule = InstantTaskExecutorRule() @@ -50,467 +59,1555 @@ class SyncInfoViewModelTest { @get:Rule val testCoroutineRule = TestCoroutineRule() - @MockK - private lateinit var configManager: ConfigManager + 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 recentUserActivityManager = mockk() + private val timeHelper = mockk() + private val logoutUseCase = mockk(relaxed = true) - @MockK - private lateinit var enrolmentRecordRepository: EnrolmentRecordRepository + private lateinit var viewModel: SyncInfoViewModel - @MockK - private lateinit var authStore: AuthStore + private companion object { + const val TEST_PROJECT_ID = "test_project_id" + const val TEST_USER_ID = "test_user_id" + const val TEST_RECENT_USER_ID = "recent_user_id" + const val TEST_MODULE_NAME = "test_module" + val TEST_TIMESTAMP = Timestamp(1000L) + } - @MockK - private lateinit var connectivityTracker: ConnectivityTracker + private val mockProjectConfiguration = mockk(relaxed = true) { + every { general } returns mockk(relaxed = true) { + every { modalities } returns emptyList() + } + } + 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 + every { isSyncConnecting() } returns false + every { isSyncRunning() } returns false + every { isSyncFailed() } returns false + every { isSyncFailedBecauseReloginRequired() } returns false + every { isSyncFailedBecauseBackendMaintenance() } returns false + every { isSyncFailedBecauseTooManyRequests() } returns false + every { getEstimatedBackendMaintenanceOutage() } returns null + every { isThereNotSyncHistory() } returns false + every { progress } returns null + every { total } returns null + } + private val mockImageSyncStatus = mockk(relaxed = true) { + every { isSyncing } returns false + every { progress } returns null + every { secondsSinceLastUpdate } returns null + } - @MockK - private lateinit var imageRepository: ImageRepository + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + mockkStatic("androidx.lifecycle.FlowLiveDataConversions") + mockkStatic("com.simprints.infra.config.store.models.ProjectConfigurationKt") + mockkStatic("com.simprints.core.tools.extentions.Flow_extKt") + setupDefaultMocks() + createViewModel() + } - @MockK - private lateinit var eventSyncManager: EventSyncManager + private fun setupDefaultMocks() { + every { authStore.signedInProjectId } returns TEST_PROJECT_ID + every { authStore.signedInUserId } returns TokenizableString.Raw(TEST_USER_ID) + every { authStore.watchSignedInProjectId() } returns MutableStateFlow(TEST_PROJECT_ID) + + val connectivityLiveData = MutableLiveData(true) + every { connectivityTracker.observeIsConnected() } returns connectivityLiveData + every { connectivityLiveData.asFlow() } returns flowOf(true) + + every { configManager.watchIfProjectRefreshing() } returns MutableStateFlow(false) + every { configManager.watchProjectConfiguration() } returns MutableStateFlow(mockProjectConfiguration) + every { configManager.watchDeviceConfiguration() } returns MutableStateFlow(mockDeviceConfiguration) + coEvery { configManager.getProjectConfiguration() } returns mockProjectConfiguration + coEvery { configManager.getDeviceConfiguration() } returns mockDeviceConfiguration + coEvery { configManager.getProject(any()) } returns mockProject + + val eventSyncLiveData = MutableLiveData(mockEventSyncState) + every { eventSyncManager.getLastSyncState() } returns eventSyncLiveData + every { eventSyncManager.getLastSyncState(any()) } returns eventSyncLiveData + every { eventSyncLiveData.asFlow() } returns flowOf(mockEventSyncState) + coEvery { eventSyncManager.getLastSyncTime() } returns TEST_TIMESTAMP + coEvery { eventSyncManager.countEventsToUpload(any()) } returns flowOf(0) + coEvery { eventSyncManager.countEventsToDownload(any()) } returns DownSyncCounts(0, isLowerBound = false) + + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockImageSyncStatus) + coEvery { syncOrchestrator.startEventSync(any()) } returns Unit + coEvery { syncOrchestrator.stopEventSync() } returns Unit + coEvery { syncOrchestrator.startImageSync() } returns Unit + coEvery { syncOrchestrator.stopImageSync() } returns Unit + + coEvery { imageRepository.getNumberOfImagesToUpload(any()) } returns 0 + coEvery { enrolmentRecordRepository.count(any()) } returns 0 + + every { timeHelper.watchOncePerMinute() } returns MutableStateFlow(Unit) + every { timeHelper.now() } returns TEST_TIMESTAMP + every { timeHelper.msBetweenNowAndTime(any()) } returns 0L + + coEvery { recentUserActivityManager.getRecentUserActivity() } returns mockk { + every { lastUserUsed } returns TokenizableString.Raw(TEST_RECENT_USER_ID) + } - @MockK - private lateinit var syncOrchestrator: SyncOrchestrator + every { tokenizationProcessor.decrypt(any(), any(), any()) } returns TokenizableString.Raw("decrypted_module") - @MockK - lateinit var recentUserActivityManager: RecentUserActivityManager + every { any().isModuleSelectionAvailable() } returns false + every { any().isEventDownSyncAllowed() } returns true + every { any().isMissingModulesToChooseFrom() } returns false + } - @MockK - private lateinit var project: Project + private fun createViewModel() { + viewModel = SyncInfoViewModel( + configManager = configManager, + connectivityTracker = connectivityTracker, + enrolmentRecordRepository = enrolmentRecordRepository, + authStore = authStore, + imageRepository = imageRepository, + eventSyncManager = eventSyncManager, + syncOrchestrator = syncOrchestrator, + tokenizationProcessor = tokenizationProcessor, + recentUserActivityManager = recentUserActivityManager, + timeHelper = timeHelper, + logoutUseCase = logoutUseCase, + ) + } - @MockK(relaxed = true) - private lateinit var tokenizationProcessor: TokenizationProcessor + // LiveData loginNavigationEventLiveData tests - private lateinit var connectionLiveData: MutableLiveData - private lateinit var stateLiveData: MutableLiveData + @Test + fun `should show login navigation when user requests login`() = runTest { + viewModel.requestNavigationToLogin() + val result = viewModel.loginNavigationEventLiveData.getOrAwaitValue() - private lateinit var viewModel: SyncInfoViewModel + assertThat(result).isNotNull() + } - @Before - fun setUp() { - MockKAnnotations.init(this, relaxed = true) + // LiveData logoutEventLiveData tests + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `should trigger logout when pre-logout sync completes successfully`() = runTest { + val mockCompletedEventSyncState = mockk(relaxed = true) { + every { isSyncCompleted() } returns true + } + val mockNotSyncingImageStatus = mockk(relaxed = true) { + every { isSyncing } returns false + every { progress } returns null + every { secondsSinceLastUpdate } returns 0 + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) + createViewModel() + viewModel.isPreLogoutUpSync = true + val observer = mockk>(relaxed = true) + val slot = slot() + val capturedValues = mutableListOf() + every { observer.onChanged(captureNullable(slot)) } answers { + capturedValues.add(slot.captured) + } + + viewModel.logoutEventLiveData.observeForever(observer) + advanceTimeBy(3100L) // after the logout delay (3000ms) + + assertThat(capturedValues).contains(Unit) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `should emit and reset a logout event after the intended delay since ready to logout`() = runTest { + val mockCompletedEventSyncState = mockk(relaxed = true) { + every { isSyncCompleted() } returns true + } + val mockNotSyncingImageStatus = mockk(relaxed = true) { + every { isSyncing } returns false + every { progress } returns null + every { secondsSinceLastUpdate } returns 0 + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) + createViewModel() + viewModel.isPreLogoutUpSync = true + val observer = mockk>(relaxed = true) + val slot = slot() + val capturedValues = mutableListOf() + every { observer.onChanged(captureNullable(slot)) } answers { + capturedValues.add(slot.captured) + } + + viewModel.logoutEventLiveData.observeForever(observer) + advanceTimeBy(2900L) // still during the debounce delay + + assertThat(capturedValues).isEmpty() // no logout event yet + + advanceTimeBy(200L) // after the debounce delay (total 3100ms > 3000ms) + + assertThat(capturedValues).isEqualTo(listOf(Unit, null)) // "flicked" the logout event to prevent persistence + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `should not trigger logout when not in pre-logout mode`() = runTest { + val mockCompletedEventSyncState = mockk(relaxed = true) { + every { isSyncCompleted() } returns true + } + val mockNotSyncingImageStatus = mockk(relaxed = true) { + every { isSyncing } returns false + every { progress } returns null + every { secondsSinceLastUpdate } returns 0 + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) + createViewModel() + viewModel.isPreLogoutUpSync = false + val observer = mockk>(relaxed = true) + val slot = slot() + val capturedValues = mutableListOf() + every { observer.onChanged(captureNullable(slot)) } answers { + capturedValues.add(slot.captured) + } + + viewModel.logoutEventLiveData.observeForever(observer) + advanceTimeBy(3100L) // after the logout delay (3000ms) + + assertThat(capturedValues).isEmpty() + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `should not trigger logout when records still syncing`() = runTest { + val mockInProgressEventSyncState = mockk(relaxed = true) { + every { isSyncCompleted() } returns false + every { isSyncInProgress() } returns true + } + val mockNotSyncingImageStatus = mockk(relaxed = true) { + every { isSyncing } returns false + every { progress } returns null + every { secondsSinceLastUpdate } returns 0 + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockInProgressEventSyncState) + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) + createViewModel() + viewModel.isPreLogoutUpSync = true + val observer = mockk>(relaxed = true) + val slot = slot() + val capturedValues = mutableListOf() + every { observer.onChanged(captureNullable(slot)) } answers { + capturedValues.add(slot.captured) + } - every { authStore.signedInProjectId } returns PROJECT_ID + viewModel.logoutEventLiveData.observeForever(observer) + advanceTimeBy(3100L) // after the logout delay (3000ms) - connectionLiveData = MutableLiveData() - every { connectivityTracker.observeIsConnected() } returns connectionLiveData + assertThat(capturedValues).isEmpty() + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `should not trigger logout when images still syncing`() = runTest { + val mockCompletedEventSyncState = mockk(relaxed = true) { + every { isSyncCompleted() } returns true + } + val mockSyncingImageStatus = mockk(relaxed = true) { + every { isSyncing } returns true + every { progress } returns Pair(1, 2) + every { secondsSinceLastUpdate } returns null + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockSyncingImageStatus) + createViewModel() + viewModel.isPreLogoutUpSync = true + val observer = mockk>(relaxed = true) + val slot = slot() + val capturedValues = mutableListOf() + every { observer.onChanged(captureNullable(slot)) } answers { + capturedValues.add(slot.captured) + } + + viewModel.logoutEventLiveData.observeForever(observer) + advanceTimeBy(3100L) // after the logout delay (3000ms) - stateLiveData = MutableLiveData() - every { eventSyncManager.getLastSyncState() } returns stateLiveData - coEvery { configManager.getProject(PROJECT_ID) } returns project - viewModel = createViewModel() + assertThat(capturedValues).isEmpty() } - private fun createViewModel() = SyncInfoViewModel( - configManager = configManager, - connectivityTracker = connectivityTracker, - enrolmentRecordRepository = enrolmentRecordRepository, - authStore = authStore, - imageRepository = imageRepository, - eventSyncManager = eventSyncManager, - syncOrchestrator = syncOrchestrator, - tokenizationProcessor = tokenizationProcessor, - recentUserActivityManager = recentUserActivityManager, - ) + // LiveData syncInfoLiveData tests @Test - fun `should initialize the configuration live data correctly`() = runTest { - val configuration = mockk(relaxed = true) - coEvery { configManager.getProjectConfiguration() } returns configuration + fun `should not show re-login prompt when sync has not failed due to authentication`() = runTest { + val mockNormalEventSyncState = mockk(relaxed = true) { + every { isSyncFailedBecauseReloginRequired() } returns false + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockNormalEventSyncState) + createViewModel() - viewModel.refreshInformation() + val result = viewModel.syncInfoLiveData.getOrAwaitValue() - assertThat(viewModel.configuration.getOrAwaitValue()).isEqualTo(configuration) + assertThat(result.isLoginPromptSectionVisible).isFalse() } @Test - fun `should initialize the recordsInLocal live data correctly`() = runTest { - val number = 10 - coEvery { enrolmentRecordRepository.count(SubjectQuery(projectId = PROJECT_ID)) } returns number + fun `should show configuration loading when project is refreshing`() = runTest { + every { configManager.watchIfProjectRefreshing() } returns MutableStateFlow(true) + createViewModel() - viewModel.refreshInformation() + val result = viewModel.syncInfoLiveData.getOrAwaitValue() - assertThat(viewModel.recordsInLocal.getOrAwaitValue()).isEqualTo(number) + assertThat(result.isConfigurationLoadingProgressBarVisible).isTrue() } @Test - fun `should initialize the recordsToUpSync live data correctly`() = runTest { - val number = 10 - coEvery { - eventSyncManager.countEventsToUpload(listOf(EventType.ENROLMENT_V2, EventType.ENROLMENT_V4)) - } returns flowOf(number) + fun `should show re-login prompt when sync failed due to authentication required`() = runTest { + val mockFailedEventSyncState = mockk(relaxed = true) { + every { isSyncFailedBecauseReloginRequired() } returns true + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockFailedEventSyncState) + createViewModel() - // upSync count collected on init, so need to rebuild for mocking to take effect - viewModel = createViewModel() + val result = viewModel.syncInfoLiveData.getOrAwaitValue() - assertThat(viewModel.recordsToUpSync.getOrAwaitValue()).isEqualTo(number) + assertThat(result.isLoginPromptSectionVisible).isTrue() } + // Section-specific tests + @Test - fun `should initialize the imagesToUpload live data correctly`() = runTest { - val number = 10 - coEvery { imageRepository.getNumberOfImagesToUpload(PROJECT_ID) } returns number + fun `should emit SyncInfo with correct syncInfoSectionRecords instruction visibility`() = runTest { + val mockOfflineEventSyncState = mockk(relaxed = true) { + every { isSyncFailed() } returns false + every { isSyncInProgress() } returns false + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockOfflineEventSyncState) + every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(false) + createViewModel() - viewModel.refreshInformation() + val result = viewModel.syncInfoLiveData.getOrAwaitValue() - assertThat(viewModel.imagesToUpload.getOrAwaitValue()).isEqualTo(number) + assertThat(result.syncInfoSectionRecords.isInstructionOfflineVisible).isTrue() + assertThat(result.syncInfoSectionRecords.isInstructionErrorVisible).isFalse() + assertThat(result.syncInfoSectionRecords.isInstructionDefaultVisible).isFalse() } @Test - fun `should initialize the moduleCounts live data correctly`() = runTest { - val module1 = "module1".asTokenizableEncrypted() - val module2 = "module2".asTokenizableEncrypted() - val numberForModule1 = 10 - val numberForModule2 = 20 - coEvery { configManager.getDeviceConfiguration() } returns mockk { - every { selectedModules } returns listOf(module1, module2) + fun `should emit SyncInfo with correct syncInfoSectionRecords button states`() = runTest { + val mockNormalEventSyncState = mockk(relaxed = true) { + every { isSyncRunning() } returns false + every { isSyncFailedBecauseReloginRequired() } returns false + every { isSyncFailed() } returns false } - coEvery { - enrolmentRecordRepository.count( - SubjectQuery( - projectId = PROJECT_ID, - moduleId = module1, - ), - ) - } returns numberForModule1 - coEvery { - enrolmentRecordRepository.count( - SubjectQuery( - projectId = PROJECT_ID, - moduleId = module2, - ), - ) - } returns numberForModule2 - listOf(module1, module2).forEach { moduleName -> - every { - tokenizationProcessor.decrypt( - encrypted = moduleName, - tokenKeyType = TokenKeyType.ModuleId, - project = project, - ) - } returns moduleName + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockNormalEventSyncState) + every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) + createViewModel() + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(result.syncInfoSectionRecords.isSyncButtonEnabled).isTrue() + assertThat(result.syncInfoSectionRecords.isSyncButtonVisible).isTrue() + assertThat(result.syncInfoSectionRecords.isSyncButtonForRetry).isFalse() + } + + @Test + fun `should emit SyncInfo with correct syncInfoSectionRecords footer states`() = runTest { + val mockCompletedEventSyncState = mockk(relaxed = true) { + every { isSyncInProgress() } returns false } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) + coEvery { eventSyncManager.getLastSyncTime() } returns TEST_TIMESTAMP + createViewModel() + viewModel.isPreLogoutUpSync = false - viewModel.refreshInformation() + val result = viewModel.syncInfoLiveData.getOrAwaitValue() - assertThat(viewModel.moduleCounts.getOrAwaitValue()).isEqualTo( - listOf( - ModuleCount(module1.value, numberForModule1), - ModuleCount(module2.value, numberForModule2), - ), - ) + assertThat(result.syncInfoSectionRecords.isFooterLastSyncTimeVisible).isTrue() + assertThat(result.syncInfoSectionRecords.footerLastSyncMinutesAgo).isEqualTo(0) + assertThat(result.syncInfoSectionRecords.isFooterSyncInProgressVisible).isFalse() } @Test - fun `should initialize the recordsToDownSync live data to the count otherwise`() = runTest { - val module1 = "module1".asTokenizableEncrypted() - coEvery { configManager.getDeviceConfiguration() } returns mockk { - every { selectedModules } returns listOf(module1) + fun `should emit SyncInfo with correct syncInfoSectionImages instruction visibility`() = runTest { + val mockNotSyncingImageStatus = mockk(relaxed = true) { + every { isSyncing } returns false + every { progress } returns null } - coEvery { - eventSyncManager.countEventsToDownload() - } returns DownSyncCounts(15, isLowerBound = false) + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) + every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(false) + createViewModel() - viewModel.refreshInformation() + val result = viewModel.syncInfoLiveData.getOrAwaitValue() - assertThat(viewModel.recordsToDownSync.getOrAwaitValue()?.count).isEqualTo(15) + assertThat(result.syncInfoSectionImages.isInstructionOfflineVisible).isTrue() + assertThat(result.syncInfoSectionImages.isInstructionDefaultVisible).isFalse() } @Test - fun `should initialize the recordsToDownSync live data to the default count value if fetch fails`() = runTest { - val module1 = "module1".asTokenizableEncrypted() - coEvery { configManager.getDeviceConfiguration() } returns mockk { - every { selectedModules } returns listOf(module1) + fun `should emit SyncInfo with correct syncInfoSectionImages button states`() = runTest { + val mockNormalEventSyncState = mockk(relaxed = true) { + every { isSyncFailedBecauseReloginRequired() } returns false } - coEvery { - eventSyncManager.countEventsToDownload() - } throws Exception() + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockNormalEventSyncState) + every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) + createViewModel() - viewModel.refreshInformation() + val result = viewModel.syncInfoLiveData.getOrAwaitValue() - assertThat(viewModel.recordsToDownSync.getOrAwaitValue()?.count).isEqualTo(0) + assertThat(result.syncInfoSectionImages.isSyncButtonEnabled).isTrue() } @Test - fun `refreshInformation should first reset the information and then reload`() = runTest { - coEvery { enrolmentRecordRepository.count(SubjectQuery(projectId = PROJECT_ID)) } returnsMany listOf( - 2, - 4, - ) - viewModel.refreshInformation() + fun `should emit SyncInfo with correct syncInfoSectionImages footer states`() = runTest { + val mockImageStatusWithLastSync = mockk(relaxed = true) { + every { isSyncing } returns false + every { progress } returns null + every { secondsSinceLastUpdate } returns 120 // 2 minutes + } + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockImageStatusWithLastSync) + createViewModel() + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(result.syncInfoSectionImages.isFooterLastSyncTimeVisible).isTrue() + assertThat(result.syncInfoSectionImages.footerLastSyncMinutesAgo).isEqualTo(2) + } + + @Test + fun `should emit SyncInfo with correct syncInfoSectionModules data`() = runTest { + val mockProjectConfigWithModules = mockk { + every { general } returns mockk { + every { modalities } returns listOf(GeneralConfiguration.Modality.FINGERPRINT) + } + } + val mockDeviceConfigWithModules = mockk { + every { selectedModules } returns listOf(TokenizableString.Raw(TEST_MODULE_NAME)) + } + every { configManager.watchProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithModules) + every { configManager.watchDeviceConfiguration() } returns MutableStateFlow(mockDeviceConfigWithModules) + coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithModules + coEvery { configManager.getDeviceConfiguration() } returns mockDeviceConfigWithModules + coEvery { enrolmentRecordRepository.count(any()) } returns 50 + every { mockProjectConfigWithModules.isModuleSelectionAvailable() } returns true + createViewModel() + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + 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") + } - val records = viewModel.recordsInLocal.getOrAwaitValues(3) { - viewModel.refreshInformation() + // Progress calculation tests + + @Test + fun `should calculate correct event sync progress when sync in progress`() = runTest { + val mockInProgressEventSyncState = mockk(relaxed = true) { + every { isSyncInProgress() } returns true + every { isSyncCompleted() } returns false + every { progress } returns 5 + every { total } returns 10 } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockInProgressEventSyncState) + createViewModel() - // Init, refresh and reload - assertThat(records).isEqualTo(listOf(2, null, 4)) + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(result.syncInfoSectionRecords.isProgressVisible).isTrue() + assertThat(result.syncInfoSectionRecords.progress.progressBarPercentage).isEqualTo(50) // half } @Test - fun `fetchSyncInformationIfNeeded should not fetch the information if there is a non succeeded worker`() = runTest { - viewModel.fetchSyncInformationIfNeeded( - EventSyncState( - syncId = "", - progress = 0, - total = 0, - upSyncWorkersInfo = listOf(), - downSyncWorkersInfo = listOf( - EventSyncState.SyncWorkerInfo( - EventSyncWorkerType.DOWNLOADER, - EventSyncWorkerState.Running, - ), - ), - reporterStates = listOf(), - ), - ) + fun `should calculate correct event sync progress when sync connecting`() = runTest { + val mockConnectingEventSyncState = mockk(relaxed = true) { + every { isSyncConnecting() } returns true + every { isSyncInProgress() } returns false + every { isSyncCompleted() } returns false + every { isThereNotSyncHistory() } returns false + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockConnectingEventSyncState) + createViewModel() - coVerify(exactly = 0) { enrolmentRecordRepository.count(SubjectQuery(projectId = PROJECT_ID)) } - } - - @Test - fun `fetchSyncInformationIfNeeded should fetch the information if there is only succeeded worker`() = runTest { - viewModel.fetchSyncInformationIfNeeded( - EventSyncState( - syncId = "", - progress = 0, - total = 0, - upSyncWorkersInfo = listOf(), - downSyncWorkersInfo = listOf( - EventSyncState.SyncWorkerInfo( - EventSyncWorkerType.DOWNLOADER, - EventSyncWorkerState.Succeeded, - ), - ), - reporterStates = listOf(), - ), - ) + val result = viewModel.syncInfoLiveData.getOrAwaitValue() - coVerify(exactly = 1) { enrolmentRecordRepository.count(SubjectQuery(projectId = PROJECT_ID)) } + assertThat(result.syncInfoSectionRecords.progress.progressBarPercentage).isEqualTo(0) // not started } @Test - fun `fetchSyncInformationIfNeeded should not fetch the information if the state hasn't changed`() = runTest { - val state = EventSyncState( - syncId = "", - progress = 0, - total = 0, - upSyncWorkersInfo = listOf(), - downSyncWorkersInfo = listOf( - EventSyncState.SyncWorkerInfo( - EventSyncWorkerType.DOWNLOADER, - EventSyncWorkerState.Succeeded, - ), - ), - reporterStates = listOf(), - ) + fun `should calculate correct event sync progress when sync approached completion`() = runTest { + val mockCompletedEventSyncState = mockk(relaxed = true) { + every { isSyncInProgress() } returns true + every { progress } returns 10 + every { total } returns 10 + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) + createViewModel() + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(result.syncInfoSectionRecords.progress.progressBarPercentage).isEqualTo(100) + } + + @Test + fun `should not show event sync progress when sync completed`() = runTest { + val mockCompletedEventSyncState = mockk(relaxed = true) { + every { isSyncCompleted() } returns true + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) + createViewModel() + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(result.syncInfoSectionRecords.isProgressVisible).isFalse() + } + + @Test + fun `should calculate correct combined progress during pre-logout sync events phase`() = runTest { + val mockInProgressEventSyncState = mockk(relaxed = true) { + every { isSyncInProgress() } returns true + every { isSyncCompleted() } returns false + every { progress } returns 3 + every { total } returns 6 + } + val mockNotSyncingImageStatus = mockk(relaxed = true) { + every { isSyncing } returns false + every { progress } returns null + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockInProgressEventSyncState) + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) + createViewModel() + viewModel.isPreLogoutUpSync = true + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + // 50% of the first half (0-50%) of scale dedicated to the records, so 25% total + assertThat(result.syncInfoSectionRecords.progress.progressBarPercentage).isEqualTo(25) + } + + @Test + fun `should calculate correct combined progress during pre-logout sync images phase`() = runTest { + val mockCompletedEventSyncState = mockk(relaxed = true) { + every { isSyncCompleted() } returns true + every { isSyncInProgress() } returns false + } + val mockSyncingImageStatus = mockk(relaxed = true) { + every { isSyncing } returns true + every { progress } returns Pair(2, 4) // 2 out of 4 images + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockSyncingImageStatus) + createViewModel() + viewModel.isPreLogoutUpSync = true + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + // 50% of the second half (50-75%) of scale dedicated to the images, so 75% total + assertThat(result.syncInfoSectionRecords.progress.progressBarPercentage).isEqualTo(75) + } + + @Test + fun `should calculate correct image sync progress when images syncing`() = runTest { + val mockSyncingImageStatus = mockk(relaxed = true) { + every { isSyncing } returns true + every { progress } returns Pair(3, 10) // 3 out of 10 images + } + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockSyncingImageStatus) + createViewModel() + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(result.syncInfoSectionImages.isProgressVisible).isTrue() + assertThat(result.syncInfoSectionImages.progress.progressBarPercentage).isEqualTo(30) + } + + @Test + fun `should calculate correct image sync progress when images not syncing`() = runTest { + val mockNotSyncingImageStatus = mockk(relaxed = true) { + every { isSyncing } returns false + every { progress } returns null + } + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) + coEvery { imageRepository.getNumberOfImagesToUpload(any()) } returns 0 + createViewModel() + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(result.syncInfoSectionImages.isProgressVisible).isFalse() + assertThat(result.syncInfoSectionImages.progress.progressBarPercentage).isEqualTo(0) + } + + // Counter tests + + @Test + fun `should emit SyncInfo with correct record counters when sync not in progress`() = runTest { + val mockIdleEventSyncState = mockk(relaxed = true) { + every { isSyncInProgress() } returns false + every { isSyncRunning() } returns false + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) + coEvery { enrolmentRecordRepository.count(any()) } returns 25 + coEvery { eventSyncManager.countEventsToUpload(any()) } returns flowOf(5) + coEvery { eventSyncManager.countEventsToDownload(any()) } returns DownSyncCounts(8, isLowerBound = false) + createViewModel() + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(result.syncInfoSectionRecords.counterTotalRecords).isEqualTo("25") + assertThat(result.syncInfoSectionRecords.counterRecordsToUpload).isEqualTo("5") + assertThat(result.syncInfoSectionRecords.counterRecordsToDownload).isEqualTo("8") + } + + @Test + fun `should emit SyncInfo with empty record counters when sync in progress`() = runTest { + val mockInProgressEventSyncState = mockk(relaxed = true) { + every { isSyncInProgress() } returns true + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockInProgressEventSyncState) + createViewModel() + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() - viewModel.fetchSyncInformationIfNeeded(state) - viewModel.fetchSyncInformationIfNeeded(state) - - coVerify(exactly = 1) { enrolmentRecordRepository.count(SubjectQuery(projectId = PROJECT_ID)) } - } - - @Test - fun `should invoke sync manager when sync is requested`() = runTest { - viewModel.forceSync() - - verify(exactly = 1) { syncOrchestrator.startEventSync() } - assertThat(viewModel.isSyncAvailable.getOrAwaitValue()).isEqualTo(false) - } - - @Test - fun `isModuleSyncAndModuleIdOptionsNotEmpty returns true only if module sync and has modules`() = runTest { - // Not module sync - assertThat( - viewModel.isModuleSyncAndModuleIdOptionsNotEmpty( - createMockDownSyncConfig( - partitionType = DownSynchronizationConfiguration.PartitionType.USER, - ), - ), - ).isFalse() - // Module sync + no modules - assertThat( - viewModel.isModuleSyncAndModuleIdOptionsNotEmpty( - createMockDownSyncConfig( - partitionType = DownSynchronizationConfiguration.PartitionType.MODULE, - ), - ), - ).isFalse() - // Module sync + has modules - assertThat( - viewModel.isModuleSyncAndModuleIdOptionsNotEmpty( - createMockDownSyncConfig( - partitionType = DownSynchronizationConfiguration.PartitionType.MODULE, - modules = listOf("module"), - ), - ), - ).isTrue() - } - - @Test - fun `emit correct sync availability when connection status changes`() = runTest { - coEvery { configManager.getProjectConfiguration() } returns mockk { - every { synchronization } returns createMockDownSyncConfig( - partitionType = DownSynchronizationConfiguration.PartitionType.MODULE, - modules = listOf("module"), - ) - } - viewModel.refreshInformation() - stateLiveData.value = EventSyncState("", 0, 0, emptyList(), emptyList(), emptyList()) - - connectionLiveData.value = false - assertThat(viewModel.isSyncAvailable.getOrAwaitValue()).isFalse() - - connectionLiveData.value = true - assertThat(viewModel.isSyncAvailable.getOrAwaitValue()).isTrue() - } - - @Test - fun `emit correct sync availability when sync status changes`() = runTest { - coEvery { configManager.getProjectConfiguration() } returns mockk { - every { synchronization } returns createMockDownSyncConfig( - partitionType = DownSynchronizationConfiguration.PartitionType.MODULE, - modules = listOf("module"), - ) - } - viewModel.refreshInformation() - connectionLiveData.value = true - - stateLiveData.value = EventSyncState("", 0, 0, emptyList(), emptyList(), emptyList()) - assertThat(viewModel.isSyncAvailable.getOrAwaitValue()).isTrue() - - stateLiveData.value = EventSyncState( - syncId = "", - progress = 0, - total = 0, - upSyncWorkersInfo = emptyList(), - downSyncWorkersInfo = listOf( - EventSyncState.SyncWorkerInfo( - EventSyncWorkerType.DOWNLOADER, - EventSyncWorkerState.Running, - ), - ), - reporterStates = listOf(), + assertThat(result.syncInfoSectionRecords.counterTotalRecords).isEmpty() + assertThat(result.syncInfoSectionRecords.counterRecordsToUpload).isEmpty() + assertThat(result.syncInfoSectionRecords.counterRecordsToDownload).isEmpty() + } + + @Test + fun `should emit SyncInfo with correct images to upload counter when sync not in progress`() = runTest { + val mockNotSyncingImageStatus = mockk(relaxed = true) { + every { isSyncing } returns false + every { progress } returns null + } + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) + coEvery { imageRepository.getNumberOfImagesToUpload(any()) } returns 15 + createViewModel() + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(result.syncInfoSectionRecords.counterImagesToUpload).isEqualTo("15") // may be shown within records + assertThat(result.syncInfoSectionImages.counterImagesToUpload).isEqualTo("15") + } + + @Test + fun `should emit SyncInfo with empty images counter when sync in progress`() = runTest { + val mockSyncingImageStatus = mockk(relaxed = true) { + every { isSyncing } returns true + every { progress } returns null + } + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockSyncingImageStatus) + createViewModel() + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(result.syncInfoSectionRecords.counterImagesToUpload).isEmpty() // may be shown within records + assertThat(result.syncInfoSectionImages.counterImagesToUpload).isEmpty() + } + + @Test + fun `should emit SyncInfo with correct module counts when modules selected`() = runTest { + val mockProjectConfigWithModules = mockk { + every { general } returns mockk { + every { modalities } returns listOf(GeneralConfiguration.Modality.FINGERPRINT) + } + } + val mockDeviceConfigWithModules = mockk { + every { selectedModules } returns listOf(TokenizableString.Raw("module_1"), TokenizableString.Raw("module_2")) + } + every { configManager.watchProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithModules) + every { configManager.watchDeviceConfiguration() } 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 { mockProjectConfigWithModules.isModuleSelectionAvailable() } returns true + createViewModel() + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + 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") + // module_1 + assertThat(result.syncInfoSectionModules.moduleCounts[1]).isEqualTo( + SyncInfoModuleCount(isTotal = false, name = "module_1", count = "15") ) - assertThat(viewModel.isSyncAvailable.getOrAwaitValue()).isFalse() - - stateLiveData.value = EventSyncState( - syncId = "", - progress = 0, - total = 0, - upSyncWorkersInfo = emptyList(), - downSyncWorkersInfo = listOf( - EventSyncState.SyncWorkerInfo( - EventSyncWorkerType.DOWNLOADER, - EventSyncWorkerState.Succeeded, - ), - ), - reporterStates = listOf(), + // module_2 + assertThat(result.syncInfoSectionModules.moduleCounts[2]).isEqualTo( + SyncInfoModuleCount(isTotal = false, name = "module_2", count = "25") ) - assertThat(viewModel.isSyncAvailable.getOrAwaitValue()).isTrue() } @Test - fun `emit correct sync availability when non-module config`() = runTest { - coEvery { configManager.getProjectConfiguration() } returns mockk { - every { synchronization } returns createMockDownSyncConfig( - partitionType = DownSynchronizationConfiguration.PartitionType.USER, - ) + fun `should emit SyncInfo with empty module counts when no modules selected`() = runTest { + val mockProjectConfigWithoutModules = mockk { + every { general } returns mockk { + every { modalities } returns emptyList() + } + } + val mockDeviceConfigWithoutModules = mockk { + every { selectedModules } returns emptyList() } - viewModel.refreshInformation() - connectionLiveData.value = true - stateLiveData.value = EventSyncState("", 0, 0, emptyList(), emptyList(), emptyList()) + every { configManager.watchProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithoutModules) + every { configManager.watchDeviceConfiguration() } returns MutableStateFlow(mockDeviceConfigWithoutModules) + coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithoutModules + coEvery { configManager.getDeviceConfiguration() } returns mockDeviceConfigWithoutModules + every { mockProjectConfigWithoutModules.isModuleSelectionAvailable() } returns false + createViewModel() - assertThat(viewModel.isSyncAvailable.getOrAwaitValue()).isTrue() + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(result.syncInfoSectionModules.isSectionAvailable).isFalse() + assertThat(result.syncInfoSectionModules.moduleCounts).isEmpty() } @Test - fun `emit correct sync availability when module config without modules`() = runTest { - coEvery { configManager.getProjectConfiguration() } returns mockk { - every { synchronization } returns createMockDownSyncConfig( - partitionType = DownSynchronizationConfiguration.PartitionType.MODULE, - modules = emptyList(), - ) + fun `should emit SyncInfo with correct records to download counter visible when allowed`() = runTest { + val mockProjectConfigWithDownSync = mockk { + every { general } returns mockk { + every { modalities } returns emptyList() + } + } + val mockIdleEventSyncState = mockk(relaxed = true) { + every { isSyncInProgress() } returns false } - viewModel.refreshInformation() - connectionLiveData.value = true - stateLiveData.value = EventSyncState("", 0, 0, emptyList(), emptyList(), emptyList()) - assertThat(viewModel.isSyncAvailable.getOrAwaitValue()).isFalse() + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) + every { configManager.watchProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithDownSync) + coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithDownSync + coEvery { eventSyncManager.countEventsToDownload(any()) } returns DownSyncCounts(42, isLowerBound = false) + every { mockProjectConfigWithDownSync.isEventDownSyncAllowed() } returns true + every { mockProjectConfigWithDownSync.isModuleSelectionAvailable() } returns false + createViewModel() + viewModel.isPreLogoutUpSync = false + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(result.syncInfoSectionRecords.isCounterRecordsToDownloadVisible).isTrue() + assertThat(result.syncInfoSectionRecords.counterRecordsToDownload).isEqualTo("42") } @Test - fun `emit ReloginRequired = false when lastSyncState updates with different status`() = runTest { - stateLiveData.value = EventSyncState( - "", - 0, - 0, - listOf(), - listOf( - EventSyncState.SyncWorkerInfo( - EventSyncWorkerType.DOWNLOADER, - EventSyncWorkerState.Failed(failedBecauseBackendMaintenance = true), - ), - ), - reporterStates = listOf(), - ) + fun `should emit SyncInfo with hidden records to download counter when pre-logout mode`() = runTest { + val mockIdleEventSyncState = mockk(relaxed = true) { + every { isSyncInProgress() } returns false + } - assertThat(viewModel.isReloginRequired.getOrAwaitValue()).isFalse() + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) + createViewModel() + viewModel.isPreLogoutUpSync = true + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(result.syncInfoSectionRecords.isCounterRecordsToDownloadVisible).isFalse() } @Test - fun `emit ReloginRequired = true when lastSyncState updates with such status`() = runTest { - stateLiveData.value = EventSyncState( - "", - 0, - 0, - listOf(), - listOf( - EventSyncState.SyncWorkerInfo( - EventSyncWorkerType.DOWNLOADER, - EventSyncWorkerState.Failed(failedBecauseReloginRequired = true), - ), - ), - reporterStates = listOf(), - ) + fun `should handle timeout when counting records to download`() = runTest { + val mockProjectConfigWithDownSync = mockk { + every { general } returns mockk { + every { modalities } returns emptyList() + } + } + val mockIdleEventSyncState = mockk(relaxed = true) { + every { isSyncInProgress() } returns false + } + + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) + every { configManager.watchProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithDownSync) + coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithDownSync + coEvery { eventSyncManager.countEventsToDownload(any()) } throws Exception("Timeout") + every { mockProjectConfigWithDownSync.isEventDownSyncAllowed() } returns true + every { mockProjectConfigWithDownSync.isModuleSelectionAvailable() } returns false + createViewModel() + viewModel.isPreLogoutUpSync = false + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(result.syncInfoSectionRecords.counterRecordsToDownload).isEqualTo("0") + } + + @Test + fun `should handle when records download counting throws exception`() = runTest { + val mockProjectConfigWithDownSync = mockk { + every { general } returns mockk { + every { modalities } returns emptyList() + } + } + val mockIdleEventSyncState = mockk(relaxed = true) { + every { isSyncInProgress() } returns false + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) + every { configManager.watchProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithDownSync) + coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithDownSync + coEvery { eventSyncManager.countEventsToDownload(any()) } throws RuntimeException("Network error") + every { mockProjectConfigWithDownSync.isEventDownSyncAllowed() } returns true + every { mockProjectConfigWithDownSync.isModuleSelectionAvailable() } returns false + createViewModel() + viewModel.isPreLogoutUpSync = false + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(result.syncInfoSectionRecords.counterRecordsToDownload).isEqualTo("0") + } + + @Test + fun `should handle network errors indication`() = runTest { + val connectivityFlow = MutableStateFlow(false) // start offline + every { connectivityTracker.observeIsConnected().asFlow() } returns connectivityFlow + createViewModel() + + val offlineResult = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(offlineResult.syncInfoSectionRecords.isInstructionOfflineVisible).isTrue() + assertThat(offlineResult.syncInfoSectionRecords.isSyncButtonEnabled).isFalse() + assertThat(offlineResult.syncInfoSectionImages.isSyncButtonEnabled).isFalse() + + connectivityFlow.value = true + + val onlineResult = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(onlineResult.syncInfoSectionRecords.isInstructionOfflineVisible).isFalse() + assertThat(onlineResult.syncInfoSectionRecords.isSyncButtonEnabled).isTrue() + assertThat(onlineResult.syncInfoSectionImages.isSyncButtonEnabled).isTrue() + } + + // Flow combination tests + + @Test + fun `should handle changes in connectivity stream`() = runTest { + val connectivityFlow = MutableStateFlow(false) // started offline + every { connectivityTracker.observeIsConnected().asFlow() } returns connectivityFlow + createViewModel() + + val offlineResult = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(offlineResult.syncInfoSectionRecords.isInstructionOfflineVisible).isTrue() + + connectivityFlow.value = true // changed to online + + val onlineResult = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(onlineResult.syncInfoSectionRecords.isInstructionOfflineVisible).isFalse() + } + + @Test + fun `should handle changes in auth stream`() = runTest { + val authFlow = MutableStateFlow("") // started not signed in + every { authStore.watchSignedInProjectId() } returns authFlow + createViewModel() + + val loggedOutResult = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(loggedOutResult.isLoggedIn).isFalse() + + authFlow.value = TEST_PROJECT_ID // changed to signed in + + val loggedInResult = viewModel.syncInfoLiveData.getOrAwaitValue() - assertThat(viewModel.isReloginRequired.getOrAwaitValue()).isTrue() + assertThat(loggedInResult.isLoggedIn).isTrue() } @Test - fun `calling login() sends respective event to the view`() { - viewModel.login() + fun `should handle changes in project refreshing stream`() = runTest { + val refreshingFlow = MutableStateFlow(false) // started non refreshing + every { configManager.watchIfProjectRefreshing() } returns refreshingFlow + createViewModel() - val loginRequestedEvent = viewModel.loginRequestedEventLiveData.getOrAwaitValue() - assertThat(loginRequestedEvent).isNotNull() + val notRefreshingResult = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(notRefreshingResult.isConfigurationLoadingProgressBarVisible).isFalse() + + refreshingFlow.value = true // changed to refreshing + + val refreshingResult = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(refreshingResult.isConfigurationLoadingProgressBarVisible).isTrue() } @Test - fun `calling handleLoginResult() triggers sync if result is success`() { - viewModel.handleLoginResult(LoginResult(true)) + fun `should handle changes in event sync state stream`() = runTest { + val eventSyncStateFlow = MutableLiveData() + every { eventSyncManager.getLastSyncState(any()) } returns eventSyncStateFlow + createViewModel() + val mockIdleState = mockk(relaxed = true) { + every { isSyncInProgress() } returns false + } + eventSyncStateFlow.value = mockIdleState // started not syncing + + val idleResult = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(idleResult.syncInfoSectionRecords.isProgressVisible).isFalse() - verify(exactly = 1) { syncOrchestrator.startEventSync() } + val mockSyncingState = mockk(relaxed = true) { + every { isSyncInProgress() } returns true + every { progress } returns 1 + every { total } returns 2 + } + eventSyncStateFlow.value = mockSyncingState // changed to syncing + + val syncingResult = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(syncingResult.syncInfoSectionRecords.isProgressVisible).isTrue() } @Test - fun `calling handleLoginResult() does not trigger sync if result is not success`() { - viewModel.handleLoginResult(LoginResult(false)) + fun `should handle changes in image sync status stream`() = runTest { + val imageSyncStatusFlow = MutableStateFlow(mockk { + every { isSyncing } returns false + every { progress } returns null + every { secondsSinceLastUpdate } returns null + }) // started not syncing + every { syncOrchestrator.observeImageSyncStatus() } returns imageSyncStatusFlow + createViewModel() + + val notSyncingResult = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(notSyncingResult.syncInfoSectionImages.isProgressVisible).isFalse() + + imageSyncStatusFlow.value = mockk { + every { isSyncing } returns true + every { progress } returns Pair(1, 2) + every { secondsSinceLastUpdate } returns null + } // changed to syncing + + val syncingResult = viewModel.syncInfoLiveData.getOrAwaitValue() - verify(exactly = 0) { syncOrchestrator.startEventSync() } + assertThat(syncingResult.syncInfoSectionImages.isProgressVisible).isTrue() } - private fun createMockDownSyncConfig( - partitionType: DownSynchronizationConfiguration.PartitionType, - modules: List = emptyList(), - ) = mockk { - every { down.simprints }.returns( - DownSynchronizationConfiguration.SimprintsDownSynchronizationConfiguration( - partitionType = partitionType, - moduleOptions = modules.map(String::asTokenizableRaw), - maxNbOfModules = 0, - maxAge = "PT24H", - frequency = Frequency.PERIODICALLY, - ), + @Test + fun `should handle changes in project config stream`() = runTest { + val projectConfigFlow = MutableStateFlow(mockProjectConfiguration) + every { configManager.watchProjectConfiguration() } returns projectConfigFlow // started without modules + createViewModel() + + val initialResult = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(initialResult.syncInfoSectionModules.isSectionAvailable).isFalse() + + val mockConfigWithModules = mockk { + every { general } returns mockk { + every { modalities } returns listOf(GeneralConfiguration.Modality.FINGERPRINT) + } + } + every { mockConfigWithModules.isModuleSelectionAvailable() } returns true + projectConfigFlow.value = mockConfigWithModules // now with modules + + val moduleConfigResult = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(moduleConfigResult.syncInfoSectionModules.isSectionAvailable).isTrue() + } + + @Test + fun `should handle changes in device config stream`() = runTest { + every { configManager.watchProjectConfiguration() } returns flowOf( + mockk { + every { general } returns mockk { + every { modalities } returns emptyList() + } + } ) + val deviceConfigFlow = MutableStateFlow( + mockk(relaxed = true) { + every { selectedModules } returns emptyList() + } + ) // started without selected modules + every { configManager.watchDeviceConfiguration() } returns deviceConfigFlow + createViewModel() + + val noModulesResult = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(noModulesResult.syncInfoSectionModules.moduleCounts).isEmpty() + + deviceConfigFlow.emit( + mockk(relaxed = true) { + every { selectedModules } returns listOf(TokenizableString.Raw(TEST_MODULE_NAME)) + } + ) // now with selected modules + + val withModulesResult = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(withModulesResult.syncInfoSectionModules.moduleCounts).isNotEmpty() + } + + @Test + fun `should handle changes in time pacing stream`() = runTest { + val mockIdleEventSyncState = mockk(relaxed = true) { + every { isSyncRunning() } returns false + } + every { eventSyncManager.getLastSyncState() } returns MutableLiveData(mockIdleEventSyncState) + coEvery { eventSyncManager.getLastSyncTime() } returns TEST_TIMESTAMP + every { timeHelper.now() } returnsMany listOf(TEST_TIMESTAMP, Timestamp(TEST_TIMESTAMP.ms + 60_000)) + // MutableStateFlow of Unit won't emit another (identical) Unit, so we'll count minutes and map to Units + val timePaceFlow = MutableStateFlow(0) + every { timeHelper.watchOncePerMinute() } returns timePaceFlow.map { Unit } + createViewModel() + + val initialResult = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(initialResult.syncInfoSectionRecords.footerLastSyncMinutesAgo).isEqualTo(0) + + timePaceFlow.value = -1 // just a different value for a time beat, doesn't matter which + + val updatedResult = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(updatedResult.syncInfoSectionRecords.footerLastSyncMinutesAgo).isEqualTo(1) + } + + // UI state tests + + @Test + fun `should calculate correct record last sync time when sync time available`() = runTest { + val fiveMinutesAgo = Timestamp(TEST_TIMESTAMP.ms - 300000) // 5 minutes before test timestamp + coEvery { eventSyncManager.getLastSyncTime() } returns fiveMinutesAgo + every { timeHelper.msBetweenNowAndTime(fiveMinutesAgo) } returns 300000L // 5 minutes + createViewModel() + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(result.syncInfoSectionRecords.isFooterLastSyncTimeVisible).isTrue() + assertThat(result.syncInfoSectionRecords.footerLastSyncMinutesAgo).isEqualTo(5) + } + + @Test + fun `should have hidden record last sync time footer when no sync history`() = runTest { + coEvery { eventSyncManager.getLastSyncTime() } returns null + createViewModel() + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(result.syncInfoSectionRecords.isFooterLastSyncTimeVisible).isFalse() + } + + @Test + fun `should calculate correct image last sync time when available`() = runTest { + val mockImageStatusWithLastSync = mockk(relaxed = true) { + every { isSyncing } returns false + every { progress } returns null + every { secondsSinceLastUpdate } returns 180 // 3 minutes + } + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockImageStatusWithLastSync) + createViewModel() + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(result.syncInfoSectionImages.isFooterLastSyncTimeVisible).isTrue() + assertThat(result.syncInfoSectionImages.footerLastSyncMinutesAgo).isEqualTo(3) + } + + @Test + fun `should have hidden image last sync time footer when unavailable`() = runTest { + val mockImageStatusWithoutLastSync = mockk(relaxed = true) { + every { isSyncing } returns false + every { progress } returns null + every { secondsSinceLastUpdate } returns null + } + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockImageStatusWithoutLastSync) + createViewModel() + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(result.syncInfoSectionImages.isFooterLastSyncTimeVisible).isFalse() + } + + @Test + fun `should show correct visibility states for offline instructions`() = runTest { + every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(false) + createViewModel() + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(result.syncInfoSectionRecords.isInstructionOfflineVisible).isTrue() + assertThat(result.syncInfoSectionRecords.isInstructionDefaultVisible).isFalse() + assertThat(result.syncInfoSectionRecords.isInstructionErrorVisible).isFalse() + assertThat(result.syncInfoSectionImages.isInstructionOfflineVisible).isTrue() + assertThat(result.syncInfoSectionImages.isInstructionDefaultVisible).isFalse() + } + + @Test + fun `should show correct visibility states for error instructions`() = runTest { + val mockFailedEventSyncState = mockk(relaxed = true) { + every { isSyncFailed() } returns true + every { isSyncInProgress() } returns false + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockFailedEventSyncState) + every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) + createViewModel() + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(result.syncInfoSectionRecords.isInstructionErrorVisible).isTrue() + assertThat(result.syncInfoSectionRecords.isInstructionDefaultVisible).isFalse() + assertThat(result.syncInfoSectionRecords.isInstructionOfflineVisible).isFalse() + } + + @Test + fun `should show correct visibility states for module selection instructions`() = runTest { + val mockProjectConfigRequiringModules = mockk { + every { general } returns mockk { + every { modalities } returns listOf(GeneralConfiguration.Modality.FINGERPRINT) + } + } + val mockEmptyDeviceConfig = mockk { + every { selectedModules } returns emptyList() + } + val mockIdleEventSyncState = mockk(relaxed = true) { + every { isSyncFailed() } returns false + every { isSyncInProgress() } returns false + } + every { configManager.watchProjectConfiguration() } returns MutableStateFlow(mockProjectConfigRequiringModules) + every { configManager.watchDeviceConfiguration() } returns MutableStateFlow(mockEmptyDeviceConfig) + coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigRequiringModules + coEvery { configManager.getDeviceConfiguration() } returns mockEmptyDeviceConfig + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) + every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) + every { mockProjectConfigRequiringModules.isModuleSelectionAvailable() } returns true + createViewModel() + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(result.syncInfoSectionRecords.isInstructionNoModulesVisible).isTrue() + assertThat(result.syncInfoSectionRecords.isInstructionDefaultVisible).isFalse() + assertThat(result.syncInfoSectionRecords.isInstructionOfflineVisible).isFalse() + assertThat(result.syncInfoSectionRecords.isInstructionErrorVisible).isFalse() + } + + @Test + fun `should show correct visibility states for default instructions`() = runTest { + val mockIdleEventSyncState = mockk(relaxed = true) { + every { isSyncFailed() } returns false + every { isSyncInProgress() } returns false + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) + every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) + createViewModel() + viewModel.isPreLogoutUpSync = false + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(result.syncInfoSectionRecords.isInstructionDefaultVisible).isTrue() + assertThat(result.syncInfoSectionRecords.isInstructionOfflineVisible).isFalse() + assertThat(result.syncInfoSectionRecords.isInstructionErrorVisible).isFalse() + assertThat(result.syncInfoSectionRecords.isInstructionNoModulesVisible).isFalse() + assertThat(result.syncInfoSectionImages.isInstructionDefaultVisible).isTrue() + assertThat(result.syncInfoSectionImages.isInstructionOfflineVisible).isFalse() + } + + @Test + fun `should handle failed sync retry indication correctly`() = runTest { + val eventSyncStateFlow = MutableLiveData() + every { eventSyncManager.getLastSyncState(any()) } returns eventSyncStateFlow + createViewModel() + val mockFailedState = mockk(relaxed = true) { + every { isSyncFailed() } returns true + every { isSyncInProgress() } returns false + every { isSyncFailedBecauseReloginRequired() } returns false + } + eventSyncStateFlow.value = mockFailedState + + val failedResult = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(failedResult.syncInfoSectionRecords.isInstructionErrorVisible).isTrue() + assertThat(failedResult.syncInfoSectionRecords.isSyncButtonForRetry).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(GeneralConfiguration.Modality.FINGERPRINT) + } + } + val mockDeviceConfigWithTokenizedModules = mockk { + every { selectedModules } returns listOf(tokenizedModule) + } + every { configManager.watchProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithModules) + every { configManager.watchDeviceConfiguration() } 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 + createViewModel() + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + 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(GeneralConfiguration.Modality.FINGERPRINT) + } + } + val mockDeviceConfigWithRawModules = mockk { + every { selectedModules } returns listOf(rawModule) + } + every { configManager.watchProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithModules) + every { configManager.watchDeviceConfiguration() } 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 + createViewModel() + + val result = viewModel.syncInfoLiveData.getOrAwaitValue() + + 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()) } + } + + // forceEventSync() tests + + @Test + fun `should start event sync with down sync allowed when not pre-logout`() = runTest { + viewModel.isPreLogoutUpSync = false + + viewModel.forceEventSync() + + coVerify { syncOrchestrator.stopEventSync() } + coVerify { syncOrchestrator.startEventSync(isDownSyncAllowed = true) } + } + + @Test + fun `should start event sync with down sync disabled when pre-logout`() = runTest { + viewModel.isPreLogoutUpSync = true + + viewModel.forceEventSync() + + coVerify { syncOrchestrator.stopEventSync() } + coVerify { syncOrchestrator.startEventSync(isDownSyncAllowed = false) } } + + @Test + fun `should start event sync with down sync disabled when project ending`() = runTest { + val mockEndingProject = mockk { + every { state } returns ProjectState.PROJECT_ENDING + } + coEvery { configManager.getProject(any()) } returns mockEndingProject + createViewModel() + viewModel.isPreLogoutUpSync = false + + viewModel.forceEventSync() + + coVerify { syncOrchestrator.stopEventSync() } + coVerify { syncOrchestrator.startEventSync(isDownSyncAllowed = false) } + } + + @Test + fun `should stop current event sync before starting new one`() = runTest { + viewModel.forceEventSync() + + coVerify { syncOrchestrator.stopEventSync() } + coVerify { syncOrchestrator.startEventSync(any()) } + } + + // toggleImageSync() tests + + @Test + fun `should start image sync when not currently syncing images`() = runTest { + val mockNotSyncingImageStatus = mockk(relaxed = true) { + every { isSyncing } returns false + } + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) + createViewModel() + + viewModel.toggleImageSync() + + coVerify { syncOrchestrator.startImageSync() } + coVerify(exactly = 0) { syncOrchestrator.stopImageSync() } + } + + @Test + fun `should stop image sync when currently syncing images`() = runTest { + val mockSyncingImageStatus = mockk(relaxed = true) { + every { isSyncing } returns true + } + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockSyncingImageStatus) + createViewModel() + + viewModel.toggleImageSync() + + coVerify { syncOrchestrator.stopImageSync() } + coVerify(exactly = 0) { syncOrchestrator.startImageSync() } + } + + // logout() tests + + @Test + fun `should call logout use case when logout invoked`() = runTest { + viewModel.logout() + + verify { logoutUseCase() } + } + + // requestNavigationToLogin() tests + + @Test + fun `should emit login navigation event with signed in project ID`() = runTest { + viewModel.requestNavigationToLogin() + + val result = viewModel.loginNavigationEventLiveData.getOrAwaitValue() + + assertThat(result.projectId).isEqualTo(TEST_PROJECT_ID) + assertThat(result).isNotNull() + } + + @Test + fun `should emit login navigation event with signed in user ID when available`() = runTest { + viewModel.requestNavigationToLogin() + + val result = viewModel.loginNavigationEventLiveData.getOrAwaitValue() + + assertThat(result.userId.value).isEqualTo(TEST_USER_ID) + } + + @Test + fun `should emit login navigation event with recent user ID when signed in user unavailable`() = runTest { + every { authStore.signedInUserId } returns null + createViewModel() + + viewModel.requestNavigationToLogin() + + val result = viewModel.loginNavigationEventLiveData.getOrAwaitValue() + assertThat(result.userId.value).isEqualTo(TEST_RECENT_USER_ID) + } + + // handleLoginResult() tests + + @Test + fun `should trigger forceEventSync when login result is success`() = runTest { + val successResult = mockk { + every { isSuccess } returns true + } + + viewModel.handleLoginResult(successResult) + + coVerify { syncOrchestrator.startEventSync(any()) } + } + + @Test + fun `should not trigger forceEventSync when login result is failure`() = runTest { + val failureResult = mockk { + every { isSuccess } returns false + } + + viewModel.handleLoginResult(failureResult) + + coVerify(exactly = 0) { syncOrchestrator.startEventSync(any()) } + } + + // Other/combined UX case tests + + @Test + fun `should trigger initial sync when no previous sync history`() = runTest { + val mockIdleEventSyncState = mockk(relaxed = true) { + every { isSyncRunning() } returns false + } + every { eventSyncManager.getLastSyncState() } returns MutableLiveData(mockIdleEventSyncState) + coEvery { eventSyncManager.getLastSyncTime() } returns null + createViewModel() + + viewModel.syncInfoLiveData.getOrAwaitValue() + + coVerify { syncOrchestrator.startEventSync(any()) } + } + + @Test + fun `should trigger initial sync when last sync too old`() = runTest { + val oldTimestamp = Timestamp(TEST_TIMESTAMP.ms - 600000) // 10 minutes ago, over threshold of 5 + val mockIdleEventSyncState = mockk(relaxed = true) { + every { isSyncRunning() } returns false + } + every { eventSyncManager.getLastSyncState() } returns MutableLiveData(mockIdleEventSyncState) + coEvery { eventSyncManager.getLastSyncTime() } returns oldTimestamp + every { timeHelper.msBetweenNowAndTime(oldTimestamp) } returns 600000L // 10 minutes + createViewModel() + + viewModel.syncInfoLiveData.getOrAwaitValue() + + coVerify { syncOrchestrator.startEventSync(any()) } + } + + @Test + fun `should not trigger initial sync when recently synced`() = runTest { + val recentTimestamp = Timestamp(TEST_TIMESTAMP.ms - 60000) // 1 minute ago, under threshold of 5 + val mockIdleEventSyncState = mockk(relaxed = true) { + every { isSyncRunning() } returns false + } + every { eventSyncManager.getLastSyncState() } returns MutableLiveData(mockIdleEventSyncState) + coEvery { eventSyncManager.getLastSyncTime() } returns recentTimestamp + every { timeHelper.msBetweenNowAndTime(recentTimestamp) } returns 60000L // 1 minute + createViewModel() + + viewModel.syncInfoLiveData.getOrAwaitValue() + + coVerify(exactly = 0) { syncOrchestrator.startEventSync(any()) } + } + + @Test + fun `should not trigger initial sync when sync already running`() = runTest { + val mockRunningSyncState = mockk(relaxed = true) { + every { isSyncRunning() } returns true + } + every { eventSyncManager.getLastSyncState() } returns MutableLiveData(mockRunningSyncState) + coEvery { eventSyncManager.getLastSyncTime() } returns null + createViewModel() + + viewModel.syncInfoLiveData.getOrAwaitValue() + + coVerify(exactly = 0) { syncOrchestrator.startEventSync(any()) } + } + + @Test + fun `should trigger initial sync in pre-logout mode regardless of history`() = runTest { + val recentTimestamp = Timestamp(TEST_TIMESTAMP.ms - 60000) // 1 minute ago, under threshold of 5 + val mockIdleEventSyncState = mockk(relaxed = true) { + every { isSyncRunning() } returns false + } + every { eventSyncManager.getLastSyncState() } returns MutableLiveData(mockIdleEventSyncState) + coEvery { eventSyncManager.getLastSyncTime() } returns recentTimestamp + every { timeHelper.msBetweenNowAndTime(recentTimestamp) } returns 60000L // 1 minute + createViewModel() + viewModel.isPreLogoutUpSync = true + + viewModel.syncInfoLiveData.getOrAwaitValue() + + coVerify(atLeast = 0) { syncOrchestrator.startEventSync(any()) } + } + + @Test + fun `should not trigger initial sync when module selection required`() = runTest { + val mockProjectConfigRequiringModules = mockk { + every { general } returns mockk { + every { modalities } returns listOf(GeneralConfiguration.Modality.FINGERPRINT) + } + } + val mockEmptyDeviceConfig = mockk { + every { selectedModules } returns emptyList() + } + coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigRequiringModules + coEvery { configManager.getDeviceConfiguration() } returns mockEmptyDeviceConfig + every { mockProjectConfigRequiringModules.isModuleSelectionAvailable() } returns true + coEvery { eventSyncManager.getLastSyncTime() } returns null + createViewModel() + viewModel.isPreLogoutUpSync = true + + viewModel.syncInfoLiveData.getOrAwaitValue() + + coVerify(exactly = 0) { syncOrchestrator.startEventSync(any()) } + } + + @Test + fun `should start image sync after event sync completes in pre-logout mode`() = runTest { + val eventSyncStateFlow = MutableLiveData() + every { eventSyncManager.getLastSyncState(any()) } returns eventSyncStateFlow + every { any().isModuleSelectionAvailable() } returns false + val mockInitialEventSyncState = mockk(relaxed = true) { + every { isSyncCompleted() } returns false + } + eventSyncStateFlow.value = mockInitialEventSyncState + val mockFlow = MutableStateFlow(mockInitialEventSyncState) + every { eventSyncStateFlow.asFlow() } returns mockFlow + createViewModel() + viewModel.isPreLogoutUpSync = true + + viewModel.syncInfoLiveData.getOrAwaitValue() + + val mockCompletedEventSyncState = mockk(relaxed = true) { + every { isSyncCompleted() } returns true + } + mockFlow.value = mockCompletedEventSyncState + + coVerify { syncOrchestrator.startImageSync() } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `should apply sync completion hold delay after record sync completes`() = runTest { + val eventSyncStateFlow = MutableLiveData() + every { eventSyncManager.getLastSyncState(any()) } returns eventSyncStateFlow + createViewModel() + val mockInProgressState = mockk(relaxed = true) { + every { isSyncInProgress() } returns true + } + eventSyncStateFlow.value = mockInProgressState + + val firstResult = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(firstResult.syncInfoSectionRecords.isProgressVisible).isTrue() + + val mockCompletedState = mockk(relaxed = true) { + every { isSyncInProgress() } returns false + every { isSyncCompleted() } returns true + } + eventSyncStateFlow.value = mockCompletedState + advanceTimeBy(900L) // after completion but still under the holding delay + + val holdingResult = viewModel.syncInfoLiveData.getOrAwaitValue() + assertThat(holdingResult.syncInfoSectionRecords.isProgressVisible).isTrue() + + advanceTimeBy(200L) // total 1100 ms being after the holding delay of 1 s + + val completedResult = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(completedResult.syncInfoSectionRecords.isProgressVisible).isFalse() + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `should apply sync completion hold delay after image sync completes`() = runTest { + val imageSyncStatusFlow = MutableStateFlow(mockk { + every { isSyncing } returns true + every { progress } returns Pair(1, 2) + every { secondsSinceLastUpdate } returns null + }) + every { syncOrchestrator.observeImageSyncStatus() } returns imageSyncStatusFlow + createViewModel() + + val firstResult = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(firstResult.syncInfoSectionImages.isProgressVisible).isTrue() + + imageSyncStatusFlow.value = mockk { + every { isSyncing } returns false + every { progress } returns null + every { secondsSinceLastUpdate } returns 0L + } + advanceTimeBy(900L) // after completion but still under the holding delay + + val holdingResult = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(holdingResult.syncInfoSectionImages.isProgressVisible).isTrue() + + advanceTimeBy(200L) // total 1100 ms being after the holding delay of 1 s + + val completedResult = viewModel.syncInfoLiveData.getOrAwaitValue() + + assertThat(completedResult.syncInfoSectionImages.isProgressVisible).isFalse() + } + } From c39fe54daae9116639f2f906c204afa1de4e99eb Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 24 Jul 2025 12:22:56 +0100 Subject: [PATCH 13/45] MS-939 Test name correction for EventSyncManager new UI/UX related changes --- .../com/simprints/infra/eventsync/EventSyncManagerTest.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/EventSyncManagerTest.kt b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/EventSyncManagerTest.kt index 0eb287ec90..0531b254b1 100644 --- a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/EventSyncManagerTest.kt +++ b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/EventSyncManagerTest.kt @@ -171,7 +171,7 @@ internal class EventSyncManagerTest { } @Test - fun `countEventsToDownload uses cache when within max age`() = runTest { + fun `countEventsToDownload bypasses cache when within max age`() = runTest { every { timeHelper.now() } returnsMany listOf(Timestamp(1000), Timestamp(5000)) coEvery { eventDownSyncScopeRepository.getDownSyncScope(any(), any(), any()) @@ -182,13 +182,13 @@ internal class EventSyncManagerTest { } eventSyncManagerImpl.countEventsToDownload(2000) // remote fetch - eventSyncManagerImpl.countEventsToDownload(2000) // cache hit + eventSyncManagerImpl.countEventsToDownload(2000) // remote fetch coVerify(exactly = 2) { eventDownSyncScopeRepository.getDownSyncScope(any(), any(), any()) } } @Test - fun `countEventsToDownload bypasses cache when exceeds max age`() = runTest { + fun `countEventsToDownload uses cache when exceeds max age`() = runTest { every { timeHelper.now() } returnsMany listOf(Timestamp(1000), Timestamp(2000)) coEvery { eventDownSyncScopeRepository.getDownSyncScope(any(), any(), any()) @@ -198,7 +198,7 @@ internal class EventSyncManagerTest { } eventSyncManagerImpl.countEventsToDownload(2000) // remote fetch - eventSyncManagerImpl.countEventsToDownload(2000) // remote fetch + eventSyncManagerImpl.countEventsToDownload(2000) // cache hit coVerify(exactly = 1) { eventDownSyncScopeRepository.getDownSyncScope(any(), any(), any()) } } From dbabaeb58c9a01e91cec1a0dff519fb6e4fcb62f Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 30 Jul 2025 10:30:23 +0100 Subject: [PATCH 14/45] MS-939 EventSyncMasterWorkerTest completed --- .../sync/master/EventSyncMasterWorkerTest.kt | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/sync/master/EventSyncMasterWorkerTest.kt b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/sync/master/EventSyncMasterWorkerTest.kt index c90bb951be..8fdbc0b706 100644 --- a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/sync/master/EventSyncMasterWorkerTest.kt +++ b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/sync/master/EventSyncMasterWorkerTest.kt @@ -139,6 +139,7 @@ internal class EventSyncMasterWorkerTest { appContext = ctx, params = mockk(relaxed = true) { every { tags } returns setOf(MASTER_SYNC_SCHEDULER_PERIODIC_TIME) + every { inputData.getBoolean(EventSyncMasterWorker.IS_DOWN_SYNC_ALLOWED, true) } returns true }, downSyncWorkerBuilder = downSyncWorkerBuilder, upSyncWorkerBuilder = upSyncWorkerBuilder, @@ -304,6 +305,66 @@ internal class EventSyncMasterWorkerTest { verify(exactly = 0) { timeHelper.now() } } + @Test + fun `doWork should enqueue down sync worker when IS_DOWN_SYNC_ALLOWED is true and can down sync`() = runTest { + shouldSyncRun(false) + canDownSync(true) + canUpSync(true) + val uniqueSyncId = masterWorker.uniqueSyncId + + val result = masterWorker.doWork() + + assertThat(result).isEqualTo( + ListenableWorker.Result.success( + workDataOf( + EventSyncMasterWorker.OUTPUT_LAST_SYNC_ID to uniqueSyncId, + ), + ), + ) + coVerify(exactly = 1) { eventRepository.createEventScope(EventScopeType.UP_SYNC, any()) } + coVerify(exactly = 1) { eventRepository.createEventScope(EventScopeType.DOWN_SYNC, any()) } + coVerify(exactly = 1) { upSyncWorkerBuilder.buildUpSyncWorkerChain(uniqueSyncId, any()) } + coVerify(exactly = 1) { downSyncWorkerBuilder.buildDownSyncWorkerChain(uniqueSyncId, any()) } + } + + @Test + fun `doWork should not enqueue down sync worker when IS_DOWN_SYNC_ALLOWED is false`() = runTest { + shouldSyncRun(false) + canDownSync(true) + canUpSync(true) + val workerWithDownSyncDisabled = EventSyncMasterWorker( + appContext = ctx, + params = mockk(relaxed = true) { + every { tags } returns setOf(MASTER_SYNC_SCHEDULER_PERIODIC_TIME) + every { inputData.getBoolean(EventSyncMasterWorker.IS_DOWN_SYNC_ALLOWED, true) } returns false + }, + downSyncWorkerBuilder = downSyncWorkerBuilder, + upSyncWorkerBuilder = upSyncWorkerBuilder, + configManager = configManager, + eventSyncCache = eventSyncCache, + eventSyncSubMasterWorkersBuilder = eventSyncSubMasterWorkersBuilder, + timeHelper = timeHelper, + dispatcher = testCoroutineRule.testCoroutineDispatcher, + securityManager = securityManager, + eventRepository = eventRepository, + ) + val uniqueSyncId = workerWithDownSyncDisabled.uniqueSyncId + + val result = workerWithDownSyncDisabled.doWork() + + assertThat(result).isEqualTo( + ListenableWorker.Result.success( + workDataOf( + EventSyncMasterWorker.OUTPUT_LAST_SYNC_ID to uniqueSyncId, + ), + ), + ) + coVerify(exactly = 1) { eventRepository.createEventScope(EventScopeType.UP_SYNC, any()) } + coVerify(exactly = 0) { eventRepository.createEventScope(EventScopeType.DOWN_SYNC, any()) } + coVerify(exactly = 1) { upSyncWorkerBuilder.buildUpSyncWorkerChain(uniqueSyncId, any()) } + coVerify(exactly = 0) { downSyncWorkerBuilder.buildDownSyncWorkerChain(uniqueSyncId, any()) } + } + private suspend fun getIsEventDownSyncAllowedResult( projectState: ProjectState, syncConfig: Frequency, From 50e387e23a51ff195cb42d063fd686d0d29327bd Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 30 Jul 2025 13:20:16 +0100 Subject: [PATCH 15/45] MS-939 Logout/MainSyncFragmentTest completed --- .../dashboard/tools/di/FakeCoreModule.kt | 4 +-- .../dashboard/tools/di/FakeEventSyncModule.kt | 2 +- .../dashboard/tools/di/FakeLoginModule.kt | 2 +- .../dashboard/tools/di/FakeSecurityModule.kt | 26 +++++++++++++++++++ 4 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeSecurityModule.kt diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeCoreModule.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeCoreModule.kt index 10fde14e01..fa89d0b0b1 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeCoreModule.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeCoreModule.kt @@ -42,7 +42,7 @@ object FakeCoreModule { @Provides @Singleton - fun provideTimeHelper(): TimeHelper = mockk() + fun provideTimeHelper(): TimeHelper = mockk(relaxed = true) @Provides @Singleton @@ -95,5 +95,5 @@ object FakeCoreModule { @Provides fun provideWorkManager( @ApplicationContext context: Context, - ): WorkManager = WorkManager.getInstance(context) + ): WorkManager = mockk(relaxed = true) } diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeEventSyncModule.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeEventSyncModule.kt index a377737ae9..782c4f7380 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeEventSyncModule.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeEventSyncModule.kt @@ -17,5 +17,5 @@ import javax.inject.Singleton object FakeEventSyncModule { @Provides @Singleton - fun provideEventSyncManager(): EventSyncManager = mockk() + fun provideEventSyncManager(): EventSyncManager = mockk(relaxed = true) } diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeLoginModule.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeLoginModule.kt index ef2a5c215b..727808c1f4 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeLoginModule.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeLoginModule.kt @@ -17,5 +17,5 @@ import javax.inject.Singleton object FakeLoginModule { @Provides @Singleton - fun provideAuthStore(): AuthStore = mockk() + fun provideAuthStore(): AuthStore = mockk(relaxed = true) } diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeSecurityModule.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeSecurityModule.kt new file mode 100644 index 0000000000..4e5817e599 --- /dev/null +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeSecurityModule.kt @@ -0,0 +1,26 @@ +package com.simprints.feature.dashboard.tools.di + +import android.content.SharedPreferences +import com.simprints.infra.security.SecurityManager +import com.simprints.infra.security.SecurityModule +import dagger.Module +import dagger.Provides +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import io.mockk.every +import io.mockk.mockk +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [SecurityModule::class], +) +object FakeSecurityModule { + + @Provides + @Singleton + fun provideSecurityManager(): SecurityManager = mockk { + every { buildEncryptedSharedPreferences(any()) } returns mockk(relaxed = true) + } +} \ No newline at end of file From 14797da081a76a537684d293db923f7e637c7dee Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 11 Aug 2025 15:38:11 +0100 Subject: [PATCH 16/45] MS-939 Criteria fix for isModuleSelectionAvailable --- .../infra/config/store/models/ProjectConfiguration.kt | 2 +- .../infra/config/store/models/ProjectConfigurationTest.kt | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ProjectConfiguration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ProjectConfiguration.kt index 8978928cd1..2aa61638b5 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ProjectConfiguration.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ProjectConfiguration.kt @@ -83,7 +83,7 @@ fun ProjectConfiguration.isProjectWithPeriodicallyUpSync(): Boolean = synchronization.up.simprints.frequency == Frequency.ONLY_PERIODICALLY_UP_SYNC fun ProjectConfiguration.isModuleSelectionAvailable(): Boolean = - isProjectWithModuleSync() && isProjectWithPeriodicallyUpSync() + isProjectWithModuleSync() && !isProjectWithPeriodicallyUpSync() fun ProjectConfiguration.areModuleOptionsEmpty(): Boolean = synchronization.down.simprints.moduleOptions.isEmpty() diff --git a/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ProjectConfigurationTest.kt b/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ProjectConfigurationTest.kt index 7ce6a172ac..3db95bb601 100644 --- a/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ProjectConfigurationTest.kt +++ b/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ProjectConfigurationTest.kt @@ -522,7 +522,7 @@ class ProjectConfigurationTest { } @Test - fun `isModuleSelectionAvailable should return true when project has MODULE and ONLY_PERIODICALLY_UP_SYNC`() { + fun `isModuleSelectionAvailable should return true when project has MODULE and not ONLY_PERIODICALLY_UP_SYNC`() { val config = projectConfiguration.copy( synchronization = synchronizationConfiguration.copy( down = synchronizationConfiguration.down.copy( @@ -532,7 +532,7 @@ class ProjectConfigurationTest { ), up = synchronizationConfiguration.up.copy( simprints = simprintsUpSyncConfigurationConfiguration.copy( - frequency = Frequency.ONLY_PERIODICALLY_UP_SYNC, + frequency = Frequency.PERIODICALLY, ), ), ), @@ -560,7 +560,7 @@ class ProjectConfigurationTest { } @Test - fun `isModuleSelectionAvailable should return false when frequency is not ONLY_PERIODICALLY_UP_SYNC`() { + fun `isModuleSelectionAvailable should return false when frequency is ONLY_PERIODICALLY_UP_SYNC`() { val config = projectConfiguration.copy( synchronization = synchronizationConfiguration.copy( down = synchronizationConfiguration.down.copy( @@ -570,7 +570,7 @@ class ProjectConfigurationTest { ), up = synchronizationConfiguration.up.copy( simprints = simprintsUpSyncConfigurationConfiguration.copy( - frequency = Frequency.PERIODICALLY, + frequency = Frequency.ONLY_PERIODICALLY_UP_SYNC, ), ), ), From 5753b6bcd9eea45c8315af34685d2e37dea97b4b Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 11 Aug 2025 15:42:12 +0100 Subject: [PATCH 17/45] MS-939 Stream observer function naming unified to observe --- .../dashboard/logout/LogoutSyncViewModel.kt | 2 +- .../dashboard/settings/SettingsViewModel.kt | 2 +- .../settings/syncinfo/SyncInfoViewModel.kt | 10 ++-- .../logout/LogoutSyncViewModelTest.kt | 7 ++- .../settings/SettingsViewModelTest.kt | 2 +- .../syncinfo/SyncInfoViewModelTest.kt | 54 +++++++++---------- .../simprints/infra/authstore/AuthStore.kt | 2 +- .../infra/authstore/AuthStoreImpl.kt | 2 +- .../infra/authstore/domain/LoginInfoStore.kt | 2 +- .../infra/authstore/AuthStoreImplTest.kt | 24 ++++----- .../authstore/domain/LoginInfoStoreTest.kt | 16 +++--- .../infra/config/store/ConfigRepository.kt | 4 +- .../config/store/ConfigRepositoryImpl.kt | 4 +- .../store/local/ConfigLocalDataSource.kt | 4 +- .../store/local/ConfigLocalDataSourceImpl.kt | 4 +- .../config/store/ConfigRepositoryImplTest.kt | 14 ++--- .../local/ConfigLocalDataSourceImplTest.kt | 14 ++--- .../infra/config/sync/ConfigManager.kt | 16 +++--- .../infra/config/sync/ConfigManagerTest.kt | 34 ++++++------ .../core/tools/time/KronosTimeHelperImpl.kt | 2 +- .../simprints/core/tools/time/TimeHelper.kt | 2 +- .../tools/time/KronosTimeHelperImplTest.kt | 12 ++--- 22 files changed, 116 insertions(+), 117 deletions(-) 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 b1c0f5297d..c6af511e3e 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 @@ -32,7 +32,7 @@ internal class LogoutSyncViewModel @Inject constructor( ) : ViewModel() { val logoutEventLiveData: LiveData = - authStore.watchSignedInProjectId().filter { projectId -> + authStore.observeSignedInProjectId().filter { projectId -> projectId.isEmpty() }.distinctUntilChanged().map { /* Unit on every "true" */ }.asLiveData() diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/SettingsViewModel.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/SettingsViewModel.kt index 739c6aeb04..38ff55fbfd 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/SettingsViewModel.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/SettingsViewModel.kt @@ -32,7 +32,7 @@ internal class SettingsViewModel @Inject constructor( get() = _generalConfiguration private val _generalConfiguration = MutableLiveData() - val experimentalConfiguration = configManager.watchProjectConfiguration() + val experimentalConfiguration = configManager.observeProjectConfiguration() .map(ProjectConfiguration::experimental) .asLiveData(viewModelScope.coroutineContext) 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 396ed3e563..1033e06292 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 @@ -89,13 +89,13 @@ internal class SyncInfoViewModel @Inject constructor( val syncInfoLiveData: LiveData = combine8( connectivityTracker.observeIsConnected().asFlow(), - authStore.watchSignedInProjectId().map(String::isNotEmpty), - configManager.watchIfProjectRefreshing(), + authStore.observeSignedInProjectId().map(String::isNotEmpty), + configManager.observeIsProjectRefreshing(), eventSyncStateFlow, imageSyncStatusFlow, - configManager.watchProjectConfiguration(), - configManager.watchDeviceConfiguration(), - timeHelper.watchOncePerMinute(), + configManager.observeProjectConfiguration(), + configManager.observeDeviceConfiguration(), + timeHelper.observeTickOncePerMinute(), ) { isConnected, isLoggedIn, isRefreshing, eventSyncState, imageSyncStatus, projectConfig, deviceConfig, _ -> val currentEvents = eventSyncState.progress?.coerceAtLeast(0) ?: 0 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 6884ad5dac..0d72976b81 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 @@ -83,7 +83,7 @@ internal class LogoutSyncViewModelTest { @Test fun `logoutEventLiveData should emit momentarily when user is signed out`() { - every { authStore.watchSignedInProjectId() } returns MutableStateFlow("") + every { authStore.observeSignedInProjectId() } returns MutableStateFlow("") val viewModel = createViewModel() @@ -93,7 +93,7 @@ internal class LogoutSyncViewModelTest { @Test fun `logoutEventLiveData should not emit when user is signed in`() { - every { authStore.watchSignedInProjectId() } returns MutableStateFlow("userId123") + every { authStore.observeSignedInProjectId() } returns MutableStateFlow("userId123") val viewModel = createViewModel() @@ -196,8 +196,7 @@ internal class LogoutSyncViewModelTest { every { eventSyncLiveData.asFlow() } returns flowOf(eventSyncState) every { eventSyncManager.getLastSyncState(useDefaultValue = true) } returns eventSyncLiveData every { syncOrchestrator.observeImageSyncStatus() } returns flowOf(imageSyncStatus) - every { configManager.watchProjectConfiguration() } returns flowOf(projectConfig) - every { configManager.watchDeviceConfiguration() } returns flowOf(deviceConfig) + every { configManager.observeProjectConfiguration() } returns flowOf(projectConfig) } private fun createViewModel() = LogoutSyncViewModel( diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/SettingsViewModelTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/SettingsViewModelTest.kt index 52bb0d9982..785a944088 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/SettingsViewModelTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/SettingsViewModelTest.kt @@ -68,7 +68,7 @@ class SettingsViewModelTest { val experimentalConfig1 = mapOf("key1" to "value1") val experimentalConfig2 = mapOf("key2" to "value2") - coEvery { configManager.watchProjectConfiguration() } returns flowOf( + coEvery { configManager.observeProjectConfiguration() } returns flowOf( mockk(relaxed = true) { every { custom } returns experimentalConfig1 }, 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 50a40f85b2..a7b52b0b18 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 @@ -125,15 +125,15 @@ class SyncInfoViewModelTest { private fun setupDefaultMocks() { every { authStore.signedInProjectId } returns TEST_PROJECT_ID every { authStore.signedInUserId } returns TokenizableString.Raw(TEST_USER_ID) - every { authStore.watchSignedInProjectId() } returns MutableStateFlow(TEST_PROJECT_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.watchIfProjectRefreshing() } returns MutableStateFlow(false) - every { configManager.watchProjectConfiguration() } returns MutableStateFlow(mockProjectConfiguration) - every { configManager.watchDeviceConfiguration() } returns MutableStateFlow(mockDeviceConfiguration) + 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(any()) } returns mockProject @@ -155,7 +155,7 @@ class SyncInfoViewModelTest { coEvery { imageRepository.getNumberOfImagesToUpload(any()) } returns 0 coEvery { enrolmentRecordRepository.count(any()) } returns 0 - every { timeHelper.watchOncePerMinute() } returns MutableStateFlow(Unit) + every { timeHelper.observeTickOncePerMinute() } returns MutableStateFlow(Unit) every { timeHelper.now() } returns TEST_TIMESTAMP every { timeHelper.msBetweenNowAndTime(any()) } returns 0L @@ -360,7 +360,7 @@ class SyncInfoViewModelTest { @Test fun `should show configuration loading when project is refreshing`() = runTest { - every { configManager.watchIfProjectRefreshing() } returns MutableStateFlow(true) + every { configManager.observeIsProjectRefreshing() } returns MutableStateFlow(true) createViewModel() val result = viewModel.syncInfoLiveData.getOrAwaitValue() @@ -491,8 +491,8 @@ class SyncInfoViewModelTest { val mockDeviceConfigWithModules = mockk { every { selectedModules } returns listOf(TokenizableString.Raw(TEST_MODULE_NAME)) } - every { configManager.watchProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithModules) - every { configManager.watchDeviceConfiguration() } returns MutableStateFlow(mockDeviceConfigWithModules) + 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 @@ -722,8 +722,8 @@ class SyncInfoViewModelTest { val mockDeviceConfigWithModules = mockk { every { selectedModules } returns listOf(TokenizableString.Raw("module_1"), TokenizableString.Raw("module_2")) } - every { configManager.watchProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithModules) - every { configManager.watchDeviceConfiguration() } returns MutableStateFlow(mockDeviceConfigWithModules) + 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 @@ -757,8 +757,8 @@ class SyncInfoViewModelTest { val mockDeviceConfigWithoutModules = mockk { every { selectedModules } returns emptyList() } - every { configManager.watchProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithoutModules) - every { configManager.watchDeviceConfiguration() } returns MutableStateFlow(mockDeviceConfigWithoutModules) + every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithoutModules) + every { configManager.observeDeviceConfiguration() } returns MutableStateFlow(mockDeviceConfigWithoutModules) coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithoutModules coEvery { configManager.getDeviceConfiguration() } returns mockDeviceConfigWithoutModules every { mockProjectConfigWithoutModules.isModuleSelectionAvailable() } returns false @@ -782,7 +782,7 @@ class SyncInfoViewModelTest { } every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) - every { configManager.watchProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithDownSync) + every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithDownSync) coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithDownSync coEvery { eventSyncManager.countEventsToDownload(any()) } returns DownSyncCounts(42, isLowerBound = false) every { mockProjectConfigWithDownSync.isEventDownSyncAllowed() } returns true @@ -823,7 +823,7 @@ class SyncInfoViewModelTest { } every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) - every { configManager.watchProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithDownSync) + every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithDownSync) coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithDownSync coEvery { eventSyncManager.countEventsToDownload(any()) } throws Exception("Timeout") every { mockProjectConfigWithDownSync.isEventDownSyncAllowed() } returns true @@ -847,7 +847,7 @@ class SyncInfoViewModelTest { every { isSyncInProgress() } returns false } every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) - every { configManager.watchProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithDownSync) + every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithDownSync) coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithDownSync coEvery { eventSyncManager.countEventsToDownload(any()) } throws RuntimeException("Network error") every { mockProjectConfigWithDownSync.isEventDownSyncAllowed() } returns true @@ -903,7 +903,7 @@ class SyncInfoViewModelTest { @Test fun `should handle changes in auth stream`() = runTest { val authFlow = MutableStateFlow("") // started not signed in - every { authStore.watchSignedInProjectId() } returns authFlow + every { authStore.observeSignedInProjectId() } returns authFlow createViewModel() val loggedOutResult = viewModel.syncInfoLiveData.getOrAwaitValue() @@ -920,7 +920,7 @@ class SyncInfoViewModelTest { @Test fun `should handle changes in project refreshing stream`() = runTest { val refreshingFlow = MutableStateFlow(false) // started non refreshing - every { configManager.watchIfProjectRefreshing() } returns refreshingFlow + every { configManager.observeIsProjectRefreshing() } returns refreshingFlow createViewModel() val notRefreshingResult = viewModel.syncInfoLiveData.getOrAwaitValue() @@ -988,7 +988,7 @@ class SyncInfoViewModelTest { @Test fun `should handle changes in project config stream`() = runTest { val projectConfigFlow = MutableStateFlow(mockProjectConfiguration) - every { configManager.watchProjectConfiguration() } returns projectConfigFlow // started without modules + every { configManager.observeProjectConfiguration() } returns projectConfigFlow // started without modules createViewModel() val initialResult = viewModel.syncInfoLiveData.getOrAwaitValue() @@ -1010,7 +1010,7 @@ class SyncInfoViewModelTest { @Test fun `should handle changes in device config stream`() = runTest { - every { configManager.watchProjectConfiguration() } returns flowOf( + every { configManager.observeProjectConfiguration() } returns flowOf( mockk { every { general } returns mockk { every { modalities } returns emptyList() @@ -1022,7 +1022,7 @@ class SyncInfoViewModelTest { every { selectedModules } returns emptyList() } ) // started without selected modules - every { configManager.watchDeviceConfiguration() } returns deviceConfigFlow + every { configManager.observeDeviceConfiguration() } returns deviceConfigFlow createViewModel() val noModulesResult = viewModel.syncInfoLiveData.getOrAwaitValue() @@ -1050,7 +1050,7 @@ class SyncInfoViewModelTest { every { timeHelper.now() } returnsMany listOf(TEST_TIMESTAMP, Timestamp(TEST_TIMESTAMP.ms + 60_000)) // MutableStateFlow of Unit won't emit another (identical) Unit, so we'll count minutes and map to Units val timePaceFlow = MutableStateFlow(0) - every { timeHelper.watchOncePerMinute() } returns timePaceFlow.map { Unit } + every { timeHelper.observeTickOncePerMinute() } returns timePaceFlow.map { Unit } createViewModel() val initialResult = viewModel.syncInfoLiveData.getOrAwaitValue() @@ -1165,8 +1165,8 @@ class SyncInfoViewModelTest { every { isSyncFailed() } returns false every { isSyncInProgress() } returns false } - every { configManager.watchProjectConfiguration() } returns MutableStateFlow(mockProjectConfigRequiringModules) - every { configManager.watchDeviceConfiguration() } returns MutableStateFlow(mockEmptyDeviceConfig) + every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigRequiringModules) + every { configManager.observeDeviceConfiguration() } returns MutableStateFlow(mockEmptyDeviceConfig) coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigRequiringModules coEvery { configManager.getDeviceConfiguration() } returns mockEmptyDeviceConfig every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) @@ -1234,8 +1234,8 @@ class SyncInfoViewModelTest { val mockDeviceConfigWithTokenizedModules = mockk { every { selectedModules } returns listOf(tokenizedModule) } - every { configManager.watchProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithModules) - every { configManager.watchDeviceConfiguration() } returns MutableStateFlow(mockDeviceConfigWithTokenizedModules) + 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 @@ -1264,8 +1264,8 @@ class SyncInfoViewModelTest { val mockDeviceConfigWithRawModules = mockk { every { selectedModules } returns listOf(rawModule) } - every { configManager.watchProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithModules) - every { configManager.watchDeviceConfiguration() } returns MutableStateFlow(mockDeviceConfigWithRawModules) + 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 diff --git a/infra/auth-store/src/main/java/com/simprints/infra/authstore/AuthStore.kt b/infra/auth-store/src/main/java/com/simprints/infra/authstore/AuthStore.kt index 48c94da859..85b12c5207 100644 --- a/infra/auth-store/src/main/java/com/simprints/infra/authstore/AuthStore.kt +++ b/infra/auth-store/src/main/java/com/simprints/infra/authstore/AuthStore.kt @@ -14,7 +14,7 @@ interface AuthStore { fun isProjectIdSignedIn(possibleProjectId: String): Boolean - fun watchSignedInProjectId(): StateFlow + fun observeSignedInProjectId(): StateFlow fun cleanCredentials() diff --git a/infra/auth-store/src/main/java/com/simprints/infra/authstore/AuthStoreImpl.kt b/infra/auth-store/src/main/java/com/simprints/infra/authstore/AuthStoreImpl.kt index d8a766b510..4efa677f03 100644 --- a/infra/auth-store/src/main/java/com/simprints/infra/authstore/AuthStoreImpl.kt +++ b/infra/auth-store/src/main/java/com/simprints/infra/authstore/AuthStoreImpl.kt @@ -29,7 +29,7 @@ internal class AuthStoreImpl @Inject constructor( loginInfoStore.signedInProjectId = value } - override fun watchSignedInProjectId(): StateFlow = loginInfoStore.watchSignedInProjectId() + override fun observeSignedInProjectId(): StateFlow = loginInfoStore.observeSignedInProjectId() override fun isProjectIdSignedIn(possibleProjectId: String): Boolean = loginInfoStore.isProjectIdSignedIn(possibleProjectId) diff --git a/infra/auth-store/src/main/java/com/simprints/infra/authstore/domain/LoginInfoStore.kt b/infra/auth-store/src/main/java/com/simprints/infra/authstore/domain/LoginInfoStore.kt index da39a6b93b..9c48a6eba3 100644 --- a/infra/auth-store/src/main/java/com/simprints/infra/authstore/domain/LoginInfoStore.kt +++ b/infra/auth-store/src/main/java/com/simprints/infra/authstore/domain/LoginInfoStore.kt @@ -90,7 +90,7 @@ internal class LoginInfoStore @Inject constructor( signedInProjectIdFlow.tryEmit(value) } - fun watchSignedInProjectId(): StateFlow = signedInProjectIdFlow.asStateFlow() + fun observeSignedInProjectId(): StateFlow = signedInProjectIdFlow.asStateFlow() // Core Firebase Project details. We store them to initialize the core Firebase project. var coreFirebaseProjectId: String = "" diff --git a/infra/auth-store/src/test/java/com/simprints/infra/authstore/AuthStoreImplTest.kt b/infra/auth-store/src/test/java/com/simprints/infra/authstore/AuthStoreImplTest.kt index ef4229e2f1..f15e01c631 100644 --- a/infra/auth-store/src/test/java/com/simprints/infra/authstore/AuthStoreImplTest.kt +++ b/infra/auth-store/src/test/java/com/simprints/infra/authstore/AuthStoreImplTest.kt @@ -132,38 +132,38 @@ class AuthStoreImplTest { } @Test - fun `watchSignedInProjectId should return flow with initial project id value`() = runTest { + fun `observeSignedInProjectId should return flow with initial project id value`() = runTest { val expectedFlow = MutableStateFlow("initial-project-id") - every { loginInfoStore.watchSignedInProjectId() } returns expectedFlow + every { loginInfoStore.observeSignedInProjectId() } returns expectedFlow - val flow = loginManagerManagerImpl.watchSignedInProjectId() + val flow = loginManagerManagerImpl.observeSignedInProjectId() val initialValue = flow.first() assertThat(initialValue).isEqualTo("initial-project-id") - verify(exactly = 1) { loginInfoStore.watchSignedInProjectId() } + verify(exactly = 1) { loginInfoStore.observeSignedInProjectId() } } @Test - fun `watchSignedInProjectId should return flow with empty string when project id is empty`() = runTest { + fun `observeSignedInProjectId should return flow with empty string when project id is empty`() = runTest { val expectedFlow = MutableStateFlow("") - every { loginInfoStore.watchSignedInProjectId() } returns expectedFlow + every { loginInfoStore.observeSignedInProjectId() } returns expectedFlow - val flow = loginManagerManagerImpl.watchSignedInProjectId() + val flow = loginManagerManagerImpl.observeSignedInProjectId() val initialValue = flow.first() assertThat(initialValue).isEqualTo("") - verify(exactly = 1) { loginInfoStore.watchSignedInProjectId() } + verify(exactly = 1) { loginInfoStore.observeSignedInProjectId() } } @Test - fun `watchSignedInProjectId should listen to the logged in project id values`() = runTest { + fun `observeSignedInProjectId should listen to the logged in project id values`() = runTest { val expectedValues = MutableStateFlow("project1").apply { emit("project2") } - every { loginInfoStore.watchSignedInProjectId() } returns expectedValues + every { loginInfoStore.observeSignedInProjectId() } returns expectedValues - val receivedFlow = loginManagerManagerImpl.watchSignedInProjectId() + val receivedFlow = loginManagerManagerImpl.observeSignedInProjectId() assertThat(receivedFlow).isEqualTo(expectedValues) - verify(exactly = 1) { loginInfoStore.watchSignedInProjectId() } + verify(exactly = 1) { loginInfoStore.observeSignedInProjectId() } } companion object { diff --git a/infra/auth-store/src/test/java/com/simprints/infra/authstore/domain/LoginInfoStoreTest.kt b/infra/auth-store/src/test/java/com/simprints/infra/authstore/domain/LoginInfoStoreTest.kt index f88c5e4ba2..c6832a3cf0 100644 --- a/infra/auth-store/src/test/java/com/simprints/infra/authstore/domain/LoginInfoStoreTest.kt +++ b/infra/auth-store/src/test/java/com/simprints/infra/authstore/domain/LoginInfoStoreTest.kt @@ -274,28 +274,28 @@ class LoginInfoStoreTest { } @Test - fun `watchSignedInProjectId should return flow with initial project id value`() = runTest { + fun `observeSignedInProjectId should return flow with initial project id value`() = runTest { loginInfoStoreImpl.signedInProjectId = "initial-project-id" - val flow = loginInfoStoreImpl.watchSignedInProjectId() + val flow = loginInfoStoreImpl.observeSignedInProjectId() val initialValue = flow.first() assertThat(initialValue).isEqualTo("initial-project-id") } @Test - fun `watchSignedInProjectId should return flow with empty string when project id is empty`() = runTest { + fun `observeSignedInProjectId should return flow with empty string when project id is empty`() = runTest { loginInfoStoreImpl.signedInProjectId = "" - val flow = loginInfoStoreImpl.watchSignedInProjectId() + val flow = loginInfoStoreImpl.observeSignedInProjectId() val initialValue = flow.first() assertThat(initialValue).isEqualTo("") } @Test - fun `watchSignedInProjectId should emit new values when signedInProjectId is updated`() = runTest { - val flow = loginInfoStoreImpl.watchSignedInProjectId() + fun `observeSignedInProjectId should emit new values when signedInProjectId is updated`() = runTest { + val flow = loginInfoStoreImpl.observeSignedInProjectId() loginInfoStoreImpl.signedInProjectId = "initial-project-id" val initialValue = flow.first() @@ -308,9 +308,9 @@ class LoginInfoStoreTest { } @Test - fun `watchSignedInProjectId should emit empty string when credentials are cleared`() = runTest { + fun `observeSignedInProjectId should emit empty string when credentials are cleared`() = runTest { loginInfoStoreImpl.signedInProjectId = "project-id" - val flow = loginInfoStoreImpl.watchSignedInProjectId() + val flow = loginInfoStoreImpl.observeSignedInProjectId() val initialValue = flow.first() assertThat(initialValue).isEqualTo("project-id") diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigRepository.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigRepository.kt index 190f284150..e8f633a25e 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigRepository.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigRepository.kt @@ -15,13 +15,13 @@ interface ConfigRepository { suspend fun getProjectConfiguration(): ProjectConfiguration - fun watchProjectConfiguration(): Flow + fun observeProjectConfiguration(): Flow suspend fun getDeviceState(): DeviceState suspend fun getDeviceConfiguration(): DeviceConfiguration - fun watchDeviceConfiguration(): Flow + fun observeDeviceConfiguration(): Flow suspend fun updateDeviceConfiguration(update: suspend (t: DeviceConfiguration) -> DeviceConfiguration) diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigRepositoryImpl.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigRepositoryImpl.kt index ff3564ca1b..855d9807e2 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigRepositoryImpl.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/ConfigRepositoryImpl.kt @@ -58,7 +58,7 @@ internal class ConfigRepositoryImpl @Inject constructor( return tokenizeModules(config) } - override fun watchProjectConfiguration(): Flow = localDataSource.watchProjectConfiguration().map { config -> + override fun observeProjectConfiguration(): Flow = localDataSource.observeProjectConfiguration().map { config -> tokenizeModules(config) } @@ -71,7 +71,7 @@ internal class ConfigRepositoryImpl @Inject constructor( override suspend fun getDeviceConfiguration(): DeviceConfiguration = localDataSource.getDeviceConfiguration() - override fun watchDeviceConfiguration(): Flow = localDataSource.watchDeviceConfiguration() + override fun observeDeviceConfiguration(): Flow = localDataSource.observeDeviceConfiguration() override suspend fun updateDeviceConfiguration(update: suspend (t: DeviceConfiguration) -> DeviceConfiguration) = localDataSource.updateDeviceConfiguration(update) diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/ConfigLocalDataSource.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/ConfigLocalDataSource.kt index bd3facab32..9e8286e648 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/ConfigLocalDataSource.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/ConfigLocalDataSource.kt @@ -16,13 +16,13 @@ internal interface ConfigLocalDataSource { suspend fun getProjectConfiguration(): ProjectConfiguration - fun watchProjectConfiguration(): Flow + fun observeProjectConfiguration(): Flow suspend fun clearProjectConfiguration() suspend fun getDeviceConfiguration(): DeviceConfiguration - fun watchDeviceConfiguration(): Flow + fun observeDeviceConfiguration(): Flow suspend fun updateDeviceConfiguration(update: suspend (t: DeviceConfiguration) -> DeviceConfiguration) diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/ConfigLocalDataSourceImpl.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/ConfigLocalDataSourceImpl.kt index 17c785c90c..2e16cf9164 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/local/ConfigLocalDataSourceImpl.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/local/ConfigLocalDataSourceImpl.kt @@ -75,7 +75,7 @@ internal class ConfigLocalDataSourceImpl @Inject constructor( override suspend fun getProjectConfiguration(): ProjectConfiguration = configDataStore.data.first().toDomain() - override fun watchProjectConfiguration(): Flow = + override fun observeProjectConfiguration(): Flow = configDataStore.data.map(ProtoProjectConfiguration::toDomain) override suspend fun clearProjectConfiguration() { @@ -87,7 +87,7 @@ internal class ConfigLocalDataSourceImpl @Inject constructor( selectedModules = selectedModules.mapToTokenizedModuleIds() } - override fun watchDeviceConfiguration(): Flow = + override fun observeDeviceConfiguration(): Flow = deviceConfigDataStore.data.map(ProtoDeviceConfiguration::toDomain).map { config -> config.apply { selectedModules = selectedModules.mapToTokenizedModuleIds() diff --git a/infra/config-store/src/test/java/com/simprints/infra/config/store/ConfigRepositoryImplTest.kt b/infra/config-store/src/test/java/com/simprints/infra/config/store/ConfigRepositoryImplTest.kt index db6edbc314..608ecff2f5 100644 --- a/infra/config-store/src/test/java/com/simprints/infra/config/store/ConfigRepositoryImplTest.kt +++ b/infra/config-store/src/test/java/com/simprints/infra/config/store/ConfigRepositoryImplTest.kt @@ -249,16 +249,16 @@ class ConfigRepositoryImplTest { } @Test - fun `watchProjectConfiguration should emit values from the local data source`() = runTest { + fun `observeProjectConfiguration should emit values from the local data source`() = runTest { val config1 = projectConfiguration.copy(projectId = "project1") val config2 = projectConfiguration.copy(projectId = "project2") - coEvery { localDataSource.watchProjectConfiguration() } returns flow { + coEvery { localDataSource.observeProjectConfiguration() } returns flow { emit(config1) emit(config2) } - val emittedConfigs = configServiceImpl.watchProjectConfiguration().toList() + val emittedConfigs = configServiceImpl.observeProjectConfiguration().toList() assertThat(emittedConfigs).hasSize(2) assertThat(emittedConfigs[0]).isEqualTo(config1) @@ -316,7 +316,7 @@ class ConfigRepositoryImplTest { } @Test - fun `watchDeviceConfiguration should track values from the local data source`() = runTest { + fun `observeDeviceConfiguration should track values from the local data source`() = runTest { val config1 = deviceConfiguration.copy(selectedModules = emptyList()) val config2 = deviceConfiguration.copy( selectedModules = listOf( @@ -325,16 +325,16 @@ class ConfigRepositoryImplTest { ) ) - coEvery { localDataSource.watchDeviceConfiguration() } returns flow { + coEvery { localDataSource.observeDeviceConfiguration() } returns flow { emit(config1) emit(config2) } - val emittedConfigs = configServiceImpl.watchDeviceConfiguration().toList() + val emittedConfigs = configServiceImpl.observeDeviceConfiguration().toList() assertThat(emittedConfigs).hasSize(2) assertThat(emittedConfigs[0]).isEqualTo(config1) assertThat(emittedConfigs[1]).isEqualTo(config2) - coVerify(exactly = 1) { localDataSource.watchDeviceConfiguration() } + coVerify(exactly = 1) { localDataSource.observeDeviceConfiguration() } } } diff --git a/infra/config-store/src/test/java/com/simprints/infra/config/store/local/ConfigLocalDataSourceImplTest.kt b/infra/config-store/src/test/java/com/simprints/infra/config/store/local/ConfigLocalDataSourceImplTest.kt index c6d72dd9eb..f4e57f2324 100644 --- a/infra/config-store/src/test/java/com/simprints/infra/config/store/local/ConfigLocalDataSourceImplTest.kt +++ b/infra/config-store/src/test/java/com/simprints/infra/config/store/local/ConfigLocalDataSourceImplTest.kt @@ -300,7 +300,7 @@ class ConfigLocalDataSourceImplTest { } @Test - fun `watchProjectConfiguration should emit updated values when configuration changes`() = runTest { + fun `observeProjectConfiguration should emit updated values when configuration changes`() = runTest { val config1 = projectConfiguration.copy(projectId = "project1") val config2 = projectConfiguration.copy(projectId = "project2") val config3 = projectConfiguration.copy(projectId = "project3") @@ -311,7 +311,7 @@ class ConfigLocalDataSourceImplTest { configLocalDataSourceImpl.saveProjectConfiguration(config2) // will replay when collection starts below val job = launch { - configLocalDataSourceImpl.watchProjectConfiguration().collect { emittedConfigs.add(it) } + configLocalDataSourceImpl.observeProjectConfiguration().collect { emittedConfigs.add(it) } } configLocalDataSourceImpl.saveProjectConfiguration(config3) @@ -325,7 +325,7 @@ class ConfigLocalDataSourceImplTest { } @Test - fun `watchDeviceConfiguration should emit updated values when configuration changes`() = runTest { + fun `observeDeviceConfiguration should emit updated values when configuration changes`() = runTest { configLocalDataSourceImpl.saveProject(project) val config1 = DeviceConfiguration("en", listOf(), "instruction1") @@ -333,20 +333,20 @@ class ConfigLocalDataSourceImplTest { configLocalDataSourceImpl.updateDeviceConfiguration { config1 } - val result1 = configLocalDataSourceImpl.watchDeviceConfiguration().first() + val result1 = configLocalDataSourceImpl.observeDeviceConfiguration().first() assertThat(result1).isEqualTo(config1) configLocalDataSourceImpl.updateDeviceConfiguration { config2 } - val result2 = configLocalDataSourceImpl.watchDeviceConfiguration().first() + val result2 = configLocalDataSourceImpl.observeDeviceConfiguration().first() assertThat(result2).isEqualTo(config2) } @Test - fun `watchDeviceConfiguration should emit default configuration initially`() = runTest { - val result = configLocalDataSourceImpl.watchDeviceConfiguration().first() + fun `observeDeviceConfiguration should emit default configuration initially`() = runTest { + val result = configLocalDataSourceImpl.observeDeviceConfiguration().first() assertThat(result).isEqualTo(ConfigLocalDataSourceImpl.defaultDeviceConfiguration.toDomain()) } diff --git a/infra/config-sync/src/main/java/com/simprints/infra/config/sync/ConfigManager.kt b/infra/config-sync/src/main/java/com/simprints/infra/config/sync/ConfigManager.kt index e8f2d307dd..ed3c37befd 100644 --- a/infra/config-sync/src/main/java/com/simprints/infra/config/sync/ConfigManager.kt +++ b/infra/config-sync/src/main/java/com/simprints/infra/config/sync/ConfigManager.kt @@ -21,10 +21,10 @@ class ConfigManager @Inject constructor( private val configSyncCache: ConfigSyncCache, private val realmToRoomMigrationScheduler: RealmToRoomMigrationScheduler, ) { - private val ifProjectRefreshingFlow: MutableStateFlow = MutableStateFlow(false) + private val isProjectRefreshingFlow: MutableStateFlow = MutableStateFlow(false) suspend fun refreshProject(projectId: String): ProjectWithConfig { - ifProjectRefreshingFlow.tryEmit(true) + isProjectRefreshingFlow.tryEmit(true) try { return configRepository.refreshProject(projectId).also { enrolmentRecordRepository.tokenizeExistingRecords(it.project) @@ -32,7 +32,7 @@ class ConfigManager @Inject constructor( realmToRoomMigrationScheduler.scheduleMigrationWorkerIfNeeded() } } finally { - ifProjectRefreshingFlow.tryEmit(false) + isProjectRefreshingFlow.tryEmit(false) } } @@ -60,16 +60,16 @@ class ConfigManager @Inject constructor( } } - fun watchIfProjectRefreshing(): Flow = ifProjectRefreshingFlow.asStateFlow() + fun observeIsProjectRefreshing(): Flow = isProjectRefreshingFlow.asStateFlow() - fun watchProjectConfiguration(): Flow = configRepository - .watchProjectConfiguration() + fun observeProjectConfiguration(): Flow = configRepository + .observeProjectConfiguration() .onStart { getProjectConfiguration() } // to invoke download if empty suspend fun getDeviceConfiguration(): DeviceConfiguration = configRepository.getDeviceConfiguration() - fun watchDeviceConfiguration(): Flow = configRepository - .watchDeviceConfiguration() + fun observeDeviceConfiguration(): Flow = configRepository + .observeDeviceConfiguration() .onStart { getDeviceConfiguration() } suspend fun updateDeviceConfiguration(update: suspend (t: DeviceConfiguration) -> DeviceConfiguration) = diff --git a/infra/config-sync/src/test/java/com/simprints/infra/config/sync/ConfigManagerTest.kt b/infra/config-sync/src/test/java/com/simprints/infra/config/sync/ConfigManagerTest.kt index 8836acc6c9..5173116b30 100644 --- a/infra/config-sync/src/test/java/com/simprints/infra/config/sync/ConfigManagerTest.kt +++ b/infra/config-sync/src/test/java/com/simprints/infra/config/sync/ConfigManagerTest.kt @@ -149,16 +149,16 @@ class ConfigManagerTest { } @Test - fun `watchProjectConfiguration should emit values from the local data source`() = runTest { + fun `observeProjectConfiguration should emit values from the local data source`() = runTest { val config1 = projectConfiguration.copy(projectId = "project1") val config2 = projectConfiguration.copy(projectId = "project2") - coEvery { configRepository.watchProjectConfiguration() } returns flow { + coEvery { configRepository.observeProjectConfiguration() } returns flow { emit(config1) emit(config2) } - val emittedConfigs = configManager.watchProjectConfiguration().toList() + val emittedConfigs = configManager.observeProjectConfiguration().toList() assertThat(emittedConfigs).hasSize(2) assertThat(emittedConfigs[0]).isEqualTo(config1) @@ -166,12 +166,12 @@ class ConfigManagerTest { } @Test - fun `watchProjectConfiguration should call getProjectConfiguration on start to invoke download if config empty`() = runTest { - coEvery { configRepository.watchProjectConfiguration() } returns flow { + fun `observeProjectConfiguration should call getProjectConfiguration on start to invoke download if config empty`() = runTest { + coEvery { configRepository.observeProjectConfiguration() } returns flow { emit(projectConfiguration) } - val emittedConfigs = configManager.watchProjectConfiguration().toList() + val emittedConfigs = configManager.observeProjectConfiguration().toList() coVerify(exactly = 1) { configRepository.getProjectConfiguration() } @@ -180,50 +180,50 @@ class ConfigManagerTest { } @Test - fun `watchIfProjectRefreshing should initially emit false`() = runTest { - val isRefreshing = configManager.watchIfProjectRefreshing().first() + fun `observeIsProjectRefreshing should initially emit false`() = runTest { + val isRefreshing = configManager.observeIsProjectRefreshing().first() assertThat(isRefreshing).isFalse() } @Test - fun `watchIfProjectRefreshing should emit false after refreshProject completes`() = runTest { + fun `observeIsProjectRefreshing should emit false after refreshProject completes`() = runTest { coEvery { configRepository.refreshProject(PROJECT_ID) } returns projectWithConfig configManager.refreshProject(PROJECT_ID) - val isRefreshing = configManager.watchIfProjectRefreshing().first() + val isRefreshing = configManager.observeIsProjectRefreshing().first() assertThat(isRefreshing).isFalse() } @OptIn(ExperimentalCoroutinesApi::class) @Test - fun `watchIfProjectRefreshing should emit true during refreshProject and false when done`() = runTest { + fun `observeIsProjectRefreshing should emit true during refreshProject and false when done`() = runTest { coEvery { configRepository.refreshProject(PROJECT_ID) } coAnswers { delay(1000) projectWithConfig } - assertThat(configManager.watchIfProjectRefreshing().first()).isFalse() // before + assertThat(configManager.observeIsProjectRefreshing().first()).isFalse() // before launch { configManager.refreshProject(PROJECT_ID) } advanceTimeBy(500) - assertThat(configManager.watchIfProjectRefreshing().first()).isTrue() // during + assertThat(configManager.observeIsProjectRefreshing().first()).isTrue() // during advanceTimeBy(1000) - assertThat(configManager.watchIfProjectRefreshing().first()).isFalse() // after + assertThat(configManager.observeIsProjectRefreshing().first()).isFalse() // after } @OptIn(ExperimentalCoroutinesApi::class) @Test - fun `watchIfProjectRefreshing should emit false even when refreshProject fails`() = runTest { + fun `observeIsProjectRefreshing should emit false even when refreshProject fails`() = runTest { coEvery { configRepository.refreshProject(PROJECT_ID) } coAnswers { delay(500) throw Exception("Test exception") } - assertThat(configManager.watchIfProjectRefreshing().first()).isFalse() // before + assertThat(configManager.observeIsProjectRefreshing().first()).isFalse() // before launch { try { @@ -234,6 +234,6 @@ class ConfigManagerTest { } advanceTimeBy(1000) - assertThat(configManager.watchIfProjectRefreshing().first()).isFalse() // after failure + assertThat(configManager.observeIsProjectRefreshing().first()).isFalse() // after failure } } diff --git a/infra/core/src/main/java/com/simprints/core/tools/time/KronosTimeHelperImpl.kt b/infra/core/src/main/java/com/simprints/core/tools/time/KronosTimeHelperImpl.kt index 4daf6faceb..1f6ef384a4 100644 --- a/infra/core/src/main/java/com/simprints/core/tools/time/KronosTimeHelperImpl.kt +++ b/infra/core/src/main/java/com/simprints/core/tools/time/KronosTimeHelperImpl.kt @@ -63,7 +63,7 @@ class KronosTimeHelperImpl @Inject constructor( timeInMillis } - override fun watchOncePerMinute(): Flow = flow { + override fun observeTickOncePerMinute(): Flow = flow { while (true) { emit(Unit) delay(ONE_MINUTE_IN_MILLIS) diff --git a/infra/core/src/main/java/com/simprints/core/tools/time/TimeHelper.kt b/infra/core/src/main/java/com/simprints/core/tools/time/TimeHelper.kt index bd4d6014e1..6f12878965 100644 --- a/infra/core/src/main/java/com/simprints/core/tools/time/TimeHelper.kt +++ b/infra/core/src/main/java/com/simprints/core/tools/time/TimeHelper.kt @@ -19,5 +19,5 @@ interface TimeHelper { fun tomorrowInMillis(): Long - fun watchOncePerMinute(): Flow + fun observeTickOncePerMinute(): Flow } diff --git a/infra/core/src/test/java/com/simprints/core/tools/time/KronosTimeHelperImplTest.kt b/infra/core/src/test/java/com/simprints/core/tools/time/KronosTimeHelperImplTest.kt index faa6afba16..eacd578b13 100644 --- a/infra/core/src/test/java/com/simprints/core/tools/time/KronosTimeHelperImplTest.kt +++ b/infra/core/src/test/java/com/simprints/core/tools/time/KronosTimeHelperImplTest.kt @@ -93,8 +93,8 @@ class KronosTimeHelperImplTest { } @Test - fun testWatchOncePerMinute_emitsImmediately() = runTest { - val result = timeHelperImpl.watchOncePerMinute() + fun testObserveTickOncePerMinute_emitsImmediately() = runTest { + val result = timeHelperImpl.observeTickOncePerMinute() .take(1) .toList() @@ -103,8 +103,8 @@ class KronosTimeHelperImplTest { } @Test - fun testWatchOncePerMinute_emitsMultipleTimes() = runTest { - val result = timeHelperImpl.watchOncePerMinute() + fun testObserveTickOncePerMinute_emitsMultipleTimes() = runTest { + val result = timeHelperImpl.observeTickOncePerMinute() .take(3) .toList() @@ -113,8 +113,8 @@ class KronosTimeHelperImplTest { @OptIn(ExperimentalCoroutinesApi::class) @Test - fun testWatchOncePerMinute_waitsForCorrectTime() = runTest { - val flow = timeHelperImpl.watchOncePerMinute() + fun testObserveTickOncePerMinute_waitsForCorrectTime() = runTest { + val flow = timeHelperImpl.observeTickOncePerMinute() // 1st tick immediately assertThat(flow.first()).isEqualTo(Unit) From c10299e538d84605065432a7ced9abefd8a6138e Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 11 Aug 2025 15:46:11 +0100 Subject: [PATCH 18/45] MS-939 countEventsToDownload cache timestamping fix --- .../com/simprints/infra/eventsync/EventSyncManagerImpl.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManagerImpl.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManagerImpl.kt index 73bd0723c2..973cb9a96c 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManagerImpl.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManagerImpl.kt @@ -86,9 +86,9 @@ internal class EventSyncManagerImpl @Inject constructor( timeNowMs - cachedEventCountToDownloadTimestamp < maxCacheAgeMillis }?.let { return it - }.also { - cachedEventCountToDownloadTimestamp = timeNowMs } + cachedEventCountToDownloadTimestamp = timeNowMs + val projectConfig = configRepository.getProjectConfiguration() val deviceConfig = configRepository.getDeviceConfiguration() From acecdd30a1ee9755c8901e57ab9686a53815b33f Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 11 Aug 2025 15:46:34 +0100 Subject: [PATCH 19/45] MS-939 countEventsToDownload swapped tests fix --- .../infra/eventsync/EventSyncManagerTest.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/EventSyncManagerTest.kt b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/EventSyncManagerTest.kt index 0531b254b1..c00b474fc1 100644 --- a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/EventSyncManagerTest.kt +++ b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/EventSyncManagerTest.kt @@ -171,8 +171,8 @@ internal class EventSyncManagerTest { } @Test - fun `countEventsToDownload bypasses cache when within max age`() = runTest { - every { timeHelper.now() } returnsMany listOf(Timestamp(1000), Timestamp(5000)) + fun `countEventsToDownload bypasses cache when exceeds max age`() = runTest { + every { timeHelper.now() } returnsMany listOf(Timestamp(1000), Timestamp(5000/* 4 seconds later */)) coEvery { eventDownSyncScopeRepository.getDownSyncScope(any(), any(), any()) } returns SampleSyncScopes.modulesDownSyncScope @@ -182,14 +182,14 @@ internal class EventSyncManagerTest { } eventSyncManagerImpl.countEventsToDownload(2000) // remote fetch - eventSyncManagerImpl.countEventsToDownload(2000) // remote fetch + eventSyncManagerImpl.countEventsToDownload(2000) // remote fetch 4 seconds later coVerify(exactly = 2) { eventDownSyncScopeRepository.getDownSyncScope(any(), any(), any()) } } @Test - fun `countEventsToDownload uses cache when exceeds max age`() = runTest { - every { timeHelper.now() } returnsMany listOf(Timestamp(1000), Timestamp(2000)) + fun `countEventsToDownload uses cache when within max age`() = runTest { + every { timeHelper.now() } returnsMany listOf(Timestamp(1000), Timestamp(2000/* 1 second later */)) coEvery { eventDownSyncScopeRepository.getDownSyncScope(any(), any(), any()) } returns SampleSyncScopes.modulesDownSyncScope @@ -198,7 +198,7 @@ internal class EventSyncManagerTest { } eventSyncManagerImpl.countEventsToDownload(2000) // remote fetch - eventSyncManagerImpl.countEventsToDownload(2000) // cache hit + eventSyncManagerImpl.countEventsToDownload(2000) // cache hit 1 second later coVerify(exactly = 1) { eventDownSyncScopeRepository.getDownSyncScope(any(), any(), any()) } } From de97425aeb02ada7e9fbf0151090d4446ad2d3a7 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 11 Aug 2025 15:47:23 +0100 Subject: [PATCH 20/45] MS-939 Layout: panel gap reduced; code cleanup --- .../dashboard/src/main/res/layout/fragment_sync_info.xml | 8 ++++---- .../dashboard/src/main/res/layout/item_module_count.xml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/feature/dashboard/src/main/res/layout/fragment_sync_info.xml b/feature/dashboard/src/main/res/layout/fragment_sync_info.xml index 2495b4f9fe..53982f11aa 100644 --- a/feature/dashboard/src/main/res/layout/fragment_sync_info.xml +++ b/feature/dashboard/src/main/res/layout/fragment_sync_info.xml @@ -431,7 +431,7 @@ android:layout_height="wrap_content" android:layout_marginHorizontal="8dp" android:layout_marginVertical="8dp" - android:progress="63" + tools:progress="63" android:visibility="visible" /> @@ -502,7 +502,7 @@ + android:layout_height="2dp"/> @@ -627,7 +627,7 @@ android:layout_height="wrap_content" android:layout_marginHorizontal="8dp" android:layout_marginVertical="8dp" - android:progress="63" + tools:progress="63" android:visibility="visible" /> @@ -658,7 +658,7 @@ + android:layout_height="2dp"/> diff --git a/feature/dashboard/src/main/res/layout/item_module_count.xml b/feature/dashboard/src/main/res/layout/item_module_count.xml index dfbf89b42f..d2ad51ea2b 100644 --- a/feature/dashboard/src/main/res/layout/item_module_count.xml +++ b/feature/dashboard/src/main/res/layout/item_module_count.xml @@ -35,7 +35,7 @@ android:layout_marginEnd="7dp" android:layout_weight="0.15" android:gravity="end|center_vertical" - tools:text="39" + tools:text="42" android:textSize="16sp" /> From 35f0425e9cf1a68a6163488cfde4b0a6c44156d5 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 11 Aug 2025 15:48:41 +0100 Subject: [PATCH 21/45] MS-939 Module selection requirement dropped for pre-logout sync info --- .../dashboard/logout/LogoutSyncViewModel.kt | 9 +--- .../logout/LogoutSyncViewModelTest.kt | 46 ++----------------- 2 files changed, 5 insertions(+), 50 deletions(-) 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 c6af511e3e..15512b9916 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,6 @@ import com.simprints.core.livedata.LiveDataEventWithContent import com.simprints.feature.dashboard.logout.usecase.LogoutUseCase import com.simprints.infra.authstore.AuthStore import com.simprints.infra.config.store.models.SettingsPasswordConfig -import com.simprints.infra.config.store.models.isModuleSelectionAvailable import com.simprints.infra.config.sync.ConfigManager import com.simprints.infra.eventsync.EventSyncManager import com.simprints.infra.sync.SyncOrchestrator @@ -39,12 +38,8 @@ internal class LogoutSyncViewModel @Inject constructor( val isLogoutWithoutSyncVisibleLiveData: LiveData = combine( eventSyncManager.getLastSyncState(useDefaultValue = true).asFlow(), syncOrchestrator.observeImageSyncStatus(), - configManager.watchProjectConfiguration(), - configManager.watchDeviceConfiguration(), - ) { eventSyncState, imageSyncStatus, projectConfig, deviceConfig -> - val isModuleSelectionRequired = - projectConfig.isModuleSelectionAvailable() && deviceConfig.selectedModules.isEmpty() - !eventSyncState.isSyncCompleted() || imageSyncStatus.isSyncing || isModuleSelectionRequired + ) { eventSyncState, imageSyncStatus -> + !eventSyncState.isSyncCompleted() || imageSyncStatus.isSyncing }.debounce(timeoutMillis = ANTI_JITTER_DELAY_MILLIS).asLiveData() val settingsLocked: LiveData> 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 0d72976b81..50132ef4ad 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 @@ -6,10 +6,8 @@ import androidx.lifecycle.asFlow import com.google.common.truth.Truth.assertThat import com.simprints.feature.dashboard.logout.usecase.LogoutUseCase import com.simprints.infra.authstore.AuthStore -import com.simprints.infra.config.store.models.DeviceConfiguration import com.simprints.infra.config.store.models.ProjectConfiguration import com.simprints.infra.config.store.models.SettingsPasswordConfig -import com.simprints.infra.config.store.models.isModuleSelectionAvailable import com.simprints.infra.config.sync.ConfigManager import com.simprints.infra.eventsync.EventSyncManager import com.simprints.infra.eventsync.status.models.EventSyncState @@ -107,12 +105,8 @@ internal class LogoutSyncViewModelTest { } val imageSyncStatus = ImageSyncStatus(isSyncing = false, progress = null, secondsSinceLastUpdate = null) val projectConfig = mockk(relaxed = true) - val deviceConfig = mockk { - every { selectedModules } returns listOf(mockk()) - } - mockProjectConfigExtension(projectConfig, isModuleSelectionAvailable = false) - setupSyncMocks(eventSyncState, imageSyncStatus, projectConfig, deviceConfig) + setupSyncMocks(eventSyncState, imageSyncStatus, projectConfig) val viewModel = createViewModel() @@ -127,32 +121,8 @@ internal class LogoutSyncViewModelTest { } val imageSyncStatus = ImageSyncStatus(isSyncing = true, progress = null, secondsSinceLastUpdate = null) val projectConfig = mockk(relaxed = true) - val deviceConfig = mockk { - every { selectedModules } returns listOf(mockk()) - } - - mockProjectConfigExtension(projectConfig, isModuleSelectionAvailable = false) - setupSyncMocks(eventSyncState, imageSyncStatus, projectConfig, deviceConfig) - - val viewModel = createViewModel() - - val result = viewModel.isLogoutWithoutSyncVisibleLiveData.getOrAwaitValue() - assertThat(result).isTrue() - } - - @Test - fun `isLogoutWithoutSyncVisibleLiveData should return true when module selection is required`() { - val eventSyncState = mockk { - every { isSyncCompleted() } returns true - } - val imageSyncStatus = ImageSyncStatus(isSyncing = false, progress = null, secondsSinceLastUpdate = null) - val projectConfig = mockk(relaxed = true) - val deviceConfig = mockk { - every { selectedModules } returns emptyList() - } - mockProjectConfigExtension(projectConfig, isModuleSelectionAvailable = true) - setupSyncMocks(eventSyncState, imageSyncStatus, projectConfig, deviceConfig) + setupSyncMocks(eventSyncState, imageSyncStatus, projectConfig) val viewModel = createViewModel() @@ -167,12 +137,8 @@ internal class LogoutSyncViewModelTest { } val imageSyncStatus = ImageSyncStatus(isSyncing = false, progress = null, secondsSinceLastUpdate = null) val projectConfig = mockk(relaxed = true) - val deviceConfig = mockk { - every { selectedModules } returns listOf(mockk()) - } - mockProjectConfigExtension(projectConfig, isModuleSelectionAvailable = false) - setupSyncMocks(eventSyncState, imageSyncStatus, projectConfig, deviceConfig) + setupSyncMocks(eventSyncState, imageSyncStatus, projectConfig) val viewModel = createViewModel() @@ -180,16 +146,10 @@ internal class LogoutSyncViewModelTest { assertThat(result).isFalse() } - private fun mockProjectConfigExtension(projectConfig: ProjectConfiguration, isModuleSelectionAvailable: Boolean) { - mockkStatic("com.simprints.infra.config.store.models.ProjectConfigurationKt") - every { projectConfig.isModuleSelectionAvailable() } returns isModuleSelectionAvailable - } - private fun setupSyncMocks( eventSyncState: EventSyncState, imageSyncStatus: ImageSyncStatus, projectConfig: ProjectConfiguration, - deviceConfig: DeviceConfiguration ) { mockkStatic("androidx.lifecycle.FlowLiveDataConversions") val eventSyncLiveData = mockk>(relaxed = true) From 222a8d4b48d23900bace4e708502a5e0aa6e3407 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 11 Aug 2025 15:49:22 +0100 Subject: [PATCH 22/45] MS-939 Sync info fragment parts visibility control code cleanup --- .../settings/syncinfo/SyncInfoFragment.kt | 94 +++++++++---------- 1 file changed, 47 insertions(+), 47 deletions(-) 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 375a76634d..f61795eecb 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 @@ -12,6 +12,8 @@ import android.view.animation.AccelerateDecelerateInterpolator import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat +import androidx.core.view.isGone +import androidx.core.view.isInvisible import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle @@ -113,7 +115,6 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { } private fun observeUI() { - renderSyncInfo(SyncInfo(), syncInfoConfig) lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.RESUMED) { renderSyncInfo(SyncInfo(), syncInfoConfig) @@ -133,41 +134,40 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { } } - viewModel.loginNavigationEventLiveData.observe( - viewLifecycleOwner, - { loginParams -> - findNavController().navigate(com.simprints.feature.login.R.id.graph_login, loginParams.toBundle()) - }, - ) + viewModel.loginNavigationEventLiveData.observe(viewLifecycleOwner) { loginParams -> + findNavController().navigate(com.simprints.feature.login.R.id.graph_login, loginParams.toBundle()) + } } private fun renderSyncInfo(syncInfo: SyncInfo, config: SyncInfoFragmentConfig) { + // note: ".isGone = not" is preferred to ".isVisible =" below for non-ambiguity of the no-show state + // App toolbar - binding.appBarLayout.visibility = if (config.isSyncInfoToolbarVisible) View.VISIBLE else View.GONE + binding.appBarLayout.isGone = !config.isSyncInfoToolbarVisible // Config loading progress bar - binding.progressConfigRefresh.visibility = if (syncInfo.isConfigurationLoadingProgressBarVisible) View.VISIBLE else View.INVISIBLE + binding.progressConfigRefresh.isInvisible = !syncInfo.isConfigurationLoadingProgressBarVisible // Sync info header - binding.syncStatusHeader.visibility = if (config.isSyncInfoStatusHeaderVisible) View.VISIBLE else View.GONE - binding.syncSettingsButton.visibility = if (config.isSyncInfoStatusHeaderSettingsButtonVisible) View.VISIBLE else View.GONE + binding.syncStatusHeader.isGone = !config.isSyncInfoStatusHeaderVisible + binding.syncSettingsButton.isGone = !config.isSyncInfoStatusHeaderSettingsButtonVisible // Section separators - binding.headerRecordSync.visibility = if (config.areSyncInfoSectionHeadersVisible) View.VISIBLE else View.GONE - binding.sectionDivider1.visibility = if (config.areSyncInfoSectionHeadersVisible) View.VISIBLE else View.GONE - binding.headerImageSync.visibility = if (config.areSyncInfoSectionHeadersVisible) View.VISIBLE else View.GONE - binding.sectionDivider2.visibility = if (config.areSyncInfoSectionHeadersVisible) View.VISIBLE else View.GONE - binding.headerModuleSelection.visibility = if (config.areSyncInfoSectionHeadersVisible) View.VISIBLE else View.GONE - binding.sectionFooter.visibility = if (config.areSyncInfoSectionHeadersVisible) View.GONE else View.VISIBLE + binding.headerRecordSync.isGone = !config.areSyncInfoSectionHeadersVisible + binding.sectionDivider1.isGone = !config.areSyncInfoSectionHeadersVisible + binding.headerImageSync.isGone = !config.areSyncInfoSectionHeadersVisible + binding.sectionDivider2.isGone = !config.areSyncInfoSectionHeadersVisible + binding.headerModuleSelection.isGone = !config.areSyncInfoSectionHeadersVisible + binding.sectionFooter.isGone = config.areSyncInfoSectionHeadersVisible // Re-login section - binding.syncReLoginRequiredSection.visibility = if (syncInfo.isLoginPromptSectionVisible) View.VISIBLE else View.GONE + binding.syncReLoginRequiredSection.isGone = !syncInfo.isLoginPromptSectionVisible // Records section renderRecordsSection(syncInfo.syncInfoSectionRecords, config) // Images section - binding.layoutImagesSync.visibility = if (config.isSyncInfoImageSyncVisible) View.VISIBLE else View.GONE + binding.layoutImagesSync.isGone = !config.isSyncInfoImageSyncVisible renderImagesSection(syncInfo.syncInfoSectionImages) // Modules section @@ -176,36 +176,36 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { private fun renderRecordsSection(records: SyncInfoSectionRecords, config: SyncInfoFragmentConfig) { // Counter - total records - binding.totalRecordsCount.visibility = if (records.counterTotalRecords.isBlank()) View.GONE else View.VISIBLE + binding.totalRecordsCount.isGone = records.counterTotalRecords.isBlank() binding.totalRecordsCount.text = records.counterTotalRecords - binding.totalRecordsProgress.visibility = if (records.counterTotalRecords.isBlank()) View.VISIBLE else View.GONE + binding.totalRecordsProgress.isGone = records.counterTotalRecords.isNotBlank() // Counter - records to upload - binding.layoutRecordsToDownload.visibility = if (records.isCounterRecordsToDownloadVisible) View.VISIBLE else View.GONE - binding.recordsToUploadCount.visibility = if (records.counterRecordsToUpload.isBlank()) View.GONE else View.VISIBLE + binding.layoutRecordsToDownload.isGone = !records.isCounterRecordsToDownloadVisible + binding.recordsToUploadCount.isGone = records.counterRecordsToUpload.isBlank() binding.recordsToUploadCount.text = records.counterRecordsToUpload - binding.recordsToUploadProgress.visibility = if (records.counterRecordsToUpload.isBlank()) View.VISIBLE else View.GONE + binding.recordsToUploadProgress.isGone = records.counterRecordsToUpload.isNotBlank() // Counter - records to download - binding.recordsToDownloadCount.visibility = if (records.counterRecordsToDownload.isBlank()) View.GONE else View.VISIBLE + binding.recordsToDownloadCount.isGone = records.counterRecordsToDownload.isBlank() binding.recordsToDownloadCount.text = records.counterRecordsToDownload - binding.recordsToDownloadProgress.visibility = if (records.counterRecordsToDownload.isBlank()) View.VISIBLE else View.GONE + binding.recordsToDownloadProgress.isGone = records.counterRecordsToDownload.isNotBlank() // Counter - images to upload (may be combined with records) - binding.layoutComboImageCounter.visibility = if (config.isSyncInfoRecordsImagesCombined) View.VISIBLE else View.GONE - binding.comboImagesToUploadCount.visibility = if (records.counterImagesToUpload.isBlank()) View.GONE else View.VISIBLE + binding.layoutComboImageCounter.isGone = !config.isSyncInfoRecordsImagesCombined + binding.comboImagesToUploadCount.isGone = records.counterImagesToUpload.isBlank() binding.comboImagesToUploadCount.text = records.counterImagesToUpload - binding.comboImagesToUploadProgress.visibility = if (records.counterImagesToUpload.isBlank()) View.VISIBLE else View.GONE + binding.comboImagesToUploadProgress.isGone = records.counterImagesToUpload.isNotBlank() // Instructions - binding.textEventSyncInstructionsDefault.visibility = if (records.isInstructionDefaultVisible) View.VISIBLE else View.GONE - binding.textEventSyncInstructionsOffline.visibility = if (records.isInstructionOfflineVisible) View.VISIBLE else View.GONE - binding.textEventSyncInstructionsNoModules.visibility = if (records.isInstructionNoModulesVisible) View.VISIBLE else View.GONE - binding.textEventSyncInstructionsError.visibility = if (records.isInstructionErrorVisible) View.VISIBLE else View.GONE + binding.textEventSyncInstructionsDefault.isGone = !records.isInstructionDefaultVisible + binding.textEventSyncInstructionsOffline.isGone = !records.isInstructionOfflineVisible + binding.textEventSyncInstructionsNoModules.isGone = !records.isInstructionNoModulesVisible + binding.textEventSyncInstructionsError.isGone = !records.isInstructionErrorVisible records.instructionPopupErrorInfo.configureErrorPopup() // Progress - binding.layoutEventSyncProgress.visibility = if (records.isProgressVisible) View.VISIBLE else View.INVISIBLE + binding.layoutEventSyncProgress.isInvisible = !records.isProgressVisible renderProgress( records.progress, binding.eventSyncProgressBar, @@ -217,7 +217,7 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { // Sync button val isSyncButtonVisible = !config.isSyncInfoLogoutOnComplete || records.isSyncButtonVisible - binding.buttonSyncRecordsNow.visibility = if (isSyncButtonVisible) View.VISIBLE else View.GONE + binding.buttonSyncRecordsNow.isGone = !isSyncButtonVisible binding.buttonSyncRecordsNow.isEnabled = records.isSyncButtonEnabled binding.buttonSyncRecordsNow.text = getString( when { @@ -229,10 +229,10 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { // Footer val isFooterSyncInProgressVisible = config.isSyncInfoLogoutOnComplete && records.isFooterSyncInProgressVisible - binding.textFooterRecordSyncInProgress.visibility = if (isFooterSyncInProgressVisible) View.VISIBLE else View.GONE - binding.textFooterRecordLoggingOut.visibility = if (records.isFooterReadyToLogOutVisible) View.VISIBLE else View.GONE - binding.textFooterRecordSyncIncomplete.visibility = if (records.isFooterSyncIncompleteVisible) View.VISIBLE else View.GONE - binding.textFooterRecordLastSyncedWhen.visibility = if (records.isFooterLastSyncTimeVisible) View.VISIBLE else View.GONE + binding.textFooterRecordSyncInProgress.isGone = !isFooterSyncInProgressVisible + binding.textFooterRecordLoggingOut.isGone = !records.isFooterReadyToLogOutVisible + binding.textFooterRecordSyncIncomplete.isGone = !records.isFooterSyncIncompleteVisible + binding.textFooterRecordLastSyncedWhen.isGone = !records.isFooterLastSyncTimeVisible binding.textFooterRecordLastSyncedWhen.text = formatLastSyncTime(records.footerLastSyncMinutesAgo) } @@ -261,16 +261,16 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { private fun renderImagesSection(images: SyncInfoSectionImages) { // Counter - images to upload - binding.imagesToUploadCount.visibility = if (images.counterImagesToUpload.isBlank()) View.GONE else View.VISIBLE + binding.imagesToUploadCount.isGone = images.counterImagesToUpload.isBlank() binding.imagesToUploadCount.text = images.counterImagesToUpload - binding.imagesToUploadProgress.visibility = if (images.counterImagesToUpload.isBlank()) View.VISIBLE else View.GONE + binding.imagesToUploadProgress.isGone = images.counterImagesToUpload.isNotBlank() // Handle instruction visibility - binding.textImageSyncInstructionsDefault.visibility = if (images.isInstructionDefaultVisible) View.VISIBLE else View.GONE - binding.textImageSyncInstructionsOffline.visibility = if (images.isInstructionOfflineVisible) View.VISIBLE else View.GONE + binding.textImageSyncInstructionsDefault.isGone = !images.isInstructionDefaultVisible + binding.textImageSyncInstructionsOffline.isGone = !images.isInstructionOfflineVisible // Progress - binding.layoutImageSyncProgress.visibility = if (images.isProgressVisible) View.VISIBLE else View.INVISIBLE + binding.layoutImageSyncProgress.isInvisible = !images.isProgressVisible renderProgress(images.progress, binding.imageSyncProgressBar, binding.textImageSyncProgress, IDR.string.sync_info_item_image) binding.imageSyncProgressBar.setPulseAnimation(isEnabled = images.isProgressVisible) @@ -301,15 +301,15 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { ) // Footer - binding.textFooterImageLastSyncedWhen.visibility = if (images.isFooterLastSyncTimeVisible) View.VISIBLE else View.INVISIBLE + binding.textFooterImageLastSyncedWhen.isInvisible = !images.isFooterLastSyncTimeVisible binding.textFooterImageLastSyncedWhen.text = formatLastSyncTime(images.footerLastSyncMinutesAgo) } private fun renderModulesSection(modules: SyncInfoSectionModules, config: SyncInfoFragmentConfig) { val isModuleSectionVisible = modules.isSectionAvailable && (config.isSyncInfoModuleListVisible || modules.moduleCounts.isEmpty()) - binding.layoutModuleSelection.visibility = if (isModuleSectionVisible) View.VISIBLE else View.GONE - binding.selectedModulesView.visibility = if (config.isSyncInfoModuleListVisible) View.VISIBLE else View.GONE + binding.layoutModuleSelection.isGone = !isModuleSectionVisible + binding.selectedModulesView.isGone = !config.isSyncInfoModuleListVisible val moduleCountsForAdapter = modules.moduleCounts.map { syncInfoModuleCount -> ModuleCount( From 2dd3ac800995894d55d990d0014a8af6b3848182 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 11 Aug 2025 15:50:06 +0100 Subject: [PATCH 23/45] MS-939 Module selection requirement dropped for pre-logout sync info pt.2 --- .../settings/syncinfo/SyncInfoViewModel.kt | 7 +++--- .../syncinfo/SyncInfoViewModelTest.kt | 24 ++++++++++++++++++- 2 files changed, 26 insertions(+), 5 deletions(-) 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 1033e06292..b64273948a 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 @@ -77,7 +77,6 @@ internal class SyncInfoViewModel @Inject constructor( ) { eventSyncState, imageSyncStatus -> val isReadyToLogOut = isPreLogoutUpSync && eventSyncState.isSyncCompleted() && !imageSyncStatus.isSyncing - && !configManager.isModuleSelectionRequired() return@combine isReadyToLogOut }.debounce(LOGOUT_DELAY_MILLIS).transformLatest { isReadyToLogOut -> if (isReadyToLogOut) { @@ -179,7 +178,7 @@ internal class SyncInfoViewModel @Inject constructor( val isReLoginRequired = eventSyncState.isSyncFailedBecauseReloginRequired() val isModuleSelectionRequired = - projectConfig.isModuleSelectionAvailable() && deviceConfig.selectedModules.isEmpty() + !isPreLogoutUpSync && projectConfig.isModuleSelectionAvailable() && deviceConfig.selectedModules.isEmpty() val isEventSyncAvailable = !isReLoginRequired && isConnected && !eventSyncState.isSyncRunning() && !projectConfig.isMissingModulesToChooseFrom() && !isModuleSelectionRequired @@ -374,8 +373,8 @@ internal class SyncInfoViewModel @Inject constructor( val lastUpdate = eventSyncManager.getLastSyncTime() val isForceEventSync = when { - configManager.isModuleSelectionRequired() -> false isPreLogoutUpSync -> true + configManager.isModuleSelectionRequired() -> false isRunning -> false lastUpdate == null -> true timeHelper.msBetweenNowAndTime(lastUpdate) > RE_SYNC_TIMEOUT_MILLIS -> true @@ -394,7 +393,7 @@ internal class SyncInfoViewModel @Inject constructor( .map { it.isSyncCompleted() } .distinctUntilChanged() .collect { isEventSyncCompleted -> - if (isEventSyncCompleted && !configManager.isModuleSelectionRequired()) { + if (isEventSyncCompleted) { syncOrchestrator.startImageSync() } } 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 a7b52b0b18..6070f35107 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 @@ -1500,7 +1500,7 @@ class SyncInfoViewModelTest { } @Test - fun `should not trigger initial sync when module selection required`() = runTest { + fun `should trigger initial sync when in pre-logout mode and module selection required`() = runTest { val mockProjectConfigRequiringModules = mockk { every { general } returns mockk { every { modalities } returns listOf(GeneralConfiguration.Modality.FINGERPRINT) @@ -1518,6 +1518,28 @@ class SyncInfoViewModelTest { viewModel.syncInfoLiveData.getOrAwaitValue() + coVerify(exactly = 1) { syncOrchestrator.startEventSync(any()) } + } + + @Test + fun `should not trigger initial sync when not in pre-logout mode and module selection required`() = runTest { + val mockProjectConfigRequiringModules = mockk { + every { general } returns mockk { + every { modalities } returns listOf(GeneralConfiguration.Modality.FINGERPRINT) + } + } + val mockEmptyDeviceConfig = mockk { + every { selectedModules } returns emptyList() + } + coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigRequiringModules + coEvery { configManager.getDeviceConfiguration() } returns mockEmptyDeviceConfig + every { mockProjectConfigRequiringModules.isModuleSelectionAvailable() } returns true + coEvery { eventSyncManager.getLastSyncTime() } returns null + createViewModel() + viewModel.isPreLogoutUpSync = false + + viewModel.syncInfoLiveData.getOrAwaitValue() + coVerify(exactly = 0) { syncOrchestrator.startEventSync(any()) } } From 113268282299c59de997947b131dc79ce7dbc95a Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 11 Aug 2025 15:50:41 +0100 Subject: [PATCH 24/45] MS-939 Sync info placeholder code cleanup --- .../settings/syncinfo/SyncInfoViewModel.kt | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) 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 b64273948a..91965d5c71 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 @@ -246,12 +246,12 @@ internal class SyncInfoViewModel @Inject constructor( ) val syncInfoSectionRecords = SyncInfoSectionRecords( - counterTotalRecords = recordsTotal?.toString() ?: "", - counterRecordsToUpload = recordsToUpload?.toString() ?: "", + counterTotalRecords = recordsTotal?.toString().orEmpty(), + counterRecordsToUpload = recordsToUpload?.toString().orEmpty(), isCounterRecordsToDownloadVisible = !isPreLogoutUpSync && !isProjectEnding, - counterRecordsToDownload = recordsToDownload?.let { "${it.count}${if (it.isLowerBound) "+" else ""}" } ?: "", + counterRecordsToDownload = recordsToDownload?.let { "${it.count}${if (it.isLowerBound) "+" else ""}" }.orEmpty(), isCounterImagesToUploadVisible = isPreLogoutUpSync, - counterImagesToUpload = imagesToUpload?.toString() ?: "", + counterImagesToUpload = imagesToUpload?.toString().orEmpty(), isInstructionDefaultVisible = !isModuleSelectionRequired && isConnected && !eventSyncState.isSyncFailed() && !eventSyncState.isSyncInProgress() && !isPreLogoutUpSync, isInstructionNoModulesVisible = isConnected && isModuleSelectionRequired && !isEventSyncInProgress, @@ -268,15 +268,14 @@ internal class SyncInfoViewModel @Inject constructor( isSyncButtonEnabled = isEventSyncAvailable, isSyncButtonForRetry = eventSyncState.isSyncFailed(), isFooterSyncInProgressVisible = isPreLogoutUpSync && isEventSyncInProgress, - isFooterReadyToLogOutVisible = isPreLogoutUpSync && eventSyncState.isSyncCompleted() && !imageSyncStatus.isSyncing - && !isModuleSelectionRequired, + isFooterReadyToLogOutVisible = isPreLogoutUpSync && eventSyncState.isSyncCompleted() && !imageSyncStatus.isSyncing, isFooterSyncIncompleteVisible = isPreLogoutUpSync && eventSyncState.isSyncFailed(), isFooterLastSyncTimeVisible = !isPreLogoutUpSync && !eventSyncState.isSyncInProgress() && eventLastSyncMinutes >= 0, footerLastSyncMinutesAgo = eventLastSyncMinutes, ) val syncInfoSectionImages = SyncInfoSectionImages( - counterImagesToUpload = imagesToUpload?.toString() ?: "", + counterImagesToUpload = imagesToUpload?.toString().orEmpty(), isInstructionDefaultVisible = !imageSyncStatus.isSyncing && isConnected, isInstructionOfflineVisible = !isConnected, isProgressVisible = imageSyncStatus.isSyncing, From f7ce046dfcb068671caaf449d80e49a853f9fd6a Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 11 Aug 2025 15:51:18 +0100 Subject: [PATCH 25/45] MS-939 SyncOrchestratorImpl associateWithIfSyncing explained in more detail --- .../java/com/simprints/infra/sync/SyncOrchestratorImpl.kt | 7 +++++++ 1 file changed, 7 insertions(+) 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 bcc6195bbb..e430fbf68e 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 @@ -164,6 +164,13 @@ internal class SyncOrchestratorImpl @Inject constructor( } } + /** + * Converts the flow of WorkInfo in the receiver into a flow of WorkInfo paired to whether sync is ongoing or not. + * + * Whether sync is ongoing or not - is calculated from the WorkInfo. + * A special case is handled for a job that succeeds promptly: a "pulse" of positive sync is emitted additionally. + * This allows immediately succeeding syncs to be detected in the return flow. + */ private fun Flow>.associateWithIfSyncing() = transformLatest { workInfos -> val isJustUpdated = imageSyncTimestampProvider.getSecondsSinceLastImageSync() == 0L when { From e2e470d32e49c6cc6408736dbeae9ba3f54a4c6f Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 11 Aug 2025 16:02:10 +0100 Subject: [PATCH 26/45] MS-939 FragmentContainerView for sync info named SyncFragmentContainerView --- .../ConfigurableSyncInfoFragmentContainer.kt | 18 +++++++++--------- .../dashboard/src/main/res/values/attrs.xml | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/view/ConfigurableSyncInfoFragmentContainer.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/view/ConfigurableSyncInfoFragmentContainer.kt index a9ac98d58c..7f80829cf5 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/view/ConfigurableSyncInfoFragmentContainer.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/view/ConfigurableSyncInfoFragmentContainer.kt @@ -15,16 +15,16 @@ class ConfigurableSyncInfoFragmentContainer @JvmOverloads constructor( val syncInfoFragmentConfig: SyncInfoFragmentConfig? = attrs?.let { var config: SyncInfoFragmentConfig? = null - context.withStyledAttributes(attrs, R.styleable.FragmentContainerView) { + context.withStyledAttributes(attrs, R.styleable.SyncFragmentContainerView) { config = SyncInfoFragmentConfig( - isSyncInfoToolbarVisible = getBoolean(R.styleable.FragmentContainerView_isSyncInfoToolbarVisible, true), - isSyncInfoStatusHeaderVisible = getBoolean(R.styleable.FragmentContainerView_isSyncInfoStatusHeaderVisible, false), - isSyncInfoStatusHeaderSettingsButtonVisible = getBoolean(R.styleable.FragmentContainerView_isSyncInfoStatusHeaderSettingsButtonVisible, false), - areSyncInfoSectionHeadersVisible = getBoolean(R.styleable.FragmentContainerView_areSyncInfoSectionHeadersVisible, true), - isSyncInfoImageSyncVisible = getBoolean(R.styleable.FragmentContainerView_isSyncInfoImageSyncVisible, true), - isSyncInfoRecordsImagesCombined = getBoolean(R.styleable.FragmentContainerView_isSyncInfoRecordsImagesCombined, false), - isSyncInfoLogoutOnComplete = getBoolean(R.styleable.FragmentContainerView_isSyncInfoLogoutOnComplete, false), - isSyncInfoModuleListVisible = getBoolean(R.styleable.FragmentContainerView_isSyncInfoModuleListVisible, true) + isSyncInfoToolbarVisible = getBoolean(R.styleable.SyncFragmentContainerView_isSyncInfoToolbarVisible, true), + isSyncInfoStatusHeaderVisible = getBoolean(R.styleable.SyncFragmentContainerView_isSyncInfoStatusHeaderVisible, false), + isSyncInfoStatusHeaderSettingsButtonVisible = getBoolean(R.styleable.SyncFragmentContainerView_isSyncInfoStatusHeaderSettingsButtonVisible, false), + areSyncInfoSectionHeadersVisible = getBoolean(R.styleable.SyncFragmentContainerView_areSyncInfoSectionHeadersVisible, true), + isSyncInfoImageSyncVisible = getBoolean(R.styleable.SyncFragmentContainerView_isSyncInfoImageSyncVisible, true), + isSyncInfoRecordsImagesCombined = getBoolean(R.styleable.SyncFragmentContainerView_isSyncInfoRecordsImagesCombined, false), + isSyncInfoLogoutOnComplete = getBoolean(R.styleable.SyncFragmentContainerView_isSyncInfoLogoutOnComplete, false), + isSyncInfoModuleListVisible = getBoolean(R.styleable.SyncFragmentContainerView_isSyncInfoModuleListVisible, true) ) } config diff --git a/feature/dashboard/src/main/res/values/attrs.xml b/feature/dashboard/src/main/res/values/attrs.xml index 672695d0ee..206b0b62df 100644 --- a/feature/dashboard/src/main/res/values/attrs.xml +++ b/feature/dashboard/src/main/res/values/attrs.xml @@ -1,6 +1,6 @@ - + From 2f5464ad5d0d612c933e32f591535e2ca338acea Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 11 Aug 2025 16:25:28 +0100 Subject: [PATCH 27/45] MS-939 Logout even made consumable after onResume --- .../settings/syncinfo/SyncInfoFragment.kt | 9 ++-- .../settings/syncinfo/SyncInfoViewModel.kt | 15 +++--- .../syncinfo/SyncInfoViewModelTest.kt | 48 ++++++++++--------- 3 files changed, 36 insertions(+), 36 deletions(-) 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 f61795eecb..3f70617813 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 @@ -20,6 +20,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController +import com.simprints.core.livedata.LiveDataEventWithContentObserver import kotlinx.coroutines.launch import com.simprints.core.tools.utils.TimeUtils import com.simprints.feature.dashboard.R @@ -126,11 +127,9 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.RESUMED) { - viewModel.logoutEventLiveData.observe(viewLifecycleOwner) { logoutIfNotNull -> - logoutIfNotNull?.let { - viewModel.logout() - } - } + viewModel.logoutEventLiveData.observe(viewLifecycleOwner, LiveDataEventWithContentObserver { + viewModel.logout() + }) } } 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 91965d5c71..e11e962513 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 @@ -7,6 +7,7 @@ import androidx.lifecycle.asFlow import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.core.livedata.LiveDataEventWithContent import com.simprints.core.tools.extentions.combine8 import com.simprints.core.tools.extentions.onChange import com.simprints.core.tools.time.TimeHelper @@ -37,10 +38,10 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import javax.inject.Inject @@ -71,19 +72,17 @@ internal class SyncInfoViewModel @Inject constructor( private val imageSyncStatusFlow = syncOrchestrator.observeImageSyncStatus() - val logoutEventLiveData: LiveData = combine( + val logoutEventLiveData: LiveData> = combine( eventSyncStateFlow, imageSyncStatusFlow, ) { eventSyncState, imageSyncStatus -> val isReadyToLogOut = isPreLogoutUpSync && eventSyncState.isSyncCompleted() && !imageSyncStatus.isSyncing return@combine isReadyToLogOut - }.debounce(LOGOUT_DELAY_MILLIS).transformLatest { isReadyToLogOut -> - if (isReadyToLogOut) { - // "flick" the logout event to prevent persistence while not observed, and to avoid unexpected logout later - emit(Unit) - emit(null) - } + }.debounce(LOGOUT_DELAY_MILLIS).filter { isReadyToLogOut -> + isReadyToLogOut // only when ready + }.map { + LiveDataEventWithContent(Unit) }.asLiveData() val syncInfoLiveData: LiveData = combine8( 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 6070f35107..aba3a50276 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 @@ -3,6 +3,7 @@ package com.simprints.feature.dashboard.settings.syncinfo import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer +import com.simprints.core.livedata.LiveDataEventWithContent import androidx.lifecycle.asFlow import com.google.common.truth.Truth.assertThat import com.simprints.core.domain.tokenization.TokenizableString @@ -213,22 +214,22 @@ class SyncInfoViewModelTest { every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) createViewModel() viewModel.isPreLogoutUpSync = true - val observer = mockk>(relaxed = true) - val slot = slot() - val capturedValues = mutableListOf() - every { observer.onChanged(captureNullable(slot)) } answers { + val observer = mockk>>(relaxed = true) + val slot = slot>() + val capturedValues = mutableListOf>() + every { observer.onChanged(capture(slot)) } answers { capturedValues.add(slot.captured) } viewModel.logoutEventLiveData.observeForever(observer) advanceTimeBy(3100L) // after the logout delay (3000ms) - assertThat(capturedValues).contains(Unit) + assertThat(capturedValues.map { it.peekContent() }).contains(Unit) } @OptIn(ExperimentalCoroutinesApi::class) @Test - fun `should emit and reset a logout event after the intended delay since ready to logout`() = runTest { + fun `should emit a logout event after the intended delay since ready to logout`() = runTest { val mockCompletedEventSyncState = mockk(relaxed = true) { every { isSyncCompleted() } returns true } @@ -241,10 +242,10 @@ class SyncInfoViewModelTest { every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) createViewModel() viewModel.isPreLogoutUpSync = true - val observer = mockk>(relaxed = true) - val slot = slot() - val capturedValues = mutableListOf() - every { observer.onChanged(captureNullable(slot)) } answers { + val observer = mockk>>(relaxed = true) + val slot = slot>() + val capturedValues = mutableListOf>() + every { observer.onChanged(capture(slot)) } answers { capturedValues.add(slot.captured) } @@ -255,7 +256,8 @@ class SyncInfoViewModelTest { advanceTimeBy(200L) // after the debounce delay (total 3100ms > 3000ms) - assertThat(capturedValues).isEqualTo(listOf(Unit, null)) // "flicked" the logout event to prevent persistence + assertThat(capturedValues).hasSize(1) + assertThat(capturedValues[0].peekContent()).isEqualTo(Unit) } @OptIn(ExperimentalCoroutinesApi::class) @@ -273,10 +275,10 @@ class SyncInfoViewModelTest { every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) createViewModel() viewModel.isPreLogoutUpSync = false - val observer = mockk>(relaxed = true) - val slot = slot() - val capturedValues = mutableListOf() - every { observer.onChanged(captureNullable(slot)) } answers { + val observer = mockk>>(relaxed = true) + val slot = slot>() + val capturedValues = mutableListOf>() + every { observer.onChanged(capture(slot)) } answers { capturedValues.add(slot.captured) } @@ -302,10 +304,10 @@ class SyncInfoViewModelTest { every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) createViewModel() viewModel.isPreLogoutUpSync = true - val observer = mockk>(relaxed = true) - val slot = slot() - val capturedValues = mutableListOf() - every { observer.onChanged(captureNullable(slot)) } answers { + val observer = mockk>>(relaxed = true) + val slot = slot>() + val capturedValues = mutableListOf>() + every { observer.onChanged(capture(slot)) } answers { capturedValues.add(slot.captured) } @@ -330,10 +332,10 @@ class SyncInfoViewModelTest { every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockSyncingImageStatus) createViewModel() viewModel.isPreLogoutUpSync = true - val observer = mockk>(relaxed = true) - val slot = slot() - val capturedValues = mutableListOf() - every { observer.onChanged(captureNullable(slot)) } answers { + val observer = mockk>>(relaxed = true) + val slot = slot>() + val capturedValues = mutableListOf>() + every { observer.onChanged(capture(slot)) } answers { capturedValues.add(slot.captured) } From b4c4b6ebfaf78b3aecdbdb068681b1b3528be4a5 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 11 Aug 2025 18:10:14 +0100 Subject: [PATCH 28/45] MS-939 Timer extracted for TimeHelper --- .../settings/syncinfo/SyncInfoViewModel.kt | 4 +- .../syncinfo/SyncInfoViewModelTest.kt | 7 +- .../dashboard/tools/di/FakeCoreModule.kt | 5 ++ .../java/com/simprints/core/CoreModule.kt | 6 ++ .../core/tools/time/KronosTimeHelperImpl.kt | 14 ---- .../simprints/core/tools/time/TimeHelper.kt | 3 - .../com/simprints/core/tools/time/Timer.kt | 9 +++ .../simprints/core/tools/time/TimerImpl.kt | 20 ++++++ .../tools/time/KronosTimeHelperImplTest.kt | 51 -------------- .../core/tools/time/TimerImplTest.kt | 68 +++++++++++++++++++ 10 files changed, 116 insertions(+), 71 deletions(-) create mode 100644 infra/core/src/main/java/com/simprints/core/tools/time/Timer.kt create mode 100644 infra/core/src/main/java/com/simprints/core/tools/time/TimerImpl.kt create mode 100644 infra/core/src/test/java/com/simprints/core/tools/time/TimerImplTest.kt 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 e11e962513..951c57e56a 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 @@ -11,6 +11,7 @@ import com.simprints.core.livedata.LiveDataEventWithContent import com.simprints.core.tools.extentions.combine8 import com.simprints.core.tools.extentions.onChange import com.simprints.core.tools.time.TimeHelper +import com.simprints.core.tools.time.Timer import com.simprints.feature.dashboard.logout.usecase.LogoutUseCase import com.simprints.feature.dashboard.settings.syncinfo.modulecount.ModuleCount import com.simprints.feature.login.LoginParams @@ -59,6 +60,7 @@ internal class SyncInfoViewModel @Inject constructor( private val tokenizationProcessor: TokenizationProcessor, private val recentUserActivityManager: RecentUserActivityManager, private val timeHelper: TimeHelper, + timer: Timer, private val logoutUseCase: LogoutUseCase, ) : ViewModel() { var isPreLogoutUpSync = false @@ -93,7 +95,7 @@ internal class SyncInfoViewModel @Inject constructor( imageSyncStatusFlow, configManager.observeProjectConfiguration(), configManager.observeDeviceConfiguration(), - timeHelper.observeTickOncePerMinute(), + timer.observeTickOncePerMinute(), ) { isConnected, isLoggedIn, isRefreshing, eventSyncState, imageSyncStatus, projectConfig, deviceConfig, _ -> val currentEvents = eventSyncState.progress?.coerceAtLeast(0) ?: 0 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 aba3a50276..ef38cbd0b0 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 @@ -8,6 +8,7 @@ import androidx.lifecycle.asFlow import com.google.common.truth.Truth.assertThat import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.core.tools.time.TimeHelper +import com.simprints.core.tools.time.Timer import com.simprints.core.tools.time.Timestamp import com.simprints.feature.dashboard.logout.usecase.LogoutUseCase import com.simprints.feature.login.LoginResult @@ -70,6 +71,7 @@ class SyncInfoViewModelTest { private val tokenizationProcessor = mockk() private val recentUserActivityManager = mockk() private val timeHelper = mockk() + private val timer = mockk() private val logoutUseCase = mockk(relaxed = true) private lateinit var viewModel: SyncInfoViewModel @@ -156,7 +158,7 @@ class SyncInfoViewModelTest { coEvery { imageRepository.getNumberOfImagesToUpload(any()) } returns 0 coEvery { enrolmentRecordRepository.count(any()) } returns 0 - every { timeHelper.observeTickOncePerMinute() } returns MutableStateFlow(Unit) + every { timer.observeTickOncePerMinute() } returns MutableStateFlow(Unit) every { timeHelper.now() } returns TEST_TIMESTAMP every { timeHelper.msBetweenNowAndTime(any()) } returns 0L @@ -183,6 +185,7 @@ class SyncInfoViewModelTest { tokenizationProcessor = tokenizationProcessor, recentUserActivityManager = recentUserActivityManager, timeHelper = timeHelper, + timer = timer, logoutUseCase = logoutUseCase, ) } @@ -1052,7 +1055,7 @@ class SyncInfoViewModelTest { every { timeHelper.now() } returnsMany listOf(TEST_TIMESTAMP, Timestamp(TEST_TIMESTAMP.ms + 60_000)) // MutableStateFlow of Unit won't emit another (identical) Unit, so we'll count minutes and map to Units val timePaceFlow = MutableStateFlow(0) - every { timeHelper.observeTickOncePerMinute() } returns timePaceFlow.map { Unit } + every { timer.observeTickOncePerMinute() } returns timePaceFlow.map { Unit } createViewModel() val initialResult = viewModel.syncInfoLiveData.getOrAwaitValue() diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeCoreModule.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeCoreModule.kt index fa89d0b0b1..766e7291d4 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeCoreModule.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeCoreModule.kt @@ -14,6 +14,7 @@ import com.simprints.core.PackageVersionName import com.simprints.core.SessionCoroutineScope import com.simprints.core.tools.json.JsonHelper import com.simprints.core.tools.time.TimeHelper +import com.simprints.core.tools.time.Timer import com.simprints.core.tools.utils.EncodingUtils import com.simprints.core.tools.utils.StringTokenizer import com.simprints.testtools.unit.EncodingUtilsImplForTests @@ -44,6 +45,10 @@ object FakeCoreModule { @Singleton fun provideTimeHelper(): TimeHelper = mockk(relaxed = true) + @Provides + @Singleton + fun provideTimer(): Timer = mockk() + @Provides @Singleton fun provideJsonHelper(): JsonHelper = mockk() 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 d2dc20408f..f4dc62f7dd 100644 --- a/infra/core/src/main/java/com/simprints/core/CoreModule.kt +++ b/infra/core/src/main/java/com/simprints/core/CoreModule.kt @@ -9,6 +9,8 @@ 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.Timer +import com.simprints.core.tools.time.TimerImpl import com.simprints.core.tools.utils.EncodingUtils import com.simprints.core.tools.utils.EncodingUtilsImpl import com.simprints.core.tools.utils.SimNetworkUtils @@ -45,6 +47,10 @@ object CoreModule { ), ) + @Provides + @Singleton + fun provideTimer(): Timer = TimerImpl() + @Provides @Singleton fun provideSimNetworkUtils( diff --git a/infra/core/src/main/java/com/simprints/core/tools/time/KronosTimeHelperImpl.kt b/infra/core/src/main/java/com/simprints/core/tools/time/KronosTimeHelperImpl.kt index 1f6ef384a4..67073f2ae9 100644 --- a/infra/core/src/main/java/com/simprints/core/tools/time/KronosTimeHelperImpl.kt +++ b/infra/core/src/main/java/com/simprints/core/tools/time/KronosTimeHelperImpl.kt @@ -4,9 +4,6 @@ import android.text.format.DateUtils.FORMAT_SHOW_DATE import android.text.format.DateUtils.MINUTE_IN_MILLIS import android.text.format.DateUtils.getRelativeTimeSpanString import com.lyft.kronos.KronosClock -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow import java.text.DateFormat import java.util.Calendar import java.util.Date @@ -62,15 +59,4 @@ class KronosTimeHelperImpl @Inject constructor( timeInMillis } - - 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/main/java/com/simprints/core/tools/time/TimeHelper.kt b/infra/core/src/main/java/com/simprints/core/tools/time/TimeHelper.kt index 6f12878965..75bf735a6a 100644 --- a/infra/core/src/main/java/com/simprints/core/tools/time/TimeHelper.kt +++ b/infra/core/src/main/java/com/simprints/core/tools/time/TimeHelper.kt @@ -1,7 +1,6 @@ package com.simprints.core.tools.time import androidx.annotation.Keep -import kotlinx.coroutines.flow.Flow @Keep interface TimeHelper { @@ -18,6 +17,4 @@ interface TimeHelper { fun todayInMillis(): Long fun tomorrowInMillis(): Long - - fun observeTickOncePerMinute(): Flow } diff --git a/infra/core/src/main/java/com/simprints/core/tools/time/Timer.kt b/infra/core/src/main/java/com/simprints/core/tools/time/Timer.kt new file mode 100644 index 0000000000..a0a8f02d0c --- /dev/null +++ b/infra/core/src/main/java/com/simprints/core/tools/time/Timer.kt @@ -0,0 +1,9 @@ +package com.simprints.core.tools.time + +import androidx.annotation.Keep +import kotlinx.coroutines.flow.Flow + +@Keep +interface Timer { + fun observeTickOncePerMinute(): Flow +} diff --git a/infra/core/src/main/java/com/simprints/core/tools/time/TimerImpl.kt b/infra/core/src/main/java/com/simprints/core/tools/time/TimerImpl.kt new file mode 100644 index 0000000000..2bc000dd31 --- /dev/null +++ b/infra/core/src/main/java/com/simprints/core/tools/time/TimerImpl.kt @@ -0,0 +1,20 @@ +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 TimerImpl @Inject constructor() : Timer { + + 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/KronosTimeHelperImplTest.kt b/infra/core/src/test/java/com/simprints/core/tools/time/KronosTimeHelperImplTest.kt index eacd578b13..8254d89661 100644 --- a/infra/core/src/test/java/com/simprints/core/tools/time/KronosTimeHelperImplTest.kt +++ b/infra/core/src/test/java/com/simprints/core/tools/time/KronosTimeHelperImplTest.kt @@ -3,28 +3,15 @@ package com.simprints.core.tools.time import com.google.common.truth.Truth.assertThat import com.lyft.kronos.KronosClock import com.lyft.kronos.KronosTime -import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.verify -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Ignore -import org.junit.Rule import org.junit.Test class KronosTimeHelperImplTest { - @get:Rule - val testCoroutineRule = TestCoroutineRule() - @MockK private lateinit var kronosClock: KronosClock @@ -92,44 +79,6 @@ class KronosTimeHelperImplTest { assertThat(result).isEqualTo(TIMESTAMP_TOMORROW) } - @Test - fun testObserveTickOncePerMinute_emitsImmediately() = runTest { - val result = timeHelperImpl.observeTickOncePerMinute() - .take(1) - .toList() - - assertThat(result).hasSize(1) - assertThat(result[0]).isEqualTo(Unit) - } - - @Test - fun testObserveTickOncePerMinute_emitsMultipleTimes() = runTest { - val result = timeHelperImpl.observeTickOncePerMinute() - .take(3) - .toList() - - assertThat(result).hasSize(3) - } - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun testObserveTickOncePerMinute_waitsForCorrectTime() = runTest { - val flow = timeHelperImpl.observeTickOncePerMinute() - - // 1st tick immediately - assertThat(flow.first()).isEqualTo(Unit) - - // no next tick earlier than in a minute - val deferred = async { flow.drop(1).first() } - advanceTimeBy(59_000L) - assertThat(deferred.isCompleted).isFalse() - - // next tick in a full minute - advanceTimeBy(1_000L) - deferred.await() - assertThat(deferred.isCompleted).isTrue() - } - companion object { // Random date at random time private const val TIMESTAMP = 1_542_537_183_000L diff --git a/infra/core/src/test/java/com/simprints/core/tools/time/TimerImplTest.kt b/infra/core/src/test/java/com/simprints/core/tools/time/TimerImplTest.kt new file mode 100644 index 0000000000..cc58c48d94 --- /dev/null +++ b/infra/core/src/test/java/com/simprints/core/tools/time/TimerImplTest.kt @@ -0,0 +1,68 @@ +package com.simprints.core.tools.time + +import com.google.common.truth.Truth.assertThat +import com.simprints.testtools.common.coroutines.TestCoroutineRule +import io.mockk.MockKAnnotations +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class TimerImplTest { + @get:Rule + val testCoroutineRule = TestCoroutineRule() + + private lateinit var timerImpl: TimerImpl + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + + timerImpl = TimerImpl() + } + + @Test + fun testObserveTickOncePerMinute_emitsImmediately() = runTest { + val result = timerImpl.observeTickOncePerMinute() + .take(1) + .toList() + + assertThat(result).hasSize(1) + assertThat(result[0]).isEqualTo(Unit) + } + + @Test + fun testObserveTickOncePerMinute_emitsMultipleTimes() = runTest { + val result = timerImpl.observeTickOncePerMinute() + .take(3) + .toList() + + assertThat(result).hasSize(3) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun testObserveTickOncePerMinute_waitsForCorrectTime() = runTest { + val flow = timerImpl.observeTickOncePerMinute() + + // 1st tick immediately + assertThat(flow.first()).isEqualTo(Unit) + + // no next tick earlier than in a minute + val deferred = async { flow.drop(1).first() } + advanceTimeBy(59_000L) + assertThat(deferred.isCompleted).isFalse() + + // next tick in a full minute + advanceTimeBy(1_000L) + deferred.await() + assertThat(deferred.isCompleted).isTrue() + } +} From 1d6c1a3a437ed5bb25584a2110b51c7edb8be71a Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 12 Aug 2025 09:57:57 +0100 Subject: [PATCH 29/45] MS-939 Sync info observer calculation use case extracted from the viewmodel --- .../settings/syncinfo/SyncInfoFragment.kt | 2 +- .../settings/syncinfo/SyncInfoViewModel.kt | 263 +--- .../usecase/ObserveSyncInfoUseCase.kt | 297 ++++ .../syncinfo/SyncInfoViewModelTest.kt | 1345 +++-------------- .../usecase/ObserveSyncInfoUseCaseTest.kt | 1140 ++++++++++++++ 5 files changed, 1615 insertions(+), 1432 deletions(-) create mode 100644 feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCase.kt create mode 100644 feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCaseTest.kt 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 3f70617813..13a1c5a0b4 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 @@ -128,7 +128,7 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.RESUMED) { viewModel.logoutEventLiveData.observe(viewLifecycleOwner, LiveDataEventWithContentObserver { - viewModel.logout() + viewModel.performLogout() }) } } 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 951c57e56a..23b2de695e 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 @@ -6,36 +6,20 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.asFlow import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope -import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.core.livedata.LiveDataEventWithContent -import com.simprints.core.tools.extentions.combine8 -import com.simprints.core.tools.extentions.onChange import com.simprints.core.tools.time.TimeHelper -import com.simprints.core.tools.time.Timer import com.simprints.feature.dashboard.logout.usecase.LogoutUseCase -import com.simprints.feature.dashboard.settings.syncinfo.modulecount.ModuleCount +import com.simprints.feature.dashboard.settings.syncinfo.usecase.ObserveSyncInfoUseCase import com.simprints.feature.login.LoginParams import com.simprints.feature.login.LoginResult 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.isEventDownSyncAllowed -import com.simprints.infra.config.store.models.isMissingModulesToChooseFrom import com.simprints.infra.config.store.models.isModuleSelectionAvailable -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 import com.simprints.infra.eventsync.EventSyncManager -import com.simprints.infra.eventsync.status.models.DownSyncCounts -import com.simprints.infra.images.ImageRepository -import com.simprints.infra.network.ConnectivityTracker import com.simprints.infra.recent.user.activity.RecentUserActivityManager import com.simprints.infra.sync.SyncOrchestrator import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged @@ -44,23 +28,17 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout import javax.inject.Inject -import kotlin.math.roundToInt @HiltViewModel internal class SyncInfoViewModel @Inject constructor( private val configManager: ConfigManager, - connectivityTracker: ConnectivityTracker, - private val enrolmentRecordRepository: EnrolmentRecordRepository, private val authStore: AuthStore, - private val imageRepository: ImageRepository, private val eventSyncManager: EventSyncManager, private val syncOrchestrator: SyncOrchestrator, - private val tokenizationProcessor: TokenizationProcessor, private val recentUserActivityManager: RecentUserActivityManager, private val timeHelper: TimeHelper, - timer: Timer, + observeSyncInfo: ObserveSyncInfoUseCase, private val logoutUseCase: LogoutUseCase, ) : ViewModel() { var isPreLogoutUpSync = false @@ -87,221 +65,9 @@ internal class SyncInfoViewModel @Inject constructor( LiveDataEventWithContent(Unit) }.asLiveData() - val syncInfoLiveData: LiveData = combine8( - connectivityTracker.observeIsConnected().asFlow(), - authStore.observeSignedInProjectId().map(String::isNotEmpty), - configManager.observeIsProjectRefreshing(), - eventSyncStateFlow, - imageSyncStatusFlow, - configManager.observeProjectConfiguration(), - configManager.observeDeviceConfiguration(), - timer.observeTickOncePerMinute(), - ) { isConnected, isLoggedIn, isRefreshing, eventSyncState, imageSyncStatus, projectConfig, deviceConfig, _ -> - - 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.isSyncInProgress() && totalEvents > 0 -> - (0.5f * currentEvents / totalEvents).coerceIn(0f, 0.5f) // combined progress 1st half - events - - eventSyncState.isSyncInProgress() && totalEvents > 0 -> - (currentEvents.toFloat() / totalEvents).coerceIn(0f, 1f) - - eventSyncState.isSyncConnecting() || eventSyncState.isThereNotSyncHistory() -> 0f - else -> 1f - } - val imagesNormalizedProgress = when { - imageSyncStatus.isSyncing && totalImages > 0 -> - (currentImages.toFloat() / totalImages).coerceIn(0f, 1f) - - else -> 1f - } - - val imagesToUpload = - if (imageSyncStatus.isSyncing) { - null - } else { - imageRepository.getNumberOfImagesToUpload(projectId = authStore.signedInProjectId) - } - - val eventSyncProgressPart = SyncInfoProgressPart( - isPending = eventSyncState.isSyncConnecting() || eventSyncState.isThereNotSyncHistory(), - isDone = eventSyncState.isSyncCompleted(), - areNumbersVisible = eventSyncState.isSyncInProgress() && totalEvents > 0, - currentNumber = currentEvents, - totalNumber = totalEvents, - ) - val imageSyncProgressPart = SyncInfoProgressPart( - isPending = eventSyncState.isSyncInProgress() && !imageSyncStatus.isSyncing, - isDone = !eventSyncState.isSyncInProgress() && !imageSyncStatus.isSyncing && imagesToUpload == 0, - areNumbersVisible = imageSyncStatus.isSyncing && totalImages > 0, - currentNumber = currentImages, - totalNumber = totalImages, - ) - - val isEventSyncInProgress = - eventSyncState.isSyncInProgress() - || (isPreLogoutUpSync && imageSyncStatus.isSyncing) // if combined with images - val eventSyncProgress = if (isEventSyncInProgress) { - SyncInfoProgress( - progressParts = if (isPreLogoutUpSync) { - listOf(eventSyncProgressPart, imageSyncProgressPart) - } else { - listOf(eventSyncProgressPart) - }, - progressBarPercentage = (eventsNormalizedProgress * 100).roundToInt(), - ) - } else { - SyncInfoProgress() - } - val imageSyncProgress = if (imageSyncStatus.isSyncing) { - SyncInfoProgress( - progressParts = listOf(imageSyncProgressPart), - progressBarPercentage = (imagesNormalizedProgress * 100).roundToInt(), - ) - } else { - SyncInfoProgress() - } - - val eventLastSyncMinutes = eventSyncManager.getLastSyncTime()?.run { - (timeHelper.now().ms - ms) / 60 / 1000 - }?.toInt() ?: -1 - val imageLastSyncMinutes = imageSyncStatus.secondsSinceLastUpdate?.let { - (it / 60).toInt() - } ?: -1 - - val isReLoginRequired = eventSyncState.isSyncFailedBecauseReloginRequired() - - val isModuleSelectionRequired = - !isPreLogoutUpSync && projectConfig.isModuleSelectionAvailable() && deviceConfig.selectedModules.isEmpty() - val isEventSyncAvailable = - !isReLoginRequired && isConnected && !eventSyncState.isSyncRunning() && !projectConfig.isMissingModulesToChooseFrom() - && !isModuleSelectionRequired - - val projectId = authStore.signedInProjectId - - val recordsTotal = when { - isEventSyncInProgress -> null - else -> enrolmentRecordRepository.count(SubjectQuery(projectId)) - } - val recordsToUpload = when { - isEventSyncInProgress -> null - else -> eventSyncManager.countEventsToUpload( - listOf(EventType.ENROLMENT_V2, EventType.ENROLMENT_V4) - ).firstOrNull() ?: 0 - } - val recordsToDownload = when { - isEventSyncInProgress -> null - isPreLogoutUpSync -> null - projectConfig.isEventDownSyncAllowed() -> try { - withTimeout(COUNT_EVENTS_TIMEOUT_MILLIS) { - eventSyncManager.countEventsToDownload(maxCacheAgeMillis = COUNT_EVENTS_TIMEOUT_MILLIS) - } - } catch (t: Throwable) { - DownSyncCounts(0, isLowerBound = false) - } - - else -> DownSyncCounts(0, isLowerBound = false) - } - - val project = configManager.getProject(projectId) - val isProjectEnding = - project.state == ProjectState.PROJECT_ENDING - val moduleCounts = 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), - ), - ) - } - 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(), - ) - } - ) - - val syncInfoSectionRecords = SyncInfoSectionRecords( - counterTotalRecords = recordsTotal?.toString().orEmpty(), - counterRecordsToUpload = recordsToUpload?.toString().orEmpty(), - isCounterRecordsToDownloadVisible = !isPreLogoutUpSync && !isProjectEnding, - counterRecordsToDownload = recordsToDownload?.let { "${it.count}${if (it.isLowerBound) "+" else ""}" }.orEmpty(), - isCounterImagesToUploadVisible = isPreLogoutUpSync, - counterImagesToUpload = imagesToUpload?.toString().orEmpty(), - isInstructionDefaultVisible = !isModuleSelectionRequired && isConnected && !eventSyncState.isSyncFailed() - && !eventSyncState.isSyncInProgress() && !isPreLogoutUpSync, - isInstructionNoModulesVisible = isConnected && isModuleSelectionRequired && !isEventSyncInProgress, - isInstructionOfflineVisible = !isConnected, - isInstructionErrorVisible = isConnected && eventSyncState.isSyncFailed(), - instructionPopupErrorInfo = SyncInfoError( - isBackendMaintenance = eventSyncState.isSyncFailedBecauseBackendMaintenance(), - backendMaintenanceEstimatedOutage = eventSyncState.getEstimatedBackendMaintenanceOutage() ?: -1, - isTooManyRequests = eventSyncState.isSyncFailedBecauseTooManyRequests() - ), - isProgressVisible = isEventSyncInProgress, - progress = eventSyncProgress, - isSyncButtonVisible = !isPreLogoutUpSync || eventSyncState.isSyncFailed(), - isSyncButtonEnabled = isEventSyncAvailable, - isSyncButtonForRetry = eventSyncState.isSyncFailed(), - isFooterSyncInProgressVisible = isPreLogoutUpSync && isEventSyncInProgress, - isFooterReadyToLogOutVisible = isPreLogoutUpSync && eventSyncState.isSyncCompleted() && !imageSyncStatus.isSyncing, - isFooterSyncIncompleteVisible = isPreLogoutUpSync && eventSyncState.isSyncFailed(), - isFooterLastSyncTimeVisible = !isPreLogoutUpSync && !eventSyncState.isSyncInProgress() && eventLastSyncMinutes >= 0, - footerLastSyncMinutesAgo = eventLastSyncMinutes, - ) - - val syncInfoSectionImages = SyncInfoSectionImages( - counterImagesToUpload = imagesToUpload?.toString().orEmpty(), - isInstructionDefaultVisible = !imageSyncStatus.isSyncing && isConnected, - isInstructionOfflineVisible = !isConnected, - isProgressVisible = imageSyncStatus.isSyncing, - progress = imageSyncProgress, - isSyncButtonEnabled = isConnected && !isReLoginRequired, - isFooterLastSyncTimeVisible = !imageSyncStatus.isSyncing && imageLastSyncMinutes >= 0, - footerLastSyncMinutesAgo = imageLastSyncMinutes, - ) - - val syncInfo = SyncInfo( - isLoggedIn, - isConfigurationLoadingProgressBarVisible = isRefreshing, - isLoginPromptSectionVisible = isReLoginRequired && !isPreLogoutUpSync, - syncInfoSectionRecords, - syncInfoSectionImages, - syncInfoSectionModules, - ) - return@combine8 syncInfo - }.onStart { + val syncInfoLiveData: LiveData = observeSyncInfo(isPreLogoutUpSync).onStart { startInitialSyncIfRequired() syncImagesAfterEventsWhenRequired() - }.onRecordSyncComplete { - delay(timeMillis = SYNC_COMPLETION_HOLD_MILLIS) - }.onImageSyncComplete { - delay(timeMillis = SYNC_COMPLETION_HOLD_MILLIS) }.asLiveData() fun forceEventSync() { @@ -324,7 +90,7 @@ internal class SyncInfoViewModel @Inject constructor( } } - fun logout() { + fun performLogout() { logoutUseCase() } @@ -346,25 +112,6 @@ internal class SyncInfoViewModel @Inject constructor( } - // sync info change detection helpers - - private fun Flow.onRecordSyncComplete(action: suspend (SyncInfo) -> Unit) = - onChange( - comparator = { previous, current -> - previous.syncInfoSectionRecords.isProgressVisible && !current.syncInfoSectionRecords.isProgressVisible - }, - action, - ) - - private fun Flow.onImageSyncComplete(action: suspend (SyncInfo) -> Unit) = - onChange( - comparator = { previous, current -> - previous.syncInfoSectionImages.isProgressVisible && !current.syncInfoSectionImages.isProgressVisible - }, - action, - ) - - // initial actions private fun startInitialSyncIfRequired() { @@ -406,8 +153,6 @@ internal class SyncInfoViewModel @Inject constructor( private companion object { private const val RE_SYNC_TIMEOUT_MILLIS = 5 * 60 * 1000L - private const val SYNC_COMPLETION_HOLD_MILLIS = 1000L private const val LOGOUT_DELAY_MILLIS = 3000L - private const val COUNT_EVENTS_TIMEOUT_MILLIS = 10 * 1000L } } 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 new file mode 100644 index 0000000000..a94c28b6d9 --- /dev/null +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCase.kt @@ -0,0 +1,297 @@ +package com.simprints.feature.dashboard.settings.syncinfo.usecase + +import androidx.lifecycle.asFlow +import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.core.tools.extentions.combine8 +import com.simprints.core.tools.extentions.onChange +import com.simprints.core.tools.time.TimeHelper +import com.simprints.core.tools.time.Timer +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.ProjectState +import com.simprints.infra.config.store.models.TokenKeyType +import com.simprints.infra.config.store.models.isEventDownSyncAllowed +import com.simprints.infra.config.store.models.isMissingModulesToChooseFrom +import com.simprints.infra.config.store.models.isModuleSelectionAvailable +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 +import com.simprints.infra.eventsync.EventSyncManager +import com.simprints.infra.eventsync.status.models.DownSyncCounts +import com.simprints.infra.images.ImageRepository +import com.simprints.infra.network.ConnectivityTracker +import com.simprints.infra.sync.SyncOrchestrator +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withTimeout +import javax.inject.Inject +import kotlin.math.roundToInt + +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 timer: Timer, +) { + private val eventSyncStateFlow = + eventSyncManager.getLastSyncState(useDefaultValue = true /* otherwise value not guaranteed */).asFlow() + private val imageSyncStatusFlow = + syncOrchestrator.observeImageSyncStatus() + + operator fun invoke(isPreLogoutUpSync: Boolean = false): Flow = combine8( + connectivityTracker.observeIsConnected().asFlow(), + authStore.observeSignedInProjectId().map(String::isNotEmpty), + configManager.observeIsProjectRefreshing(), + eventSyncStateFlow, + imageSyncStatusFlow, + configManager.observeProjectConfiguration(), + configManager.observeDeviceConfiguration(), + timer.observeTickOncePerMinute(), + ) { isConnected, isLoggedIn, isRefreshing, eventSyncState, imageSyncStatus, projectConfig, deviceConfig, _ -> + + 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.isSyncInProgress() && totalEvents > 0 -> + (0.5f * currentEvents / totalEvents).coerceIn(0f, 0.5f) // combined progress 1st half - events + + eventSyncState.isSyncInProgress() && totalEvents > 0 -> + (currentEvents.toFloat() / totalEvents).coerceIn(0f, 1f) + + eventSyncState.isSyncConnecting() || eventSyncState.isThereNotSyncHistory() -> 0f + else -> 1f + } + val imagesNormalizedProgress = when { + imageSyncStatus.isSyncing && totalImages > 0 -> + (currentImages.toFloat() / totalImages).coerceIn(0f, 1f) + + else -> 1f + } + + val imagesToUpload = + if (imageSyncStatus.isSyncing) { + null + } else { + imageRepository.getNumberOfImagesToUpload(projectId = authStore.signedInProjectId) + } + + val eventSyncProgressPart = SyncInfoProgressPart( + isPending = eventSyncState.isSyncConnecting() || eventSyncState.isThereNotSyncHistory(), + isDone = eventSyncState.isSyncCompleted(), + areNumbersVisible = eventSyncState.isSyncInProgress() && totalEvents > 0, + currentNumber = currentEvents, + totalNumber = totalEvents, + ) + val imageSyncProgressPart = SyncInfoProgressPart( + isPending = eventSyncState.isSyncInProgress() && !imageSyncStatus.isSyncing, + isDone = !eventSyncState.isSyncInProgress() && !imageSyncStatus.isSyncing && imagesToUpload == 0, + areNumbersVisible = imageSyncStatus.isSyncing && totalImages > 0, + currentNumber = currentImages, + totalNumber = totalImages, + ) + + val isEventSyncInProgress = + eventSyncState.isSyncInProgress() + || (isPreLogoutUpSync && imageSyncStatus.isSyncing) // if combined with images + val eventSyncProgress = if (isEventSyncInProgress) { + SyncInfoProgress( + progressParts = if (isPreLogoutUpSync) { + listOf(eventSyncProgressPart, imageSyncProgressPart) + } else { + listOf(eventSyncProgressPart) + }, + progressBarPercentage = (eventsNormalizedProgress * 100).roundToInt(), + ) + } else { + SyncInfoProgress() + } + val imageSyncProgress = if (imageSyncStatus.isSyncing) { + SyncInfoProgress( + progressParts = listOf(imageSyncProgressPart), + progressBarPercentage = (imagesNormalizedProgress * 100).roundToInt(), + ) + } else { + SyncInfoProgress() + } + + val eventLastSyncMinutes = eventSyncManager.getLastSyncTime()?.run { + (timeHelper.now().ms - ms) / 60 / 1000 + }?.toInt() ?: -1 + val imageLastSyncMinutes = imageSyncStatus.secondsSinceLastUpdate?.let { + (it / 60).toInt() + } ?: -1 + + val isReLoginRequired = eventSyncState.isSyncFailedBecauseReloginRequired() + + val isModuleSelectionRequired = + !isPreLogoutUpSync && projectConfig.isModuleSelectionAvailable() && deviceConfig.selectedModules.isEmpty() + val isEventSyncAvailable = + !isReLoginRequired && isConnected && !eventSyncState.isSyncRunning() && !projectConfig.isMissingModulesToChooseFrom() + && !isModuleSelectionRequired + + val projectId = authStore.signedInProjectId + + val recordsTotal = when { + isEventSyncInProgress -> null + else -> enrolmentRecordRepository.count(SubjectQuery(projectId)) + } + val recordsToUpload = when { + isEventSyncInProgress -> null + else -> eventSyncManager.countEventsToUpload( + listOf(EventType.ENROLMENT_V2, EventType.ENROLMENT_V4) + ).firstOrNull() ?: 0 + } + val recordsToDownload = when { + isEventSyncInProgress -> null + isPreLogoutUpSync -> null + projectConfig.isEventDownSyncAllowed() -> try { + withTimeout(COUNT_EVENTS_TIMEOUT_MILLIS) { + eventSyncManager.countEventsToDownload(maxCacheAgeMillis = COUNT_EVENTS_TIMEOUT_MILLIS) + } + } catch (_: Throwable) { + DownSyncCounts(0, isLowerBound = false) + } + + else -> DownSyncCounts(0, isLowerBound = false) + } + + val project = configManager.getProject(projectId) + val isProjectEnding = + project.state == ProjectState.PROJECT_ENDING + val moduleCounts = 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), + ), + ) + } + 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(), + ) + } + ) + + val syncInfoSectionRecords = SyncInfoSectionRecords( + counterTotalRecords = recordsTotal?.toString().orEmpty(), + counterRecordsToUpload = recordsToUpload?.toString().orEmpty(), + isCounterRecordsToDownloadVisible = !isPreLogoutUpSync && !isProjectEnding, + counterRecordsToDownload = recordsToDownload?.let { "${it.count}${if (it.isLowerBound) "+" else ""}" }.orEmpty(), + isCounterImagesToUploadVisible = isPreLogoutUpSync, + counterImagesToUpload = imagesToUpload?.toString().orEmpty(), + isInstructionDefaultVisible = !isModuleSelectionRequired && isConnected && !eventSyncState.isSyncFailed() + && !eventSyncState.isSyncInProgress() && !isPreLogoutUpSync, + isInstructionNoModulesVisible = isConnected && isModuleSelectionRequired && !isEventSyncInProgress, + isInstructionOfflineVisible = !isConnected, + isInstructionErrorVisible = isConnected && eventSyncState.isSyncFailed(), + instructionPopupErrorInfo = SyncInfoError( + isBackendMaintenance = eventSyncState.isSyncFailedBecauseBackendMaintenance(), + backendMaintenanceEstimatedOutage = eventSyncState.getEstimatedBackendMaintenanceOutage() ?: -1, + isTooManyRequests = eventSyncState.isSyncFailedBecauseTooManyRequests() + ), + isProgressVisible = isEventSyncInProgress, + progress = eventSyncProgress, + isSyncButtonVisible = !isPreLogoutUpSync || eventSyncState.isSyncFailed(), + isSyncButtonEnabled = isEventSyncAvailable, + isSyncButtonForRetry = eventSyncState.isSyncFailed(), + isFooterSyncInProgressVisible = isPreLogoutUpSync && isEventSyncInProgress, + isFooterReadyToLogOutVisible = isPreLogoutUpSync && eventSyncState.isSyncCompleted() && !imageSyncStatus.isSyncing, + isFooterSyncIncompleteVisible = isPreLogoutUpSync && eventSyncState.isSyncFailed(), + isFooterLastSyncTimeVisible = !isPreLogoutUpSync && !eventSyncState.isSyncInProgress() && eventLastSyncMinutes >= 0, + footerLastSyncMinutesAgo = eventLastSyncMinutes, + ) + + val syncInfoSectionImages = SyncInfoSectionImages( + counterImagesToUpload = imagesToUpload?.toString().orEmpty(), + isInstructionDefaultVisible = !imageSyncStatus.isSyncing && isConnected, + isInstructionOfflineVisible = !isConnected, + isProgressVisible = imageSyncStatus.isSyncing, + progress = imageSyncProgress, + isSyncButtonEnabled = isConnected && !isReLoginRequired, + isFooterLastSyncTimeVisible = !imageSyncStatus.isSyncing && imageLastSyncMinutes >= 0, + footerLastSyncMinutesAgo = imageLastSyncMinutes, + ) + + val syncInfo = SyncInfo( + isLoggedIn, + isConfigurationLoadingProgressBarVisible = isRefreshing, + isLoginPromptSectionVisible = isReLoginRequired && !isPreLogoutUpSync, + syncInfoSectionRecords, + syncInfoSectionImages, + syncInfoSectionModules, + ) + return@combine8 syncInfo + }.onRecordSyncComplete { + delay(timeMillis = SYNC_COMPLETION_HOLD_MILLIS) + }.onImageSyncComplete { + delay(timeMillis = SYNC_COMPLETION_HOLD_MILLIS) + } + + + // sync info change detection helpers + + private fun Flow.onRecordSyncComplete(action: suspend (SyncInfo) -> Unit) = + onChange( + comparator = { previous, current -> + previous.syncInfoSectionRecords.isProgressVisible && !current.syncInfoSectionRecords.isProgressVisible + }, + action, + ) + + private fun Flow.onImageSyncComplete(action: suspend (SyncInfo) -> Unit) = + onChange( + comparator = { previous, current -> + previous.syncInfoSectionImages.isProgressVisible && !current.syncInfoSectionImages.isProgressVisible + }, + action, + ) + + + private companion object { + private const val SYNC_COMPLETION_HOLD_MILLIS = 1000L + private const val COUNT_EVENTS_TIMEOUT_MILLIS = 10 * 1000L + } +} 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 ef38cbd0b0..c022647c1d 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 @@ -8,9 +8,9 @@ import androidx.lifecycle.asFlow import com.google.common.truth.Truth.assertThat import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.core.tools.time.TimeHelper -import com.simprints.core.tools.time.Timer import com.simprints.core.tools.time.Timestamp import com.simprints.feature.dashboard.logout.usecase.LogoutUseCase +import com.simprints.feature.dashboard.settings.syncinfo.usecase.ObserveSyncInfoUseCase import com.simprints.feature.login.LoginResult import com.simprints.infra.authstore.AuthStore import com.simprints.infra.config.store.models.DeviceConfiguration @@ -18,18 +18,13 @@ 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.TokenKeyType import com.simprints.infra.config.store.models.isEventDownSyncAllowed import com.simprints.infra.config.store.models.isModuleSelectionAvailable import com.simprints.infra.config.store.models.isMissingModulesToChooseFrom -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.status.models.DownSyncCounts import com.simprints.infra.eventsync.status.models.EventSyncState -import com.simprints.infra.images.ImageRepository -import com.simprints.infra.network.ConnectivityTracker import com.simprints.infra.recent.user.activity.RecentUserActivityManager import com.simprints.infra.sync.SyncOrchestrator import com.simprints.infra.sync.ImageSyncStatus @@ -46,7 +41,6 @@ import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import org.junit.Before @@ -62,16 +56,12 @@ class SyncInfoViewModelTest { 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 recentUserActivityManager = mockk() private val timeHelper = mockk() - private val timer = mockk() + private val observeSyncInfo = mockk() private val logoutUseCase = mockk(relaxed = true) private lateinit var viewModel: SyncInfoViewModel @@ -80,7 +70,6 @@ class SyncInfoViewModelTest { const val TEST_PROJECT_ID = "test_project_id" const val TEST_USER_ID = "test_user_id" const val TEST_RECENT_USER_ID = "recent_user_id" - const val TEST_MODULE_NAME = "test_module" val TEST_TIMESTAMP = Timestamp(1000L) } @@ -131,7 +120,6 @@ class SyncInfoViewModelTest { 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) @@ -155,1134 +143,236 @@ class SyncInfoViewModelTest { coEvery { syncOrchestrator.startImageSync() } returns Unit coEvery { syncOrchestrator.stopImageSync() } returns Unit - coEvery { imageRepository.getNumberOfImagesToUpload(any()) } returns 0 - coEvery { enrolmentRecordRepository.count(any()) } returns 0 - - every { timer.observeTickOncePerMinute() } returns MutableStateFlow(Unit) - every { timeHelper.now() } returns TEST_TIMESTAMP - every { timeHelper.msBetweenNowAndTime(any()) } returns 0L - - coEvery { recentUserActivityManager.getRecentUserActivity() } returns mockk { - every { lastUserUsed } returns TokenizableString.Raw(TEST_RECENT_USER_ID) - } - - every { tokenizationProcessor.decrypt(any(), any(), any()) } returns TokenizableString.Raw("decrypted_module") - - every { any().isModuleSelectionAvailable() } returns false - every { any().isEventDownSyncAllowed() } returns true - every { any().isMissingModulesToChooseFrom() } returns false - } - - private fun createViewModel() { - viewModel = SyncInfoViewModel( - configManager = configManager, - connectivityTracker = connectivityTracker, - enrolmentRecordRepository = enrolmentRecordRepository, - authStore = authStore, - imageRepository = imageRepository, - eventSyncManager = eventSyncManager, - syncOrchestrator = syncOrchestrator, - tokenizationProcessor = tokenizationProcessor, - recentUserActivityManager = recentUserActivityManager, - timeHelper = timeHelper, - timer = timer, - logoutUseCase = logoutUseCase, - ) - } - - // LiveData loginNavigationEventLiveData tests - - @Test - fun `should show login navigation when user requests login`() = runTest { - viewModel.requestNavigationToLogin() - val result = viewModel.loginNavigationEventLiveData.getOrAwaitValue() - - assertThat(result).isNotNull() - } - - // LiveData logoutEventLiveData tests - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun `should trigger logout when pre-logout sync completes successfully`() = runTest { - val mockCompletedEventSyncState = mockk(relaxed = true) { - every { isSyncCompleted() } returns true - } - val mockNotSyncingImageStatus = mockk(relaxed = true) { - every { isSyncing } returns false - every { progress } returns null - every { secondsSinceLastUpdate } returns 0 - } - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) - every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) - createViewModel() - viewModel.isPreLogoutUpSync = true - val observer = mockk>>(relaxed = true) - val slot = slot>() - val capturedValues = mutableListOf>() - every { observer.onChanged(capture(slot)) } answers { - capturedValues.add(slot.captured) - } - - viewModel.logoutEventLiveData.observeForever(observer) - advanceTimeBy(3100L) // after the logout delay (3000ms) - - assertThat(capturedValues.map { it.peekContent() }).contains(Unit) - } - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun `should emit a logout event after the intended delay since ready to logout`() = runTest { - val mockCompletedEventSyncState = mockk(relaxed = true) { - every { isSyncCompleted() } returns true - } - val mockNotSyncingImageStatus = mockk(relaxed = true) { - every { isSyncing } returns false - every { progress } returns null - every { secondsSinceLastUpdate } returns 0 - } - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) - every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) - createViewModel() - viewModel.isPreLogoutUpSync = true - val observer = mockk>>(relaxed = true) - val slot = slot>() - val capturedValues = mutableListOf>() - every { observer.onChanged(capture(slot)) } answers { - capturedValues.add(slot.captured) - } - - viewModel.logoutEventLiveData.observeForever(observer) - advanceTimeBy(2900L) // still during the debounce delay - - assertThat(capturedValues).isEmpty() // no logout event yet - - advanceTimeBy(200L) // after the debounce delay (total 3100ms > 3000ms) - - assertThat(capturedValues).hasSize(1) - assertThat(capturedValues[0].peekContent()).isEqualTo(Unit) - } - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun `should not trigger logout when not in pre-logout mode`() = runTest { - val mockCompletedEventSyncState = mockk(relaxed = true) { - every { isSyncCompleted() } returns true - } - val mockNotSyncingImageStatus = mockk(relaxed = true) { - every { isSyncing } returns false - every { progress } returns null - every { secondsSinceLastUpdate } returns 0 - } - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) - every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) - createViewModel() - viewModel.isPreLogoutUpSync = false - val observer = mockk>>(relaxed = true) - val slot = slot>() - val capturedValues = mutableListOf>() - every { observer.onChanged(capture(slot)) } answers { - capturedValues.add(slot.captured) - } - - viewModel.logoutEventLiveData.observeForever(observer) - advanceTimeBy(3100L) // after the logout delay (3000ms) - - assertThat(capturedValues).isEmpty() - } - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun `should not trigger logout when records still syncing`() = runTest { - val mockInProgressEventSyncState = mockk(relaxed = true) { - every { isSyncCompleted() } returns false - every { isSyncInProgress() } returns true - } - val mockNotSyncingImageStatus = mockk(relaxed = true) { - every { isSyncing } returns false - every { progress } returns null - every { secondsSinceLastUpdate } returns 0 - } - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockInProgressEventSyncState) - every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) - createViewModel() - viewModel.isPreLogoutUpSync = true - val observer = mockk>>(relaxed = true) - val slot = slot>() - val capturedValues = mutableListOf>() - every { observer.onChanged(capture(slot)) } answers { - capturedValues.add(slot.captured) - } - - viewModel.logoutEventLiveData.observeForever(observer) - advanceTimeBy(3100L) // after the logout delay (3000ms) - - assertThat(capturedValues).isEmpty() - } - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun `should not trigger logout when images still syncing`() = runTest { - val mockCompletedEventSyncState = mockk(relaxed = true) { - every { isSyncCompleted() } returns true - } - val mockSyncingImageStatus = mockk(relaxed = true) { - every { isSyncing } returns true - every { progress } returns Pair(1, 2) - every { secondsSinceLastUpdate } returns null - } - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) - every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockSyncingImageStatus) - createViewModel() - viewModel.isPreLogoutUpSync = true - val observer = mockk>>(relaxed = true) - val slot = slot>() - val capturedValues = mutableListOf>() - every { observer.onChanged(capture(slot)) } answers { - capturedValues.add(slot.captured) - } - - viewModel.logoutEventLiveData.observeForever(observer) - advanceTimeBy(3100L) // after the logout delay (3000ms) - - assertThat(capturedValues).isEmpty() - } - - // LiveData syncInfoLiveData tests - - @Test - fun `should not show re-login prompt when sync has not failed due to authentication`() = runTest { - val mockNormalEventSyncState = mockk(relaxed = true) { - every { isSyncFailedBecauseReloginRequired() } returns false - } - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockNormalEventSyncState) - createViewModel() - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.isLoginPromptSectionVisible).isFalse() - } - - @Test - fun `should show configuration loading when project is refreshing`() = runTest { - every { configManager.observeIsProjectRefreshing() } returns MutableStateFlow(true) - createViewModel() - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.isConfigurationLoadingProgressBarVisible).isTrue() - } - - @Test - fun `should show re-login prompt when sync failed due to authentication required`() = runTest { - val mockFailedEventSyncState = mockk(relaxed = true) { - every { isSyncFailedBecauseReloginRequired() } returns true - } - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockFailedEventSyncState) - createViewModel() - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.isLoginPromptSectionVisible).isTrue() - } - - // Section-specific tests - - @Test - fun `should emit SyncInfo with correct syncInfoSectionRecords instruction visibility`() = runTest { - val mockOfflineEventSyncState = mockk(relaxed = true) { - every { isSyncFailed() } returns false - every { isSyncInProgress() } returns false - } - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockOfflineEventSyncState) - every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(false) - createViewModel() - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.syncInfoSectionRecords.isInstructionOfflineVisible).isTrue() - assertThat(result.syncInfoSectionRecords.isInstructionErrorVisible).isFalse() - assertThat(result.syncInfoSectionRecords.isInstructionDefaultVisible).isFalse() - } - - @Test - fun `should emit SyncInfo with correct syncInfoSectionRecords button states`() = runTest { - val mockNormalEventSyncState = mockk(relaxed = true) { - every { isSyncRunning() } returns false - every { isSyncFailedBecauseReloginRequired() } returns false - every { isSyncFailed() } returns false - } - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockNormalEventSyncState) - every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) - createViewModel() - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.syncInfoSectionRecords.isSyncButtonEnabled).isTrue() - assertThat(result.syncInfoSectionRecords.isSyncButtonVisible).isTrue() - assertThat(result.syncInfoSectionRecords.isSyncButtonForRetry).isFalse() - } - - @Test - fun `should emit SyncInfo with correct syncInfoSectionRecords footer states`() = runTest { - val mockCompletedEventSyncState = mockk(relaxed = true) { - every { isSyncInProgress() } returns false - } - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) - coEvery { eventSyncManager.getLastSyncTime() } returns TEST_TIMESTAMP - createViewModel() - viewModel.isPreLogoutUpSync = false - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.syncInfoSectionRecords.isFooterLastSyncTimeVisible).isTrue() - assertThat(result.syncInfoSectionRecords.footerLastSyncMinutesAgo).isEqualTo(0) - assertThat(result.syncInfoSectionRecords.isFooterSyncInProgressVisible).isFalse() - } - - @Test - fun `should emit SyncInfo with correct syncInfoSectionImages instruction visibility`() = runTest { - val mockNotSyncingImageStatus = mockk(relaxed = true) { - every { isSyncing } returns false - every { progress } returns null - } - every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) - every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(false) - createViewModel() - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.syncInfoSectionImages.isInstructionOfflineVisible).isTrue() - assertThat(result.syncInfoSectionImages.isInstructionDefaultVisible).isFalse() - } - - @Test - fun `should emit SyncInfo with correct syncInfoSectionImages button states`() = runTest { - val mockNormalEventSyncState = mockk(relaxed = true) { - every { isSyncFailedBecauseReloginRequired() } returns false - } - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockNormalEventSyncState) - every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) - createViewModel() - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.syncInfoSectionImages.isSyncButtonEnabled).isTrue() - } - - @Test - fun `should emit SyncInfo with correct syncInfoSectionImages footer states`() = runTest { - val mockImageStatusWithLastSync = mockk(relaxed = true) { - every { isSyncing } returns false - every { progress } returns null - every { secondsSinceLastUpdate } returns 120 // 2 minutes - } - every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockImageStatusWithLastSync) - createViewModel() - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.syncInfoSectionImages.isFooterLastSyncTimeVisible).isTrue() - assertThat(result.syncInfoSectionImages.footerLastSyncMinutesAgo).isEqualTo(2) - } - - @Test - fun `should emit SyncInfo with correct syncInfoSectionModules data`() = runTest { - val mockProjectConfigWithModules = mockk { - every { general } returns mockk { - every { modalities } returns listOf(GeneralConfiguration.Modality.FINGERPRINT) - } - } - 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 { mockProjectConfigWithModules.isModuleSelectionAvailable() } returns true - createViewModel() - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - 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") - } - - // Progress calculation tests - - @Test - fun `should calculate correct event sync progress when sync in progress`() = runTest { - val mockInProgressEventSyncState = mockk(relaxed = true) { - every { isSyncInProgress() } returns true - every { isSyncCompleted() } returns false - every { progress } returns 5 - every { total } returns 10 - } - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockInProgressEventSyncState) - createViewModel() - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.syncInfoSectionRecords.isProgressVisible).isTrue() - assertThat(result.syncInfoSectionRecords.progress.progressBarPercentage).isEqualTo(50) // half - } - - @Test - fun `should calculate correct event sync progress when sync connecting`() = runTest { - val mockConnectingEventSyncState = mockk(relaxed = true) { - every { isSyncConnecting() } returns true - every { isSyncInProgress() } returns false - every { isSyncCompleted() } returns false - every { isThereNotSyncHistory() } returns false - } - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockConnectingEventSyncState) - createViewModel() - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.syncInfoSectionRecords.progress.progressBarPercentage).isEqualTo(0) // not started - } - - @Test - fun `should calculate correct event sync progress when sync approached completion`() = runTest { - val mockCompletedEventSyncState = mockk(relaxed = true) { - every { isSyncInProgress() } returns true - every { progress } returns 10 - every { total } returns 10 - } - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) - createViewModel() - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.syncInfoSectionRecords.progress.progressBarPercentage).isEqualTo(100) - } - - @Test - fun `should not show event sync progress when sync completed`() = runTest { - val mockCompletedEventSyncState = mockk(relaxed = true) { - every { isSyncCompleted() } returns true - } - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) - createViewModel() - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.syncInfoSectionRecords.isProgressVisible).isFalse() - } - - @Test - fun `should calculate correct combined progress during pre-logout sync events phase`() = runTest { - val mockInProgressEventSyncState = mockk(relaxed = true) { - every { isSyncInProgress() } returns true - every { isSyncCompleted() } returns false - every { progress } returns 3 - every { total } returns 6 - } - val mockNotSyncingImageStatus = mockk(relaxed = true) { - every { isSyncing } returns false - every { progress } returns null - } - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockInProgressEventSyncState) - every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) - createViewModel() - viewModel.isPreLogoutUpSync = true - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - // 50% of the first half (0-50%) of scale dedicated to the records, so 25% total - assertThat(result.syncInfoSectionRecords.progress.progressBarPercentage).isEqualTo(25) - } - - @Test - fun `should calculate correct combined progress during pre-logout sync images phase`() = runTest { - val mockCompletedEventSyncState = mockk(relaxed = true) { - every { isSyncCompleted() } returns true - every { isSyncInProgress() } returns false - } - val mockSyncingImageStatus = mockk(relaxed = true) { - every { isSyncing } returns true - every { progress } returns Pair(2, 4) // 2 out of 4 images - } - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) - every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockSyncingImageStatus) - createViewModel() - viewModel.isPreLogoutUpSync = true - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - // 50% of the second half (50-75%) of scale dedicated to the images, so 75% total - assertThat(result.syncInfoSectionRecords.progress.progressBarPercentage).isEqualTo(75) - } - - @Test - fun `should calculate correct image sync progress when images syncing`() = runTest { - val mockSyncingImageStatus = mockk(relaxed = true) { - every { isSyncing } returns true - every { progress } returns Pair(3, 10) // 3 out of 10 images - } - every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockSyncingImageStatus) - createViewModel() - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.syncInfoSectionImages.isProgressVisible).isTrue() - assertThat(result.syncInfoSectionImages.progress.progressBarPercentage).isEqualTo(30) - } - - @Test - fun `should calculate correct image sync progress when images not syncing`() = runTest { - val mockNotSyncingImageStatus = mockk(relaxed = true) { - every { isSyncing } returns false - every { progress } returns null - } - every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) - coEvery { imageRepository.getNumberOfImagesToUpload(any()) } returns 0 - createViewModel() - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.syncInfoSectionImages.isProgressVisible).isFalse() - assertThat(result.syncInfoSectionImages.progress.progressBarPercentage).isEqualTo(0) - } - - // Counter tests - - @Test - fun `should emit SyncInfo with correct record counters when sync not in progress`() = runTest { - val mockIdleEventSyncState = mockk(relaxed = true) { - every { isSyncInProgress() } returns false - every { isSyncRunning() } returns false - } - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) - coEvery { enrolmentRecordRepository.count(any()) } returns 25 - coEvery { eventSyncManager.countEventsToUpload(any()) } returns flowOf(5) - coEvery { eventSyncManager.countEventsToDownload(any()) } returns DownSyncCounts(8, isLowerBound = false) - createViewModel() - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.syncInfoSectionRecords.counterTotalRecords).isEqualTo("25") - assertThat(result.syncInfoSectionRecords.counterRecordsToUpload).isEqualTo("5") - assertThat(result.syncInfoSectionRecords.counterRecordsToDownload).isEqualTo("8") - } - - @Test - fun `should emit SyncInfo with empty record counters when sync in progress`() = runTest { - val mockInProgressEventSyncState = mockk(relaxed = true) { - every { isSyncInProgress() } returns true - } - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockInProgressEventSyncState) - createViewModel() - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.syncInfoSectionRecords.counterTotalRecords).isEmpty() - assertThat(result.syncInfoSectionRecords.counterRecordsToUpload).isEmpty() - assertThat(result.syncInfoSectionRecords.counterRecordsToDownload).isEmpty() - } - - @Test - fun `should emit SyncInfo with correct images to upload counter when sync not in progress`() = runTest { - val mockNotSyncingImageStatus = mockk(relaxed = true) { - every { isSyncing } returns false - every { progress } returns null - } - every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) - coEvery { imageRepository.getNumberOfImagesToUpload(any()) } returns 15 - createViewModel() - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.syncInfoSectionRecords.counterImagesToUpload).isEqualTo("15") // may be shown within records - assertThat(result.syncInfoSectionImages.counterImagesToUpload).isEqualTo("15") - } - - @Test - fun `should emit SyncInfo with empty images counter when sync in progress`() = runTest { - val mockSyncingImageStatus = mockk(relaxed = true) { - every { isSyncing } returns true - every { progress } returns null - } - every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockSyncingImageStatus) - createViewModel() - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.syncInfoSectionRecords.counterImagesToUpload).isEmpty() // may be shown within records - assertThat(result.syncInfoSectionImages.counterImagesToUpload).isEmpty() - } - - @Test - fun `should emit SyncInfo with correct module counts when modules selected`() = runTest { - val mockProjectConfigWithModules = mockk { - every { general } returns mockk { - every { modalities } returns listOf(GeneralConfiguration.Modality.FINGERPRINT) - } - } - 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 { mockProjectConfigWithModules.isModuleSelectionAvailable() } returns true - createViewModel() - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - 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") - // module_1 - assertThat(result.syncInfoSectionModules.moduleCounts[1]).isEqualTo( - SyncInfoModuleCount(isTotal = false, name = "module_1", count = "15") - ) - // module_2 - assertThat(result.syncInfoSectionModules.moduleCounts[2]).isEqualTo( - SyncInfoModuleCount(isTotal = false, name = "module_2", count = "25") - ) - } - - @Test - fun `should emit SyncInfo with empty module counts when no modules selected`() = runTest { - val mockProjectConfigWithoutModules = mockk { - every { general } returns mockk { - every { modalities } returns emptyList() - } - } - 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 { mockProjectConfigWithoutModules.isModuleSelectionAvailable() } returns false - createViewModel() - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.syncInfoSectionModules.isSectionAvailable).isFalse() - assertThat(result.syncInfoSectionModules.moduleCounts).isEmpty() - } - - @Test - fun `should emit SyncInfo with correct records to download counter visible when allowed`() = runTest { - val mockProjectConfigWithDownSync = mockk { - every { general } returns mockk { - every { modalities } returns emptyList() - } - } - val mockIdleEventSyncState = mockk(relaxed = true) { - every { isSyncInProgress() } returns false - } - - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) - every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithDownSync) - coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithDownSync - coEvery { eventSyncManager.countEventsToDownload(any()) } returns DownSyncCounts(42, isLowerBound = false) - every { mockProjectConfigWithDownSync.isEventDownSyncAllowed() } returns true - every { mockProjectConfigWithDownSync.isModuleSelectionAvailable() } returns false - createViewModel() - viewModel.isPreLogoutUpSync = false - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.syncInfoSectionRecords.isCounterRecordsToDownloadVisible).isTrue() - assertThat(result.syncInfoSectionRecords.counterRecordsToDownload).isEqualTo("42") - } - - @Test - fun `should emit SyncInfo with hidden records to download counter when pre-logout mode`() = runTest { - val mockIdleEventSyncState = mockk(relaxed = true) { - every { isSyncInProgress() } returns false - } - - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) - createViewModel() - viewModel.isPreLogoutUpSync = true - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.syncInfoSectionRecords.isCounterRecordsToDownloadVisible).isFalse() - } - - @Test - fun `should handle timeout when counting records to download`() = runTest { - val mockProjectConfigWithDownSync = mockk { - every { general } returns mockk { - every { modalities } returns emptyList() - } - } - val mockIdleEventSyncState = mockk(relaxed = true) { - every { isSyncInProgress() } returns false - } - - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) - every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithDownSync) - coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithDownSync - coEvery { eventSyncManager.countEventsToDownload(any()) } throws Exception("Timeout") - every { mockProjectConfigWithDownSync.isEventDownSyncAllowed() } returns true - every { mockProjectConfigWithDownSync.isModuleSelectionAvailable() } returns false - createViewModel() - viewModel.isPreLogoutUpSync = false - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.syncInfoSectionRecords.counterRecordsToDownload).isEqualTo("0") - } - - @Test - fun `should handle when records download counting throws exception`() = runTest { - val mockProjectConfigWithDownSync = mockk { - every { general } returns mockk { - every { modalities } returns emptyList() - } - } - val mockIdleEventSyncState = mockk(relaxed = true) { - every { isSyncInProgress() } returns false - } - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) - every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithDownSync) - coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithDownSync - coEvery { eventSyncManager.countEventsToDownload(any()) } throws RuntimeException("Network error") - every { mockProjectConfigWithDownSync.isEventDownSyncAllowed() } returns true - every { mockProjectConfigWithDownSync.isModuleSelectionAvailable() } returns false - createViewModel() - viewModel.isPreLogoutUpSync = false - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.syncInfoSectionRecords.counterRecordsToDownload).isEqualTo("0") - } - - @Test - fun `should handle network errors indication`() = runTest { - val connectivityFlow = MutableStateFlow(false) // start offline - every { connectivityTracker.observeIsConnected().asFlow() } returns connectivityFlow - createViewModel() - - val offlineResult = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(offlineResult.syncInfoSectionRecords.isInstructionOfflineVisible).isTrue() - assertThat(offlineResult.syncInfoSectionRecords.isSyncButtonEnabled).isFalse() - assertThat(offlineResult.syncInfoSectionImages.isSyncButtonEnabled).isFalse() - - connectivityFlow.value = true - - val onlineResult = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(onlineResult.syncInfoSectionRecords.isInstructionOfflineVisible).isFalse() - assertThat(onlineResult.syncInfoSectionRecords.isSyncButtonEnabled).isTrue() - assertThat(onlineResult.syncInfoSectionImages.isSyncButtonEnabled).isTrue() - } - - // Flow combination tests - - @Test - fun `should handle changes in connectivity stream`() = runTest { - val connectivityFlow = MutableStateFlow(false) // started offline - every { connectivityTracker.observeIsConnected().asFlow() } returns connectivityFlow - createViewModel() - - val offlineResult = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(offlineResult.syncInfoSectionRecords.isInstructionOfflineVisible).isTrue() - - connectivityFlow.value = true // changed to online - - val onlineResult = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(onlineResult.syncInfoSectionRecords.isInstructionOfflineVisible).isFalse() - } - - @Test - fun `should handle changes in auth stream`() = runTest { - val authFlow = MutableStateFlow("") // started not signed in - every { authStore.observeSignedInProjectId() } returns authFlow - createViewModel() - - val loggedOutResult = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(loggedOutResult.isLoggedIn).isFalse() - - authFlow.value = TEST_PROJECT_ID // changed to signed in - - val loggedInResult = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(loggedInResult.isLoggedIn).isTrue() - } - - @Test - fun `should handle changes in project refreshing stream`() = runTest { - val refreshingFlow = MutableStateFlow(false) // started non refreshing - every { configManager.observeIsProjectRefreshing() } returns refreshingFlow - createViewModel() - - val notRefreshingResult = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(notRefreshingResult.isConfigurationLoadingProgressBarVisible).isFalse() - - refreshingFlow.value = true // changed to refreshing - - val refreshingResult = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(refreshingResult.isConfigurationLoadingProgressBarVisible).isTrue() - } - - @Test - fun `should handle changes in event sync state stream`() = runTest { - val eventSyncStateFlow = MutableLiveData() - every { eventSyncManager.getLastSyncState(any()) } returns eventSyncStateFlow - createViewModel() - val mockIdleState = mockk(relaxed = true) { - every { isSyncInProgress() } returns false - } - eventSyncStateFlow.value = mockIdleState // started not syncing - - val idleResult = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(idleResult.syncInfoSectionRecords.isProgressVisible).isFalse() - - val mockSyncingState = mockk(relaxed = true) { - every { isSyncInProgress() } returns true - every { progress } returns 1 - every { total } returns 2 - } - eventSyncStateFlow.value = mockSyncingState // changed to syncing - - val syncingResult = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(syncingResult.syncInfoSectionRecords.isProgressVisible).isTrue() - } - - @Test - fun `should handle changes in image sync status stream`() = runTest { - val imageSyncStatusFlow = MutableStateFlow(mockk { - every { isSyncing } returns false - every { progress } returns null - every { secondsSinceLastUpdate } returns null - }) // started not syncing - every { syncOrchestrator.observeImageSyncStatus() } returns imageSyncStatusFlow - createViewModel() - - val notSyncingResult = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(notSyncingResult.syncInfoSectionImages.isProgressVisible).isFalse() - - imageSyncStatusFlow.value = mockk { - every { isSyncing } returns true - every { progress } returns Pair(1, 2) - every { secondsSinceLastUpdate } returns null - } // changed to syncing - - val syncingResult = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(syncingResult.syncInfoSectionImages.isProgressVisible).isTrue() - } - - @Test - fun `should handle changes in project config stream`() = runTest { - val projectConfigFlow = MutableStateFlow(mockProjectConfiguration) - every { configManager.observeProjectConfiguration() } returns projectConfigFlow // started without modules - createViewModel() - - val initialResult = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(initialResult.syncInfoSectionModules.isSectionAvailable).isFalse() + every { timeHelper.now() } returns TEST_TIMESTAMP + every { timeHelper.msBetweenNowAndTime(any()) } returns 0L - val mockConfigWithModules = mockk { - every { general } returns mockk { - every { modalities } returns listOf(GeneralConfiguration.Modality.FINGERPRINT) - } + coEvery { recentUserActivityManager.getRecentUserActivity() } returns mockk { + every { lastUserUsed } returns TokenizableString.Raw(TEST_RECENT_USER_ID) } - every { mockConfigWithModules.isModuleSelectionAvailable() } returns true - projectConfigFlow.value = mockConfigWithModules // now with modules - val moduleConfigResult = viewModel.syncInfoLiveData.getOrAwaitValue() + every { any().isModuleSelectionAvailable() } returns false + every { any().isEventDownSyncAllowed() } returns true + every { any().isMissingModulesToChooseFrom() } returns false - assertThat(moduleConfigResult.syncInfoSectionModules.isSectionAvailable).isTrue() + every { observeSyncInfo(any()) } returns flowOf(createDefaultSyncInfo()) } - @Test - fun `should handle changes in device config stream`() = runTest { - every { configManager.observeProjectConfiguration() } returns flowOf( - mockk { - every { general } returns mockk { - every { modalities } returns emptyList() - } - } + private fun createViewModel() { + viewModel = SyncInfoViewModel( + configManager = configManager, + authStore = authStore, + eventSyncManager = eventSyncManager, + syncOrchestrator = syncOrchestrator, + recentUserActivityManager = recentUserActivityManager, + timeHelper = timeHelper, + observeSyncInfo = observeSyncInfo, + logoutUseCase = logoutUseCase, ) - val deviceConfigFlow = MutableStateFlow( - mockk(relaxed = true) { - every { selectedModules } returns emptyList() - } - ) // started without selected modules - every { configManager.observeDeviceConfiguration() } returns deviceConfigFlow - createViewModel() - - val noModulesResult = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(noModulesResult.syncInfoSectionModules.moduleCounts).isEmpty() - - deviceConfigFlow.emit( - mockk(relaxed = true) { - every { selectedModules } returns listOf(TokenizableString.Raw(TEST_MODULE_NAME)) - } - ) // now with selected modules - - val withModulesResult = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(withModulesResult.syncInfoSectionModules.moduleCounts).isNotEmpty() } - @Test - fun `should handle changes in time pacing stream`() = runTest { - val mockIdleEventSyncState = mockk(relaxed = true) { - every { isSyncRunning() } returns false - } - every { eventSyncManager.getLastSyncState() } returns MutableLiveData(mockIdleEventSyncState) - coEvery { eventSyncManager.getLastSyncTime() } returns TEST_TIMESTAMP - every { timeHelper.now() } returnsMany listOf(TEST_TIMESTAMP, Timestamp(TEST_TIMESTAMP.ms + 60_000)) - // MutableStateFlow of Unit won't emit another (identical) Unit, so we'll count minutes and map to Units - val timePaceFlow = MutableStateFlow(0) - every { timer.observeTickOncePerMinute() } returns timePaceFlow.map { Unit } - createViewModel() - - val initialResult = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(initialResult.syncInfoSectionRecords.footerLastSyncMinutesAgo).isEqualTo(0) - - timePaceFlow.value = -1 // just a different value for a time beat, doesn't matter which - - val updatedResult = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(updatedResult.syncInfoSectionRecords.footerLastSyncMinutesAgo).isEqualTo(1) - } + private fun createDefaultSyncInfo() = SyncInfo( + isLoggedIn = true, + isConfigurationLoadingProgressBarVisible = false, + isLoginPromptSectionVisible = false, + syncInfoSectionRecords = SyncInfoSectionRecords( + counterTotalRecords = "0", + counterRecordsToUpload = "0", + isCounterRecordsToDownloadVisible = false, + counterRecordsToDownload = "0", + isCounterImagesToUploadVisible = false, + counterImagesToUpload = "0", + isInstructionDefaultVisible = true, + isInstructionNoModulesVisible = false, + isInstructionOfflineVisible = false, + isInstructionErrorVisible = false, + instructionPopupErrorInfo = SyncInfoError( + isBackendMaintenance = false, + backendMaintenanceEstimatedOutage = -1, + isTooManyRequests = false + ), + isProgressVisible = false, + progress = SyncInfoProgress(), + isSyncButtonVisible = true, + isSyncButtonEnabled = true, + isSyncButtonForRetry = false, + isFooterSyncInProgressVisible = false, + isFooterReadyToLogOutVisible = false, + isFooterSyncIncompleteVisible = false, + isFooterLastSyncTimeVisible = false, + footerLastSyncMinutesAgo = 0 + ), + syncInfoSectionImages = SyncInfoSectionImages( + counterImagesToUpload = "0", + isInstructionDefaultVisible = true, + isInstructionOfflineVisible = false, + isProgressVisible = false, + progress = SyncInfoProgress(), + isSyncButtonEnabled = true, + isFooterLastSyncTimeVisible = false, + footerLastSyncMinutesAgo = 0 + ), + syncInfoSectionModules = SyncInfoSectionModules( + isSectionAvailable = false, + moduleCounts = emptyList() + ) + ) - // UI state tests + // LiveData loginNavigationEventLiveData tests @Test - fun `should calculate correct record last sync time when sync time available`() = runTest { - val fiveMinutesAgo = Timestamp(TEST_TIMESTAMP.ms - 300000) // 5 minutes before test timestamp - coEvery { eventSyncManager.getLastSyncTime() } returns fiveMinutesAgo - every { timeHelper.msBetweenNowAndTime(fiveMinutesAgo) } returns 300000L // 5 minutes - createViewModel() - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() + fun `should show login navigation when user requests login`() = runTest { + viewModel.requestNavigationToLogin() + val result = viewModel.loginNavigationEventLiveData.getOrAwaitValue() - assertThat(result.syncInfoSectionRecords.isFooterLastSyncTimeVisible).isTrue() - assertThat(result.syncInfoSectionRecords.footerLastSyncMinutesAgo).isEqualTo(5) + assertThat(result).isNotNull() } - @Test - fun `should have hidden record last sync time footer when no sync history`() = runTest { - coEvery { eventSyncManager.getLastSyncTime() } returns null - createViewModel() - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.syncInfoSectionRecords.isFooterLastSyncTimeVisible).isFalse() - } + // LiveData logoutEventLiveData tests + @OptIn(ExperimentalCoroutinesApi::class) @Test - fun `should calculate correct image last sync time when available`() = runTest { - val mockImageStatusWithLastSync = mockk(relaxed = true) { + fun `should trigger logout when pre-logout sync completes successfully`() = runTest { + val mockCompletedEventSyncState = mockk(relaxed = true) { + every { isSyncCompleted() } returns true + } + val mockNotSyncingImageStatus = mockk(relaxed = true) { every { isSyncing } returns false every { progress } returns null - every { secondsSinceLastUpdate } returns 180 // 3 minutes + every { secondsSinceLastUpdate } returns 0 } - every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockImageStatusWithLastSync) + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) createViewModel() + viewModel.isPreLogoutUpSync = true + val observer = mockk>>(relaxed = true) + val slot = slot>() + val capturedValues = mutableListOf>() + every { observer.onChanged(capture(slot)) } answers { + capturedValues.add(slot.captured) + } - val result = viewModel.syncInfoLiveData.getOrAwaitValue() + viewModel.logoutEventLiveData.observeForever(observer) + advanceTimeBy(3100L) // after the logout delay (3000ms) - assertThat(result.syncInfoSectionImages.isFooterLastSyncTimeVisible).isTrue() - assertThat(result.syncInfoSectionImages.footerLastSyncMinutesAgo).isEqualTo(3) + assertThat(capturedValues.map { it.peekContent() }).contains(Unit) } + @OptIn(ExperimentalCoroutinesApi::class) @Test - fun `should have hidden image last sync time footer when unavailable`() = runTest { - val mockImageStatusWithoutLastSync = mockk(relaxed = true) { + fun `should emit a logout event after the intended delay since ready to logout`() = runTest { + val mockCompletedEventSyncState = mockk(relaxed = true) { + every { isSyncCompleted() } returns true + } + val mockNotSyncingImageStatus = mockk(relaxed = true) { every { isSyncing } returns false every { progress } returns null - every { secondsSinceLastUpdate } returns null + every { secondsSinceLastUpdate } returns 0 } - every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockImageStatusWithoutLastSync) - createViewModel() - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.syncInfoSectionImages.isFooterLastSyncTimeVisible).isFalse() - } - - @Test - fun `should show correct visibility states for offline instructions`() = runTest { - every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(false) + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) createViewModel() - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.syncInfoSectionRecords.isInstructionOfflineVisible).isTrue() - assertThat(result.syncInfoSectionRecords.isInstructionDefaultVisible).isFalse() - assertThat(result.syncInfoSectionRecords.isInstructionErrorVisible).isFalse() - assertThat(result.syncInfoSectionImages.isInstructionOfflineVisible).isTrue() - assertThat(result.syncInfoSectionImages.isInstructionDefaultVisible).isFalse() - } - - @Test - fun `should show correct visibility states for error instructions`() = runTest { - val mockFailedEventSyncState = mockk(relaxed = true) { - every { isSyncFailed() } returns true - every { isSyncInProgress() } returns false + viewModel.isPreLogoutUpSync = true + val observer = mockk>>(relaxed = true) + val slot = slot>() + val capturedValues = mutableListOf>() + every { observer.onChanged(capture(slot)) } answers { + capturedValues.add(slot.captured) } - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockFailedEventSyncState) - every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) - createViewModel() - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.syncInfoSectionRecords.isInstructionErrorVisible).isTrue() - assertThat(result.syncInfoSectionRecords.isInstructionDefaultVisible).isFalse() - assertThat(result.syncInfoSectionRecords.isInstructionOfflineVisible).isFalse() - } + viewModel.logoutEventLiveData.observeForever(observer) + advanceTimeBy(2900L) // still during the debounce delay - @Test - fun `should show correct visibility states for module selection instructions`() = runTest { - val mockProjectConfigRequiringModules = mockk { - every { general } returns mockk { - every { modalities } returns listOf(GeneralConfiguration.Modality.FINGERPRINT) - } - } - 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 { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) - every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) - every { mockProjectConfigRequiringModules.isModuleSelectionAvailable() } returns true - createViewModel() + assertThat(capturedValues).isEmpty() // no logout event yet - val result = viewModel.syncInfoLiveData.getOrAwaitValue() + advanceTimeBy(200L) // after the debounce delay (total 3100ms > 3000ms) - assertThat(result.syncInfoSectionRecords.isInstructionNoModulesVisible).isTrue() - assertThat(result.syncInfoSectionRecords.isInstructionDefaultVisible).isFalse() - assertThat(result.syncInfoSectionRecords.isInstructionOfflineVisible).isFalse() - assertThat(result.syncInfoSectionRecords.isInstructionErrorVisible).isFalse() + assertThat(capturedValues).hasSize(1) + assertThat(capturedValues[0].peekContent()).isEqualTo(Unit) } + @OptIn(ExperimentalCoroutinesApi::class) @Test - fun `should show correct visibility states for default instructions`() = runTest { - val mockIdleEventSyncState = mockk(relaxed = true) { - every { isSyncFailed() } returns false - every { isSyncInProgress() } returns false + fun `should not trigger logout when not in pre-logout mode`() = runTest { + val mockCompletedEventSyncState = mockk(relaxed = true) { + every { isSyncCompleted() } returns true } - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) - every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) + val mockNotSyncingImageStatus = mockk(relaxed = true) { + every { isSyncing } returns false + every { progress } returns null + every { secondsSinceLastUpdate } returns 0 + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) createViewModel() viewModel.isPreLogoutUpSync = false - - val result = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(result.syncInfoSectionRecords.isInstructionDefaultVisible).isTrue() - assertThat(result.syncInfoSectionRecords.isInstructionOfflineVisible).isFalse() - assertThat(result.syncInfoSectionRecords.isInstructionErrorVisible).isFalse() - assertThat(result.syncInfoSectionRecords.isInstructionNoModulesVisible).isFalse() - assertThat(result.syncInfoSectionImages.isInstructionDefaultVisible).isTrue() - assertThat(result.syncInfoSectionImages.isInstructionOfflineVisible).isFalse() - } - - @Test - fun `should handle failed sync retry indication correctly`() = runTest { - val eventSyncStateFlow = MutableLiveData() - every { eventSyncManager.getLastSyncState(any()) } returns eventSyncStateFlow - createViewModel() - val mockFailedState = mockk(relaxed = true) { - every { isSyncFailed() } returns true - every { isSyncInProgress() } returns false - every { isSyncFailedBecauseReloginRequired() } returns false + val observer = mockk>>(relaxed = true) + val slot = slot>() + val capturedValues = mutableListOf>() + every { observer.onChanged(capture(slot)) } answers { + capturedValues.add(slot.captured) } - eventSyncStateFlow.value = mockFailedState - val failedResult = viewModel.syncInfoLiveData.getOrAwaitValue() + viewModel.logoutEventLiveData.observeForever(observer) + advanceTimeBy(3100L) // after the logout delay (3000ms) - assertThat(failedResult.syncInfoSectionRecords.isInstructionErrorVisible).isTrue() - assertThat(failedResult.syncInfoSectionRecords.isSyncButtonForRetry).isTrue() + assertThat(capturedValues).isEmpty() } - // Module tokenization tests - + @OptIn(ExperimentalCoroutinesApi::class) @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(GeneralConfiguration.Modality.FINGERPRINT) - } + fun `should not trigger logout when records still syncing`() = runTest { + val mockInProgressEventSyncState = mockk(relaxed = true) { + every { isSyncCompleted() } returns false + every { isSyncInProgress() } returns true } - val mockDeviceConfigWithTokenizedModules = mockk { - every { selectedModules } returns listOf(tokenizedModule) + val mockNotSyncingImageStatus = mockk(relaxed = true) { + every { isSyncing } returns false + every { progress } returns null + every { secondsSinceLastUpdate } returns 0 } - 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 + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockInProgressEventSyncState) + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) createViewModel() + viewModel.isPreLogoutUpSync = true + val observer = mockk>>(relaxed = true) + val slot = slot>() + val capturedValues = mutableListOf>() + every { observer.onChanged(capture(slot)) } answers { + capturedValues.add(slot.captured) + } - val result = viewModel.syncInfoLiveData.getOrAwaitValue() + viewModel.logoutEventLiveData.observeForever(observer) + advanceTimeBy(3100L) // after the logout delay (3000ms) - 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()) } + assertThat(capturedValues).isEmpty() } + @OptIn(ExperimentalCoroutinesApi::class) @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(GeneralConfiguration.Modality.FINGERPRINT) - } + fun `should not trigger logout when images still syncing`() = runTest { + val mockCompletedEventSyncState = mockk(relaxed = true) { + every { isSyncCompleted() } returns true } - val mockDeviceConfigWithRawModules = mockk { - every { selectedModules } returns listOf(rawModule) + val mockSyncingImageStatus = mockk(relaxed = true) { + every { isSyncing } returns true + every { progress } returns Pair(1, 2) + every { secondsSinceLastUpdate } returns null } - 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 + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockSyncingImageStatus) createViewModel() + viewModel.isPreLogoutUpSync = true + val observer = mockk>>(relaxed = true) + val slot = slot>() + val capturedValues = mutableListOf>() + every { observer.onChanged(capture(slot)) } answers { + capturedValues.add(slot.captured) + } - val result = viewModel.syncInfoLiveData.getOrAwaitValue() + viewModel.logoutEventLiveData.observeForever(observer) + advanceTimeBy(3100L) // after the logout delay (3000ms) - 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()) } + assertThat(capturedValues).isEmpty() } // forceEventSync() tests @@ -1364,7 +454,7 @@ class SyncInfoViewModelTest { @Test fun `should call logout use case when logout invoked`() = runTest { - viewModel.logout() + viewModel.performLogout() verify { logoutUseCase() } } @@ -1548,93 +638,4 @@ class SyncInfoViewModelTest { coVerify(exactly = 0) { syncOrchestrator.startEventSync(any()) } } - @Test - fun `should start image sync after event sync completes in pre-logout mode`() = runTest { - val eventSyncStateFlow = MutableLiveData() - every { eventSyncManager.getLastSyncState(any()) } returns eventSyncStateFlow - every { any().isModuleSelectionAvailable() } returns false - val mockInitialEventSyncState = mockk(relaxed = true) { - every { isSyncCompleted() } returns false - } - eventSyncStateFlow.value = mockInitialEventSyncState - val mockFlow = MutableStateFlow(mockInitialEventSyncState) - every { eventSyncStateFlow.asFlow() } returns mockFlow - createViewModel() - viewModel.isPreLogoutUpSync = true - - viewModel.syncInfoLiveData.getOrAwaitValue() - - val mockCompletedEventSyncState = mockk(relaxed = true) { - every { isSyncCompleted() } returns true - } - mockFlow.value = mockCompletedEventSyncState - - coVerify { syncOrchestrator.startImageSync() } - } - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun `should apply sync completion hold delay after record sync completes`() = runTest { - val eventSyncStateFlow = MutableLiveData() - every { eventSyncManager.getLastSyncState(any()) } returns eventSyncStateFlow - createViewModel() - val mockInProgressState = mockk(relaxed = true) { - every { isSyncInProgress() } returns true - } - eventSyncStateFlow.value = mockInProgressState - - val firstResult = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(firstResult.syncInfoSectionRecords.isProgressVisible).isTrue() - - val mockCompletedState = mockk(relaxed = true) { - every { isSyncInProgress() } returns false - every { isSyncCompleted() } returns true - } - eventSyncStateFlow.value = mockCompletedState - advanceTimeBy(900L) // after completion but still under the holding delay - - val holdingResult = viewModel.syncInfoLiveData.getOrAwaitValue() - assertThat(holdingResult.syncInfoSectionRecords.isProgressVisible).isTrue() - - advanceTimeBy(200L) // total 1100 ms being after the holding delay of 1 s - - val completedResult = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(completedResult.syncInfoSectionRecords.isProgressVisible).isFalse() - } - - @OptIn(ExperimentalCoroutinesApi::class) - @Test - fun `should apply sync completion hold delay after image sync completes`() = runTest { - val imageSyncStatusFlow = MutableStateFlow(mockk { - every { isSyncing } returns true - every { progress } returns Pair(1, 2) - every { secondsSinceLastUpdate } returns null - }) - every { syncOrchestrator.observeImageSyncStatus() } returns imageSyncStatusFlow - createViewModel() - - val firstResult = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(firstResult.syncInfoSectionImages.isProgressVisible).isTrue() - - imageSyncStatusFlow.value = mockk { - every { isSyncing } returns false - every { progress } returns null - every { secondsSinceLastUpdate } returns 0L - } - advanceTimeBy(900L) // after completion but still under the holding delay - - val holdingResult = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(holdingResult.syncInfoSectionImages.isProgressVisible).isTrue() - - advanceTimeBy(200L) // total 1100 ms being after the holding delay of 1 s - - val completedResult = viewModel.syncInfoLiveData.getOrAwaitValue() - - assertThat(completedResult.syncInfoSectionImages.isProgressVisible).isFalse() - } - } 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 new file mode 100644 index 0000000000..e410ff9014 --- /dev/null +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCaseTest.kt @@ -0,0 +1,1140 @@ +package com.simprints.feature.dashboard.settings.syncinfo.usecase + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.asFlow +import com.google.common.truth.Truth.assertThat +import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.core.tools.time.TimeHelper +import com.simprints.core.tools.time.Timer +import com.simprints.core.tools.time.Timestamp +import com.simprints.feature.dashboard.settings.syncinfo.SyncInfoModuleCount +import com.simprints.infra.authstore.AuthStore +import com.simprints.infra.config.store.models.DeviceConfiguration +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.TokenKeyType +import com.simprints.infra.config.store.models.isEventDownSyncAllowed +import com.simprints.infra.config.store.models.isModuleSelectionAvailable +import com.simprints.infra.config.store.models.isMissingModulesToChooseFrom +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.status.models.DownSyncCounts +import com.simprints.infra.eventsync.status.models.EventSyncState +import com.simprints.infra.images.ImageRepository +import com.simprints.infra.network.ConnectivityTracker +import com.simprints.infra.sync.SyncOrchestrator +import com.simprints.infra.sync.ImageSyncStatus +import com.simprints.testtools.common.coroutines.TestCoroutineRule +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class ObserveSyncInfoUseCaseTest { + + @get:Rule + val rule = InstantTaskExecutorRule() + + @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 timer = mockk() + + private lateinit var useCase: ObserveSyncInfoUseCase + + 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) + } + + private val mockProjectConfiguration = mockk(relaxed = true) { + every { general } returns mockk(relaxed = true) { + every { modalities } returns emptyList() + } + } + 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 + every { isSyncConnecting() } returns false + every { isSyncRunning() } returns false + every { isSyncFailed() } returns false + every { isSyncFailedBecauseReloginRequired() } returns false + every { isSyncFailedBecauseBackendMaintenance() } returns false + every { isSyncFailedBecauseTooManyRequests() } returns false + every { getEstimatedBackendMaintenanceOutage() } returns null + every { isThereNotSyncHistory() } returns false + every { progress } returns null + every { total } returns null + } + private val mockImageSyncStatus = mockk(relaxed = true) { + every { isSyncing } returns false + every { progress } returns null + every { secondsSinceLastUpdate } returns null + } + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + mockkStatic("androidx.lifecycle.FlowLiveDataConversions") + mockkStatic("com.simprints.infra.config.store.models.ProjectConfigurationKt") + mockkStatic("com.simprints.core.tools.extentions.Flow_extKt") + setupDefaultMocks() + createUseCase() + } + + 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(any()) } returns mockProject + + val eventSyncLiveData = MutableLiveData(mockEventSyncState) + every { eventSyncManager.getLastSyncState() } returns eventSyncLiveData + every { eventSyncManager.getLastSyncState(any()) } returns eventSyncLiveData + every { eventSyncLiveData.asFlow() } returns flowOf(mockEventSyncState) + coEvery { eventSyncManager.getLastSyncTime() } returns TEST_TIMESTAMP + coEvery { eventSyncManager.countEventsToUpload(any()) } returns flowOf(0) + coEvery { eventSyncManager.countEventsToDownload(any()) } returns DownSyncCounts(0, isLowerBound = false) + + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockImageSyncStatus) + coEvery { syncOrchestrator.startEventSync(any()) } returns Unit + coEvery { syncOrchestrator.stopEventSync() } returns Unit + coEvery { syncOrchestrator.startImageSync() } returns Unit + coEvery { syncOrchestrator.stopImageSync() } returns Unit + + coEvery { imageRepository.getNumberOfImagesToUpload(any()) } returns 0 + coEvery { enrolmentRecordRepository.count(any()) } returns 0 + + every { timer.observeTickOncePerMinute() } returns MutableStateFlow(Unit) + every { timeHelper.now() } returns TEST_TIMESTAMP + every { timeHelper.msBetweenNowAndTime(any()) } returns 0L + + every { tokenizationProcessor.decrypt(any(), any(), any()) } returns TokenizableString.Raw("decrypted_module") + + every { any().isModuleSelectionAvailable() } returns false + every { any().isEventDownSyncAllowed() } returns true + every { any().isMissingModulesToChooseFrom() } returns false + } + + private fun createUseCase() { + useCase = ObserveSyncInfoUseCase( + configManager = configManager, + connectivityTracker = connectivityTracker, + enrolmentRecordRepository = enrolmentRecordRepository, + authStore = authStore, + imageRepository = imageRepository, + eventSyncManager = eventSyncManager, + syncOrchestrator = syncOrchestrator, + tokenizationProcessor = tokenizationProcessor, + timeHelper = timeHelper, + timer = timer, + ) + } + + @Test + fun `should not show re-login prompt when sync has not failed due to authentication`() = runTest { + val mockNormalEventSyncState = mockk(relaxed = true) { + every { isSyncFailedBecauseReloginRequired() } returns false + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockNormalEventSyncState) + createUseCase() + + val result = useCase().first() + + assertThat(result.isLoginPromptSectionVisible).isFalse() + } + + @Test + fun `should show configuration loading when project is refreshing`() = runTest { + every { configManager.observeIsProjectRefreshing() } returns MutableStateFlow(true) + createUseCase() + + val result = useCase().first() + + assertThat(result.isConfigurationLoadingProgressBarVisible).isTrue() + } + + @Test + fun `should show re-login prompt when sync failed due to authentication required`() = runTest { + val mockFailedEventSyncState = mockk(relaxed = true) { + every { isSyncFailedBecauseReloginRequired() } returns true + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockFailedEventSyncState) + createUseCase() + + val result = useCase().first() + + assertThat(result.isLoginPromptSectionVisible).isTrue() + } + + @Test + fun `should show re-login prompt correctly based on pre-logout mode`() = runTest { + val mockFailedEventSyncState = mockk(relaxed = true) { + every { isSyncFailedBecauseReloginRequired() } returns true + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockFailedEventSyncState) + createUseCase() + + val result = useCase(isPreLogoutUpSync = true /* This should hide the login prompt */).first() + + assertThat(result.isLoginPromptSectionVisible).isFalse() + } + + @Test + fun `should handle project state correctly in sync info`() = runTest { + val mockEndingProject = mockk { + every { state } returns ProjectState.PROJECT_ENDING + } + coEvery { configManager.getProject(any()) } returns mockEndingProject + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.isCounterRecordsToDownloadVisible).isFalse() + } + + @Test + fun `should show correct login prompt visibility when not logged in`() = runTest { + every { authStore.observeSignedInProjectId() } returns MutableStateFlow("") + createUseCase() + + val result = useCase().first() + + assertThat(result.isLoggedIn).isFalse() + } + + // Section-specific tests + + @Test + fun `should emit SyncInfo with correct syncInfoSectionRecords instruction visibility`() = runTest { + val mockOfflineEventSyncState = mockk(relaxed = true) { + every { isSyncFailed() } returns false + every { isSyncInProgress() } returns false + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockOfflineEventSyncState) + every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(false) + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.isInstructionOfflineVisible).isTrue() + assertThat(result.syncInfoSectionRecords.isInstructionErrorVisible).isFalse() + assertThat(result.syncInfoSectionRecords.isInstructionDefaultVisible).isFalse() + } + + @Test + fun `should emit SyncInfo with correct syncInfoSectionRecords button states`() = runTest { + val mockNormalEventSyncState = mockk(relaxed = true) { + every { isSyncRunning() } returns false + every { isSyncFailedBecauseReloginRequired() } returns false + every { isSyncFailed() } returns false + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockNormalEventSyncState) + every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.isSyncButtonEnabled).isTrue() + assertThat(result.syncInfoSectionRecords.isSyncButtonVisible).isTrue() + assertThat(result.syncInfoSectionRecords.isSyncButtonForRetry).isFalse() + } + + @Test + fun `should emit SyncInfo with correct syncInfoSectionRecords footer states`() = runTest { + val mockCompletedEventSyncState = mockk(relaxed = true) { + every { isSyncInProgress() } returns false + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) + coEvery { eventSyncManager.getLastSyncTime() } returns TEST_TIMESTAMP + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.isFooterLastSyncTimeVisible).isTrue() + assertThat(result.syncInfoSectionRecords.footerLastSyncMinutesAgo).isEqualTo(0) + assertThat(result.syncInfoSectionRecords.isFooterSyncInProgressVisible).isFalse() + } + + @Test + fun `should emit SyncInfo with correct syncInfoSectionImages instruction visibility`() = runTest { + val mockNotSyncingImageStatus = mockk(relaxed = true) { + every { isSyncing } returns false + every { progress } returns null + } + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) + every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(false) + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionImages.isInstructionOfflineVisible).isTrue() + assertThat(result.syncInfoSectionImages.isInstructionDefaultVisible).isFalse() + } + + @Test + fun `should emit SyncInfo with correct syncInfoSectionImages button states`() = runTest { + val mockNormalEventSyncState = mockk(relaxed = true) { + every { isSyncFailedBecauseReloginRequired() } returns false + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockNormalEventSyncState) + every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionImages.isSyncButtonEnabled).isTrue() + } + + @Test + fun `should emit SyncInfo with correct syncInfoSectionImages footer states`() = runTest { + val mockImageStatusWithLastSync = mockk(relaxed = true) { + every { isSyncing } returns false + every { progress } returns null + every { secondsSinceLastUpdate } returns 120 // 2 minutes + } + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockImageStatusWithLastSync) + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionImages.isFooterLastSyncTimeVisible).isTrue() + assertThat(result.syncInfoSectionImages.footerLastSyncMinutesAgo).isEqualTo(2) + } + + @Test + fun `should emit SyncInfo with correct syncInfoSectionModules data`() = runTest { + val mockProjectConfigWithModules = mockk { + every { general } returns mockk { + every { modalities } returns listOf(GeneralConfiguration.Modality.FINGERPRINT) + } + } + 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 { mockProjectConfigWithModules.isModuleSelectionAvailable() } returns true + createUseCase() + + val result = useCase().first() + + 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") + } + + // Progress calculation tests + + @Test + fun `should calculate correct event sync progress when sync in progress`() = runTest { + val mockInProgressEventSyncState = mockk(relaxed = true) { + every { isSyncInProgress() } returns true + every { isSyncCompleted() } returns false + every { progress } returns 5 + every { total } returns 10 + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockInProgressEventSyncState) + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.isProgressVisible).isTrue() + assertThat(result.syncInfoSectionRecords.progress.progressBarPercentage).isEqualTo(50) // half + } + + @Test + fun `should calculate correct event sync progress when sync connecting`() = runTest { + val mockConnectingEventSyncState = mockk(relaxed = true) { + every { isSyncConnecting() } returns true + every { isSyncInProgress() } returns false + every { isSyncCompleted() } returns false + every { isThereNotSyncHistory() } returns false + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockConnectingEventSyncState) + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.progress.progressBarPercentage).isEqualTo(0) // not started + } + + @Test + fun `should calculate correct event sync progress when sync approached completion`() = runTest { + val mockCompletedEventSyncState = mockk(relaxed = true) { + every { isSyncInProgress() } returns true + every { progress } returns 10 + every { total } returns 10 + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.progress.progressBarPercentage).isEqualTo(100) + } + + @Test + fun `should not show event sync progress when sync completed`() = runTest { + val mockCompletedEventSyncState = mockk(relaxed = true) { + every { isSyncCompleted() } returns true + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.isProgressVisible).isFalse() + } + + @Test + fun `should calculate correct combined progress during pre-logout sync events phase`() = runTest { + val mockInProgressEventSyncState = mockk(relaxed = true) { + every { isSyncInProgress() } returns true + every { isSyncCompleted() } returns false + every { progress } returns 3 + every { total } returns 6 + } + val mockNotSyncingImageStatus = mockk(relaxed = true) { + every { isSyncing } returns false + every { progress } returns null + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockInProgressEventSyncState) + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) + createUseCase() + + val result = useCase(isPreLogoutUpSync = true).first() + + // 50% of the first half (0-50%) of scale dedicated to the records, so 25% total + assertThat(result.syncInfoSectionRecords.progress.progressBarPercentage).isEqualTo(25) + } + + @Test + fun `should calculate correct combined progress during pre-logout sync images phase`() = runTest { + val mockCompletedEventSyncState = mockk(relaxed = true) { + every { isSyncCompleted() } returns true + every { isSyncInProgress() } returns false + } + val mockSyncingImageStatus = mockk(relaxed = true) { + every { isSyncing } returns true + every { progress } returns Pair(2, 4) // 2 out of 4 images + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockSyncingImageStatus) + createUseCase() + + val result = useCase(isPreLogoutUpSync = true).first() + + // 50% of the second half (50-75%) of scale dedicated to the images, so 75% total + assertThat(result.syncInfoSectionRecords.progress.progressBarPercentage).isEqualTo(75) + } + + @Test + fun `should calculate correct image sync progress when images syncing`() = runTest { + val mockSyncingImageStatus = mockk(relaxed = true) { + every { isSyncing } returns true + every { progress } returns Pair(3, 10) // 3 out of 10 images + } + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockSyncingImageStatus) + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionImages.isProgressVisible).isTrue() + assertThat(result.syncInfoSectionImages.progress.progressBarPercentage).isEqualTo(30) + } + + @Test + fun `should calculate correct image sync progress when images not syncing`() = runTest { + val mockNotSyncingImageStatus = mockk(relaxed = true) { + every { isSyncing } returns false + every { progress } returns null + } + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) + coEvery { imageRepository.getNumberOfImagesToUpload(any()) } returns 0 + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionImages.isProgressVisible).isFalse() + assertThat(result.syncInfoSectionImages.progress.progressBarPercentage).isEqualTo(0) + } + + // Counter tests + + @Test + fun `should emit SyncInfo with correct record counters when sync not in progress`() = runTest { + val mockIdleEventSyncState = mockk(relaxed = true) { + every { isSyncInProgress() } returns false + every { isSyncRunning() } returns false + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) + coEvery { enrolmentRecordRepository.count(any()) } returns 25 + coEvery { eventSyncManager.countEventsToUpload(any()) } returns flowOf(5) + coEvery { eventSyncManager.countEventsToDownload(any()) } returns DownSyncCounts(8, isLowerBound = false) + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.counterTotalRecords).isEqualTo("25") + assertThat(result.syncInfoSectionRecords.counterRecordsToUpload).isEqualTo("5") + assertThat(result.syncInfoSectionRecords.counterRecordsToDownload).isEqualTo("8") + } + + @Test + fun `should emit SyncInfo with empty record counters when sync in progress`() = runTest { + val mockInProgressEventSyncState = mockk(relaxed = true) { + every { isSyncInProgress() } returns true + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockInProgressEventSyncState) + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.counterTotalRecords).isEmpty() + assertThat(result.syncInfoSectionRecords.counterRecordsToUpload).isEmpty() + assertThat(result.syncInfoSectionRecords.counterRecordsToDownload).isEmpty() + } + + @Test + fun `should emit SyncInfo with correct images to upload counter when sync not in progress`() = runTest { + val mockNotSyncingImageStatus = mockk(relaxed = true) { + every { isSyncing } returns false + every { progress } returns null + } + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) + coEvery { imageRepository.getNumberOfImagesToUpload(any()) } returns 15 + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.counterImagesToUpload).isEqualTo("15") // may be shown within records + assertThat(result.syncInfoSectionImages.counterImagesToUpload).isEqualTo("15") + } + + @Test + fun `should emit SyncInfo with empty images counter when sync in progress`() = runTest { + val mockSyncingImageStatus = mockk(relaxed = true) { + every { isSyncing } returns true + every { progress } returns null + } + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockSyncingImageStatus) + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.counterImagesToUpload).isEmpty() // may be shown within records + assertThat(result.syncInfoSectionImages.counterImagesToUpload).isEmpty() + } + + @Test + fun `should emit SyncInfo with correct module counts when modules selected`() = runTest { + val mockProjectConfigWithModules = mockk { + every { general } returns mockk { + every { modalities } returns listOf(GeneralConfiguration.Modality.FINGERPRINT) + } + } + 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 { mockProjectConfigWithModules.isModuleSelectionAvailable() } returns true + createUseCase() + + val result = useCase().first() + + 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") + // module_1 + assertThat(result.syncInfoSectionModules.moduleCounts[1]).isEqualTo( + SyncInfoModuleCount(isTotal = false, name = "module_1", count = "15") + ) + // module_2 + assertThat(result.syncInfoSectionModules.moduleCounts[2]).isEqualTo( + SyncInfoModuleCount(isTotal = false, name = "module_2", count = "25") + ) + } + + @Test + fun `should emit SyncInfo with empty module counts when no modules selected`() = runTest { + val mockProjectConfigWithoutModules = mockk { + every { general } returns mockk { + every { modalities } returns emptyList() + } + } + 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 { mockProjectConfigWithoutModules.isModuleSelectionAvailable() } returns false + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionModules.isSectionAvailable).isFalse() + assertThat(result.syncInfoSectionModules.moduleCounts).isEmpty() + } + + @Test + fun `should emit SyncInfo with correct records to download counter visible when allowed`() = runTest { + val mockProjectConfigWithDownSync = mockk { + every { general } returns mockk { + every { modalities } returns emptyList() + } + } + val mockIdleEventSyncState = mockk(relaxed = true) { + every { isSyncInProgress() } returns false + } + + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) + every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithDownSync) + coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithDownSync + coEvery { eventSyncManager.countEventsToDownload(any()) } returns DownSyncCounts(42, isLowerBound = false) + every { mockProjectConfigWithDownSync.isEventDownSyncAllowed() } returns true + every { mockProjectConfigWithDownSync.isModuleSelectionAvailable() } returns false + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.isCounterRecordsToDownloadVisible).isTrue() + assertThat(result.syncInfoSectionRecords.counterRecordsToDownload).isEqualTo("42") + } + + @Test + fun `should emit SyncInfo with hidden records to download counter when pre-logout mode`() = runTest { + val mockIdleEventSyncState = mockk(relaxed = true) { + every { isSyncInProgress() } returns false + } + + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) + createUseCase() + + val result = useCase(isPreLogoutUpSync = true).first() + + assertThat(result.syncInfoSectionRecords.isCounterRecordsToDownloadVisible).isFalse() + } + + @Test + fun `should handle timeout when counting records to download`() = runTest { + val mockProjectConfigWithDownSync = mockk { + every { general } returns mockk { + every { modalities } returns emptyList() + } + } + val mockIdleEventSyncState = mockk(relaxed = true) { + every { isSyncInProgress() } returns false + } + + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) + every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithDownSync) + coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithDownSync + coEvery { eventSyncManager.countEventsToDownload(any()) } throws Exception("Timeout") + every { mockProjectConfigWithDownSync.isEventDownSyncAllowed() } returns true + every { mockProjectConfigWithDownSync.isModuleSelectionAvailable() } returns false + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.counterRecordsToDownload).isEqualTo("0") + } + + @Test + fun `should handle when records download counting throws exception`() = runTest { + val mockProjectConfigWithDownSync = mockk { + every { general } returns mockk { + every { modalities } returns emptyList() + } + } + val mockIdleEventSyncState = mockk(relaxed = true) { + every { isSyncInProgress() } returns false + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) + every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithDownSync) + coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithDownSync + coEvery { eventSyncManager.countEventsToDownload(any()) } throws RuntimeException("Network error") + every { mockProjectConfigWithDownSync.isEventDownSyncAllowed() } returns true + every { mockProjectConfigWithDownSync.isModuleSelectionAvailable() } returns false + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.counterRecordsToDownload).isEqualTo("0") + } + + @Test + fun `should handle network errors indication`() = runTest { + val connectivityFlow = MutableStateFlow(false) // start offline + every { connectivityTracker.observeIsConnected().asFlow() } returns connectivityFlow + createUseCase() + + val offlineResult = useCase().first() + + assertThat(offlineResult.syncInfoSectionRecords.isInstructionOfflineVisible).isTrue() + assertThat(offlineResult.syncInfoSectionRecords.isSyncButtonEnabled).isFalse() + assertThat(offlineResult.syncInfoSectionImages.isSyncButtonEnabled).isFalse() + + connectivityFlow.value = true + + val onlineResult = useCase().first() + + assertThat(onlineResult.syncInfoSectionRecords.isInstructionOfflineVisible).isFalse() + assertThat(onlineResult.syncInfoSectionRecords.isSyncButtonEnabled).isTrue() + assertThat(onlineResult.syncInfoSectionImages.isSyncButtonEnabled).isTrue() + } + + // Flow combination tests + + @Test + fun `should handle changes in connectivity stream`() = runTest { + val connectivityFlow = MutableStateFlow(false) // started offline + every { connectivityTracker.observeIsConnected().asFlow() } returns connectivityFlow + createUseCase() + + val offlineResult = useCase().first() + + assertThat(offlineResult.syncInfoSectionRecords.isInstructionOfflineVisible).isTrue() + + connectivityFlow.value = true // changed to online + + val onlineResult = useCase().first() + + assertThat(onlineResult.syncInfoSectionRecords.isInstructionOfflineVisible).isFalse() + } + + @Test + fun `should handle changes in auth stream`() = runTest { + val authFlow = MutableStateFlow("") // started not signed in + every { authStore.observeSignedInProjectId() } returns authFlow + createUseCase() + + val loggedOutResult = useCase().first() + + assertThat(loggedOutResult.isLoggedIn).isFalse() + + authFlow.value = TEST_PROJECT_ID // changed to signed in + + val loggedInResult = useCase().first() + + assertThat(loggedInResult.isLoggedIn).isTrue() + } + + @Test + fun `should handle changes in project refreshing stream`() = runTest { + val refreshingFlow = MutableStateFlow(false) // started non refreshing + every { configManager.observeIsProjectRefreshing() } returns refreshingFlow + createUseCase() + + val notRefreshingResult = useCase().first() + + assertThat(notRefreshingResult.isConfigurationLoadingProgressBarVisible).isFalse() + + refreshingFlow.value = true // changed to refreshing + + val refreshingResult = useCase().first() + + assertThat(refreshingResult.isConfigurationLoadingProgressBarVisible).isTrue() + } + + @Test + fun `should handle changes in event sync state stream`() = runTest { + val eventSyncStateFlow = MutableLiveData() + every { eventSyncManager.getLastSyncState(any()) } returns eventSyncStateFlow + createUseCase() + val mockIdleState = mockk(relaxed = true) { + every { isSyncInProgress() } returns false + } + eventSyncStateFlow.value = mockIdleState // started not syncing + + val idleResult = useCase().first() + + assertThat(idleResult.syncInfoSectionRecords.isProgressVisible).isFalse() + + val mockSyncingState = mockk(relaxed = true) { + every { isSyncInProgress() } returns true + every { progress } returns 1 + every { total } returns 2 + } + eventSyncStateFlow.value = mockSyncingState // changed to syncing + + val syncingResult = useCase().first() + + assertThat(syncingResult.syncInfoSectionRecords.isProgressVisible).isTrue() + } + + @Test + fun `should handle changes in image sync status stream`() = runTest { + val imageSyncStatusFlow = MutableStateFlow(mockk { + every { isSyncing } returns false + every { progress } returns null + every { secondsSinceLastUpdate } returns null + }) // started not syncing + every { syncOrchestrator.observeImageSyncStatus() } returns imageSyncStatusFlow + createUseCase() + + val notSyncingResult = useCase().first() + + assertThat(notSyncingResult.syncInfoSectionImages.isProgressVisible).isFalse() + + imageSyncStatusFlow.value = mockk { + every { isSyncing } returns true + every { progress } returns Pair(1, 2) + every { secondsSinceLastUpdate } returns null + } // changed to syncing + + val syncingResult = useCase().first() + + assertThat(syncingResult.syncInfoSectionImages.isProgressVisible).isTrue() + } + + @Test + fun `should handle changes in project config stream`() = runTest { + val projectConfigFlow = MutableStateFlow(mockProjectConfiguration) + every { configManager.observeProjectConfiguration() } returns projectConfigFlow // started without modules + createUseCase() + + val initialResult = useCase().first() + + assertThat(initialResult.syncInfoSectionModules.isSectionAvailable).isFalse() + + val mockConfigWithModules = mockk { + every { general } returns mockk { + every { modalities } returns listOf(GeneralConfiguration.Modality.FINGERPRINT) + } + } + every { mockConfigWithModules.isModuleSelectionAvailable() } returns true + projectConfigFlow.value = mockConfigWithModules // now with modules + + val moduleConfigResult = useCase().first() + + assertThat(moduleConfigResult.syncInfoSectionModules.isSectionAvailable).isTrue() + } + + @Test + fun `should handle changes in device config stream`() = runTest { + every { configManager.observeProjectConfiguration() } returns flowOf( + mockk { + every { general } returns mockk { + every { modalities } returns emptyList() + } + } + ) + val deviceConfigFlow = MutableStateFlow( + mockk(relaxed = true) { + every { selectedModules } returns emptyList() + } + ) // started without selected modules + every { configManager.observeDeviceConfiguration() } returns deviceConfigFlow + createUseCase() + + val noModulesResult = useCase().first() + + assertThat(noModulesResult.syncInfoSectionModules.moduleCounts).isEmpty() + + deviceConfigFlow.emit( + mockk(relaxed = true) { + every { selectedModules } returns listOf(TokenizableString.Raw(TEST_MODULE_NAME)) + } + ) // now with selected modules + + val withModulesResult = useCase().first() + + assertThat(withModulesResult.syncInfoSectionModules.moduleCounts).isNotEmpty() + } + + @Test + fun `should handle changes in time pacing stream`() = runTest { + val mockIdleEventSyncState = mockk(relaxed = true) { + every { isSyncRunning() } returns false + } + every { eventSyncManager.getLastSyncState() } returns MutableLiveData(mockIdleEventSyncState) + coEvery { eventSyncManager.getLastSyncTime() } returns TEST_TIMESTAMP + every { timeHelper.now() } returnsMany listOf(TEST_TIMESTAMP, Timestamp(TEST_TIMESTAMP.ms + 60_000)) + // MutableStateFlow of Unit won't emit another (identical) Unit, so we'll count minutes and map to Units + val timePaceFlow = MutableStateFlow(0) + every { timer.observeTickOncePerMinute() } returns timePaceFlow.map { } + createUseCase() + + val initialResult = useCase().first() + + assertThat(initialResult.syncInfoSectionRecords.footerLastSyncMinutesAgo).isEqualTo(0) + + timePaceFlow.value = -1 // just a different value for a time beat, doesn't matter which + + val updatedResult = useCase().first() + + assertThat(updatedResult.syncInfoSectionRecords.footerLastSyncMinutesAgo).isEqualTo(1) + } + + // UI state tests + + @Test + fun `should calculate correct record last sync time when sync time available`() = runTest { + val fiveMinutesAgo = Timestamp(TEST_TIMESTAMP.ms - 300000) // 5 minutes before test timestamp + coEvery { eventSyncManager.getLastSyncTime() } returns fiveMinutesAgo + every { timeHelper.msBetweenNowAndTime(fiveMinutesAgo) } returns 300000L // 5 minutes + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.isFooterLastSyncTimeVisible).isTrue() + assertThat(result.syncInfoSectionRecords.footerLastSyncMinutesAgo).isEqualTo(5) + } + + @Test + fun `should have hidden record last sync time footer when no sync history`() = runTest { + coEvery { eventSyncManager.getLastSyncTime() } returns null + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.isFooterLastSyncTimeVisible).isFalse() + } + + @Test + fun `should calculate correct image last sync time when available`() = runTest { + val mockImageStatusWithLastSync = mockk(relaxed = true) { + every { isSyncing } returns false + every { progress } returns null + every { secondsSinceLastUpdate } returns 180 // 3 minutes + } + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockImageStatusWithLastSync) + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionImages.isFooterLastSyncTimeVisible).isTrue() + assertThat(result.syncInfoSectionImages.footerLastSyncMinutesAgo).isEqualTo(3) + } + + @Test + fun `should have hidden image last sync time footer when unavailable`() = runTest { + val mockImageStatusWithoutLastSync = mockk(relaxed = true) { + every { isSyncing } returns false + every { progress } returns null + every { secondsSinceLastUpdate } returns null + } + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockImageStatusWithoutLastSync) + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionImages.isFooterLastSyncTimeVisible).isFalse() + } + + @Test + fun `should show correct visibility states for offline instructions`() = runTest { + every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(false) + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.isInstructionOfflineVisible).isTrue() + assertThat(result.syncInfoSectionRecords.isInstructionDefaultVisible).isFalse() + assertThat(result.syncInfoSectionRecords.isInstructionErrorVisible).isFalse() + assertThat(result.syncInfoSectionImages.isInstructionOfflineVisible).isTrue() + assertThat(result.syncInfoSectionImages.isInstructionDefaultVisible).isFalse() + } + + @Test + fun `should show correct visibility states for error instructions`() = runTest { + val mockFailedEventSyncState = mockk(relaxed = true) { + every { isSyncFailed() } returns true + every { isSyncInProgress() } returns false + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockFailedEventSyncState) + every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.isInstructionErrorVisible).isTrue() + assertThat(result.syncInfoSectionRecords.isInstructionDefaultVisible).isFalse() + assertThat(result.syncInfoSectionRecords.isInstructionOfflineVisible).isFalse() + } + + @Test + fun `should show correct visibility states for module selection instructions`() = runTest { + val mockProjectConfigRequiringModules = mockk { + every { general } returns mockk { + every { modalities } returns listOf(GeneralConfiguration.Modality.FINGERPRINT) + } + } + 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 { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) + every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) + every { mockProjectConfigRequiringModules.isModuleSelectionAvailable() } returns true + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.isInstructionNoModulesVisible).isTrue() + assertThat(result.syncInfoSectionRecords.isInstructionDefaultVisible).isFalse() + assertThat(result.syncInfoSectionRecords.isInstructionOfflineVisible).isFalse() + assertThat(result.syncInfoSectionRecords.isInstructionErrorVisible).isFalse() + } + + @Test + fun `should show correct visibility states for default instructions`() = runTest { + val mockIdleEventSyncState = mockk(relaxed = true) { + every { isSyncFailed() } returns false + every { isSyncInProgress() } returns false + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) + every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.isInstructionDefaultVisible).isTrue() + assertThat(result.syncInfoSectionRecords.isInstructionOfflineVisible).isFalse() + assertThat(result.syncInfoSectionRecords.isInstructionErrorVisible).isFalse() + assertThat(result.syncInfoSectionRecords.isInstructionNoModulesVisible).isFalse() + assertThat(result.syncInfoSectionImages.isInstructionDefaultVisible).isTrue() + assertThat(result.syncInfoSectionImages.isInstructionOfflineVisible).isFalse() + } + + @Test + fun `should handle failed sync retry indication correctly`() = runTest { + val eventSyncStateFlow = MutableLiveData() + every { eventSyncManager.getLastSyncState(any()) } returns eventSyncStateFlow + createUseCase() + val mockFailedState = mockk(relaxed = true) { + every { isSyncFailed() } returns true + every { isSyncInProgress() } returns false + every { isSyncFailedBecauseReloginRequired() } returns false + } + eventSyncStateFlow.value = mockFailedState + + val failedResult = useCase().first() + + assertThat(failedResult.syncInfoSectionRecords.isInstructionErrorVisible).isTrue() + assertThat(failedResult.syncInfoSectionRecords.isSyncButtonForRetry).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(GeneralConfiguration.Modality.FINGERPRINT) + } + } + 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(GeneralConfiguration.Modality.FINGERPRINT) + } + } + 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()) } + } + +} + From 470f54a0594b7ca2ba48f3a001e2f7946c18871b Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 12 Aug 2025 10:13:33 +0100 Subject: [PATCH 30/45] MS-939 Image sync button coloring moved to XML --- .../settings/syncinfo/SyncInfoFragment.kt | 24 ++++++------------- .../button_sync_images_background_default.xml | 5 ++++ .../button_sync_images_background_red.xml | 5 ++++ 3 files changed, 17 insertions(+), 17 deletions(-) create mode 100644 infra/resources/src/main/res/color/button_sync_images_background_default.xml create mode 100644 infra/resources/src/main/res/color/button_sync_images_background_red.xml 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 13a1c5a0b4..f92be6b45a 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 @@ -2,7 +2,6 @@ package com.simprints.feature.dashboard.settings.syncinfo import android.animation.ObjectAnimator import android.content.Intent -import android.content.res.ColorStateList import android.os.Bundle import android.provider.Settings import android.view.LayoutInflater @@ -281,22 +280,13 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { else -> IDR.string.sync_info_button_sync_images } ) - binding.buttonSyncImagesNow.backgroundTintList = ColorStateList( - arrayOf( - intArrayOf(android.R.attr.state_enabled), // enabled - intArrayOf(-android.R.attr.state_enabled) // disabled - ), - intArrayOf( - ContextCompat.getColor( - requireContext(), - if (images.isProgressVisible) { - IDR.color.simprints_red_dark - } else { - IDR.color.simprints_orange - } - ), - ContextCompat.getColor(requireContext(), IDR.color.simprints_grey_disabled), - ), + binding.buttonSyncImagesNow.backgroundTintList = ContextCompat.getColorStateList( + requireContext(), + if (images.isProgressVisible) { + IDR.color.button_sync_images_background_red + } else { + IDR.color.button_sync_images_background_default + } ) // Footer diff --git a/infra/resources/src/main/res/color/button_sync_images_background_default.xml b/infra/resources/src/main/res/color/button_sync_images_background_default.xml new file mode 100644 index 0000000000..e71195149e --- /dev/null +++ b/infra/resources/src/main/res/color/button_sync_images_background_default.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/infra/resources/src/main/res/color/button_sync_images_background_red.xml b/infra/resources/src/main/res/color/button_sync_images_background_red.xml new file mode 100644 index 0000000000..f4de1739b9 --- /dev/null +++ b/infra/resources/src/main/res/color/button_sync_images_background_red.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file From 2ed78a2380d00b539a8fd0d222355a917d0afcf1 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 12 Aug 2025 19:09:48 +0100 Subject: [PATCH 31/45] MS-939 Existing timestamp formatter reused --- .../dashboard/settings/syncinfo/SyncInfo.kt | 4 ++-- .../settings/syncinfo/SyncInfoFragment.kt | 16 ++------------ .../usecase/ObserveSyncInfoUseCase.kt | 19 ++++++++-------- .../syncinfo/SyncInfoViewModelTest.kt | 4 ++-- .../usecase/ObserveSyncInfoUseCaseTest.kt | 22 +++++++++++-------- .../resources/src/main/res/values/strings.xml | 8 ------- 6 files changed, 28 insertions(+), 45 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 a40f8e185b..ec164edf98 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 @@ -39,7 +39,7 @@ data class SyncInfoSectionRecords( val isFooterReadyToLogOutVisible: Boolean = false, val isFooterSyncIncompleteVisible: Boolean = false, val isFooterLastSyncTimeVisible: Boolean = false, - val footerLastSyncMinutesAgo: Int = -1, + val footerLastSyncMinutesAgo: String = "", ) data class SyncInfoError( @@ -65,7 +65,7 @@ data class SyncInfoSectionImages( // footer val isFooterLastSyncTimeVisible: Boolean = false, - val footerLastSyncMinutesAgo: Int = -1, + val footerLastSyncMinutesAgo: String = "", ) data class SyncInfoProgress( 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 f92be6b45a..ecf87bf238 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 @@ -231,7 +231,7 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { binding.textFooterRecordLoggingOut.isGone = !records.isFooterReadyToLogOutVisible binding.textFooterRecordSyncIncomplete.isGone = !records.isFooterSyncIncompleteVisible binding.textFooterRecordLastSyncedWhen.isGone = !records.isFooterLastSyncTimeVisible - binding.textFooterRecordLastSyncedWhen.text = formatLastSyncTime(records.footerLastSyncMinutesAgo) + binding.textFooterRecordLastSyncedWhen.text = records.footerLastSyncMinutesAgo } private fun SyncInfoError.configureErrorPopup() { @@ -291,7 +291,7 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { // Footer binding.textFooterImageLastSyncedWhen.isInvisible = !images.isFooterLastSyncTimeVisible - binding.textFooterImageLastSyncedWhen.text = formatLastSyncTime(images.footerLastSyncMinutesAgo) + binding.textFooterImageLastSyncedWhen.text = images.footerLastSyncMinutesAgo } private fun renderModulesSection(modules: SyncInfoSectionModules, config: SyncInfoFragmentConfig) { @@ -346,18 +346,6 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { textView.text = progressText } - private fun formatLastSyncTime(minutesAgo: Int): String = - when { - minutesAgo < 0 -> getString(IDR.string.sync_info_footer_time_none) - minutesAgo == 0 -> getString(IDR.string.sync_info_footer_time_now) - minutesAgo == 1 -> getString(IDR.string.sync_info_footer_time_1_minute) - minutesAgo < 60 -> getString(IDR.string.sync_info_footer_time_minutes, minutesAgo) - minutesAgo < 2 * 60 -> getString(IDR.string.sync_info_footer_time_1_hour) - minutesAgo < 24 * 60 -> getString(IDR.string.sync_info_footer_time_hours, minutesAgo / 60) - minutesAgo < 2 * 24 * 60 -> getString(IDR.string.sync_info_footer_time_1_day) - else -> getString(IDR.string.sync_info_footer_time_days, minutesAgo / 60 / 24) - } - private fun View.setPulseAnimation(isEnabled: Boolean) { (tag as? ObjectAnimator?)?.run { cancel() 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 a94c28b6d9..fcbf9379bf 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 @@ -6,6 +6,7 @@ import com.simprints.core.tools.extentions.combine8 import com.simprints.core.tools.extentions.onChange import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.time.Timer +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 @@ -138,12 +139,10 @@ internal class ObserveSyncInfoUseCase @Inject constructor( SyncInfoProgress() } - val eventLastSyncMinutes = eventSyncManager.getLastSyncTime()?.run { - (timeHelper.now().ms - ms) / 60 / 1000 - }?.toInt() ?: -1 - val imageLastSyncMinutes = imageSyncStatus.secondsSinceLastUpdate?.let { - (it / 60).toInt() - } ?: -1 + val eventLastSyncTimestamp = eventSyncManager.getLastSyncTime() ?: Timestamp(-1) + val imageLastSyncTimestamp = imageSyncStatus.secondsSinceLastUpdate?.let { + Timestamp(it * 1000) + } ?: Timestamp(-1) val isReLoginRequired = eventSyncState.isSyncFailedBecauseReloginRequired() @@ -240,8 +239,8 @@ internal class ObserveSyncInfoUseCase @Inject constructor( isFooterSyncInProgressVisible = isPreLogoutUpSync && isEventSyncInProgress, isFooterReadyToLogOutVisible = isPreLogoutUpSync && eventSyncState.isSyncCompleted() && !imageSyncStatus.isSyncing, isFooterSyncIncompleteVisible = isPreLogoutUpSync && eventSyncState.isSyncFailed(), - isFooterLastSyncTimeVisible = !isPreLogoutUpSync && !eventSyncState.isSyncInProgress() && eventLastSyncMinutes >= 0, - footerLastSyncMinutesAgo = eventLastSyncMinutes, + isFooterLastSyncTimeVisible = !isPreLogoutUpSync && !eventSyncState.isSyncInProgress() && eventLastSyncTimestamp.ms >= 0, + footerLastSyncMinutesAgo = timeHelper.readableBetweenNowAndTime(eventLastSyncTimestamp), ) val syncInfoSectionImages = SyncInfoSectionImages( @@ -251,8 +250,8 @@ internal class ObserveSyncInfoUseCase @Inject constructor( isProgressVisible = imageSyncStatus.isSyncing, progress = imageSyncProgress, isSyncButtonEnabled = isConnected && !isReLoginRequired, - isFooterLastSyncTimeVisible = !imageSyncStatus.isSyncing && imageLastSyncMinutes >= 0, - footerLastSyncMinutesAgo = imageLastSyncMinutes, + isFooterLastSyncTimeVisible = !imageSyncStatus.isSyncing && imageLastSyncTimestamp.ms >= 0, + footerLastSyncMinutesAgo = timeHelper.readableBetweenNowAndTime(imageLastSyncTimestamp), ) val syncInfo = SyncInfo( 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 c022647c1d..bff60eddb3 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 @@ -199,7 +199,7 @@ class SyncInfoViewModelTest { isFooterReadyToLogOutVisible = false, isFooterSyncIncompleteVisible = false, isFooterLastSyncTimeVisible = false, - footerLastSyncMinutesAgo = 0 + footerLastSyncMinutesAgo = "", ), syncInfoSectionImages = SyncInfoSectionImages( counterImagesToUpload = "0", @@ -209,7 +209,7 @@ class SyncInfoViewModelTest { progress = SyncInfoProgress(), isSyncButtonEnabled = true, isFooterLastSyncTimeVisible = false, - footerLastSyncMinutesAgo = 0 + footerLastSyncMinutesAgo = "", ), syncInfoSectionModules = SyncInfoSectionModules( isSectionAvailable = false, 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 e410ff9014..95dbd641f8 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 @@ -150,6 +150,7 @@ class ObserveSyncInfoUseCaseTest { every { timer.observeTickOncePerMinute() } 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") @@ -294,7 +295,7 @@ class ObserveSyncInfoUseCaseTest { val result = useCase().first() assertThat(result.syncInfoSectionRecords.isFooterLastSyncTimeVisible).isTrue() - assertThat(result.syncInfoSectionRecords.footerLastSyncMinutesAgo).isEqualTo(0) + assertThat(result.syncInfoSectionRecords.footerLastSyncMinutesAgo).isEqualTo("0 minutes ago") assertThat(result.syncInfoSectionRecords.isFooterSyncInProgressVisible).isFalse() } @@ -336,12 +337,13 @@ class ObserveSyncInfoUseCaseTest { every { secondsSinceLastUpdate } returns 120 // 2 minutes } every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockImageStatusWithLastSync) + every { timeHelper.readableBetweenNowAndTime(Timestamp(120 * 1000)) } returns "2 minutes ago" createUseCase() val result = useCase().first() assertThat(result.syncInfoSectionImages.isFooterLastSyncTimeVisible).isTrue() - assertThat(result.syncInfoSectionImages.footerLastSyncMinutesAgo).isEqualTo(2) + assertThat(result.syncInfoSectionImages.footerLastSyncMinutesAgo).isEqualTo("2 minutes ago") } @Test @@ -905,6 +907,7 @@ class ObserveSyncInfoUseCaseTest { every { eventSyncManager.getLastSyncState() } returns MutableLiveData(mockIdleEventSyncState) coEvery { eventSyncManager.getLastSyncTime() } returns TEST_TIMESTAMP every { timeHelper.now() } returnsMany listOf(TEST_TIMESTAMP, Timestamp(TEST_TIMESTAMP.ms + 60_000)) + 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 { timer.observeTickOncePerMinute() } returns timePaceFlow.map { } @@ -912,28 +915,28 @@ class ObserveSyncInfoUseCaseTest { val initialResult = useCase().first() - assertThat(initialResult.syncInfoSectionRecords.footerLastSyncMinutesAgo).isEqualTo(0) + assertThat(initialResult.syncInfoSectionRecords.footerLastSyncMinutesAgo).isEqualTo("0 minutes ago") timePaceFlow.value = -1 // just a different value for a time beat, doesn't matter which val updatedResult = useCase().first() - assertThat(updatedResult.syncInfoSectionRecords.footerLastSyncMinutesAgo).isEqualTo(1) + assertThat(updatedResult.syncInfoSectionRecords.footerLastSyncMinutesAgo).isEqualTo("1 minute ago") } // UI state tests @Test fun `should calculate correct record last sync time when sync time available`() = runTest { - val fiveMinutesAgo = Timestamp(TEST_TIMESTAMP.ms - 300000) // 5 minutes before test timestamp - coEvery { eventSyncManager.getLastSyncTime() } returns fiveMinutesAgo - every { timeHelper.msBetweenNowAndTime(fiveMinutesAgo) } returns 300000L // 5 minutes + val timestamp = Timestamp(0L) + coEvery { eventSyncManager.getLastSyncTime() } returns timestamp + every { timeHelper.readableBetweenNowAndTime(timestamp) } returns "5 minutes ago" createUseCase() val result = useCase().first() assertThat(result.syncInfoSectionRecords.isFooterLastSyncTimeVisible).isTrue() - assertThat(result.syncInfoSectionRecords.footerLastSyncMinutesAgo).isEqualTo(5) + assertThat(result.syncInfoSectionRecords.footerLastSyncMinutesAgo).isEqualTo("5 minutes ago") } @Test @@ -954,12 +957,13 @@ class ObserveSyncInfoUseCaseTest { every { secondsSinceLastUpdate } returns 180 // 3 minutes } every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockImageStatusWithLastSync) + every { timeHelper.readableBetweenNowAndTime(Timestamp(180 * 1000)) } returns "3 minutes ago" createUseCase() val result = useCase().first() assertThat(result.syncInfoSectionImages.isFooterLastSyncTimeVisible).isTrue() - assertThat(result.syncInfoSectionImages.footerLastSyncMinutesAgo).isEqualTo(3) + assertThat(result.syncInfoSectionImages.footerLastSyncMinutesAgo).isEqualTo("3 minutes ago") } @Test diff --git a/infra/resources/src/main/res/values/strings.xml b/infra/resources/src/main/res/values/strings.xml index cf3b39ee58..c1a25fdd97 100644 --- a/infra/resources/src/main/res/values/strings.xml +++ b/infra/resources/src/main/res/values/strings.xml @@ -368,14 +368,6 @@ Sync in progress Sync incomplete Sync complete, logging you out… - - Last synced just now - Last synced 1 minute ago - Last synced %1$d minutes ago - Last synced 1 hour ago - Last synced %1$d hours ago - Last synced 1 day ago - Last synced %1$d days ago Activity: %1$s From f6f1793fedd9a94c238016db220d876bd1d46728 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 12 Aug 2025 19:21:25 +0100 Subject: [PATCH 32/45] MS-939 Image sync timestamp in millis instead of seconds --- .../syncinfo/usecase/ObserveSyncInfoUseCase.kt | 4 ++-- .../logout/LogoutSyncViewModelTest.kt | 6 +++--- .../settings/syncinfo/SyncInfoViewModelTest.kt | 12 ++++++------ .../usecase/ObserveSyncInfoUseCaseTest.kt | 12 ++++++------ .../simprints/infra/sync/ImageSyncStatus.kt | 2 +- .../infra/sync/ImageSyncTimestampProvider.kt | 4 ++-- .../infra/sync/SyncOrchestratorImpl.kt | 6 +++--- .../sync/ImageSyncTimestampProviderTest.kt | 18 +++++++++--------- .../infra/sync/SyncOrchestratorImplTest.kt | 12 ++++++------ 9 files changed, 38 insertions(+), 38 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 fcbf9379bf..a91a851311 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 @@ -140,8 +140,8 @@ internal class ObserveSyncInfoUseCase @Inject constructor( } val eventLastSyncTimestamp = eventSyncManager.getLastSyncTime() ?: Timestamp(-1) - val imageLastSyncTimestamp = imageSyncStatus.secondsSinceLastUpdate?.let { - Timestamp(it * 1000) + val imageLastSyncTimestamp = imageSyncStatus.lastUpdateTimeMillis?.let { + Timestamp(it) } ?: Timestamp(-1) val isReLoginRequired = eventSyncState.isSyncFailedBecauseReloginRequired() 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 50132ef4ad..a5a68aec94 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 @@ -103,7 +103,7 @@ internal class LogoutSyncViewModelTest { val eventSyncState = mockk { every { isSyncCompleted() } returns false } - val imageSyncStatus = ImageSyncStatus(isSyncing = false, progress = null, secondsSinceLastUpdate = null) + val imageSyncStatus = ImageSyncStatus(isSyncing = false, progress = null, lastUpdateTimeMillis = null) val projectConfig = mockk(relaxed = true) setupSyncMocks(eventSyncState, imageSyncStatus, projectConfig) @@ -119,7 +119,7 @@ internal class LogoutSyncViewModelTest { val eventSyncState = mockk { every { isSyncCompleted() } returns true } - val imageSyncStatus = ImageSyncStatus(isSyncing = true, progress = null, secondsSinceLastUpdate = null) + val imageSyncStatus = ImageSyncStatus(isSyncing = true, progress = null, lastUpdateTimeMillis = null) val projectConfig = mockk(relaxed = true) setupSyncMocks(eventSyncState, imageSyncStatus, projectConfig) @@ -135,7 +135,7 @@ internal class LogoutSyncViewModelTest { val eventSyncState = mockk { every { isSyncCompleted() } returns true } - val imageSyncStatus = ImageSyncStatus(isSyncing = false, progress = null, secondsSinceLastUpdate = null) + val imageSyncStatus = ImageSyncStatus(isSyncing = false, progress = null, lastUpdateTimeMillis = null) val projectConfig = mockk(relaxed = true) setupSyncMocks(eventSyncState, imageSyncStatus, projectConfig) 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 bff60eddb3..ae63e865ba 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 @@ -101,7 +101,7 @@ class SyncInfoViewModelTest { private val mockImageSyncStatus = mockk(relaxed = true) { every { isSyncing } returns false every { progress } returns null - every { secondsSinceLastUpdate } returns null + every { lastUpdateTimeMillis } returns null } @Before @@ -238,7 +238,7 @@ class SyncInfoViewModelTest { val mockNotSyncingImageStatus = mockk(relaxed = true) { every { isSyncing } returns false every { progress } returns null - every { secondsSinceLastUpdate } returns 0 + every { lastUpdateTimeMillis } returns 0 } every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) @@ -266,7 +266,7 @@ class SyncInfoViewModelTest { val mockNotSyncingImageStatus = mockk(relaxed = true) { every { isSyncing } returns false every { progress } returns null - every { secondsSinceLastUpdate } returns 0 + every { lastUpdateTimeMillis } returns 0 } every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) @@ -299,7 +299,7 @@ class SyncInfoViewModelTest { val mockNotSyncingImageStatus = mockk(relaxed = true) { every { isSyncing } returns false every { progress } returns null - every { secondsSinceLastUpdate } returns 0 + every { lastUpdateTimeMillis } returns 0 } every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) @@ -328,7 +328,7 @@ class SyncInfoViewModelTest { val mockNotSyncingImageStatus = mockk(relaxed = true) { every { isSyncing } returns false every { progress } returns null - every { secondsSinceLastUpdate } returns 0 + every { lastUpdateTimeMillis } returns 0 } every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockInProgressEventSyncState) every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) @@ -356,7 +356,7 @@ class SyncInfoViewModelTest { val mockSyncingImageStatus = mockk(relaxed = true) { every { isSyncing } returns true every { progress } returns Pair(1, 2) - every { secondsSinceLastUpdate } returns null + every { lastUpdateTimeMillis } returns null } every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCompletedEventSyncState) every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockSyncingImageStatus) 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 95dbd641f8..fe952fa452 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 @@ -101,7 +101,7 @@ class ObserveSyncInfoUseCaseTest { private val mockImageSyncStatus = mockk(relaxed = true) { every { isSyncing } returns false every { progress } returns null - every { secondsSinceLastUpdate } returns null + every { lastUpdateTimeMillis } returns null } @Before @@ -334,7 +334,7 @@ class ObserveSyncInfoUseCaseTest { val mockImageStatusWithLastSync = mockk(relaxed = true) { every { isSyncing } returns false every { progress } returns null - every { secondsSinceLastUpdate } returns 120 // 2 minutes + every { lastUpdateTimeMillis } returns 120_000 // 2 minutes } every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockImageStatusWithLastSync) every { timeHelper.readableBetweenNowAndTime(Timestamp(120 * 1000)) } returns "2 minutes ago" @@ -824,7 +824,7 @@ class ObserveSyncInfoUseCaseTest { val imageSyncStatusFlow = MutableStateFlow(mockk { every { isSyncing } returns false every { progress } returns null - every { secondsSinceLastUpdate } returns null + every { lastUpdateTimeMillis } returns null }) // started not syncing every { syncOrchestrator.observeImageSyncStatus() } returns imageSyncStatusFlow createUseCase() @@ -836,7 +836,7 @@ class ObserveSyncInfoUseCaseTest { imageSyncStatusFlow.value = mockk { every { isSyncing } returns true every { progress } returns Pair(1, 2) - every { secondsSinceLastUpdate } returns null + every { lastUpdateTimeMillis } returns null } // changed to syncing val syncingResult = useCase().first() @@ -954,7 +954,7 @@ class ObserveSyncInfoUseCaseTest { val mockImageStatusWithLastSync = mockk(relaxed = true) { every { isSyncing } returns false every { progress } returns null - every { secondsSinceLastUpdate } returns 180 // 3 minutes + every { lastUpdateTimeMillis } returns 180_000 // 3 minutes } every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockImageStatusWithLastSync) every { timeHelper.readableBetweenNowAndTime(Timestamp(180 * 1000)) } returns "3 minutes ago" @@ -971,7 +971,7 @@ class ObserveSyncInfoUseCaseTest { val mockImageStatusWithoutLastSync = mockk(relaxed = true) { every { isSyncing } returns false every { progress } returns null - every { secondsSinceLastUpdate } returns null + every { lastUpdateTimeMillis } returns null } every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockImageStatusWithoutLastSync) createUseCase() diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncStatus.kt b/infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncStatus.kt index 69cfbbd1c5..32e93be2a7 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncStatus.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncStatus.kt @@ -3,5 +3,5 @@ package com.simprints.infra.sync data class ImageSyncStatus( val isSyncing: Boolean, val progress: Pair?, - val secondsSinceLastUpdate: Long?, + val lastUpdateTimeMillis: Long?, ) diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncTimestampProvider.kt b/infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncTimestampProvider.kt index cf127aa494..9669377c8a 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncTimestampProvider.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncTimestampProvider.kt @@ -15,11 +15,11 @@ class ImageSyncTimestampProvider @Inject constructor( securePrefs.edit { putLong(IMAGE_SYNC_COMPLETION_TIME_MILLIS, timeHelper.now().ms) } } - fun getSecondsSinceLastImageSync(): Long? = + fun getMillisSinceLastImageSync(): Long? = securePrefs.getLong(IMAGE_SYNC_COMPLETION_TIME_MILLIS, 0).takeIf { securePrefs.contains(IMAGE_SYNC_COMPLETION_TIME_MILLIS) }?.let { lastSyncTimestamp -> - (timeHelper.now().ms - lastSyncTimestamp) / 1000 + timeHelper.now().ms - lastSyncTimestamp } fun clearTimestamp() { 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 e430fbf68e..9798662c62 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 @@ -154,13 +154,13 @@ internal class SyncOrchestratorImpl @Inject constructor( .getWorkInfosFlow(WorkQuery.fromUniqueWorkNames(SyncConstants.FILE_UP_SYNC_WORK_NAME)) .associateWithIfSyncing() .map { (workInfos, isSyncing) -> - val secondsSinceLastUpdate = imageSyncTimestampProvider.getSecondsSinceLastImageSync() + val millisSinceLastUpdate = imageSyncTimestampProvider.getMillisSinceLastImageSync() val currentIndex = workInfos.firstOrNull()?.progress ?.getInt(SyncConstants.PROGRESS_CURRENT, 0)?.coerceAtLeast(0) ?: 0 val totalCount = workInfos.firstOrNull()?.progress ?.getInt(SyncConstants.PROGRESS_MAX, 0)?.takeIf { it >= 1 } val progress = totalCount?.let { currentIndex to totalCount } - ImageSyncStatus(isSyncing, progress, secondsSinceLastUpdate) + ImageSyncStatus(isSyncing, progress, millisSinceLastUpdate) } } @@ -172,7 +172,7 @@ internal class SyncOrchestratorImpl @Inject constructor( * This allows immediately succeeding syncs to be detected in the return flow. */ private fun Flow>.associateWithIfSyncing() = transformLatest { workInfos -> - val isJustUpdated = imageSyncTimestampProvider.getSecondsSinceLastImageSync() == 0L + val isJustUpdated = imageSyncTimestampProvider.getMillisSinceLastImageSync() == 0L when { workInfos.any { it.state == WorkInfo.State.RUNNING diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/ImageSyncTimestampProviderTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/ImageSyncTimestampProviderTest.kt index cadc57be1d..44b0c2e740 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/ImageSyncTimestampProviderTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/ImageSyncTimestampProviderTest.kt @@ -57,45 +57,45 @@ class ImageSyncTimestampProviderTest { } @Test - fun `getSecondsSinceLastImageSync returns null when no timestamp exists`() { + fun `getMillisSinceLastImageSync returns null when no timestamp exists`() { every { sharedPreferences.contains("IMAGE_SYNC_COMPLETION_TIME_MILLIS") } returns false every { sharedPreferences.getLong("IMAGE_SYNC_COMPLETION_TIME_MILLIS", 0) } returns 0 - val result = imageSyncTimestampProvider.getSecondsSinceLastImageSync() + val result = imageSyncTimestampProvider.getMillisSinceLastImageSync() assertThat(result).isNull() } @Test - fun `getSecondsSinceLastImageSync returns null when timestamp is zero`() { + fun `getMillisSinceLastImageSync returns null when timestamp is zero`() { every { sharedPreferences.contains("IMAGE_SYNC_COMPLETION_TIME_MILLIS") } returns false every { sharedPreferences.getLong("IMAGE_SYNC_COMPLETION_TIME_MILLIS", 0) } returns 0 - val result = imageSyncTimestampProvider.getSecondsSinceLastImageSync() + val result = imageSyncTimestampProvider.getMillisSinceLastImageSync() assertThat(result).isNull() } @Test - fun `getSecondsSinceLastImageSync returns correct seconds when timestamp exists`() { + fun `getMillisSinceLastImageSync returns correct seconds when timestamp exists`() { val lastSyncTimeMillis = 1000000L val currentTimeMillis = 1005000L // 5 seconds later - val expectedSeconds = 5L + val expectedMillis = 5_000L every { sharedPreferences.contains("IMAGE_SYNC_COMPLETION_TIME_MILLIS") } returns true every { sharedPreferences.getLong("IMAGE_SYNC_COMPLETION_TIME_MILLIS", 0) } returns lastSyncTimeMillis every { timeHelper.now() } returns Timestamp(currentTimeMillis) - val result = imageSyncTimestampProvider.getSecondsSinceLastImageSync() + val result = imageSyncTimestampProvider.getMillisSinceLastImageSync() - assertThat(result).isEqualTo(expectedSeconds) + assertThat(result).isEqualTo(expectedMillis) } @Test fun `clearTimestamp clears all timestamp preferences`() { imageSyncTimestampProvider.clearTimestamp() - val result = imageSyncTimestampProvider.getSecondsSinceLastImageSync() + val result = imageSyncTimestampProvider.getMillisSinceLastImageSync() assertThat(result).isNull() verify { 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 1903f9e6fa..8adccc1808 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 @@ -340,24 +340,24 @@ class SyncOrchestratorImplTest { fun `observe image sync status returns syncing when worker is running`() = runTest { val workInfoFlow = flowOf(createWorkInfo(WorkInfo.State.RUNNING)) every { workManager.getWorkInfosFlow(any()) } returns workInfoFlow - every { imageSyncTimestampProvider.getSecondsSinceLastImageSync() } returns 30L + every { imageSyncTimestampProvider.getMillisSinceLastImageSync() } returns 30_000L val status = syncOrchestrator.observeImageSyncStatus().first() assertThat(status.isSyncing).isTrue() - assertThat(status.secondsSinceLastUpdate).isEqualTo(30L) + assertThat(status.lastUpdateTimeMillis).isEqualTo(30_000L) } @Test fun `observe image sync status returns not syncing when worker is cancelled`() = runTest { val workInfoFlow = flowOf(createWorkInfo(WorkInfo.State.CANCELLED)) every { workManager.getWorkInfosFlow(any()) } returns workInfoFlow - every { imageSyncTimestampProvider.getSecondsSinceLastImageSync() } returns 120L + every { imageSyncTimestampProvider.getMillisSinceLastImageSync() } returns 120_000L val status = syncOrchestrator.observeImageSyncStatus().first() assertThat(status.isSyncing).isFalse() - assertThat(status.secondsSinceLastUpdate).isEqualTo(120L) + assertThat(status.lastUpdateTimeMillis).isEqualTo(120_000L) } @Test @@ -366,7 +366,7 @@ class SyncOrchestratorImplTest { val workInfo2 = createWorkInfoWithProgress(WorkInfo.State.RUNNING) val workInfoFlow = flowOf(workInfo1, workInfo2) every { workManager.getWorkInfosFlow(any()) } returns workInfoFlow - every { imageSyncTimestampProvider.getSecondsSinceLastImageSync() } returns 0L + every { imageSyncTimestampProvider.getMillisSinceLastImageSync() } returns 0L val status1 = syncOrchestrator.observeImageSyncStatus().first() assertThat(status1.progress).isEqualTo(5 to 10) @@ -379,7 +379,7 @@ class SyncOrchestratorImplTest { fun `observe image sync status returns syncing momentarily when worker succeeds quickly`() = runTest { val workInfoFlow = flowOf(createWorkInfo(WorkInfo.State.SUCCEEDED)) every { workManager.getWorkInfosFlow(any()) } returns workInfoFlow - every { imageSyncTimestampProvider.getSecondsSinceLastImageSync() } returns 0L + every { imageSyncTimestampProvider.getMillisSinceLastImageSync() } returns 0L val status1 = syncOrchestrator.observeImageSyncStatus().first() assertThat(status1.isSyncing).isTrue() From 31610f9abc3ba63cedba9dad4e2d1fd4ef1c1d50 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 12 Aug 2025 20:48:44 +0100 Subject: [PATCH 33/45] MS-939 Down-sync event counter caching moved to the use case --- .../usecase/ObserveSyncInfoUseCase.kt | 21 +++++++++++- .../syncinfo/SyncInfoViewModelTest.kt | 2 +- .../usecase/ObserveSyncInfoUseCaseTest.kt | 34 ++++++++++++++++--- .../infra/eventsync/EventSyncManager.kt | 2 +- .../infra/eventsync/EventSyncManagerImpl.kt | 17 ++-------- .../infra/eventsync/EventSyncManagerTest.kt | 33 ------------------ 6 files changed, 53 insertions(+), 56 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 a91a851311..17d13c3243 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 @@ -169,7 +169,7 @@ internal class ObserveSyncInfoUseCase @Inject constructor( isPreLogoutUpSync -> null projectConfig.isEventDownSyncAllowed() -> try { withTimeout(COUNT_EVENTS_TIMEOUT_MILLIS) { - eventSyncManager.countEventsToDownload(maxCacheAgeMillis = COUNT_EVENTS_TIMEOUT_MILLIS) + countEventsToDownloadWithCaching() } } catch (_: Throwable) { DownSyncCounts(0, isLowerBound = false) @@ -289,8 +289,27 @@ internal class ObserveSyncInfoUseCase @Inject constructor( ) + // caching eventSyncManager.countEventsToDownload to avoid network-based delays on frequent calls + + private var cachedEventCountToDownload: DownSyncCounts? = null + private var cachedEventCountToDownloadTimestamp: Long = 0 + + 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 + } + } + private companion object { private const val SYNC_COMPLETION_HOLD_MILLIS = 1000L private const val COUNT_EVENTS_TIMEOUT_MILLIS = 10 * 1000L + private const val COUNT_EVENTS_CACHE_LIFESPAN_MILLIS = 10 * 1000L } } 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 ae63e865ba..7242c402c2 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 @@ -135,7 +135,7 @@ class SyncInfoViewModelTest { every { eventSyncLiveData.asFlow() } returns flowOf(mockEventSyncState) coEvery { eventSyncManager.getLastSyncTime() } returns TEST_TIMESTAMP coEvery { eventSyncManager.countEventsToUpload(any()) } returns flowOf(0) - coEvery { eventSyncManager.countEventsToDownload(any()) } returns DownSyncCounts(0, isLowerBound = false) + coEvery { eventSyncManager.countEventsToDownload() } returns DownSyncCounts(0, isLowerBound = false) every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockImageSyncStatus) coEvery { syncOrchestrator.startEventSync(any()) } 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 fe952fa452..4d392f5617 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 @@ -32,6 +32,7 @@ import com.simprints.infra.sync.ImageSyncStatus 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.mockk import io.mockk.mockkStatic @@ -136,7 +137,7 @@ class ObserveSyncInfoUseCaseTest { every { eventSyncLiveData.asFlow() } returns flowOf(mockEventSyncState) coEvery { eventSyncManager.getLastSyncTime() } returns TEST_TIMESTAMP coEvery { eventSyncManager.countEventsToUpload(any()) } returns flowOf(0) - coEvery { eventSyncManager.countEventsToDownload(any()) } returns DownSyncCounts(0, isLowerBound = false) + coEvery { eventSyncManager.countEventsToDownload() } returns DownSyncCounts(0, isLowerBound = false) every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockImageSyncStatus) coEvery { syncOrchestrator.startEventSync(any()) } returns Unit @@ -519,7 +520,7 @@ class ObserveSyncInfoUseCaseTest { every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) coEvery { enrolmentRecordRepository.count(any()) } returns 25 coEvery { eventSyncManager.countEventsToUpload(any()) } returns flowOf(5) - coEvery { eventSyncManager.countEventsToDownload(any()) } returns DownSyncCounts(8, isLowerBound = false) + coEvery { eventSyncManager.countEventsToDownload() } returns DownSyncCounts(8, isLowerBound = false) createUseCase() val result = useCase().first() @@ -647,7 +648,7 @@ class ObserveSyncInfoUseCaseTest { every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithDownSync) coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithDownSync - coEvery { eventSyncManager.countEventsToDownload(any()) } returns DownSyncCounts(42, isLowerBound = false) + coEvery { eventSyncManager.countEventsToDownload() } returns DownSyncCounts(42, isLowerBound = false) every { mockProjectConfigWithDownSync.isEventDownSyncAllowed() } returns true every { mockProjectConfigWithDownSync.isModuleSelectionAvailable() } returns false createUseCase() @@ -686,7 +687,7 @@ class ObserveSyncInfoUseCaseTest { every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithDownSync) coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithDownSync - coEvery { eventSyncManager.countEventsToDownload(any()) } throws Exception("Timeout") + coEvery { eventSyncManager.countEventsToDownload() } throws Exception("Timeout") every { mockProjectConfigWithDownSync.isEventDownSyncAllowed() } returns true every { mockProjectConfigWithDownSync.isModuleSelectionAvailable() } returns false createUseCase() @@ -709,7 +710,7 @@ class ObserveSyncInfoUseCaseTest { every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockIdleEventSyncState) every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithDownSync) coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithDownSync - coEvery { eventSyncManager.countEventsToDownload(any()) } throws RuntimeException("Network error") + coEvery { eventSyncManager.countEventsToDownload() } throws RuntimeException("Network error") every { mockProjectConfigWithDownSync.isEventDownSyncAllowed() } returns true every { mockProjectConfigWithDownSync.isModuleSelectionAvailable() } returns false createUseCase() @@ -740,6 +741,29 @@ class ObserveSyncInfoUseCaseTest { assertThat(onlineResult.syncInfoSectionImages.isSyncButtonEnabled).isTrue() } + + @Test + fun `down-sync event counter bypasses cache when exceeds max age`() = runTest { + every { timeHelper.now() } returnsMany listOf(TEST_TIMESTAMP, Timestamp(TEST_TIMESTAMP.ms + 60_000)) // over cache lifespan apart + createUseCase() + + useCase().first() // initial counting + useCase().first() // cache expired, re-counting + + coVerify(exactly = 2) { eventSyncManager.countEventsToDownload() } + } + + @Test + fun `down-sync event counter uses cache when within max age`() = runTest { + every { timeHelper.now() } returnsMany listOf(TEST_TIMESTAMP, Timestamp(TEST_TIMESTAMP.ms + 5_000)) // under cache lifespan apart + createUseCase() + + useCase().first() // initial counting + useCase().first() // cache hit, no re-counting + + coVerify(exactly = 1) { eventSyncManager.countEventsToDownload() } + } + // Flow combination tests @Test 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 d379b9108e..e20e6f0de0 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 @@ -22,7 +22,7 @@ interface EventSyncManager { suspend fun countEventsToUpload(types: List): Flow - suspend fun countEventsToDownload(maxCacheAgeMillis: Long = 0): DownSyncCounts + suspend fun countEventsToDownload(): DownSyncCounts suspend fun downSyncSubject( projectId: String, diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManagerImpl.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManagerImpl.kt index 973cb9a96c..59eff466e9 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManagerImpl.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManagerImpl.kt @@ -77,18 +77,7 @@ internal class EventSyncManagerImpl @Inject constructor( types.map { eventRepository.observeEventCount(it) }, ) { it.sum() } - private var cachedEventCountToDownload: DownSyncCounts? = null - private var cachedEventCountToDownloadTimestamp: Long = 0 - - override suspend fun countEventsToDownload(maxCacheAgeMillis: Long): DownSyncCounts { - val timeNowMs = timeHelper.now().ms - cachedEventCountToDownload?.takeIf { - timeNowMs - cachedEventCountToDownloadTimestamp < maxCacheAgeMillis - }?.let { - return it - } - cachedEventCountToDownloadTimestamp = timeNowMs - + override suspend fun countEventsToDownload(): DownSyncCounts { val projectConfig = configRepository.getProjectConfiguration() val deviceConfig = configRepository.getDeviceConfiguration() @@ -105,9 +94,7 @@ internal class EventSyncManagerImpl @Inject constructor( return DownSyncCounts( count = counts.sumOf { it.count }, isLowerBound = counts.any { it.isLowerBound }, - ).also { - cachedEventCountToDownload = it - } + ) } override suspend fun downSyncSubject( diff --git a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/EventSyncManagerTest.kt b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/EventSyncManagerTest.kt index c00b474fc1..1b7e0e53d3 100644 --- a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/EventSyncManagerTest.kt +++ b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/EventSyncManagerTest.kt @@ -170,39 +170,6 @@ internal class EventSyncManagerTest { assertThat(result).isEqualTo(DownSyncCounts(26, isLowerBound = true)) } - @Test - fun `countEventsToDownload bypasses cache when exceeds max age`() = runTest { - every { timeHelper.now() } returnsMany listOf(Timestamp(1000), Timestamp(5000/* 4 seconds later */)) - coEvery { - eventDownSyncScopeRepository.getDownSyncScope(any(), any(), any()) - } returns SampleSyncScopes.modulesDownSyncScope - coEvery { eventRemoteDataSource.count(any()) } returns EventCount(10, false) - coEvery { configRepository.getDeviceConfiguration() } returns mockk { - every { selectedModules } returns listOf(DEFAULT_MODULE_ID) - } - - eventSyncManagerImpl.countEventsToDownload(2000) // remote fetch - eventSyncManagerImpl.countEventsToDownload(2000) // remote fetch 4 seconds later - - coVerify(exactly = 2) { eventDownSyncScopeRepository.getDownSyncScope(any(), any(), any()) } - } - - @Test - fun `countEventsToDownload uses cache when within max age`() = runTest { - every { timeHelper.now() } returnsMany listOf(Timestamp(1000), Timestamp(2000/* 1 second later */)) - coEvery { - eventDownSyncScopeRepository.getDownSyncScope(any(), any(), any()) - } returns SampleSyncScopes.modulesDownSyncScope - coEvery { configRepository.getDeviceConfiguration() } returns mockk { - every { selectedModules } returns listOf(DEFAULT_MODULE_ID) - } - - eventSyncManagerImpl.countEventsToDownload(2000) // remote fetch - eventSyncManagerImpl.countEventsToDownload(2000) // cache hit 1 second later - - coVerify(exactly = 1) { eventDownSyncScopeRepository.getDownSyncScope(any(), any(), any()) } - } - @Test fun `downSync should call down sync helper`() = runTest { coEvery { eventRepository.createEventScope(any()) } returns eventScope From 8f268443a6048f53ada12ee6ba2a31954f0a7f69 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 12 Aug 2025 20:59:02 +0100 Subject: [PATCH 34/45] MS-939 View pulse animation helper extracted for general use --- .../settings/syncinfo/SyncInfoFragment.kt | 28 +---------------- .../simprints/infra/uibase/view/View.ext.kt | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 27 deletions(-) create mode 100644 infra/ui-base/src/main/java/com/simprints/infra/uibase/view/View.ext.kt 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 ecf87bf238..31b1a54c1b 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 @@ -1,13 +1,11 @@ package com.simprints.feature.dashboard.settings.syncinfo -import android.animation.ObjectAnimator import android.content.Intent import android.os.Bundle import android.provider.Settings import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.animation.AccelerateDecelerateInterpolator import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat @@ -31,6 +29,7 @@ import com.simprints.feature.login.LoginContract import com.simprints.infra.uibase.view.applySystemBarInsets import com.simprints.infra.uibase.navigation.handleResult import com.simprints.infra.uibase.navigation.toBundle +import com.simprints.infra.uibase.view.setPulseAnimation import com.simprints.infra.uibase.viewbinding.viewBinding import dagger.hilt.android.AndroidEntryPoint import com.simprints.infra.resources.R as IDR @@ -346,37 +345,12 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { textView.text = progressText } - private fun View.setPulseAnimation(isEnabled: Boolean) { - (tag as? ObjectAnimator?)?.run { - cancel() - tag = null - } - if (!isEnabled) return - val progressBarPulseAnimator = ObjectAnimator.ofFloat( - this, - View.ALPHA, - PULSE_ANIMATION_ALPHA_FULL, PULSE_ANIMATION_ALPHA_INTERMEDIATE, PULSE_ANIMATION_ALPHA_MIN, - ).apply { - duration = PULSE_ANIMATION_DURATION_MILLIS - repeatCount = ObjectAnimator.INFINITE - repeatMode = ObjectAnimator.REVERSE - interpolator = AccelerateDecelerateInterpolator() - start() - } - tag = progressBarPulseAnimator - } private fun getCurrentDestinationId() = parentFragment?.takeIf { !syncInfoConfig.isSyncInfoToolbarVisible }?.id // parent if this isn't standalone ?: id private companion object { - private const val PULSE_ANIMATION_ALPHA_FULL = 1.0f - private const val PULSE_ANIMATION_ALPHA_INTERMEDIATE = 0.9f - private const val PULSE_ANIMATION_ALPHA_MIN = 0.6f - - private const val PULSE_ANIMATION_DURATION_MILLIS = 2000L - private const val MAX_MODULE_LIST_HEIGHT_ITEMS = 5 } diff --git a/infra/ui-base/src/main/java/com/simprints/infra/uibase/view/View.ext.kt b/infra/ui-base/src/main/java/com/simprints/infra/uibase/view/View.ext.kt new file mode 100644 index 0000000000..383dcd5c57 --- /dev/null +++ b/infra/ui-base/src/main/java/com/simprints/infra/uibase/view/View.ext.kt @@ -0,0 +1,30 @@ +package com.simprints.infra.uibase.view + +import android.animation.ObjectAnimator +import android.view.View +import android.view.animation.AccelerateDecelerateInterpolator + +fun View.setPulseAnimation(isEnabled: Boolean) { + (tag as? ObjectAnimator?)?.run { + cancel() + tag = null + } + if (!isEnabled) return + val progressBarPulseAnimator = ObjectAnimator.ofFloat( + this, + View.ALPHA, + PULSE_ANIMATION_ALPHA_FULL, PULSE_ANIMATION_ALPHA_INTERMEDIATE, PULSE_ANIMATION_ALPHA_MIN, + ).apply { + duration = PULSE_ANIMATION_DURATION_MILLIS + repeatCount = ObjectAnimator.INFINITE + repeatMode = ObjectAnimator.REVERSE + interpolator = AccelerateDecelerateInterpolator() + start() + } + tag = progressBarPulseAnimator +} + +private const val PULSE_ANIMATION_ALPHA_FULL = 1.0f +private const val PULSE_ANIMATION_ALPHA_INTERMEDIATE = 0.9f +private const val PULSE_ANIMATION_ALPHA_MIN = 0.6f +private const val PULSE_ANIMATION_DURATION_MILLIS = 2000L From 57beaacf2461738f4d9faddfb1639aae7fad0738 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 12 Aug 2025 21:38:07 +0100 Subject: [PATCH 35/45] MS-939 Lint cleanup --- .../dashboard/logout/LogoutSyncViewModel.kt | 11 +-- .../logout/sync/LogoutSyncFragment.kt | 1 - .../dashboard/settings/syncinfo/SyncInfo.kt | 8 --- .../settings/syncinfo/SyncInfoFragment.kt | 55 ++++++++------ .../settings/syncinfo/SyncInfoViewModel.kt | 25 +++---- .../modulecount/ModuleCountViewHolder.kt | 2 +- .../usecase/ObserveSyncInfoUseCase.kt | 71 ++++++++++--------- .../logout/LogoutSyncViewModelTest.kt | 2 +- .../logout/sync/LogoutSyncFragmentTest.kt | 2 - .../syncinfo/SyncInfoViewModelTest.kt | 14 ++-- .../usecase/ObserveSyncInfoUseCaseTest.kt | 30 ++++---- .../dashboard/tools/di/FakeSecurityModule.kt | 3 +- .../infra/authstore/domain/LoginInfoStore.kt | 2 +- .../store/models/ProjectConfiguration.kt | 10 ++- .../store/models/ProjectConfigurationTest.kt | 2 +- .../core/tools/extentions/Flow.ext.kt | 34 +++++---- .../simprints/core/tools/time/TimerImpl.kt | 1 - .../core/tools/extentions/FlowExtTest.kt | 18 ++--- .../core/tools/time/TimerImplTest.kt | 6 +- .../infra/eventsync/EventSyncManagerImpl.kt | 15 ++-- .../simprints/infra/images/ImageRepository.kt | 2 +- .../infra/images/ImageRepositoryImpl.kt | 3 +- .../firestore/FirestoreSampleUploader.kt | 5 +- .../signedurl/SignedUrlSampleUploader.kt | 5 +- .../infra/sync/ImageSyncTimestampProvider.kt | 5 +- .../infra/sync/SyncOrchestratorImpl.kt | 35 +++++---- .../sync/ImageSyncTimestampProviderTest.kt | 1 - .../infra/sync/SyncOrchestratorImplTest.kt | 13 ++-- .../simprints/infra/uibase/view/View.ext.kt | 25 ++++--- 29 files changed, 218 insertions(+), 188 deletions(-) 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 15512b9916..7b5775d7a4 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 @@ -29,11 +29,14 @@ internal class LogoutSyncViewModel @Inject constructor( authStore: AuthStore, private val logoutUseCase: LogoutUseCase, ) : ViewModel() { - val logoutEventLiveData: LiveData = - authStore.observeSignedInProjectId().filter { projectId -> - projectId.isEmpty() - }.distinctUntilChanged().map { /* Unit on every "true" */ }.asLiveData() + authStore + .observeSignedInProjectId() + .filter { projectId -> + projectId.isEmpty() + }.distinctUntilChanged() + .map { /* Unit on every "true" */ } + .asLiveData() val isLogoutWithoutSyncVisibleLiveData: LiveData = combine( eventSyncManager.getLastSyncState(useDefaultValue = true).asFlow(), diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/sync/LogoutSyncFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/sync/LogoutSyncFragment.kt index fdb2971906..5bdcd83226 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/sync/LogoutSyncFragment.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/sync/LogoutSyncFragment.kt @@ -58,5 +58,4 @@ class LogoutSyncFragment : Fragment(R.layout.fragment_logout_sync) { ) } } - } 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 ec164edf98..91b9e5ad5c 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 @@ -17,23 +17,19 @@ data class SyncInfoSectionRecords( val counterRecordsToDownload: String = "", val isCounterImagesToUploadVisible: Boolean = false, // images may be combined with the records val counterImagesToUpload: String = "", - // instructions val isInstructionDefaultVisible: Boolean = false, val isInstructionNoModulesVisible: Boolean = false, val isInstructionOfflineVisible: Boolean = false, val isInstructionErrorVisible: Boolean = false, val instructionPopupErrorInfo: SyncInfoError = SyncInfoError(), - // progress text & progress bar val isProgressVisible: Boolean = false, val progress: SyncInfoProgress = SyncInfoProgress(), - // sync button val isSyncButtonVisible: Boolean = false, val isSyncButtonEnabled: Boolean = false, val isSyncButtonForRetry: Boolean = false, - // footer val isFooterSyncInProgressVisible: Boolean = true, val isFooterReadyToLogOutVisible: Boolean = false, @@ -51,18 +47,14 @@ data class SyncInfoError( data class SyncInfoSectionImages( // counters val counterImagesToUpload: String = "", - // instructions val isInstructionDefaultVisible: Boolean = false, val isInstructionOfflineVisible: Boolean = false, - // progress text & progress bar val isProgressVisible: Boolean = false, val progress: SyncInfoProgress = SyncInfoProgress(), - // sync button val isSyncButtonEnabled: Boolean = false, - // footer val isFooterLastSyncTimeVisible: Boolean = false, val footerLastSyncMinutesAgo: String = "", 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 31b1a54c1b..8ce832ad02 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 @@ -18,7 +18,6 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import com.simprints.core.livedata.LiveDataEventWithContentObserver -import kotlinx.coroutines.launch import com.simprints.core.tools.utils.TimeUtils import com.simprints.feature.dashboard.R import com.simprints.feature.dashboard.databinding.FragmentSyncInfoBinding @@ -26,12 +25,13 @@ 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 -import com.simprints.infra.uibase.view.applySystemBarInsets import com.simprints.infra.uibase.navigation.handleResult import com.simprints.infra.uibase.navigation.toBundle +import com.simprints.infra.uibase.view.applySystemBarInsets import com.simprints.infra.uibase.view.setPulseAnimation import com.simprints.infra.uibase.viewbinding.viewBinding import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import com.simprints.infra.resources.R as IDR @AndroidEntryPoint @@ -42,7 +42,11 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { private var syncInfoConfig: SyncInfoFragmentConfig = SyncInfoFragmentConfig() - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { syncInfoConfig = (container?.parent as? ConfigurableSyncInfoFragmentContainer)?.syncInfoFragmentConfig ?: SyncInfoFragmentConfig() @@ -105,7 +109,8 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { private fun View.showInfoPopupOnClick(message: String) { setOnClickListener { - AlertDialog.Builder(requireContext()) + AlertDialog + .Builder(requireContext()) .setMessage(message) .setPositiveButton(IDR.string.sync_info_details_ok) { di, _ -> di.dismiss() } .create() @@ -125,9 +130,12 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.RESUMED) { - viewModel.logoutEventLiveData.observe(viewLifecycleOwner, LiveDataEventWithContentObserver { - viewModel.performLogout() - }) + viewModel.logoutEventLiveData.observe( + viewLifecycleOwner, + LiveDataEventWithContentObserver { + viewModel.performLogout() + }, + ) } } @@ -136,7 +144,10 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { } } - private fun renderSyncInfo(syncInfo: SyncInfo, config: SyncInfoFragmentConfig) { + private fun renderSyncInfo( + syncInfo: SyncInfo, + config: SyncInfoFragmentConfig, + ) { // note: ".isGone = not" is preferred to ".isVisible =" below for non-ambiguity of the no-show state // App toolbar @@ -171,7 +182,10 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { renderModulesSection(syncInfo.syncInfoSectionModules, config) } - private fun renderRecordsSection(records: SyncInfoSectionRecords, config: SyncInfoFragmentConfig) { + private fun renderRecordsSection( + records: SyncInfoSectionRecords, + config: SyncInfoFragmentConfig, + ) { // Counter - total records binding.totalRecordsCount.isGone = records.counterTotalRecords.isBlank() binding.totalRecordsCount.text = records.counterTotalRecords @@ -221,7 +235,7 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { records.isSyncButtonForRetry -> IDR.string.sync_info_button_try_again records.isProgressVisible -> IDR.string.sync_info_button_records_syncing else -> IDR.string.sync_info_button_sync_records - } + }, ) // Footer @@ -237,7 +251,7 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { binding.textEventSyncInstructionsError.showInfoPopupOnClick( when { isTooManyRequests -> getString( - IDR.string.sync_info_details_too_many_modules + IDR.string.sync_info_details_too_many_modules, ) isBackendMaintenance && backendMaintenanceEstimatedOutage > 0 -> getString( @@ -246,13 +260,13 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { ) isBackendMaintenance -> getString( - IDR.string.error_backend_maintenance_message + IDR.string.error_backend_maintenance_message, ) else -> getString( - IDR.string.sync_info_details_error + IDR.string.sync_info_details_error, ) - } + }, ) } @@ -277,7 +291,7 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { when { images.isProgressVisible -> IDR.string.sync_info_button_images_sync_stop else -> IDR.string.sync_info_button_sync_images - } + }, ) binding.buttonSyncImagesNow.backgroundTintList = ContextCompat.getColorStateList( requireContext(), @@ -285,7 +299,7 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { IDR.color.button_sync_images_background_red } else { IDR.color.button_sync_images_background_default - } + }, ) // Footer @@ -293,7 +307,10 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { binding.textFooterImageLastSyncedWhen.text = images.footerLastSyncMinutesAgo } - private fun renderModulesSection(modules: SyncInfoSectionModules, config: SyncInfoFragmentConfig) { + private fun renderModulesSection( + modules: SyncInfoSectionModules, + config: SyncInfoFragmentConfig, + ) { val isModuleSectionVisible = modules.isSectionAvailable && (config.isSyncInfoModuleListVisible || modules.moduleCounts.isEmpty()) binding.layoutModuleSelection.isGone = !isModuleSectionVisible @@ -306,7 +323,7 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { } else { syncInfoModuleCount.name }, - count = syncInfoModuleCount.count.toIntOrNull() ?: 0 + count = syncInfoModuleCount.count.toIntOrNull() ?: 0, ) } @@ -345,7 +362,6 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { textView.text = progressText } - private fun getCurrentDestinationId() = parentFragment?.takeIf { !syncInfoConfig.isSyncInfoToolbarVisible }?.id // parent if this isn't standalone ?: id @@ -353,5 +369,4 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { private companion object { private const val MAX_MODULE_LIST_HEIGHT_ITEMS = 5 } - } 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 23b2de695e..2e088a31e7 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 @@ -59,16 +59,18 @@ internal class SyncInfoViewModel @Inject constructor( val isReadyToLogOut = isPreLogoutUpSync && eventSyncState.isSyncCompleted() && !imageSyncStatus.isSyncing return@combine isReadyToLogOut - }.debounce(LOGOUT_DELAY_MILLIS).filter { isReadyToLogOut -> - isReadyToLogOut // only when ready - }.map { - LiveDataEventWithContent(Unit) - }.asLiveData() - - val syncInfoLiveData: LiveData = observeSyncInfo(isPreLogoutUpSync).onStart { - startInitialSyncIfRequired() - syncImagesAfterEventsWhenRequired() - }.asLiveData() + }.debounce(LOGOUT_DELAY_MILLIS) + .filter { isReadyToLogOut -> + isReadyToLogOut // only when ready + }.map { + LiveDataEventWithContent(Unit) + }.asLiveData() + + val syncInfoLiveData: LiveData = observeSyncInfo(isPreLogoutUpSync) + .onStart { + startInitialSyncIfRequired() + syncImagesAfterEventsWhenRequired() + }.asLiveData() fun forceEventSync() { viewModelScope.launch { @@ -100,7 +102,7 @@ internal class SyncInfoViewModel @Inject constructor( LoginParams( projectId = authStore.signedInProjectId, userId = authStore.signedInUserId ?: recentUserActivityManager.getRecentUserActivity().lastUserUsed, - ) + ), ) } } @@ -111,7 +113,6 @@ internal class SyncInfoViewModel @Inject constructor( } } - // initial actions private fun startInitialSyncIfRequired() { 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 05ea99c23f..450b093f32 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 @@ -23,7 +23,7 @@ internal class ModuleCountViewHolder( R.drawable.ic_global } else { R.drawable.ic_module - } + }, ) moduleNameText.text = moduleCount.name moduleCountText.text = moduleCount.count.toString() 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 17d13c3243..1d8bfbf240 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 @@ -116,8 +116,8 @@ internal class ObserveSyncInfoUseCase @Inject constructor( ) val isEventSyncInProgress = - eventSyncState.isSyncInProgress() - || (isPreLogoutUpSync && imageSyncStatus.isSyncing) // if combined with images + eventSyncState.isSyncInProgress() || + (isPreLogoutUpSync && imageSyncStatus.isSyncing) // if combined with images val eventSyncProgress = if (isEventSyncInProgress) { SyncInfoProgress( progressParts = if (isPreLogoutUpSync) { @@ -149,8 +149,11 @@ internal class ObserveSyncInfoUseCase @Inject constructor( val isModuleSelectionRequired = !isPreLogoutUpSync && projectConfig.isModuleSelectionAvailable() && deviceConfig.selectedModules.isEmpty() val isEventSyncAvailable = - !isReLoginRequired && isConnected && !eventSyncState.isSyncRunning() && !projectConfig.isMissingModulesToChooseFrom() - && !isModuleSelectionRequired + !isReLoginRequired && + isConnected && + !eventSyncState.isSyncRunning() && + !projectConfig.isMissingModulesToChooseFrom() && + !isModuleSelectionRequired val projectId = authStore.signedInProjectId @@ -160,9 +163,11 @@ internal class ObserveSyncInfoUseCase @Inject constructor( } 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 @@ -204,14 +209,14 @@ internal class ObserveSyncInfoUseCase @Inject constructor( val syncInfoSectionModules = SyncInfoSectionModules( isSectionAvailable = projectConfig.isModuleSelectionAvailable(), moduleCounts = listOfNotNull( - modulesCountTotal.takeIf { moduleCounts.isNotEmpty() } + modulesCountTotal.takeIf { moduleCounts.isNotEmpty() }, ) + moduleCounts.map { moduleCount -> SyncInfoModuleCount( isTotal = false, name = moduleCount.name, count = moduleCount.count.toString(), ) - } + }, ) val syncInfoSectionRecords = SyncInfoSectionRecords( @@ -221,15 +226,18 @@ internal class ObserveSyncInfoUseCase @Inject constructor( counterRecordsToDownload = recordsToDownload?.let { "${it.count}${if (it.isLowerBound) "+" else ""}" }.orEmpty(), isCounterImagesToUploadVisible = isPreLogoutUpSync, counterImagesToUpload = imagesToUpload?.toString().orEmpty(), - isInstructionDefaultVisible = !isModuleSelectionRequired && isConnected && !eventSyncState.isSyncFailed() - && !eventSyncState.isSyncInProgress() && !isPreLogoutUpSync, + isInstructionDefaultVisible = !isModuleSelectionRequired && + isConnected && + !eventSyncState.isSyncFailed() && + !eventSyncState.isSyncInProgress() && + !isPreLogoutUpSync, isInstructionNoModulesVisible = isConnected && isModuleSelectionRequired && !isEventSyncInProgress, isInstructionOfflineVisible = !isConnected, isInstructionErrorVisible = isConnected && eventSyncState.isSyncFailed(), instructionPopupErrorInfo = SyncInfoError( isBackendMaintenance = eventSyncState.isSyncFailedBecauseBackendMaintenance(), backendMaintenanceEstimatedOutage = eventSyncState.getEstimatedBackendMaintenanceOutage() ?: -1, - isTooManyRequests = eventSyncState.isSyncFailedBecauseTooManyRequests() + isTooManyRequests = eventSyncState.isSyncFailedBecauseTooManyRequests(), ), isProgressVisible = isEventSyncInProgress, progress = eventSyncProgress, @@ -269,25 +277,21 @@ internal class ObserveSyncInfoUseCase @Inject constructor( delay(timeMillis = SYNC_COMPLETION_HOLD_MILLIS) } - // sync info change detection helpers - private fun Flow.onRecordSyncComplete(action: suspend (SyncInfo) -> Unit) = - onChange( - comparator = { previous, current -> - previous.syncInfoSectionRecords.isProgressVisible && !current.syncInfoSectionRecords.isProgressVisible - }, - action, - ) - - private fun Flow.onImageSyncComplete(action: suspend (SyncInfo) -> Unit) = - onChange( - comparator = { previous, current -> - previous.syncInfoSectionImages.isProgressVisible && !current.syncInfoSectionImages.isProgressVisible - }, - action, - ) + private fun Flow.onRecordSyncComplete(action: suspend (SyncInfo) -> Unit) = onChange( + comparator = { previous, current -> + previous.syncInfoSectionRecords.isProgressVisible && !current.syncInfoSectionRecords.isProgressVisible + }, + action, + ) + private fun Flow.onImageSyncComplete(action: suspend (SyncInfo) -> Unit) = onChange( + comparator = { previous, current -> + previous.syncInfoSectionImages.isProgressVisible && !current.syncInfoSectionImages.isProgressVisible + }, + action, + ) // caching eventSyncManager.countEventsToDownload to avoid network-based delays on frequent calls @@ -296,11 +300,12 @@ 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 - } + cachedEventCountToDownload + ?.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/logout/LogoutSyncViewModelTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/LogoutSyncViewModelTest.kt index a5a68aec94..3eff861285 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 @@ -17,11 +17,11 @@ import com.simprints.testtools.common.coroutines.TestCoroutineRule import com.simprints.testtools.common.livedata.getOrAwaitValue import io.mockk.MockKAnnotations import io.mockk.coEvery -import io.mockk.verify import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk import io.mockk.mockkStatic +import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import org.junit.Before diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/sync/LogoutSyncFragmentTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/sync/LogoutSyncFragmentTest.kt index 60f06b8c63..9e74d7593e 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/sync/LogoutSyncFragmentTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/logout/sync/LogoutSyncFragmentTest.kt @@ -27,7 +27,6 @@ import org.robolectric.annotation.Config @HiltAndroidTest @Config(application = HiltTestApplication::class) internal class LogoutSyncFragmentTest { - @get:Rule var hiltRule = HiltAndroidRule(this) @@ -63,7 +62,6 @@ internal class LogoutSyncFragmentTest { onView(withId(R.id.logoutWithoutSyncButton)).check(matches(not(isDisplayed()))) } - @Test fun `should navigate to requestLoginFragment when logout event received`() { every { logoutSyncViewModel.logoutEventLiveData } returns mockk { 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 7242c402c2..a5e28d21b8 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 @@ -3,10 +3,10 @@ package com.simprints.feature.dashboard.settings.syncinfo import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer -import com.simprints.core.livedata.LiveDataEventWithContent import androidx.lifecycle.asFlow import com.google.common.truth.Truth.assertThat import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.core.livedata.LiveDataEventWithContent import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.time.Timestamp import com.simprints.feature.dashboard.logout.usecase.LogoutUseCase @@ -19,15 +19,15 @@ 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.isEventDownSyncAllowed -import com.simprints.infra.config.store.models.isModuleSelectionAvailable import com.simprints.infra.config.store.models.isMissingModulesToChooseFrom +import com.simprints.infra.config.store.models.isModuleSelectionAvailable import com.simprints.infra.config.sync.ConfigManager import com.simprints.infra.eventsync.EventSyncManager import com.simprints.infra.eventsync.status.models.DownSyncCounts import com.simprints.infra.eventsync.status.models.EventSyncState import com.simprints.infra.recent.user.activity.RecentUserActivityManager -import com.simprints.infra.sync.SyncOrchestrator import com.simprints.infra.sync.ImageSyncStatus +import com.simprints.infra.sync.SyncOrchestrator import com.simprints.testtools.common.coroutines.TestCoroutineRule import com.simprints.testtools.common.livedata.getOrAwaitValue import io.mockk.MockKAnnotations @@ -48,7 +48,6 @@ import org.junit.Rule import org.junit.Test class SyncInfoViewModelTest { - @get:Rule val rule = InstantTaskExecutorRule() @@ -188,7 +187,7 @@ class SyncInfoViewModelTest { instructionPopupErrorInfo = SyncInfoError( isBackendMaintenance = false, backendMaintenanceEstimatedOutage = -1, - isTooManyRequests = false + isTooManyRequests = false, ), isProgressVisible = false, progress = SyncInfoProgress(), @@ -213,8 +212,8 @@ class SyncInfoViewModelTest { ), syncInfoSectionModules = SyncInfoSectionModules( isSectionAvailable = false, - moduleCounts = emptyList() - ) + moduleCounts = emptyList(), + ), ) // LiveData loginNavigationEventLiveData tests @@ -637,5 +636,4 @@ class SyncInfoViewModelTest { coVerify(exactly = 0) { syncOrchestrator.startEventSync(any()) } } - } 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 4d392f5617..e2c0b155f7 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 @@ -17,8 +17,8 @@ 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.models.isEventDownSyncAllowed -import com.simprints.infra.config.store.models.isModuleSelectionAvailable import com.simprints.infra.config.store.models.isMissingModulesToChooseFrom +import com.simprints.infra.config.store.models.isModuleSelectionAvailable import com.simprints.infra.config.store.tokenization.TokenizationProcessor import com.simprints.infra.config.sync.ConfigManager import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository @@ -27,8 +27,8 @@ import com.simprints.infra.eventsync.status.models.DownSyncCounts import com.simprints.infra.eventsync.status.models.EventSyncState import com.simprints.infra.images.ImageRepository import com.simprints.infra.network.ConnectivityTracker -import com.simprints.infra.sync.SyncOrchestrator import com.simprints.infra.sync.ImageSyncStatus +import com.simprints.infra.sync.SyncOrchestrator import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.MockKAnnotations import io.mockk.coEvery @@ -47,7 +47,6 @@ import org.junit.Rule import org.junit.Test class ObserveSyncInfoUseCaseTest { - @get:Rule val rule = InstantTaskExecutorRule() @@ -603,11 +602,11 @@ class ObserveSyncInfoUseCaseTest { assertThat(result.syncInfoSectionModules.moduleCounts[0].count).isEqualTo("40") // module_1 assertThat(result.syncInfoSectionModules.moduleCounts[1]).isEqualTo( - SyncInfoModuleCount(isTotal = false, name = "module_1", count = "15") + SyncInfoModuleCount(isTotal = false, name = "module_1", count = "15"), ) // module_2 assertThat(result.syncInfoSectionModules.moduleCounts[2]).isEqualTo( - SyncInfoModuleCount(isTotal = false, name = "module_2", count = "25") + SyncInfoModuleCount(isTotal = false, name = "module_2", count = "25"), ) } @@ -741,7 +740,6 @@ class ObserveSyncInfoUseCaseTest { assertThat(onlineResult.syncInfoSectionImages.isSyncButtonEnabled).isTrue() } - @Test fun `down-sync event counter bypasses cache when exceeds max age`() = runTest { every { timeHelper.now() } returnsMany listOf(TEST_TIMESTAMP, Timestamp(TEST_TIMESTAMP.ms + 60_000)) // over cache lifespan apart @@ -845,11 +843,13 @@ class ObserveSyncInfoUseCaseTest { @Test fun `should handle changes in image sync status stream`() = runTest { - val imageSyncStatusFlow = MutableStateFlow(mockk { - every { isSyncing } returns false - every { progress } returns null - every { lastUpdateTimeMillis } returns null - }) // started not syncing + val imageSyncStatusFlow = MutableStateFlow( + mockk { + every { isSyncing } returns false + every { progress } returns null + every { lastUpdateTimeMillis } returns null + }, + ) // started not syncing every { syncOrchestrator.observeImageSyncStatus() } returns imageSyncStatusFlow createUseCase() @@ -898,12 +898,12 @@ class ObserveSyncInfoUseCaseTest { every { general } returns mockk { every { modalities } returns emptyList() } - } + }, ) val deviceConfigFlow = MutableStateFlow( mockk(relaxed = true) { every { selectedModules } returns emptyList() - } + }, ) // started without selected modules every { configManager.observeDeviceConfiguration() } returns deviceConfigFlow createUseCase() @@ -915,7 +915,7 @@ class ObserveSyncInfoUseCaseTest { deviceConfigFlow.emit( mockk(relaxed = true) { every { selectedModules } returns listOf(TokenizableString.Raw(TEST_MODULE_NAME)) - } + }, ) // now with selected modules val withModulesResult = useCase().first() @@ -1163,6 +1163,4 @@ class ObserveSyncInfoUseCaseTest { assertThat(result.syncInfoSectionModules.moduleCounts[1].name).isEqualTo("raw_module_name") verify(exactly = 0) { tokenizationProcessor.decrypt(any(), any(), any()) } } - } - diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeSecurityModule.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeSecurityModule.kt index 4e5817e599..d75ba0c93d 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeSecurityModule.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeSecurityModule.kt @@ -17,10 +17,9 @@ import javax.inject.Singleton replaces = [SecurityModule::class], ) object FakeSecurityModule { - @Provides @Singleton fun provideSecurityManager(): SecurityManager = mockk { every { buildEncryptedSharedPreferences(any()) } returns mockk(relaxed = true) } -} \ No newline at end of file +} diff --git a/infra/auth-store/src/main/java/com/simprints/infra/authstore/domain/LoginInfoStore.kt b/infra/auth-store/src/main/java/com/simprints/infra/authstore/domain/LoginInfoStore.kt index 9c48a6eba3..5b8892d2d7 100644 --- a/infra/auth-store/src/main/java/com/simprints/infra/authstore/domain/LoginInfoStore.kt +++ b/infra/auth-store/src/main/java/com/simprints/infra/authstore/domain/LoginInfoStore.kt @@ -79,7 +79,7 @@ internal class LoginInfoStore @Inject constructor( } private val signedInProjectIdFlow: MutableStateFlow = MutableStateFlow( - getSecurePrefs().getString(PROJECT_ID, "").orEmpty() + getSecurePrefs().getString(PROJECT_ID, "").orEmpty(), ) var signedInProjectId: String = "" diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ProjectConfiguration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ProjectConfiguration.kt index 2aa61638b5..323fd86b82 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ProjectConfiguration.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ProjectConfiguration.kt @@ -82,11 +82,9 @@ fun ProjectConfiguration.isProjectWithModuleSync(): Boolean = fun ProjectConfiguration.isProjectWithPeriodicallyUpSync(): Boolean = synchronization.up.simprints.frequency == Frequency.ONLY_PERIODICALLY_UP_SYNC -fun ProjectConfiguration.isModuleSelectionAvailable(): Boolean = - isProjectWithModuleSync() && !isProjectWithPeriodicallyUpSync() +fun ProjectConfiguration.isModuleSelectionAvailable(): Boolean = isProjectWithModuleSync() && !isProjectWithPeriodicallyUpSync() -fun ProjectConfiguration.areModuleOptionsEmpty(): Boolean = - synchronization.down.simprints.moduleOptions.isEmpty() +fun ProjectConfiguration.areModuleOptionsEmpty(): Boolean = synchronization.down.simprints.moduleOptions + .isEmpty() -fun ProjectConfiguration.isMissingModulesToChooseFrom(): Boolean = - isProjectWithModuleSync() && areModuleOptionsEmpty() +fun ProjectConfiguration.isMissingModulesToChooseFrom(): Boolean = isProjectWithModuleSync() && areModuleOptionsEmpty() diff --git a/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ProjectConfigurationTest.kt b/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ProjectConfigurationTest.kt index 3db95bb601..4d59a3f6e5 100644 --- a/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ProjectConfigurationTest.kt +++ b/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ProjectConfigurationTest.kt @@ -1,13 +1,13 @@ package com.simprints.infra.config.store.models import com.google.common.truth.Truth.assertThat +import com.simprints.core.domain.tokenization.asTokenizableEncrypted import com.simprints.infra.config.store.models.UpSynchronizationConfiguration.CoSyncUpSynchronizationConfiguration import com.simprints.infra.config.store.models.UpSynchronizationConfiguration.SimprintsUpSynchronizationConfiguration import com.simprints.infra.config.store.models.UpSynchronizationConfiguration.UpSynchronizationKind.ALL import com.simprints.infra.config.store.models.UpSynchronizationConfiguration.UpSynchronizationKind.NONE import com.simprints.infra.config.store.models.UpSynchronizationConfiguration.UpSynchronizationKind.ONLY_ANALYTICS import com.simprints.infra.config.store.models.UpSynchronizationConfiguration.UpSynchronizationKind.ONLY_BIOMETRICS -import com.simprints.core.domain.tokenization.asTokenizableEncrypted import com.simprints.infra.config.store.testtools.faceConfiguration import com.simprints.infra.config.store.testtools.faceSdkConfiguration import com.simprints.infra.config.store.testtools.fingerprintConfiguration 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 bda61aff51..4ef3cf57f0 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 @@ -15,7 +15,7 @@ fun combine8( flow6: Flow, flow7: Flow, flow8: Flow, - transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8) -> R + transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8) -> R, ): Flow = combine(flow1, flow2, flow3, flow4, flow5, flow6, flow7, flow8) { args: Array<*> -> transform( args[0] as T1, @@ -29,19 +29,23 @@ fun combine8( ) } -fun Flow.onChange(comparator: (T, T) -> Boolean, action: suspend (T) -> Unit) = - windowed(2, partial = true).map { window -> - val previousOrCurrent = window.first() - val current = window.last() - if (comparator(previousOrCurrent, current)) { - action(current) - } - current +fun Flow.onChange( + comparator: (T, T) -> Boolean, + action: suspend (T) -> Unit, +) = windowed(2, partial = true).map { window -> + val previousOrCurrent = window.first() + val current = window.last() + if (comparator(previousOrCurrent, current)) { + action(current) } + current +} -fun Flow.windowed(size: Int, partial: Boolean = false): Flow> = - scan(emptyList()) { acc, value -> - (acc + value).takeLast(size) - }.drop( - if (partial) 1 else size - ) +fun Flow.windowed( + size: Int, + partial: Boolean = false, +): Flow> = scan(emptyList()) { acc, value -> + (acc + value).takeLast(size) +}.drop( + if (partial) 1 else size, +) diff --git a/infra/core/src/main/java/com/simprints/core/tools/time/TimerImpl.kt b/infra/core/src/main/java/com/simprints/core/tools/time/TimerImpl.kt index 2bc000dd31..5ed6324601 100644 --- a/infra/core/src/main/java/com/simprints/core/tools/time/TimerImpl.kt +++ b/infra/core/src/main/java/com/simprints/core/tools/time/TimerImpl.kt @@ -6,7 +6,6 @@ import kotlinx.coroutines.flow.flow import javax.inject.Inject class TimerImpl @Inject constructor() : Timer { - override fun observeTickOncePerMinute(): Flow = flow { while (true) { emit(Unit) 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 b5b1cebff2..28e1f21133 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 @@ -30,9 +30,10 @@ class FlowExtTest { val flow = flowOf(1, 2, 2, 3) val triggeredValues = mutableListOf() - val result = flow.onChange({ prev, curr -> prev != curr }) { value -> - triggeredValues.add(value) - }.toList() + val result = flow + .onChange({ prev, curr -> prev != curr }) { value -> + triggeredValues.add(value) + }.toList() assertThat(result).isEqualTo(listOf(1, 2, 2, 3)) assertThat(triggeredValues).isEqualTo(listOf(2, 3)) @@ -43,9 +44,10 @@ class FlowExtTest { val flow = flowOf(1, 1, 1) val triggeredValues = mutableListOf() - val result = flow.onChange({ prev, curr -> prev != curr }) { value -> - triggeredValues.add(value) - }.toList() + val result = flow + .onChange({ prev, curr -> prev != curr }) { value -> + triggeredValues.add(value) + }.toList() assertThat(result).isEqualTo(listOf(1, 1, 1)) assertThat(triggeredValues).isEmpty() @@ -62,7 +64,7 @@ class FlowExtTest { listOf(1, 2, 3), listOf(2, 3, 4), listOf(3, 4, 5), - ) + ), ) } @@ -79,7 +81,7 @@ class FlowExtTest { listOf(1, 2, 3), listOf(2, 3, 4), listOf(3, 4, 5), - ) + ), ) } diff --git a/infra/core/src/test/java/com/simprints/core/tools/time/TimerImplTest.kt b/infra/core/src/test/java/com/simprints/core/tools/time/TimerImplTest.kt index cc58c48d94..fd624bc68f 100644 --- a/infra/core/src/test/java/com/simprints/core/tools/time/TimerImplTest.kt +++ b/infra/core/src/test/java/com/simprints/core/tools/time/TimerImplTest.kt @@ -30,7 +30,8 @@ class TimerImplTest { @Test fun testObserveTickOncePerMinute_emitsImmediately() = runTest { - val result = timerImpl.observeTickOncePerMinute() + val result = timerImpl + .observeTickOncePerMinute() .take(1) .toList() @@ -40,7 +41,8 @@ class TimerImplTest { @Test fun testObserveTickOncePerMinute_emitsMultipleTimes() = runTest { - val result = timerImpl.observeTickOncePerMinute() + val result = timerImpl + .observeTickOncePerMinute() .take(3) .toList() diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManagerImpl.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManagerImpl.kt index a7415aaa0a..35f39f0ce5 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManagerImpl.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/EventSyncManagerImpl.kt @@ -48,15 +48,14 @@ internal class EventSyncManagerImpl @Inject constructor( ) : EventSyncManager { override suspend fun getLastSyncTime(): Timestamp? = eventSyncCache.readLastSuccessfulSyncTime() - override fun getLastSyncState(useDefaultValue: Boolean): LiveData = - MediatorLiveData().apply { - if (useDefaultValue) { - value = EventSyncState(syncId = "", null, null, emptyList(), emptyList(), emptyList()) - } - addSource(eventSyncStateProcessor.getLastSyncState()) { lastSyncState -> - value = lastSyncState - } + override fun getLastSyncState(useDefaultValue: Boolean): LiveData = MediatorLiveData().apply { + if (useDefaultValue) { + value = EventSyncState(syncId = "", null, null, emptyList(), emptyList(), emptyList()) } + addSource(eventSyncStateProcessor.getLastSyncState()) { lastSyncState -> + value = lastSyncState + } + } override fun getPeriodicWorkTags(): List = listOf( MASTER_SYNC_SCHEDULERS, diff --git a/infra/images/src/main/java/com/simprints/infra/images/ImageRepository.kt b/infra/images/src/main/java/com/simprints/infra/images/ImageRepository.kt index cb97ddd851..8d727a55cd 100644 --- a/infra/images/src/main/java/com/simprints/infra/images/ImageRepository.kt +++ b/infra/images/src/main/java/com/simprints/infra/images/ImageRepository.kt @@ -36,7 +36,7 @@ interface ImageRepository { */ suspend fun uploadStoredImagesAndDelete( projectId: String, - progressCallback: (suspend (Int, Int) -> Unit)? = null + progressCallback: (suspend (Int, Int) -> Unit)? = null, ): Boolean /** diff --git a/infra/images/src/main/java/com/simprints/infra/images/ImageRepositoryImpl.kt b/infra/images/src/main/java/com/simprints/infra/images/ImageRepositoryImpl.kt index 6a65d08e00..c270e3b44e 100644 --- a/infra/images/src/main/java/com/simprints/infra/images/ImageRepositoryImpl.kt +++ b/infra/images/src/main/java/com/simprints/infra/images/ImageRepositoryImpl.kt @@ -55,8 +55,7 @@ internal class ImageRepositoryImpl @Inject internal constructor( override suspend fun uploadStoredImagesAndDelete( projectId: String, progressCallback: (suspend (Int, Int) -> Unit)?, - ): Boolean = - getSampleUploader().uploadAllSamples(projectId, progressCallback) + ): Boolean = getSampleUploader().uploadAllSamples(projectId, progressCallback) override suspend fun deleteStoredImages() { metadataStore.deleteAllMetadata() diff --git a/infra/images/src/main/java/com/simprints/infra/images/remote/firestore/FirestoreSampleUploader.kt b/infra/images/src/main/java/com/simprints/infra/images/remote/firestore/FirestoreSampleUploader.kt index 5defb34d38..5f100c3ab8 100644 --- a/infra/images/src/main/java/com/simprints/infra/images/remote/firestore/FirestoreSampleUploader.kt +++ b/infra/images/src/main/java/com/simprints/infra/images/remote/firestore/FirestoreSampleUploader.kt @@ -22,7 +22,10 @@ internal class FirestoreSampleUploader @Inject constructor( private val localDataSource: ImageLocalDataSource, private val metadataStore: ImageMetadataStore, ) : SampleUploader { - override suspend fun uploadAllSamples(projectId: String, progressCallback: (suspend (Int, Int) -> Unit)?): Boolean { + override suspend fun uploadAllSamples( + projectId: String, + progressCallback: (suspend (Int, Int) -> Unit)?, + ): Boolean { val firebaseApp = authStore.getLegacyAppFallback() if (firebaseApp.options.projectId.isNullOrBlank()) { Simber.i("Firebase projectId is null", tag = SAMPLE_UPLOAD) diff --git a/infra/images/src/main/java/com/simprints/infra/images/remote/signedurl/SignedUrlSampleUploader.kt b/infra/images/src/main/java/com/simprints/infra/images/remote/signedurl/SignedUrlSampleUploader.kt index 41b8c379e9..dbddd2e223 100644 --- a/infra/images/src/main/java/com/simprints/infra/images/remote/signedurl/SignedUrlSampleUploader.kt +++ b/infra/images/src/main/java/com/simprints/infra/images/remote/signedurl/SignedUrlSampleUploader.kt @@ -25,7 +25,10 @@ internal class SignedUrlSampleUploader @Inject constructor( private val uploadSampleWithTracking: UploadSampleWithTrackingUseCase, private val fetchUploadUrlsPerSample: FetchUploadUrlsPerSampleUseCase, ) : SampleUploader { - override suspend fun uploadAllSamples(projectId: String, progressCallback: (suspend (Int, Int) -> Unit)?): Boolean { + override suspend fun uploadAllSamples( + projectId: String, + progressCallback: (suspend (Int, Int) -> Unit)?, + ): Boolean { var allImagesUploaded = true val batchSize = getBatchSize() val urlRequestScope = eventRepository.createEventScope(type = EventScopeType.SAMPLE_UP_SYNC) diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncTimestampProvider.kt b/infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncTimestampProvider.kt index 9669377c8a..14adfe7756 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncTimestampProvider.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncTimestampProvider.kt @@ -15,8 +15,9 @@ class ImageSyncTimestampProvider @Inject constructor( securePrefs.edit { putLong(IMAGE_SYNC_COMPLETION_TIME_MILLIS, timeHelper.now().ms) } } - fun getMillisSinceLastImageSync(): Long? = - securePrefs.getLong(IMAGE_SYNC_COMPLETION_TIME_MILLIS, 0).takeIf { + fun getMillisSinceLastImageSync(): Long? = securePrefs + .getLong(IMAGE_SYNC_COMPLETION_TIME_MILLIS, 0) + .takeIf { securePrefs.contains(IMAGE_SYNC_COMPLETION_TIME_MILLIS) }?.let { lastSyncTimestamp -> timeHelper.now().ms - lastSyncTimestamp 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 9798662c62..34ec8c275d 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 @@ -149,20 +149,24 @@ internal class SyncOrchestratorImpl @Inject constructor( workManager.cancelWorkers(SyncConstants.FILE_UP_SYNC_WORK_NAME) } - override fun observeImageSyncStatus(): Flow { - return workManager - .getWorkInfosFlow(WorkQuery.fromUniqueWorkNames(SyncConstants.FILE_UP_SYNC_WORK_NAME)) - .associateWithIfSyncing() - .map { (workInfos, isSyncing) -> - val millisSinceLastUpdate = imageSyncTimestampProvider.getMillisSinceLastImageSync() - val currentIndex = workInfos.firstOrNull()?.progress - ?.getInt(SyncConstants.PROGRESS_CURRENT, 0)?.coerceAtLeast(0) ?: 0 - val totalCount = workInfos.firstOrNull()?.progress - ?.getInt(SyncConstants.PROGRESS_MAX, 0)?.takeIf { it >= 1 } - val progress = totalCount?.let { currentIndex to totalCount } - ImageSyncStatus(isSyncing, progress, millisSinceLastUpdate) - } - } + override fun observeImageSyncStatus(): Flow = workManager + .getWorkInfosFlow(WorkQuery.fromUniqueWorkNames(SyncConstants.FILE_UP_SYNC_WORK_NAME)) + .associateWithIfSyncing() + .map { (workInfos, isSyncing) -> + val millisSinceLastUpdate = imageSyncTimestampProvider.getMillisSinceLastImageSync() + val currentIndex = workInfos + .firstOrNull() + ?.progress + ?.getInt(SyncConstants.PROGRESS_CURRENT, 0) + ?.coerceAtLeast(0) ?: 0 + val totalCount = workInfos + .firstOrNull() + ?.progress + ?.getInt(SyncConstants.PROGRESS_MAX, 0) + ?.takeIf { it >= 1 } + val progress = totalCount?.let { currentIndex to totalCount } + ImageSyncStatus(isSyncing, progress, millisSinceLastUpdate) + } /** * Converts the flow of WorkInfo in the receiver into a flow of WorkInfo paired to whether sync is ongoing or not. @@ -182,7 +186,8 @@ internal class SyncOrchestratorImpl @Inject constructor( workInfos.any { it.state == WorkInfo.State.SUCCEEDED - } && isJustUpdated -> { + } && + isJustUpdated -> { emit(workInfos to true) // at least for a moment, in case if RUNNING was missed emit(workInfos to false) } diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/ImageSyncTimestampProviderTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/ImageSyncTimestampProviderTest.kt index 44b0c2e740..90a05e46b7 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/ImageSyncTimestampProviderTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/ImageSyncTimestampProviderTest.kt @@ -13,7 +13,6 @@ import org.junit.Before import org.junit.Test class ImageSyncTimestampProviderTest { - @MockK private lateinit var securityManager: SecurityManager 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 8adccc1808..c4187a54b9 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 @@ -18,10 +18,10 @@ 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.PROGRESS_CURRENT import com.simprints.infra.sync.SyncConstants.PROGRESS_MAX +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 @@ -478,10 +478,15 @@ class SyncOrchestratorImplTest { WorkInfo(UUID.randomUUID(), state, emptySet()), ) - private fun createWorkInfoWithProgress(state: WorkInfo.State, current: Int? = null, max: Int? = null): List { + private fun createWorkInfoWithProgress( + state: WorkInfo.State, + current: Int? = null, + max: Int? = null, + ): List { val workInfo = mockk { every { this@mockk.state } returns state - every { progress } returns Data.Builder() + every { progress } returns Data + .Builder() .apply { current?.let { putInt(PROGRESS_CURRENT, current) } max?.let { putInt(PROGRESS_MAX, max) } diff --git a/infra/ui-base/src/main/java/com/simprints/infra/uibase/view/View.ext.kt b/infra/ui-base/src/main/java/com/simprints/infra/uibase/view/View.ext.kt index 383dcd5c57..b5944a4b27 100644 --- a/infra/ui-base/src/main/java/com/simprints/infra/uibase/view/View.ext.kt +++ b/infra/ui-base/src/main/java/com/simprints/infra/uibase/view/View.ext.kt @@ -10,17 +10,20 @@ fun View.setPulseAnimation(isEnabled: Boolean) { tag = null } if (!isEnabled) return - val progressBarPulseAnimator = ObjectAnimator.ofFloat( - this, - View.ALPHA, - PULSE_ANIMATION_ALPHA_FULL, PULSE_ANIMATION_ALPHA_INTERMEDIATE, PULSE_ANIMATION_ALPHA_MIN, - ).apply { - duration = PULSE_ANIMATION_DURATION_MILLIS - repeatCount = ObjectAnimator.INFINITE - repeatMode = ObjectAnimator.REVERSE - interpolator = AccelerateDecelerateInterpolator() - start() - } + val progressBarPulseAnimator = ObjectAnimator + .ofFloat( + this, + View.ALPHA, + PULSE_ANIMATION_ALPHA_FULL, + PULSE_ANIMATION_ALPHA_INTERMEDIATE, + PULSE_ANIMATION_ALPHA_MIN, + ).apply { + duration = PULSE_ANIMATION_DURATION_MILLIS + repeatCount = ObjectAnimator.INFINITE + repeatMode = ObjectAnimator.REVERSE + interpolator = AccelerateDecelerateInterpolator() + start() + } tag = progressBarPulseAnimator } From fbad0bda8859d8c52e3bfe6ba8a665408fc55ebb Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 12 Aug 2025 22:29:14 +0100 Subject: [PATCH 36/45] MS-939 Test fix --- .../com/simprints/feature/dashboard/tools/di/FakeCoreModule.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeCoreModule.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeCoreModule.kt index 766e7291d4..fd18172921 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeCoreModule.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeCoreModule.kt @@ -47,7 +47,7 @@ object FakeCoreModule { @Provides @Singleton - fun provideTimer(): Timer = mockk() + fun provideTimer(): Timer = mockk(relaxed = true) @Provides @Singleton From 5ae3531d96a6535d41faaeb99d9085d60ff00e20 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 25 Aug 2025 12:12:20 +0100 Subject: [PATCH 37/45] MS-939 CommCare changes integration step 1: post-merge cleanup --- .../usecase/ObserveSyncInfoUseCase.kt | 4 +- .../store/models/ProjectConfiguration.kt | 6 +- .../sync/master/EventSyncMasterWorkerTest.kt | 60 ------------------- 3 files changed, 5 insertions(+), 65 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 1d8bfbf240..4f5ae36f27 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 @@ -19,9 +19,9 @@ 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.isEventDownSyncAllowed import com.simprints.infra.config.store.models.isMissingModulesToChooseFrom import com.simprints.infra.config.store.models.isModuleSelectionAvailable +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 @@ -172,7 +172,7 @@ internal class ObserveSyncInfoUseCase @Inject constructor( val recordsToDownload = when { isEventSyncInProgress -> null isPreLogoutUpSync -> null - projectConfig.isEventDownSyncAllowed() -> try { + projectConfig.isSimprintsEventDownSyncAllowed() -> try { withTimeout(COUNT_EVENTS_TIMEOUT_MILLIS) { countEventsToDownloadWithCaching() } diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ProjectConfiguration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ProjectConfiguration.kt index 6ed61dd886..083857e609 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ProjectConfiguration.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ProjectConfiguration.kt @@ -81,14 +81,14 @@ fun ProjectConfiguration.experimental(): ExperimentalProjectConfiguration = Expe // module sync fun ProjectConfiguration.isProjectWithModuleSync(): Boolean = - synchronization.down.simprints.partitionType == DownSynchronizationConfiguration.PartitionType.MODULE + synchronization.down.simprints?.partitionType == DownSynchronizationConfiguration.PartitionType.MODULE fun ProjectConfiguration.isProjectWithPeriodicallyUpSync(): Boolean = synchronization.up.simprints.frequency == Frequency.ONLY_PERIODICALLY_UP_SYNC fun ProjectConfiguration.isModuleSelectionAvailable(): Boolean = isProjectWithModuleSync() && !isProjectWithPeriodicallyUpSync() -fun ProjectConfiguration.areModuleOptionsEmpty(): Boolean = synchronization.down.simprints.moduleOptions - .isEmpty() +fun ProjectConfiguration.areModuleOptionsEmpty(): Boolean = synchronization.down.simprints?.moduleOptions + ?.isEmpty() ?: true fun ProjectConfiguration.isMissingModulesToChooseFrom(): Boolean = isProjectWithModuleSync() && areModuleOptionsEmpty() diff --git a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/sync/master/EventSyncMasterWorkerTest.kt b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/sync/master/EventSyncMasterWorkerTest.kt index 6a9dec84ac..bb2b1b5c3d 100644 --- a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/sync/master/EventSyncMasterWorkerTest.kt +++ b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/sync/master/EventSyncMasterWorkerTest.kt @@ -347,66 +347,6 @@ internal class EventSyncMasterWorkerTest { verify(exactly = 0) { timeHelper.now() } } - @Test - fun `doWork should enqueue down sync worker when IS_DOWN_SYNC_ALLOWED is true and can down sync`() = runTest { - shouldSyncRun(false) - canDownSync(true) - canUpSync(true) - val uniqueSyncId = masterWorker.uniqueSyncId - - val result = masterWorker.doWork() - - assertThat(result).isEqualTo( - ListenableWorker.Result.success( - workDataOf( - EventSyncMasterWorker.OUTPUT_LAST_SYNC_ID to uniqueSyncId, - ), - ), - ) - coVerify(exactly = 1) { eventRepository.createEventScope(EventScopeType.UP_SYNC, any()) } - coVerify(exactly = 1) { eventRepository.createEventScope(EventScopeType.DOWN_SYNC, any()) } - coVerify(exactly = 1) { upSyncWorkerBuilder.buildUpSyncWorkerChain(uniqueSyncId, any()) } - coVerify(exactly = 1) { downSyncWorkerBuilder.buildDownSyncWorkerChain(uniqueSyncId, any()) } - } - - @Test - fun `doWork should not enqueue down sync worker when IS_DOWN_SYNC_ALLOWED is false`() = runTest { - shouldSyncRun(false) - canDownSync(true) - canUpSync(true) - val workerWithDownSyncDisabled = EventSyncMasterWorker( - appContext = ctx, - params = mockk(relaxed = true) { - every { tags } returns setOf(MASTER_SYNC_SCHEDULER_PERIODIC_TIME) - every { inputData.getBoolean(EventSyncMasterWorker.IS_DOWN_SYNC_ALLOWED, true) } returns false - }, - downSyncWorkerBuilder = downSyncWorkerBuilder, - upSyncWorkerBuilder = upSyncWorkerBuilder, - configManager = configManager, - eventSyncCache = eventSyncCache, - eventSyncSubMasterWorkersBuilder = eventSyncSubMasterWorkersBuilder, - timeHelper = timeHelper, - dispatcher = testCoroutineRule.testCoroutineDispatcher, - securityManager = securityManager, - eventRepository = eventRepository, - ) - val uniqueSyncId = workerWithDownSyncDisabled.uniqueSyncId - - val result = workerWithDownSyncDisabled.doWork() - - assertThat(result).isEqualTo( - ListenableWorker.Result.success( - workDataOf( - EventSyncMasterWorker.OUTPUT_LAST_SYNC_ID to uniqueSyncId, - ), - ), - ) - coVerify(exactly = 1) { eventRepository.createEventScope(EventScopeType.UP_SYNC, any()) } - coVerify(exactly = 0) { eventRepository.createEventScope(EventScopeType.DOWN_SYNC, any()) } - coVerify(exactly = 1) { upSyncWorkerBuilder.buildUpSyncWorkerChain(uniqueSyncId, any()) } - coVerify(exactly = 0) { downSyncWorkerBuilder.buildDownSyncWorkerChain(uniqueSyncId, any()) } - } - private suspend fun getIsEventDownSyncAllowedResult( projectState: ProjectState, syncConfig: Frequency, From 7f8ac190a1d8cea7df1a59dde3f6f860448f64f8 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 25 Aug 2025 18:02:45 +0100 Subject: [PATCH 38/45] MS-939 CommCare changes integration step 2: the missing functionality added --- .../dashboard/settings/syncinfo/SyncInfo.kt | 1 + .../settings/syncinfo/SyncInfoFragment.kt | 9 ++ .../usecase/ObserveSyncInfoUseCase.kt | 29 ++-- .../main/res/layout/fragment_sync_info.xml | 12 ++ .../syncinfo/SyncInfoViewModelTest.kt | 4 +- .../usecase/ObserveSyncInfoUseCaseTest.kt | 124 +++++++++++++++++- .../resources/src/main/res/values/strings.xml | 1 + 7 files changed, 164 insertions(+), 16 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 91b9e5ad5c..f506c1d115 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 @@ -19,6 +19,7 @@ data class SyncInfoSectionRecords( val counterImagesToUpload: String = "", // instructions val isInstructionDefaultVisible: Boolean = false, + val isInstructionCommCarePermissionVisible: Boolean = false, val isInstructionNoModulesVisible: Boolean = false, val isInstructionOfflineVisible: Boolean = false, val isInstructionErrorVisible: Boolean = false, 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 8ce832ad02..7befdf891a 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 @@ -1,6 +1,7 @@ package com.simprints.feature.dashboard.settings.syncinfo import android.content.Intent +import android.net.Uri import android.os.Bundle import android.provider.Settings import android.view.LayoutInflater @@ -96,6 +97,13 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { binding.buttonSyncImagesNow.setOnClickListener { viewModel.toggleImageSync() } + binding.textEventSyncInstructionsCommCarePermission.setOnClickListener { + startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", requireContext().packageName, null) + } + ) + } binding.textEventSyncInstructionsOffline.setOnClickListener { startActivity(Intent(Settings.ACTION_WIRELESS_SETTINGS)) } @@ -210,6 +218,7 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { // Instructions binding.textEventSyncInstructionsDefault.isGone = !records.isInstructionDefaultVisible + binding.textEventSyncInstructionsCommCarePermission.isGone = !records.isInstructionCommCarePermissionVisible binding.textEventSyncInstructionsOffline.isGone = !records.isInstructionOfflineVisible binding.textEventSyncInstructionsNoModules.isGone = !records.isInstructionNoModulesVisible binding.textEventSyncInstructionsError.isGone = !records.isInstructionErrorVisible 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 4f5ae36f27..9b6bd8b128 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 @@ -19,6 +19,7 @@ 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.isCommCareEventDownSyncAllowed import com.simprints.infra.config.store.models.isMissingModulesToChooseFrom import com.simprints.infra.config.store.models.isModuleSelectionAvailable import com.simprints.infra.config.store.models.isSimprintsEventDownSyncAllowed @@ -66,7 +67,11 @@ internal class ObserveSyncInfoUseCase @Inject constructor( configManager.observeProjectConfiguration(), configManager.observeDeviceConfiguration(), timer.observeTickOncePerMinute(), - ) { isConnected, isLoggedIn, isRefreshing, eventSyncState, imageSyncStatus, projectConfig, deviceConfig, _ -> + ) { isOnline, isLoggedIn, isRefreshing, eventSyncState, imageSyncStatus, projectConfig, deviceConfig, _ -> + val isEventSyncConnectionSufficient = + isOnline || (!isPreLogoutUpSync && projectConfig.isCommCareEventDownSyncAllowed()) + val isImageSyncConnectionSufficient = + isOnline val currentEvents = eventSyncState.progress?.coerceAtLeast(0) ?: 0 val totalEvents = eventSyncState.total?.takeIf { it >= 1 } ?: 0 @@ -146,12 +151,16 @@ internal class ObserveSyncInfoUseCase @Inject constructor( val isReLoginRequired = eventSyncState.isSyncFailedBecauseReloginRequired() + val isCommCarePermissionMissing = + eventSyncState.isSyncFailedBecauseCommCarePermissionIsMissing() + val isModuleSelectionRequired = !isPreLogoutUpSync && projectConfig.isModuleSelectionAvailable() && deviceConfig.selectedModules.isEmpty() val isEventSyncAvailable = !isReLoginRequired && - isConnected && + isEventSyncConnectionSufficient && !eventSyncState.isSyncRunning() && + !isCommCarePermissionMissing && !projectConfig.isMissingModulesToChooseFrom() && !isModuleSelectionRequired @@ -227,13 +236,15 @@ internal class ObserveSyncInfoUseCase @Inject constructor( isCounterImagesToUploadVisible = isPreLogoutUpSync, counterImagesToUpload = imagesToUpload?.toString().orEmpty(), isInstructionDefaultVisible = !isModuleSelectionRequired && - isConnected && + isEventSyncConnectionSufficient && !eventSyncState.isSyncFailed() && !eventSyncState.isSyncInProgress() && !isPreLogoutUpSync, - isInstructionNoModulesVisible = isConnected && isModuleSelectionRequired && !isEventSyncInProgress, - isInstructionOfflineVisible = !isConnected, - isInstructionErrorVisible = isConnected && eventSyncState.isSyncFailed(), + isInstructionCommCarePermissionVisible = isCommCarePermissionMissing, + isInstructionNoModulesVisible = isEventSyncConnectionSufficient && !isCommCarePermissionMissing && isModuleSelectionRequired + && !isEventSyncInProgress, + isInstructionOfflineVisible = !isEventSyncConnectionSufficient && !isCommCarePermissionMissing, + isInstructionErrorVisible = isEventSyncConnectionSufficient && !isCommCarePermissionMissing && eventSyncState.isSyncFailed(), instructionPopupErrorInfo = SyncInfoError( isBackendMaintenance = eventSyncState.isSyncFailedBecauseBackendMaintenance(), backendMaintenanceEstimatedOutage = eventSyncState.getEstimatedBackendMaintenanceOutage() ?: -1, @@ -253,11 +264,11 @@ internal class ObserveSyncInfoUseCase @Inject constructor( val syncInfoSectionImages = SyncInfoSectionImages( counterImagesToUpload = imagesToUpload?.toString().orEmpty(), - isInstructionDefaultVisible = !imageSyncStatus.isSyncing && isConnected, - isInstructionOfflineVisible = !isConnected, + isInstructionDefaultVisible = !imageSyncStatus.isSyncing && isImageSyncConnectionSufficient, + isInstructionOfflineVisible = !isImageSyncConnectionSufficient, isProgressVisible = imageSyncStatus.isSyncing, progress = imageSyncProgress, - isSyncButtonEnabled = isConnected && !isReLoginRequired, + isSyncButtonEnabled = isImageSyncConnectionSufficient && !isReLoginRequired, isFooterLastSyncTimeVisible = !imageSyncStatus.isSyncing && imageLastSyncTimestamp.ms >= 0, footerLastSyncMinutesAgo = timeHelper.readableBetweenNowAndTime(imageLastSyncTimestamp), ) diff --git a/feature/dashboard/src/main/res/layout/fragment_sync_info.xml b/feature/dashboard/src/main/res/layout/fragment_sync_info.xml index 53982f11aa..949b932a49 100644 --- a/feature/dashboard/src/main/res/layout/fragment_sync_info.xml +++ b/feature/dashboard/src/main/res/layout/fragment_sync_info.xml @@ -370,6 +370,18 @@ android:textColorLink="?attr/colorAccent" android:visibility="visible" /> + + ().isModuleSelectionAvailable() } returns false - every { any().isEventDownSyncAllowed() } returns true + every { any().isSimprintsEventDownSyncAllowed() } returns true every { any().isMissingModulesToChooseFrom() } returns false every { observeSyncInfo(any()) } returns flowOf(createDefaultSyncInfo()) 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 e2c0b155f7..dd5ffdd6b8 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,14 +11,17 @@ import com.simprints.core.tools.time.Timestamp import com.simprints.feature.dashboard.settings.syncinfo.SyncInfoModuleCount 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.isEventDownSyncAllowed +import com.simprints.infra.config.store.models.isCommCareEventDownSyncAllowed import com.simprints.infra.config.store.models.isMissingModulesToChooseFrom import com.simprints.infra.config.store.models.isModuleSelectionAvailable +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 @@ -77,6 +80,11 @@ class ObserveSyncInfoUseCaseTest { every { general } returns mockk(relaxed = true) { every { modalities } returns emptyList() } + every { synchronization } returns mockk(relaxed = true) { + every { down } returns mockk(relaxed = true) { + every { commCare } returns null + } + } } private val mockDeviceConfiguration = mockk(relaxed = true) { every { selectedModules } returns emptyList() @@ -91,6 +99,7 @@ class ObserveSyncInfoUseCaseTest { every { isSyncRunning() } returns false every { isSyncFailed() } returns false every { isSyncFailedBecauseReloginRequired() } returns false + every { isSyncFailedBecauseCommCarePermissionIsMissing() } returns false every { isSyncFailedBecauseBackendMaintenance() } returns false every { isSyncFailedBecauseTooManyRequests() } returns false every { getEstimatedBackendMaintenanceOutage() } returns null @@ -155,7 +164,8 @@ class ObserveSyncInfoUseCaseTest { every { tokenizationProcessor.decrypt(any(), any(), any()) } returns TokenizableString.Raw("decrypted_module") every { any().isModuleSelectionAvailable() } returns false - every { any().isEventDownSyncAllowed() } returns true + every { any().isSimprintsEventDownSyncAllowed() } returns true + every { any().isCommCareEventDownSyncAllowed() } returns false every { any().isMissingModulesToChooseFrom() } returns false } @@ -648,7 +658,7 @@ class ObserveSyncInfoUseCaseTest { every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithDownSync) coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithDownSync coEvery { eventSyncManager.countEventsToDownload() } returns DownSyncCounts(42, isLowerBound = false) - every { mockProjectConfigWithDownSync.isEventDownSyncAllowed() } returns true + every { mockProjectConfigWithDownSync.isSimprintsEventDownSyncAllowed() } returns true every { mockProjectConfigWithDownSync.isModuleSelectionAvailable() } returns false createUseCase() @@ -687,7 +697,7 @@ class ObserveSyncInfoUseCaseTest { every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithDownSync) coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithDownSync coEvery { eventSyncManager.countEventsToDownload() } throws Exception("Timeout") - every { mockProjectConfigWithDownSync.isEventDownSyncAllowed() } returns true + every { mockProjectConfigWithDownSync.isSimprintsEventDownSyncAllowed() } returns true every { mockProjectConfigWithDownSync.isModuleSelectionAvailable() } returns false createUseCase() @@ -710,7 +720,7 @@ class ObserveSyncInfoUseCaseTest { every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithDownSync) coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithDownSync coEvery { eventSyncManager.countEventsToDownload() } throws RuntimeException("Network error") - every { mockProjectConfigWithDownSync.isEventDownSyncAllowed() } returns true + every { mockProjectConfigWithDownSync.isSimprintsEventDownSyncAllowed() } returns true every { mockProjectConfigWithDownSync.isModuleSelectionAvailable() } returns false createUseCase() @@ -950,6 +960,75 @@ class ObserveSyncInfoUseCaseTest { // UI state tests + @Test + fun `should show CommCare permission missing instruction when sync failed due to missing permission`() = runTest { + val mockFailedEventSyncState = mockk(relaxed = true) { + every { isSyncFailedBecauseCommCarePermissionIsMissing() } returns true + every { isSyncFailed() } returns true + every { isSyncInProgress() } returns false + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockFailedEventSyncState) + every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.isInstructionCommCarePermissionVisible).isTrue() + assertThat(result.syncInfoSectionRecords.isInstructionDefaultVisible).isFalse() + assertThat(result.syncInfoSectionRecords.isInstructionOfflineVisible).isFalse() + assertThat(result.syncInfoSectionRecords.isInstructionErrorVisible).isFalse() + assertThat(result.syncInfoSectionRecords.isInstructionNoModulesVisible).isFalse() + assertThat(result.syncInfoSectionRecords.isInstructionDefaultVisible).isFalse() + } + + @Test + fun `should hide CommCare permission missing instruction when permission is granted`() = runTest { + val mockNormalEventSyncState = mockk(relaxed = true) { + every { isSyncFailedBecauseCommCarePermissionIsMissing() } returns false + every { isSyncFailed() } returns false + every { isSyncInProgress() } returns false + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockNormalEventSyncState) + every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.isInstructionCommCarePermissionVisible).isFalse() + assertThat(result.syncInfoSectionRecords.isInstructionDefaultVisible).isTrue() + } + + @Test + fun `should disable sync button when CommCare permission is missing`() = runTest { + val mockFailedEventSyncState = mockk(relaxed = true) { + every { isSyncFailedBecauseCommCarePermissionIsMissing() } returns true + every { isSyncRunning() } returns false + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockFailedEventSyncState) + every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.isSyncButtonEnabled).isFalse() + } + + @Test + fun `should allow sync when CommCare permission is granted and all conditions met`() = runTest { + val mockNormalEventSyncState = mockk(relaxed = true) { + every { isSyncFailedBecauseCommCarePermissionIsMissing() } returns false + every { isSyncRunning() } returns false + every { isSyncFailedBecauseReloginRequired() } returns false + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockNormalEventSyncState) + every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.isSyncButtonEnabled).isTrue() + } + @Test fun `should calculate correct record last sync time when sync time available`() = runTest { val timestamp = Timestamp(0L) @@ -1105,6 +1184,41 @@ class ObserveSyncInfoUseCaseTest { assertThat(failedResult.syncInfoSectionRecords.isSyncButtonForRetry).isTrue() } + // CommCare-specific tests + + @Test + fun `should allow sync without network connection when CommCare down sync is configured`() = runTest { + val mockProjectConfigWithCommCareDownSync = mockk(relaxed = true) { + every { general } returns mockk(relaxed = true) { + every { modalities } returns emptyList() + } + every { synchronization } returns mockk(relaxed = true) { + every { down } returns mockk(relaxed = true) { + every { commCare } returns mockk() + } + } + } + val mockNormalEventSyncState = mockk(relaxed = true) { + every { isSyncFailedBecauseCommCarePermissionIsMissing() } returns false + every { isSyncRunning() } returns false + every { isSyncFailedBecauseReloginRequired() } returns false + 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() + + assertThat(result.syncInfoSectionRecords.isSyncButtonEnabled).isTrue() + assertThat(result.syncInfoSectionRecords.isInstructionOfflineVisible).isFalse() + assertThat(result.syncInfoSectionRecords.isInstructionDefaultVisible).isTrue() + } + // Module tokenization tests @Test diff --git a/infra/resources/src/main/res/values/strings.xml b/infra/resources/src/main/res/values/strings.xml index c1a25fdd97..40c78c5480 100644 --- a/infra/resources/src/main/res/values/strings.xml +++ b/infra/resources/src/main/res/values/strings.xml @@ -331,6 +331,7 @@ Manually synchronise records by pressing this button. More info Manually send images to the cloud by pressing this button. More info Records from the selected modules will be synchronised. More info + Syncing from CommCare requires permission in Settings Sync needs an internet connection. Connection settings Sync needs modules selected. Select modules Sync failed. More info From 4c932fabff08992afe0207f46123aaceae57c336 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 26 Aug 2025 02:24:56 +0100 Subject: [PATCH 39/45] MS-939 CommCare changes integration step 2: CommCare permission checks, & cleanup --- .../usecase/ObserveSyncInfoUseCase.kt | 84 ++++++++++-------- .../syncinfo/SyncInfoViewModelTest.kt | 2 - .../usecase/ObserveSyncInfoUseCaseTest.kt | 56 +++++++++++- gradle/libs.versions.toml | 1 + .../store/models/ProjectConfiguration.kt | 5 -- .../store/models/ProjectConfigurationTest.kt | 74 ---------------- infra/core/build.gradle.kts | 1 + .../lifecycle/AppForegroundStateTracker.kt | 30 +++++++ .../core/tools/extentions/Flow.ext.kt | 8 +- .../AppForegroundStateTrackerTest.kt | 88 +++++++++++++++++++ .../core/tools/extentions/FlowExtTest.kt | 9 +- .../infra/sync/ImageSyncTimestampProvider.kt | 6 ++ .../sync/ImageSyncTimestampProviderTest.kt | 21 +++++ 13 files changed, 260 insertions(+), 125 deletions(-) create mode 100644 infra/core/src/main/java/com/simprints/core/lifecycle/AppForegroundStateTracker.kt create mode 100644 infra/core/src/test/java/com/simprints/core/lifecycle/AppForegroundStateTrackerTest.kt 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 9b6bd8b128..056fcab00c 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,7 +2,8 @@ package com.simprints.feature.dashboard.settings.syncinfo.usecase import androidx.lifecycle.asFlow import com.simprints.core.domain.tokenization.TokenizableString -import com.simprints.core.tools.extentions.combine8 +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.TimeHelper import com.simprints.core.tools.time.Timer @@ -20,7 +21,6 @@ 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.isCommCareEventDownSyncAllowed -import com.simprints.infra.config.store.models.isMissingModulesToChooseFrom import com.simprints.infra.config.store.models.isModuleSelectionAvailable import com.simprints.infra.config.store.models.isSimprintsEventDownSyncAllowed import com.simprints.infra.config.store.tokenization.TokenizationProcessor @@ -35,6 +35,7 @@ import com.simprints.infra.network.ConnectivityTracker import com.simprints.infra.sync.SyncOrchestrator import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.withTimeout @@ -52,13 +53,14 @@ internal class ObserveSyncInfoUseCase @Inject constructor( private val tokenizationProcessor: TokenizationProcessor, private val timeHelper: TimeHelper, private val timer: Timer, + private val appForegroundStateTracker: AppForegroundStateTracker, ) { private val eventSyncStateFlow = eventSyncManager.getLastSyncState(useDefaultValue = true /* otherwise value not guaranteed */).asFlow() private val imageSyncStatusFlow = syncOrchestrator.observeImageSyncStatus() - operator fun invoke(isPreLogoutUpSync: Boolean = false): Flow = combine8( + operator fun invoke(isPreLogoutUpSync: Boolean = false): Flow = combine9( connectivityTracker.observeIsConnected().asFlow(), authStore.observeSignedInProjectId().map(String::isNotEmpty), configManager.observeIsProjectRefreshing(), @@ -66,13 +68,9 @@ internal class ObserveSyncInfoUseCase @Inject constructor( imageSyncStatusFlow, configManager.observeProjectConfiguration(), configManager.observeDeviceConfiguration(), + appForegroundStateTracker.observeAppInForeground().filter { it }, // only when going to foreground timer.observeTickOncePerMinute(), - ) { isOnline, isLoggedIn, isRefreshing, eventSyncState, imageSyncStatus, projectConfig, deviceConfig, _ -> - val isEventSyncConnectionSufficient = - isOnline || (!isPreLogoutUpSync && projectConfig.isCommCareEventDownSyncAllowed()) - val isImageSyncConnectionSufficient = - isOnline - + ) { isOnline, isLoggedIn, isRefreshing, eventSyncState, imageSyncStatus, projectConfig, deviceConfig, _, _ -> val currentEvents = eventSyncState.progress?.coerceAtLeast(0) ?: 0 val totalEvents = eventSyncState.total?.takeIf { it >= 1 } ?: 0 val currentImages = imageSyncStatus.progress?.first?.coerceAtLeast(0) ?: 0 @@ -151,18 +149,25 @@ internal class ObserveSyncInfoUseCase @Inject constructor( val isReLoginRequired = eventSyncState.isSyncFailedBecauseReloginRequired() - val isCommCarePermissionMissing = - eventSyncState.isSyncFailedBecauseCommCarePermissionIsMissing() - val isModuleSelectionRequired = !isPreLogoutUpSync && projectConfig.isModuleSelectionAvailable() && deviceConfig.selectedModules.isEmpty() - val isEventSyncAvailable = - !isReLoginRequired && - isEventSyncConnectionSufficient && - !eventSyncState.isSyncRunning() && - !isCommCarePermissionMissing && - !projectConfig.isMissingModulesToChooseFrom() && - !isModuleSelectionRequired + + val isCommCareSyncExpected = + !isPreLogoutUpSync && projectConfig.isCommCareEventDownSyncAllowed() + + val isCommCareSyncBlockedByDeniedPermission = + isCommCareSyncExpected && eventSyncState.isSyncFailedBecauseCommCarePermissionIsMissing() + val isEventSyncConnectionBlocked = + !isOnline && !isCommCareSyncExpected // CommCare would be able to sync even if device is offline + + val eventSyncVisibleSection = when { + isEventSyncInProgress -> InProgress + isCommCareSyncBlockedByDeniedPermission -> CommCareError + isModuleSelectionRequired -> NoModulesError + isEventSyncConnectionBlocked -> OfflineError + eventSyncState.isSyncFailed() -> Error + else -> OnStandby + } val projectId = authStore.signedInProjectId @@ -235,25 +240,20 @@ internal class ObserveSyncInfoUseCase @Inject constructor( counterRecordsToDownload = recordsToDownload?.let { "${it.count}${if (it.isLowerBound) "+" else ""}" }.orEmpty(), isCounterImagesToUploadVisible = isPreLogoutUpSync, counterImagesToUpload = imagesToUpload?.toString().orEmpty(), - isInstructionDefaultVisible = !isModuleSelectionRequired && - isEventSyncConnectionSufficient && - !eventSyncState.isSyncFailed() && - !eventSyncState.isSyncInProgress() && - !isPreLogoutUpSync, - isInstructionCommCarePermissionVisible = isCommCarePermissionMissing, - isInstructionNoModulesVisible = isEventSyncConnectionSufficient && !isCommCarePermissionMissing && isModuleSelectionRequired - && !isEventSyncInProgress, - isInstructionOfflineVisible = !isEventSyncConnectionSufficient && !isCommCarePermissionMissing, - isInstructionErrorVisible = isEventSyncConnectionSufficient && !isCommCarePermissionMissing && eventSyncState.isSyncFailed(), + isInstructionDefaultVisible = eventSyncVisibleSection == OnStandby, + isInstructionCommCarePermissionVisible = eventSyncVisibleSection == CommCareError, + isInstructionNoModulesVisible = eventSyncVisibleSection == NoModulesError, + isInstructionOfflineVisible = eventSyncVisibleSection == OfflineError, + isInstructionErrorVisible = eventSyncVisibleSection == Error, instructionPopupErrorInfo = SyncInfoError( isBackendMaintenance = eventSyncState.isSyncFailedBecauseBackendMaintenance(), backendMaintenanceEstimatedOutage = eventSyncState.getEstimatedBackendMaintenanceOutage() ?: -1, isTooManyRequests = eventSyncState.isSyncFailedBecauseTooManyRequests(), ), - isProgressVisible = isEventSyncInProgress, + isProgressVisible = eventSyncVisibleSection == InProgress, progress = eventSyncProgress, isSyncButtonVisible = !isPreLogoutUpSync || eventSyncState.isSyncFailed(), - isSyncButtonEnabled = isEventSyncAvailable, + isSyncButtonEnabled = eventSyncVisibleSection == OnStandby && !isReLoginRequired, isSyncButtonForRetry = eventSyncState.isSyncFailed(), isFooterSyncInProgressVisible = isPreLogoutUpSync && isEventSyncInProgress, isFooterReadyToLogOutVisible = isPreLogoutUpSync && eventSyncState.isSyncCompleted() && !imageSyncStatus.isSyncing, @@ -264,11 +264,11 @@ internal class ObserveSyncInfoUseCase @Inject constructor( val syncInfoSectionImages = SyncInfoSectionImages( counterImagesToUpload = imagesToUpload?.toString().orEmpty(), - isInstructionDefaultVisible = !imageSyncStatus.isSyncing && isImageSyncConnectionSufficient, - isInstructionOfflineVisible = !isImageSyncConnectionSufficient, + isInstructionDefaultVisible = !imageSyncStatus.isSyncing && isOnline, + isInstructionOfflineVisible = !isOnline, isProgressVisible = imageSyncStatus.isSyncing, progress = imageSyncProgress, - isSyncButtonEnabled = isImageSyncConnectionSufficient && !isReLoginRequired, + isSyncButtonEnabled = isOnline && !isReLoginRequired, isFooterLastSyncTimeVisible = !imageSyncStatus.isSyncing && imageLastSyncTimestamp.ms >= 0, footerLastSyncMinutesAgo = timeHelper.readableBetweenNowAndTime(imageLastSyncTimestamp), ) @@ -281,7 +281,7 @@ internal class ObserveSyncInfoUseCase @Inject constructor( syncInfoSectionImages, syncInfoSectionModules, ) - return@combine8 syncInfo + return@combine9 syncInfo }.onRecordSyncComplete { delay(timeMillis = SYNC_COMPLETION_HOLD_MILLIS) }.onImageSyncComplete { @@ -329,3 +329,17 @@ internal class ObserveSyncInfoUseCase @Inject constructor( private const val COUNT_EVENTS_CACHE_LIFESPAN_MILLIS = 10 * 1000L } } + +private sealed class EventSyncVisibleSection + +private object OnStandby : EventSyncVisibleSection() + +private object InProgress : EventSyncVisibleSection() + +private object CommCareError : EventSyncVisibleSection() + +private object NoModulesError : EventSyncVisibleSection() + +private object OfflineError : EventSyncVisibleSection() + +private object Error : EventSyncVisibleSection() 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 5a986320db..36bd50bfd0 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 @@ -18,7 +18,6 @@ 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.isMissingModulesToChooseFrom import com.simprints.infra.config.store.models.isModuleSelectionAvailable import com.simprints.infra.config.store.models.isSimprintsEventDownSyncAllowed import com.simprints.infra.config.sync.ConfigManager @@ -151,7 +150,6 @@ class SyncInfoViewModelTest { every { any().isModuleSelectionAvailable() } returns false every { any().isSimprintsEventDownSyncAllowed() } returns true - every { any().isMissingModulesToChooseFrom() } returns false every { observeSyncInfo(any()) } returns flowOf(createDefaultSyncInfo()) } 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 dd5ffdd6b8..db56b8fe22 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,6 +5,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.asFlow import com.google.common.truth.Truth.assertThat import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.core.lifecycle.AppForegroundStateTracker import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.time.Timer import com.simprints.core.tools.time.Timestamp @@ -19,7 +20,6 @@ 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.isCommCareEventDownSyncAllowed -import com.simprints.infra.config.store.models.isMissingModulesToChooseFrom import com.simprints.infra.config.store.models.isModuleSelectionAvailable import com.simprints.infra.config.store.models.isSimprintsEventDownSyncAllowed import com.simprints.infra.config.store.tokenization.TokenizationProcessor @@ -66,6 +66,7 @@ class ObserveSyncInfoUseCaseTest { private val tokenizationProcessor = mockk() private val timeHelper = mockk() private val timer = mockk() + private val appForegroundStateTracker = mockk() private lateinit var useCase: ObserveSyncInfoUseCase @@ -163,10 +164,11 @@ class ObserveSyncInfoUseCaseTest { every { tokenizationProcessor.decrypt(any(), any(), any()) } returns TokenizableString.Raw("decrypted_module") + every { appForegroundStateTracker.observeAppInForeground() } returns flowOf(true) + every { any().isModuleSelectionAvailable() } returns false every { any().isSimprintsEventDownSyncAllowed() } returns true every { any().isCommCareEventDownSyncAllowed() } returns false - every { any().isMissingModulesToChooseFrom() } returns false } private fun createUseCase() { @@ -181,6 +183,7 @@ class ObserveSyncInfoUseCaseTest { tokenizationProcessor = tokenizationProcessor, timeHelper = timeHelper, timer = timer, + appForegroundStateTracker = appForegroundStateTracker, ) } @@ -969,6 +972,7 @@ class ObserveSyncInfoUseCaseTest { } every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockFailedEventSyncState) every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) + every { mockProjectConfiguration.isCommCareEventDownSyncAllowed() } returns true createUseCase() val result = useCase().first() @@ -1006,6 +1010,7 @@ class ObserveSyncInfoUseCaseTest { } every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockFailedEventSyncState) every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) + every { mockProjectConfiguration.isCommCareEventDownSyncAllowed() } returns true createUseCase() val result = useCase().first() @@ -1084,6 +1089,21 @@ class ObserveSyncInfoUseCaseTest { assertThat(result.syncInfoSectionImages.isFooterLastSyncTimeVisible).isFalse() } + @Test + fun `should have hidden image last sync time footer when timestamp is negative`() = runTest { + val mockImageStatusWithNegativeTimestamp = mockk(relaxed = true) { + every { isSyncing } returns false + every { progress } returns null + every { lastUpdateTimeMillis } returns -1L + } + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockImageStatusWithNegativeTimestamp) + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionImages.isFooterLastSyncTimeVisible).isFalse() + } + @Test fun `should show correct visibility states for offline instructions`() = runTest { every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(false) @@ -1277,4 +1297,36 @@ class ObserveSyncInfoUseCaseTest { 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) { + every { isSyncFailedBecauseCommCarePermissionIsMissing() } returns true + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockNormalEventSyncState) + every { mockProjectConfiguration.isCommCareEventDownSyncAllowed() } returns true + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.isInstructionCommCarePermissionVisible).isTrue() + assertThat(result.syncInfoSectionRecords.isSyncButtonEnabled).isFalse() + assertThat(result.syncInfoSectionRecords.isInstructionDefaultVisible).isFalse() + } + + @Test + fun `should hide CommCare permission instruction when does not have permission sync error`() = runTest { + val mockNormalEventSyncState = mockk(relaxed = true) { + every { isSyncFailedBecauseCommCarePermissionIsMissing() } returns false + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockNormalEventSyncState) + every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.isInstructionCommCarePermissionVisible).isFalse() + assertThat(result.syncInfoSectionRecords.isSyncButtonEnabled).isTrue() + assertThat(result.syncInfoSectionRecords.isInstructionDefaultVisible).isTrue() + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 615116d184..5f3dacf2a5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -107,6 +107,7 @@ androidX-Room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidx androidX-lifecycle = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx_lifecycle_version" } androidX-lifecycle-scope = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx_lifecycle_version" } androidX-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "androidx_lifecycle_version" } +androidX-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidx_lifecycle_version" } #UI androidX-ui-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "androidx_constraint_version" } diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ProjectConfiguration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ProjectConfiguration.kt index 083857e609..de9f8f56f2 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ProjectConfiguration.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ProjectConfiguration.kt @@ -87,8 +87,3 @@ fun ProjectConfiguration.isProjectWithPeriodicallyUpSync(): Boolean = synchronization.up.simprints.frequency == Frequency.ONLY_PERIODICALLY_UP_SYNC fun ProjectConfiguration.isModuleSelectionAvailable(): Boolean = isProjectWithModuleSync() && !isProjectWithPeriodicallyUpSync() - -fun ProjectConfiguration.areModuleOptionsEmpty(): Boolean = synchronization.down.simprints?.moduleOptions - ?.isEmpty() ?: true - -fun ProjectConfiguration.isMissingModulesToChooseFrom(): Boolean = isProjectWithModuleSync() && areModuleOptionsEmpty() diff --git a/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ProjectConfigurationTest.kt b/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ProjectConfigurationTest.kt index 31834fdc10..6eaaddc567 100644 --- a/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ProjectConfigurationTest.kt +++ b/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ProjectConfigurationTest.kt @@ -1,7 +1,6 @@ package com.simprints.infra.config.store.models import com.google.common.truth.Truth.assertThat -import com.simprints.core.domain.tokenization.asTokenizableEncrypted import com.simprints.infra.config.store.models.UpSynchronizationConfiguration.CoSyncUpSynchronizationConfiguration import com.simprints.infra.config.store.models.UpSynchronizationConfiguration.SimprintsUpSynchronizationConfiguration import com.simprints.infra.config.store.models.UpSynchronizationConfiguration.UpSynchronizationKind.ALL @@ -617,77 +616,4 @@ class ProjectConfigurationTest { ) assertThat(config.isModuleSelectionAvailable()).isFalse() } - - @Test - fun `areModuleOptionsEmpty should return true when moduleOptions is empty`() { - val config = projectConfiguration.copy( - synchronization = synchronizationConfiguration.copy( - down = synchronizationConfiguration.down.copy( - simprints = simprintsDownSyncConfigurationConfiguration.copy( - moduleOptions = emptyList(), - ), - ), - ), - ) - assertThat(config.areModuleOptionsEmpty()).isTrue() - } - - @Test - fun `areModuleOptionsEmpty should return false when moduleOptions is not empty`() { - val config = projectConfiguration.copy( - synchronization = synchronizationConfiguration.copy( - down = synchronizationConfiguration.down.copy( - simprints = simprintsDownSyncConfigurationConfiguration.copy( - moduleOptions = listOf("module1".asTokenizableEncrypted()), - ), - ), - ), - ) - assertThat(config.areModuleOptionsEmpty()).isFalse() - } - - @Test - fun `isMissingModulesToChooseFrom should return true when project has module sync and empty module options`() { - val config = projectConfiguration.copy( - synchronization = synchronizationConfiguration.copy( - down = synchronizationConfiguration.down.copy( - simprints = simprintsDownSyncConfigurationConfiguration.copy( - partitionType = DownSynchronizationConfiguration.PartitionType.MODULE, - moduleOptions = emptyList(), - ), - ), - ), - ) - assertThat(config.isMissingModulesToChooseFrom()).isTrue() - } - - @Test - fun `isMissingModulesToChooseFrom should return false when project has module sync but non-empty module options`() { - val config = projectConfiguration.copy( - synchronization = synchronizationConfiguration.copy( - down = synchronizationConfiguration.down.copy( - simprints = simprintsDownSyncConfigurationConfiguration.copy( - partitionType = DownSynchronizationConfiguration.PartitionType.MODULE, - moduleOptions = listOf("module1".asTokenizableEncrypted()), - ), - ), - ), - ) - assertThat(config.isMissingModulesToChooseFrom()).isFalse() - } - - @Test - fun `isMissingModulesToChooseFrom should return false when project has non-module sync and empty module options`() { - val config = projectConfiguration.copy( - synchronization = synchronizationConfiguration.copy( - down = synchronizationConfiguration.down.copy( - simprints = simprintsDownSyncConfigurationConfiguration.copy( - partitionType = DownSynchronizationConfiguration.PartitionType.PROJECT, - moduleOptions = listOf(), - ), - ), - ), - ) - assertThat(config.isMissingModulesToChooseFrom()).isFalse() - } } diff --git a/infra/core/build.gradle.kts b/infra/core/build.gradle.kts index da3e0fc969..a0a1282075 100644 --- a/infra/core/build.gradle.kts +++ b/infra/core/build.gradle.kts @@ -18,6 +18,7 @@ dependencies { api(libs.androidX.multidex) api(libs.androidX.annotation.annotation) api(libs.androidX.lifecycle.livedata.ktx) + api(libs.androidX.lifecycle.process) api(libs.androidX.cameraX.core) implementation(libs.androidX.cameraX.camera2) 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 new file mode 100644 index 0000000000..92971af26c --- /dev/null +++ b/infra/core/src/main/java/com/simprints/core/lifecycle/AppForegroundStateTracker.kt @@ -0,0 +1,30 @@ +package com.simprints.core.lifecycle + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AppForegroundStateTracker @Inject constructor() { + fun observeAppInForeground(): Flow = callbackFlow { + val lifecycleObserver = object : DefaultLifecycleObserver { + override fun onResume(owner: LifecycleOwner) { + trySend(true) + } + + override fun onPause(owner: LifecycleOwner) { + trySend(false) + } + } + val lifecycle = ProcessLifecycleOwner.Companion.get().lifecycle + lifecycle.addObserver(lifecycleObserver) + awaitClose { + lifecycle.removeObserver(lifecycleObserver) + } + } +} 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 4ef3cf57f0..41f2e063a3 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 @@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.scan -fun combine8( +fun combine9( flow1: Flow, flow2: Flow, flow3: Flow, @@ -15,8 +15,9 @@ fun combine8( flow6: Flow, flow7: Flow, flow8: Flow, - transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8) -> R, -): Flow = combine(flow1, flow2, flow3, flow4, flow5, flow6, flow7, flow8) { args: Array<*> -> + 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, @@ -26,6 +27,7 @@ fun combine8( args[5] as T6, args[6] as T7, args[7] as T8, + args[8] as T9, ) } 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 new file mode 100644 index 0000000000..3e6ae72a5c --- /dev/null +++ b/infra/core/src/test/java/com/simprints/core/lifecycle/AppForegroundStateTrackerTest.kt @@ -0,0 +1,88 @@ +package com.simprints.core.lifecycle + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner +import com.google.common.truth.Truth.assertThat +import com.simprints.testtools.common.coroutines.TestCoroutineRule +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class AppForegroundStateTrackerTest { + @get:Rule + val rule = InstantTaskExecutorRule() + + @get:Rule + val testCoroutineRule = TestCoroutineRule() + + private val processLifecycleOwner = mockk() + private val lifecycle = mockk() + + private lateinit var foregroundStateTracker: AppForegroundStateTracker + + @Before + fun setUp() { + MockKAnnotations.init(this) + + mockkObject(ProcessLifecycleOwner.Companion) + + every { ProcessLifecycleOwner.Companion.get() } returns processLifecycleOwner + every { processLifecycleOwner.lifecycle } returns lifecycle + every { lifecycle.addObserver(any()) } returns Unit + every { lifecycle.removeObserver(any()) } returns Unit + + foregroundStateTracker = AppForegroundStateTracker() + } + + @Test + fun `observeAppInForeground returns true when app goes into foreground`() = runTest { + val observerSlot = slot() + every { lifecycle.addObserver(capture(observerSlot)) } returns Unit + val channel = Channel(Channel.UNLIMITED) + + val job = launch { + foregroundStateTracker.observeAppInForeground().collect { + channel.trySend(it) + } + } + while (!observerSlot.isCaptured) { + testScheduler.advanceUntilIdle() + } + observerSlot.captured.onResume(mockk()) + val result = channel.receive() + job.cancel() + + assertThat(result).isTrue() + } + + @Test + fun `observeAppInForeground returns false when app goes into background`() = runTest { + val observerSlot = slot() + every { lifecycle.addObserver(capture(observerSlot)) } returns Unit + val channel = Channel(Channel.UNLIMITED) + + val job = launch { + foregroundStateTracker.observeAppInForeground().collect { + channel.trySend(it) + } + } + while (!observerSlot.isCaptured) { + testScheduler.advanceUntilIdle() + } + observerSlot.captured.onPause(mockk()) + val result = channel.receive() + job.cancel() + + assertThat(result).isFalse() + } +} 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 28e1f21133..279ef3c537 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 @@ -8,7 +8,7 @@ import org.junit.Test class FlowExtTest { @Test - fun `combine8 combines 8 flows`() = runTest { + fun `combine9 combines 9 flows`() = runTest { val flow1 = flowOf(1) val flow2 = flowOf(2) val flow3 = flowOf(3) @@ -17,12 +17,13 @@ class FlowExtTest { val flow6 = flowOf(6) val flow7 = flowOf(7) val flow8 = flowOf(8) + val flow9 = flowOf(9) - val result = combine8(flow1, flow2, flow3, flow4, flow5, flow6, flow7, flow8) { t1, t2, t3, t4, t5, t6, t7, t8 -> - t1 + t2 + t3 + t4 + t5 + t6 + t7 + t8 + 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)) + assertThat(result).isEqualTo(listOf(1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9)) } @Test diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncTimestampProvider.kt b/infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncTimestampProvider.kt index 14adfe7756..2a3a1e8fb7 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncTimestampProvider.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncTimestampProvider.kt @@ -23,6 +23,12 @@ class ImageSyncTimestampProvider @Inject constructor( timeHelper.now().ms - lastSyncTimestamp } + fun getLastImageSyncTimestamp(): Long? = securePrefs + .getLong(IMAGE_SYNC_COMPLETION_TIME_MILLIS, 0) + .takeIf { + securePrefs.contains(IMAGE_SYNC_COMPLETION_TIME_MILLIS) + } + fun clearTimestamp() { securePrefs.edit { clear() } } diff --git a/infra/sync/src/test/java/com/simprints/infra/sync/ImageSyncTimestampProviderTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/ImageSyncTimestampProviderTest.kt index 90a05e46b7..9f953dd5d9 100644 --- a/infra/sync/src/test/java/com/simprints/infra/sync/ImageSyncTimestampProviderTest.kt +++ b/infra/sync/src/test/java/com/simprints/infra/sync/ImageSyncTimestampProviderTest.kt @@ -90,6 +90,27 @@ class ImageSyncTimestampProviderTest { assertThat(result).isEqualTo(expectedMillis) } + @Test + fun `getLastImageSyncTimestamp returns null when no timestamp exists`() { + every { sharedPreferences.contains("IMAGE_SYNC_COMPLETION_TIME_MILLIS") } returns false + every { sharedPreferences.getLong("IMAGE_SYNC_COMPLETION_TIME_MILLIS", 0) } returns 0 + + val result = imageSyncTimestampProvider.getLastImageSyncTimestamp() + + assertThat(result).isNull() + } + + @Test + fun `getLastImageSyncTimestamp returns correct timestamp when exists`() { + val storedTimestamp = 1234567890L + every { sharedPreferences.contains("IMAGE_SYNC_COMPLETION_TIME_MILLIS") } returns true + every { sharedPreferences.getLong("IMAGE_SYNC_COMPLETION_TIME_MILLIS", 0) } returns storedTimestamp + + val result = imageSyncTimestampProvider.getLastImageSyncTimestamp() + + assertThat(result).isEqualTo(storedTimestamp) + } + @Test fun `clearTimestamp clears all timestamp preferences`() { imageSyncTimestampProvider.clearTimestamp() From bd779d65bda107166a4ecaa70abf9696bbe9163d Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 26 Aug 2025 02:25:16 +0100 Subject: [PATCH 40/45] MS-939 Image sync timestamp fix --- .../infra/sync/SyncOrchestratorImpl.kt | 4 ++-- .../infra/sync/SyncOrchestratorImplTest.kt | 19 +++++++++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) 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 34ec8c275d..f6429d7d56 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 @@ -153,7 +153,7 @@ internal class SyncOrchestratorImpl @Inject constructor( .getWorkInfosFlow(WorkQuery.fromUniqueWorkNames(SyncConstants.FILE_UP_SYNC_WORK_NAME)) .associateWithIfSyncing() .map { (workInfos, isSyncing) -> - val millisSinceLastUpdate = imageSyncTimestampProvider.getMillisSinceLastImageSync() + val lastUpdateTimestamp = imageSyncTimestampProvider.getLastImageSyncTimestamp() val currentIndex = workInfos .firstOrNull() ?.progress @@ -165,7 +165,7 @@ internal class SyncOrchestratorImpl @Inject constructor( ?.getInt(SyncConstants.PROGRESS_MAX, 0) ?.takeIf { it >= 1 } val progress = totalCount?.let { currentIndex to totalCount } - ImageSyncStatus(isSyncing, progress, millisSinceLastUpdate) + ImageSyncStatus(isSyncing, progress, lastUpdateTimestamp) } /** 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 c4187a54b9..314cf96755 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 @@ -341,11 +341,12 @@ class SyncOrchestratorImplTest { val workInfoFlow = flowOf(createWorkInfo(WorkInfo.State.RUNNING)) every { workManager.getWorkInfosFlow(any()) } returns workInfoFlow every { imageSyncTimestampProvider.getMillisSinceLastImageSync() } returns 30_000L + every { imageSyncTimestampProvider.getLastImageSyncTimestamp() } returns 1234567890L val status = syncOrchestrator.observeImageSyncStatus().first() assertThat(status.isSyncing).isTrue() - assertThat(status.lastUpdateTimeMillis).isEqualTo(30_000L) + assertThat(status.lastUpdateTimeMillis).isEqualTo(1234567890L) } @Test @@ -353,11 +354,25 @@ class SyncOrchestratorImplTest { val workInfoFlow = flowOf(createWorkInfo(WorkInfo.State.CANCELLED)) every { workManager.getWorkInfosFlow(any()) } returns workInfoFlow every { imageSyncTimestampProvider.getMillisSinceLastImageSync() } returns 120_000L + every { imageSyncTimestampProvider.getLastImageSyncTimestamp() } returns 1234567890L val status = syncOrchestrator.observeImageSyncStatus().first() assertThat(status.isSyncing).isFalse() - assertThat(status.lastUpdateTimeMillis).isEqualTo(120_000L) + assertThat(status.lastUpdateTimeMillis).isEqualTo(1234567890L) + } + + @Test + fun `observe image sync status returns null timestamp when no sync has occurred`() = runTest { + val workInfoFlow = flowOf(createWorkInfo(WorkInfo.State.CANCELLED)) + every { workManager.getWorkInfosFlow(any()) } returns workInfoFlow + every { imageSyncTimestampProvider.getMillisSinceLastImageSync() } returns null + every { imageSyncTimestampProvider.getLastImageSyncTimestamp() } returns null + + val status = syncOrchestrator.observeImageSyncStatus().first() + + assertThat(status.isSyncing).isFalse() + assertThat(status.lastUpdateTimeMillis).isNull() } @Test From 6081d28457eb63c9374ea54a9070d5fbb0ea0c9b Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 26 Aug 2025 05:39:19 +0100 Subject: [PATCH 41/45] MS-939 Cleanup --- .../dashboard/settings/syncinfo/SyncInfoFragment.kt | 3 ++- .../syncinfo/usecase/ObserveSyncInfoUseCase.kt | 6 +++--- .../syncinfo/usecase/ObserveSyncInfoUseCaseTest.kt | 10 +++++----- .../feature/dashboard/tools/di/FakeCoreModule.kt | 4 ++-- .../src/main/java/com/simprints/core/CoreModule.kt | 6 +++--- .../core/tools/time/{Timer.kt => Ticker.kt} | 2 +- .../core/tools/time/{TimerImpl.kt => TickerImpl.kt} | 2 +- .../time/{TimerImplTest.kt => TickerImplTest.kt} | 12 ++++++------ .../remote/signedurl/SignedUrlSampleUploader.kt | 9 +++++---- .../infra/sync/ImageSyncTimestampProvider.kt | 11 +++++------ 10 files changed, 33 insertions(+), 32 deletions(-) rename infra/core/src/main/java/com/simprints/core/tools/time/{Timer.kt => Ticker.kt} (89%) rename infra/core/src/main/java/com/simprints/core/tools/time/{TimerImpl.kt => TickerImpl.kt} (89%) rename infra/core/src/test/java/com/simprints/core/tools/time/{TimerImplTest.kt => TickerImplTest.kt} (88%) 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 7befdf891a..d58e9b23e5 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 @@ -18,6 +18,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController +import com.google.android.material.progressindicator.LinearProgressIndicator import com.simprints.core.livedata.LiveDataEventWithContentObserver import com.simprints.core.tools.utils.TimeUtils import com.simprints.feature.dashboard.R @@ -352,7 +353,7 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { private fun renderProgress( progress: SyncInfoProgress, - progressBar: com.google.android.material.progressindicator.LinearProgressIndicator, + progressBar: LinearProgressIndicator, textView: TextView, vararg itemNameResIDs: Int, ) { 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 056fcab00c..8e4a6a41d0 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 @@ -6,7 +6,7 @@ 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.TimeHelper -import com.simprints.core.tools.time.Timer +import com.simprints.core.tools.time.Ticker import com.simprints.core.tools.time.Timestamp import com.simprints.feature.dashboard.settings.syncinfo.SyncInfo import com.simprints.feature.dashboard.settings.syncinfo.SyncInfoError @@ -52,7 +52,7 @@ internal class ObserveSyncInfoUseCase @Inject constructor( syncOrchestrator: SyncOrchestrator, private val tokenizationProcessor: TokenizationProcessor, private val timeHelper: TimeHelper, - private val timer: Timer, + private val ticker: Ticker, private val appForegroundStateTracker: AppForegroundStateTracker, ) { private val eventSyncStateFlow = @@ -69,7 +69,7 @@ internal class ObserveSyncInfoUseCase @Inject constructor( configManager.observeProjectConfiguration(), configManager.observeDeviceConfiguration(), appForegroundStateTracker.observeAppInForeground().filter { it }, // only when going to foreground - timer.observeTickOncePerMinute(), + ticker.observeTickOncePerMinute(), ) { isOnline, isLoggedIn, isRefreshing, eventSyncState, imageSyncStatus, projectConfig, deviceConfig, _, _ -> val currentEvents = eventSyncState.progress?.coerceAtLeast(0) ?: 0 val totalEvents = eventSyncState.total?.takeIf { it >= 1 } ?: 0 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 db56b8fe22..e1c5160f95 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 @@ -7,7 +7,7 @@ import com.google.common.truth.Truth.assertThat import com.simprints.core.domain.tokenization.TokenizableString import com.simprints.core.lifecycle.AppForegroundStateTracker import com.simprints.core.tools.time.TimeHelper -import com.simprints.core.tools.time.Timer +import com.simprints.core.tools.time.Ticker import com.simprints.core.tools.time.Timestamp import com.simprints.feature.dashboard.settings.syncinfo.SyncInfoModuleCount import com.simprints.infra.authstore.AuthStore @@ -65,7 +65,7 @@ class ObserveSyncInfoUseCaseTest { private val syncOrchestrator = mockk() private val tokenizationProcessor = mockk() private val timeHelper = mockk() - private val timer = mockk() + private val ticker = mockk() private val appForegroundStateTracker = mockk() private lateinit var useCase: ObserveSyncInfoUseCase @@ -157,7 +157,7 @@ class ObserveSyncInfoUseCaseTest { coEvery { imageRepository.getNumberOfImagesToUpload(any()) } returns 0 coEvery { enrolmentRecordRepository.count(any()) } returns 0 - every { timer.observeTickOncePerMinute() } returns MutableStateFlow(Unit) + every { ticker.observeTickOncePerMinute() } returns MutableStateFlow(Unit) every { timeHelper.now() } returns TEST_TIMESTAMP every { timeHelper.msBetweenNowAndTime(any()) } returns 0L every { timeHelper.readableBetweenNowAndTime(any()) } returns "0 minutes ago" @@ -182,7 +182,7 @@ class ObserveSyncInfoUseCaseTest { syncOrchestrator = syncOrchestrator, tokenizationProcessor = tokenizationProcessor, timeHelper = timeHelper, - timer = timer, + ticker = ticker, appForegroundStateTracker = appForegroundStateTracker, ) } @@ -947,7 +947,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 { timer.observeTickOncePerMinute() } returns timePaceFlow.map { } + every { ticker.observeTickOncePerMinute() } returns timePaceFlow.map { } createUseCase() val initialResult = useCase().first() diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeCoreModule.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeCoreModule.kt index fd18172921..d7fedae943 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeCoreModule.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeCoreModule.kt @@ -14,7 +14,7 @@ import com.simprints.core.PackageVersionName import com.simprints.core.SessionCoroutineScope import com.simprints.core.tools.json.JsonHelper import com.simprints.core.tools.time.TimeHelper -import com.simprints.core.tools.time.Timer +import com.simprints.core.tools.time.Ticker import com.simprints.core.tools.utils.EncodingUtils import com.simprints.core.tools.utils.StringTokenizer import com.simprints.testtools.unit.EncodingUtilsImplForTests @@ -47,7 +47,7 @@ object FakeCoreModule { @Provides @Singleton - fun provideTimer(): Timer = mockk(relaxed = true) + fun provideTicker(): Ticker = mockk(relaxed = true) @Provides @Singleton 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 f4dc62f7dd..9d854f381f 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,8 @@ 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.Timer -import com.simprints.core.tools.time.TimerImpl +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 @@ -49,7 +49,7 @@ object CoreModule { @Provides @Singleton - fun provideTimer(): Timer = TimerImpl() + fun provideTicker(): Ticker = TickerImpl() @Provides @Singleton diff --git a/infra/core/src/main/java/com/simprints/core/tools/time/Timer.kt b/infra/core/src/main/java/com/simprints/core/tools/time/Ticker.kt similarity index 89% rename from infra/core/src/main/java/com/simprints/core/tools/time/Timer.kt rename to infra/core/src/main/java/com/simprints/core/tools/time/Ticker.kt index a0a8f02d0c..0a2802a16c 100644 --- a/infra/core/src/main/java/com/simprints/core/tools/time/Timer.kt +++ b/infra/core/src/main/java/com/simprints/core/tools/time/Ticker.kt @@ -4,6 +4,6 @@ import androidx.annotation.Keep import kotlinx.coroutines.flow.Flow @Keep -interface Timer { +interface Ticker { fun observeTickOncePerMinute(): Flow } diff --git a/infra/core/src/main/java/com/simprints/core/tools/time/TimerImpl.kt b/infra/core/src/main/java/com/simprints/core/tools/time/TickerImpl.kt similarity index 89% rename from infra/core/src/main/java/com/simprints/core/tools/time/TimerImpl.kt rename to infra/core/src/main/java/com/simprints/core/tools/time/TickerImpl.kt index 5ed6324601..7fb8b4eea5 100644 --- a/infra/core/src/main/java/com/simprints/core/tools/time/TimerImpl.kt +++ b/infra/core/src/main/java/com/simprints/core/tools/time/TickerImpl.kt @@ -5,7 +5,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import javax.inject.Inject -class TimerImpl @Inject constructor() : Timer { +class TickerImpl @Inject constructor() : Ticker { override fun observeTickOncePerMinute(): Flow = flow { while (true) { emit(Unit) diff --git a/infra/core/src/test/java/com/simprints/core/tools/time/TimerImplTest.kt b/infra/core/src/test/java/com/simprints/core/tools/time/TickerImplTest.kt similarity index 88% rename from infra/core/src/test/java/com/simprints/core/tools/time/TimerImplTest.kt rename to infra/core/src/test/java/com/simprints/core/tools/time/TickerImplTest.kt index fd624bc68f..0287fa191d 100644 --- a/infra/core/src/test/java/com/simprints/core/tools/time/TimerImplTest.kt +++ b/infra/core/src/test/java/com/simprints/core/tools/time/TickerImplTest.kt @@ -15,22 +15,22 @@ import org.junit.Before import org.junit.Rule import org.junit.Test -class TimerImplTest { +class TickerImplTest { @get:Rule val testCoroutineRule = TestCoroutineRule() - private lateinit var timerImpl: TimerImpl + private lateinit var tickerImpl: TickerImpl @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) - timerImpl = TimerImpl() + tickerImpl = TickerImpl() } @Test fun testObserveTickOncePerMinute_emitsImmediately() = runTest { - val result = timerImpl + val result = tickerImpl .observeTickOncePerMinute() .take(1) .toList() @@ -41,7 +41,7 @@ class TimerImplTest { @Test fun testObserveTickOncePerMinute_emitsMultipleTimes() = runTest { - val result = timerImpl + val result = tickerImpl .observeTickOncePerMinute() .take(3) .toList() @@ -52,7 +52,7 @@ class TimerImplTest { @OptIn(ExperimentalCoroutinesApi::class) @Test fun testObserveTickOncePerMinute_waitsForCorrectTime() = runTest { - val flow = timerImpl.observeTickOncePerMinute() + val flow = tickerImpl.observeTickOncePerMinute() // 1st tick immediately assertThat(flow.first()).isEqualTo(Unit) diff --git a/infra/images/src/main/java/com/simprints/infra/images/remote/signedurl/SignedUrlSampleUploader.kt b/infra/images/src/main/java/com/simprints/infra/images/remote/signedurl/SignedUrlSampleUploader.kt index dbddd2e223..9bbe229ed6 100644 --- a/infra/images/src/main/java/com/simprints/infra/images/remote/signedurl/SignedUrlSampleUploader.kt +++ b/infra/images/src/main/java/com/simprints/infra/images/remote/signedurl/SignedUrlSampleUploader.kt @@ -34,10 +34,11 @@ internal class SignedUrlSampleUploader @Inject constructor( val urlRequestScope = eventRepository.createEventScope(type = EventScopeType.SAMPLE_UP_SYNC) Simber.i("Starting image upload in batches of $batchSize (Scope ID: ${urlRequestScope.id}") - val sampleReferences = localDataSource - .listImages(projectId) var sampleIndex = 0 - val sampleReferenceBatches = sampleReferences + var samplesSize = 0 + val sampleReferenceBatches = localDataSource + .listImages(projectId) + .also { samplesSize = it.size } // Preparing the file for upload requires reading each of them to calculate md5 and size, // therefore splitting the list into batches before preparing allows to avoid some work in // cases where there are large amounts of files and the coroutine is being interrupted, @@ -81,7 +82,7 @@ internal class SignedUrlSampleUploader @Inject constructor( break } Simber.i("Uploading ${sample.sampleId}") - progressCallback?.invoke(sampleIndex++, sampleReferences.size) + progressCallback?.invoke(sampleIndex++, samplesSize) val url = sampleIdToUrlMap[sample.sampleId] if (url == null) { diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncTimestampProvider.kt b/infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncTimestampProvider.kt index 2a3a1e8fb7..1c90d1bd01 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncTimestampProvider.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncTimestampProvider.kt @@ -15,12 +15,11 @@ class ImageSyncTimestampProvider @Inject constructor( securePrefs.edit { putLong(IMAGE_SYNC_COMPLETION_TIME_MILLIS, timeHelper.now().ms) } } - fun getMillisSinceLastImageSync(): Long? = securePrefs - .getLong(IMAGE_SYNC_COMPLETION_TIME_MILLIS, 0) - .takeIf { - securePrefs.contains(IMAGE_SYNC_COMPLETION_TIME_MILLIS) - }?.let { lastSyncTimestamp -> - timeHelper.now().ms - lastSyncTimestamp + fun getMillisSinceLastImageSync(): Long? = + if (securePrefs.contains(IMAGE_SYNC_COMPLETION_TIME_MILLIS)) { + timeHelper.now().ms - securePrefs.getLong(IMAGE_SYNC_COMPLETION_TIME_MILLIS, 0) + } else { + null } fun getLastImageSyncTimestamp(): Long? = securePrefs From a76e2f32e6c9a2fd69975a1b596ff523530cc479 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 26 Aug 2025 13:07:01 +0100 Subject: [PATCH 42/45] MS-939 Sync button still enabled when CommCare permission is missing but CoSync up-sync possible --- .../usecase/ObserveSyncInfoUseCase.kt | 9 ++++++- .../usecase/ObserveSyncInfoUseCaseTest.kt | 25 +++++++++++++------ 2 files changed, 25 insertions(+), 9 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 8e4a6a41d0..94d5777b90 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 @@ -20,6 +20,7 @@ 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.canCoSyncData import com.simprints.infra.config.store.models.isCommCareEventDownSyncAllowed import com.simprints.infra.config.store.models.isModuleSelectionAvailable import com.simprints.infra.config.store.models.isSimprintsEventDownSyncAllowed @@ -169,6 +170,12 @@ internal class ObserveSyncInfoUseCase @Inject constructor( else -> OnStandby } + val isUpSyncStillAllowedWhenCommCareSyncBlockedByDeniedPermission = // quite a specific case hence the name + isCommCareSyncBlockedByDeniedPermission && projectConfig.canCoSyncData() + val isSyncButtonEnabled = + ((eventSyncVisibleSection == OnStandby) || isUpSyncStillAllowedWhenCommCareSyncBlockedByDeniedPermission) + && !isReLoginRequired + val projectId = authStore.signedInProjectId val recordsTotal = when { @@ -253,7 +260,7 @@ internal class ObserveSyncInfoUseCase @Inject constructor( isProgressVisible = eventSyncVisibleSection == InProgress, progress = eventSyncProgress, isSyncButtonVisible = !isPreLogoutUpSync || eventSyncState.isSyncFailed(), - isSyncButtonEnabled = eventSyncVisibleSection == OnStandby && !isReLoginRequired, + isSyncButtonEnabled = isSyncButtonEnabled, isSyncButtonForRetry = eventSyncState.isSyncFailed(), isFooterSyncInProgressVisible = isPreLogoutUpSync && isEventSyncInProgress, isFooterReadyToLogOutVisible = isPreLogoutUpSync && eventSyncState.isSyncCompleted() && !imageSyncStatus.isSyncing, 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 e1c5160f95..4a81ae802d 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 @@ -19,6 +19,8 @@ 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.canCoSyncData import com.simprints.infra.config.store.models.isCommCareEventDownSyncAllowed import com.simprints.infra.config.store.models.isModuleSelectionAvailable import com.simprints.infra.config.store.models.isSimprintsEventDownSyncAllowed @@ -85,6 +87,11 @@ class ObserveSyncInfoUseCaseTest { every { down } returns mockk(relaxed = true) { every { commCare } returns null } + every { up } returns mockk(relaxed = true) { + every { coSync } returns mockk(relaxed = true) { + every { kind } returns UpSynchronizationConfiguration.UpSynchronizationKind.NONE + } + } } } private val mockDeviceConfiguration = mockk(relaxed = true) { @@ -1003,7 +1010,7 @@ class ObserveSyncInfoUseCaseTest { } @Test - fun `should disable sync button when CommCare permission is missing`() = runTest { + fun `should enable sync button when CommCare permission is missing but co-sync up-sync possible`() = runTest { val mockFailedEventSyncState = mockk(relaxed = true) { every { isSyncFailedBecauseCommCarePermissionIsMissing() } returns true every { isSyncRunning() } returns false @@ -1011,27 +1018,29 @@ class ObserveSyncInfoUseCaseTest { every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockFailedEventSyncState) every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) every { mockProjectConfiguration.isCommCareEventDownSyncAllowed() } returns true + every { mockProjectConfiguration.canCoSyncData() } returns true createUseCase() val result = useCase().first() - assertThat(result.syncInfoSectionRecords.isSyncButtonEnabled).isFalse() + assertThat(result.syncInfoSectionRecords.isSyncButtonEnabled).isTrue() } @Test - fun `should allow sync when CommCare permission is granted and all conditions met`() = runTest { - val mockNormalEventSyncState = mockk(relaxed = true) { - every { isSyncFailedBecauseCommCarePermissionIsMissing() } returns false + fun `should disable sync button when CommCare permission is missing and co-sync up-sync impossible`() = runTest { + val mockFailedEventSyncState = mockk(relaxed = true) { + every { isSyncFailedBecauseCommCarePermissionIsMissing() } returns true every { isSyncRunning() } returns false - every { isSyncFailedBecauseReloginRequired() } returns false } - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockNormalEventSyncState) + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockFailedEventSyncState) every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) + every { mockProjectConfiguration.isCommCareEventDownSyncAllowed() } returns true + every { mockProjectConfiguration.canCoSyncData() } returns false createUseCase() val result = useCase().first() - assertThat(result.syncInfoSectionRecords.isSyncButtonEnabled).isTrue() + assertThat(result.syncInfoSectionRecords.isSyncButtonEnabled).isFalse() } @Test From 47803ae080eb65cf10f5c99b08840b15b13316bc Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 26 Aug 2025 13:20:51 +0100 Subject: [PATCH 43/45] MS-939 EventSyncVisibleSection explained in more detail as EventSyncVisibleState --- .../usecase/ObserveSyncInfoUseCase.kt | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 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 94d5777b90..62f11ae68f 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 @@ -161,7 +161,8 @@ internal class ObserveSyncInfoUseCase @Inject constructor( val isEventSyncConnectionBlocked = !isOnline && !isCommCareSyncExpected // CommCare would be able to sync even if device is offline - val eventSyncVisibleSection = when { + // an intermediate calculation of sync state shown in UI - not to be confused with the data layer-specific EventSyncState + val eventSyncVisibleState = when { isEventSyncInProgress -> InProgress isCommCareSyncBlockedByDeniedPermission -> CommCareError isModuleSelectionRequired -> NoModulesError @@ -173,7 +174,7 @@ internal class ObserveSyncInfoUseCase @Inject constructor( val isUpSyncStillAllowedWhenCommCareSyncBlockedByDeniedPermission = // quite a specific case hence the name isCommCareSyncBlockedByDeniedPermission && projectConfig.canCoSyncData() val isSyncButtonEnabled = - ((eventSyncVisibleSection == OnStandby) || isUpSyncStillAllowedWhenCommCareSyncBlockedByDeniedPermission) + ((eventSyncVisibleState == OnStandby) || isUpSyncStillAllowedWhenCommCareSyncBlockedByDeniedPermission) && !isReLoginRequired val projectId = authStore.signedInProjectId @@ -247,17 +248,17 @@ internal class ObserveSyncInfoUseCase @Inject constructor( counterRecordsToDownload = recordsToDownload?.let { "${it.count}${if (it.isLowerBound) "+" else ""}" }.orEmpty(), isCounterImagesToUploadVisible = isPreLogoutUpSync, counterImagesToUpload = imagesToUpload?.toString().orEmpty(), - isInstructionDefaultVisible = eventSyncVisibleSection == OnStandby, - isInstructionCommCarePermissionVisible = eventSyncVisibleSection == CommCareError, - isInstructionNoModulesVisible = eventSyncVisibleSection == NoModulesError, - isInstructionOfflineVisible = eventSyncVisibleSection == OfflineError, - isInstructionErrorVisible = eventSyncVisibleSection == Error, + isInstructionDefaultVisible = eventSyncVisibleState == OnStandby, + isInstructionCommCarePermissionVisible = eventSyncVisibleState == CommCareError, + isInstructionNoModulesVisible = eventSyncVisibleState == NoModulesError, + isInstructionOfflineVisible = eventSyncVisibleState == OfflineError, + isInstructionErrorVisible = eventSyncVisibleState == Error, instructionPopupErrorInfo = SyncInfoError( isBackendMaintenance = eventSyncState.isSyncFailedBecauseBackendMaintenance(), backendMaintenanceEstimatedOutage = eventSyncState.getEstimatedBackendMaintenanceOutage() ?: -1, isTooManyRequests = eventSyncState.isSyncFailedBecauseTooManyRequests(), ), - isProgressVisible = eventSyncVisibleSection == InProgress, + isProgressVisible = eventSyncVisibleState == InProgress, progress = eventSyncProgress, isSyncButtonVisible = !isPreLogoutUpSync || eventSyncState.isSyncFailed(), isSyncButtonEnabled = isSyncButtonEnabled, @@ -337,16 +338,21 @@ internal class ObserveSyncInfoUseCase @Inject constructor( } } -private sealed class EventSyncVisibleSection +/** + * A representation of a non-overlapping, exhaustive "sync state" as shown in UI. + * To be used in a temporary UI state calculation: good to be used with exhaustive pattern matching. + * Not to be confused with the data layer-specific EventSyncState. + */ +private sealed class EventSyncVisibleState -private object OnStandby : EventSyncVisibleSection() +private object OnStandby : EventSyncVisibleState() -private object InProgress : EventSyncVisibleSection() +private object InProgress : EventSyncVisibleState() -private object CommCareError : EventSyncVisibleSection() +private object CommCareError : EventSyncVisibleState() -private object NoModulesError : EventSyncVisibleSection() +private object NoModulesError : EventSyncVisibleState() -private object OfflineError : EventSyncVisibleSection() +private object OfflineError : EventSyncVisibleState() -private object Error : EventSyncVisibleSection() +private object Error : EventSyncVisibleState() From a8d254b798bad90fa8f86d770c3438f8605493e0 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 27 Aug 2025 09:03:57 +0100 Subject: [PATCH 44/45] MS-939 Sync button visibility logic update --- .../usecase/ObserveSyncInfoUseCase.kt | 13 +- .../usecase/ObserveSyncInfoUseCaseTest.kt | 164 +++++++++++++++--- 2 files changed, 147 insertions(+), 30 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 62f11ae68f..6f3c158d68 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 @@ -20,7 +20,7 @@ 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.canCoSyncData +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.isSimprintsEventDownSyncAllowed @@ -171,11 +171,14 @@ internal class ObserveSyncInfoUseCase @Inject constructor( else -> OnStandby } - val isUpSyncStillAllowedWhenCommCareSyncBlockedByDeniedPermission = // quite a specific case hence the name - isCommCareSyncBlockedByDeniedPermission && projectConfig.canCoSyncData() + val isEventUpSyncPossible = + projectConfig.canSyncDataToSimprints() && isOnline + val isDownSyncPossible = + (projectConfig.isSimprintsEventDownSyncAllowed() && isOnline && !isReLoginRequired) || + (projectConfig.isCommCareEventDownSyncAllowed() && !eventSyncState.isSyncFailedBecauseCommCarePermissionIsMissing()) val isSyncButtonEnabled = - ((eventSyncVisibleState == OnStandby) || isUpSyncStillAllowedWhenCommCareSyncBlockedByDeniedPermission) - && !isReLoginRequired + (eventSyncVisibleState == OnStandby) && + ((!isPreLogoutUpSync && isDownSyncPossible) || isEventUpSyncPossible) val projectId = authStore.signedInProjectId 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 4a81ae802d..25691d8a13 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 @@ -20,7 +20,7 @@ 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.canCoSyncData +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.isSimprintsEventDownSyncAllowed @@ -77,22 +77,26 @@ class ObserveSyncInfoUseCaseTest { const val TEST_USER_ID = "test_user_id" const val TEST_MODULE_NAME = "test_module" val TEST_TIMESTAMP = Timestamp(1000L) + + fun createMockSynchronizationConfiguration(): SynchronizationConfiguration { + return mockk(relaxed = true) { + every { down } returns mockk(relaxed = true) { + every { commCare } returns null + } + every { up } returns mockk(relaxed = true) { + every { coSync } returns mockk(relaxed = true) { + every { kind } returns UpSynchronizationConfiguration.UpSynchronizationKind.NONE + } + } + } + } } private val mockProjectConfiguration = mockk(relaxed = true) { every { general } returns mockk(relaxed = true) { every { modalities } returns emptyList() } - every { synchronization } returns mockk(relaxed = true) { - every { down } returns mockk(relaxed = true) { - every { commCare } returns null - } - every { up } returns mockk(relaxed = true) { - every { coSync } returns mockk(relaxed = true) { - every { kind } returns UpSynchronizationConfiguration.UpSynchronizationKind.NONE - } - } - } + every { synchronization } returns createMockSynchronizationConfiguration() } private val mockDeviceConfiguration = mockk(relaxed = true) { every { selectedModules } returns emptyList() @@ -372,6 +376,7 @@ class ObserveSyncInfoUseCaseTest { every { general } returns mockk { every { modalities } returns listOf(GeneralConfiguration.Modality.FINGERPRINT) } + every { synchronization } returns createMockSynchronizationConfiguration() } val mockDeviceConfigWithModules = mockk { every { selectedModules } returns listOf(TokenizableString.Raw(TEST_MODULE_NAME)) @@ -601,6 +606,7 @@ class ObserveSyncInfoUseCaseTest { every { general } returns mockk { every { modalities } returns listOf(GeneralConfiguration.Modality.FINGERPRINT) } + every { synchronization } returns createMockSynchronizationConfiguration() } val mockDeviceConfigWithModules = mockk { every { selectedModules } returns listOf(TokenizableString.Raw("module_1"), TokenizableString.Raw("module_2")) @@ -636,6 +642,7 @@ class ObserveSyncInfoUseCaseTest { every { general } returns mockk { every { modalities } returns emptyList() } + every { synchronization } returns createMockSynchronizationConfiguration() } val mockDeviceConfigWithoutModules = mockk { every { selectedModules } returns emptyList() @@ -659,6 +666,7 @@ class ObserveSyncInfoUseCaseTest { every { general } returns mockk { every { modalities } returns emptyList() } + every { synchronization } returns createMockSynchronizationConfiguration() } val mockIdleEventSyncState = mockk(relaxed = true) { every { isSyncInProgress() } returns false @@ -698,6 +706,7 @@ class ObserveSyncInfoUseCaseTest { every { general } returns mockk { every { modalities } returns emptyList() } + every { synchronization } returns createMockSynchronizationConfiguration() } val mockIdleEventSyncState = mockk(relaxed = true) { every { isSyncInProgress() } returns false @@ -722,6 +731,7 @@ class ObserveSyncInfoUseCaseTest { every { general } returns mockk { every { modalities } returns emptyList() } + every { synchronization } returns createMockSynchronizationConfiguration() } val mockIdleEventSyncState = mockk(relaxed = true) { every { isSyncInProgress() } returns false @@ -902,6 +912,7 @@ class ObserveSyncInfoUseCaseTest { every { general } returns mockk { every { modalities } returns listOf(GeneralConfiguration.Modality.FINGERPRINT) } + every { synchronization } returns createMockSynchronizationConfiguration() } every { mockConfigWithModules.isModuleSelectionAvailable() } returns true projectConfigFlow.value = mockConfigWithModules // now with modules @@ -918,6 +929,13 @@ class ObserveSyncInfoUseCaseTest { 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( @@ -1010,15 +1028,53 @@ class ObserveSyncInfoUseCaseTest { } @Test - fun `should enable sync button when CommCare permission is missing but co-sync up-sync possible`() = runTest { - val mockFailedEventSyncState = mockk(relaxed = true) { - every { isSyncFailedBecauseCommCarePermissionIsMissing() } returns true - every { isSyncRunning() } returns false + fun `sync button should be disabled when not on standby`() = runTest { + val mockSyncingEventSyncState = mockk(relaxed = true) { + every { isSyncInProgress() } returns true } - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockFailedEventSyncState) + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockSyncingEventSyncState) + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.isSyncButtonEnabled).isFalse() + } + + @Test + fun `sync button should be disabled when this is logout screen and offline`() = runTest { + every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(false) + createUseCase() + + val result = useCase(isPreLogoutUpSync = true).first() + + assertThat(result.syncInfoSectionRecords.isSyncButtonEnabled).isFalse() + } + + @Test + fun `sync button should be disabled when this is logout screen and no sync to Simprints`() = runTest { + every { any().canSyncDataToSimprints() } returns false + createUseCase() + + val result = useCase(isPreLogoutUpSync = true).first() + + assertThat(result.syncInfoSectionRecords.isSyncButtonEnabled).isFalse() + } + + @Test + fun `sync button should be enabled when online and there is sync to Simprints`() = runTest { every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) - every { mockProjectConfiguration.isCommCareEventDownSyncAllowed() } returns true - every { mockProjectConfiguration.canCoSyncData() } returns true + every { any().canSyncDataToSimprints() } returns true + createUseCase() + + val result = useCase().first() // here and on for the sync button state: assuming not the logout screen + + assertThat(result.syncInfoSectionRecords.isSyncButtonEnabled).isTrue() + } + + @Test + fun `sync button should be enabled when offline but CommCare down-sync allowed`() = runTest { + every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(false) + every { any().isCommCareEventDownSyncAllowed() } returns true createUseCase() val result = useCase().first() @@ -1027,15 +1083,70 @@ class ObserveSyncInfoUseCaseTest { } @Test - fun `should disable sync button when CommCare permission is missing and co-sync up-sync impossible`() = runTest { - val mockFailedEventSyncState = mockk(relaxed = true) { + fun `sync button should be enabled when Simprints down-sync allowed and re-login not required`() = runTest { + val mockNormalEventSyncState = mockk(relaxed = true) { + every { isSyncFailedBecauseReloginRequired() } returns false + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockNormalEventSyncState) + every { any().isSimprintsEventDownSyncAllowed() } returns true + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.isSyncButtonEnabled).isTrue() + } + + @Test + fun `sync button should be enabled when CommCare down-sync allowed and no CommCare permission error`() = runTest { + val mockNormalEventSyncState = mockk(relaxed = true) { + every { isSyncFailedBecauseCommCarePermissionIsMissing() } returns false + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockNormalEventSyncState) + every { any().isCommCareEventDownSyncAllowed() } returns true + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.isSyncButtonEnabled).isTrue() + } + + @Test + fun `sync button should be disabled when there is neither Simprints nor ComCare down-sync`() = runTest { + every { any().isSimprintsEventDownSyncAllowed() } returns false + every { any().isCommCareEventDownSyncAllowed() } returns false + every { any().canSyncDataToSimprints() } returns false + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.isSyncButtonEnabled).isFalse() + } + + @Test + fun `sync button should be disabled when only Simprints down-sync allowed but re-login required`() = runTest { + val mockReLoginRequiredEventSyncState = mockk(relaxed = true) { + every { isSyncFailedBecauseReloginRequired() } returns true + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockReLoginRequiredEventSyncState) + every { any().isSimprintsEventDownSyncAllowed() } returns true + every { any().isCommCareEventDownSyncAllowed() } returns false + every { any().canSyncDataToSimprints() } returns false + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.isSyncButtonEnabled).isFalse() + } + + @Test + fun `sync button should be disabled when only CommCare down-sync allowed but there is CoSync permission error`() = runTest { + val mockCommCarePermissionErrorEventSyncState = mockk(relaxed = true) { every { isSyncFailedBecauseCommCarePermissionIsMissing() } returns true - every { isSyncRunning() } returns false } - every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockFailedEventSyncState) - every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) - every { mockProjectConfiguration.isCommCareEventDownSyncAllowed() } returns true - every { mockProjectConfiguration.canCoSyncData() } returns false + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCommCarePermissionErrorEventSyncState) + every { any().isCommCareEventDownSyncAllowed() } returns true + every { any().isSimprintsEventDownSyncAllowed() } returns false + every { any().canSyncDataToSimprints() } returns false createUseCase() val result = useCase().first() @@ -1150,6 +1261,7 @@ class ObserveSyncInfoUseCaseTest { every { general } returns mockk { every { modalities } returns listOf(GeneralConfiguration.Modality.FINGERPRINT) } + every { synchronization } returns createMockSynchronizationConfiguration() } val mockEmptyDeviceConfig = mockk { every { selectedModules } returns emptyList() @@ -1257,6 +1369,7 @@ class ObserveSyncInfoUseCaseTest { every { general } returns mockk { every { modalities } returns listOf(GeneralConfiguration.Modality.FINGERPRINT) } + every { synchronization } returns createMockSynchronizationConfiguration() } val mockDeviceConfigWithTokenizedModules = mockk { every { selectedModules } returns listOf(tokenizedModule) @@ -1287,6 +1400,7 @@ class ObserveSyncInfoUseCaseTest { every { general } returns mockk { every { modalities } returns listOf(GeneralConfiguration.Modality.FINGERPRINT) } + every { synchronization } returns createMockSynchronizationConfiguration() } val mockDeviceConfigWithRawModules = mockk { every { selectedModules } returns listOf(rawModule) From ae570e48b76fcca61e1b9a82cb08a87b3bead446 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 27 Aug 2025 18:56:39 +0100 Subject: [PATCH 45/45] MS-939 Sync button visibility logic update tests cleanup --- .../syncinfo/usecase/ObserveSyncInfoUseCaseTest.kt | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) 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 25691d8a13..f482ee39cb 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 @@ -1050,16 +1050,6 @@ class ObserveSyncInfoUseCaseTest { assertThat(result.syncInfoSectionRecords.isSyncButtonEnabled).isFalse() } - @Test - fun `sync button should be disabled when this is logout screen and no sync to Simprints`() = runTest { - every { any().canSyncDataToSimprints() } returns false - createUseCase() - - val result = useCase(isPreLogoutUpSync = true).first() - - assertThat(result.syncInfoSectionRecords.isSyncButtonEnabled).isFalse() - } - @Test fun `sync button should be enabled when online and there is sync to Simprints`() = runTest { every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(true) @@ -1139,7 +1129,7 @@ class ObserveSyncInfoUseCaseTest { } @Test - fun `sync button should be disabled when only CommCare down-sync allowed but there is CoSync permission error`() = runTest { + fun `sync button should be disabled when only CommCare down-sync allowed but there is CommCare permission error`() = runTest { val mockCommCarePermissionErrorEventSyncState = mockk(relaxed = true) { every { isSyncFailedBecauseCommCarePermissionIsMissing() } returns true }