From 4c69e866ef89aef88eb3896e07568df983164825 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Wed, 10 Sep 2025 16:46:13 +0300 Subject: [PATCH 1/3] MS-1158 Handle missing project in sync info use case --- .../settings/syncinfo/SyncInfoViewModel.kt | 12 +++++- .../usecase/ObserveSyncInfoUseCase.kt | 40 +++++++++++-------- .../syncinfo/SyncInfoViewModelTest.kt | 16 ++++++++ 3 files changed, 50 insertions(+), 18 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 951b3b9a8f..69143b69fe 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 @@ -140,8 +140,16 @@ internal class SyncInfoViewModel @Inject constructor( eventSyncButtonClickFlow.emit(Unit) } syncOrchestrator.stopEventSync() - val isDownSyncAllowed = - !isPreLogoutUpSync && configManager.getProject(authStore.signedInProjectId).state == ProjectState.RUNNING + val projectState = try { + configManager.getProject(authStore.signedInProjectId).state + } catch (_: Exception) { + // 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. + ProjectState.PROJECT_ENDED + } + + val isDownSyncAllowed = !isPreLogoutUpSync && projectState == ProjectState.RUNNING syncOrchestrator.startEventSync(isDownSyncAllowed) } } 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 978b708b8d..acc2ef8ba0 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 @@ -233,23 +233,31 @@ internal class ObserveSyncInfoUseCase @Inject constructor( else -> DownSyncCounts(0, isLowerBound = false) } - val project = configManager.getProject(projectId) + val project = try { + configManager.getProject(projectId) + } catch (e: Exception) { + null + } val isProjectRunning = - project.state == ProjectState.RUNNING - 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), - ), - ) + project?.state == ProjectState.RUNNING + val moduleCounts = if (project != null) { + deviceConfig.selectedModules.map { moduleName -> + ModuleCount( + name = when (moduleName) { + is TokenizableString.Raw -> moduleName + is TokenizableString.Tokenized -> tokenizationProcessor.decrypt( + encrypted = moduleName, + tokenKeyType = TokenKeyType.ModuleId, + project, + ) + }.value, + count = enrolmentRecordRepository.count( + SubjectQuery(projectId = projectId, moduleId = moduleName), + ), + ) + } + } else { + emptyList() } val modulesCountTotal = SyncInfoModuleCount( isTotal = true, 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 19728ace50..2676e2b2d3 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 @@ -11,6 +11,7 @@ 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.authstore.exceptions.RemoteDbNotSignedInException import com.simprints.infra.config.store.models.DeviceConfiguration import com.simprints.infra.config.store.models.GeneralConfiguration import com.simprints.infra.config.store.models.Project @@ -408,6 +409,21 @@ class SyncInfoViewModelTest { coVerify { syncOrchestrator.startEventSync(isDownSyncAllowed = false) } } + @Test + fun `should start event sync with down sync disabled event sync when logged out`() = runTest { + val mockEndingProject = mockk { + every { state } throws RemoteDbNotSignedInException("stub!") + } + 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() From 326f8d9f9b0a9a81ec49331e23f827acb37bbe98 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Thu, 11 Sep 2025 13:10:41 +0300 Subject: [PATCH 2/3] MS-1158 Restore logout from compromised devices on dashboard screen --- .../dashboard/settings/syncinfo/SyncInfo.kt | 5 ++ .../settings/syncinfo/SyncInfoFragment.kt | 17 +++++- .../settings/syncinfo/SyncInfoViewModel.kt | 61 ++++++++++--------- .../usecase/ObserveSyncInfoUseCase.kt | 11 ++-- .../main/res/navigation/graph_dashboard.xml | 11 ++-- .../syncinfo/SyncInfoViewModelTest.kt | 26 ++++++-- 6 files changed, 87 insertions(+), 44 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 f506c1d115..551b691e8e 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 @@ -84,3 +84,8 @@ data class SyncInfoModuleCount( val name: String, val count: String = "", ) + +enum class LogoutActionReason { + USER_ACTION, + PROJECT_ENDING_OR_DEVICE_COMPROMISED, +} diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoFragment.kt index c68d6989ac..2f0794ab6b 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 @@ -22,11 +22,14 @@ import com.google.android.material.progressindicator.LinearProgressIndicator 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.requestlogin.LogoutReason +import com.simprints.feature.dashboard.requestlogin.RequestLoginFragmentArgs import com.simprints.feature.dashboard.settings.syncinfo.modulecount.ModuleCount import com.simprints.feature.dashboard.settings.syncinfo.modulecount.ModuleCountAdapter import com.simprints.feature.dashboard.view.ConfigurableSyncInfoFragmentContainer import com.simprints.feature.login.LoginContract 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.view.applySystemBarInsets import com.simprints.infra.uibase.view.setPulseAnimation @@ -138,8 +141,20 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.RESUMED) { - viewModel.logoutEventFlow.collect { + viewModel.logoutEventFlow.collect { reason -> viewModel.performLogout() + + val logoutReason = reason?.takeIf { it == LogoutActionReason.PROJECT_ENDING_OR_DEVICE_COMPROMISED }?.let { + 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_to_requestLoginFragment, + RequestLoginFragmentArgs(logoutReason = logoutReason).toBundle(), + ) } } } 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 69143b69fe..72c46faf86 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,6 +1,7 @@ package com.simprints.feature.dashboard.settings.syncinfo import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.asFlow @@ -8,6 +9,7 @@ import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import com.simprints.core.DispatcherIO import com.simprints.core.livedata.LiveDataEventWithContent +import com.simprints.core.livedata.send import com.simprints.core.tools.time.TimeHelper import com.simprints.feature.dashboard.logout.usecase.LogoutUseCase import com.simprints.feature.dashboard.settings.syncinfo.usecase.ObserveSyncInfoUseCase @@ -67,19 +69,19 @@ internal class SyncInfoViewModel @Inject constructor( private val eventSyncButtonClickFlow = MutableSharedFlow(extraBufferCapacity = 1) private val imageSyncButtonClickFlow = MutableSharedFlow(extraBufferCapacity = 1) - val logoutEventFlow: Flow> = combine( + val logoutEventFlow: Flow = combine( + authStore.observeSignedInProjectId(), eventSyncStateFlow, imageSyncStatusFlow, - ) { eventSyncState, imageSyncStatus -> - val isReadyToLogOut = - isPreLogoutUpSync && eventSyncState.isSyncCompleted() && !imageSyncStatus.isSyncing - return@combine isReadyToLogOut + ) { projectId, eventSyncState, imageSyncStatus -> + when { + projectId.isEmpty() -> LogoutActionReason.PROJECT_ENDING_OR_DEVICE_COMPROMISED + isPreLogoutUpSync && eventSyncState.isSyncCompleted() && !imageSyncStatus.isSyncing -> LogoutActionReason.USER_ACTION + else -> null + } }.debounce(LOGOUT_DELAY_MILLIS) - .filter { isReadyToLogOut -> - isReadyToLogOut // only when ready - }.map { - LiveDataEventWithContent(Unit) - }.flowOn(ioDispatcher) + .filter { it != null } + .flowOn(ioDispatcher) val syncInfoLiveData: LiveData by lazy { val dataLayerDrivenSyncInfoFlow = observeSyncInfo(isPreLogoutUpSync) @@ -88,7 +90,7 @@ internal class SyncInfoViewModel @Inject constructor( syncImagesAfterEventsWhenRequired() } - /** + /* * Visual sync button responsiveness optimization * * The problem: data layer-driven progress visualization is simple programmatically, but can be slow in the UI. @@ -107,21 +109,23 @@ internal class SyncInfoViewModel @Inject constructor( */ val eventSyncButtonResponsiveSyncInfo = eventSyncButtonClickFlow.flatMapLatest { - dataLayerDrivenSyncInfoFlow.dropWhile { syncInfo -> - !syncInfo.syncInfoSectionRecords.isProgressVisible - }.onStart { - val initialState = syncInfoLiveData.value ?: SyncInfo() - emit(initialState.forceEventSyncProgress()) - } + dataLayerDrivenSyncInfoFlow + .dropWhile { syncInfo -> + !syncInfo.syncInfoSectionRecords.isProgressVisible + }.onStart { + val initialState = syncInfoLiveData.value ?: SyncInfo() + emit(initialState.forceEventSyncProgress()) + } } val imageSyncButtonResponsiveSyncInfo = imageSyncButtonClickFlow.flatMapLatest { - dataLayerDrivenSyncInfoFlow.dropWhile { syncInfo -> - !syncInfo.syncInfoSectionImages.isProgressVisible - }.onStart { - val initialState = syncInfoLiveData.value ?: SyncInfo() - emit(initialState.forceImageSyncProgress()) - } + dataLayerDrivenSyncInfoFlow + .dropWhile { syncInfo -> + !syncInfo.syncInfoSectionImages.isProgressVisible + }.onStart { + val initialState = syncInfoLiveData.value ?: SyncInfo() + emit(initialState.forceImageSyncProgress()) + } } merge( @@ -129,7 +133,8 @@ internal class SyncInfoViewModel @Inject constructor( imageSyncButtonResponsiveSyncInfo, ).onStart { emit(dataLayerDrivenSyncInfoFlow.firstOrNull() ?: SyncInfo()) - }.distinctUntilChanged().flowOn(ioDispatcher) + }.distinctUntilChanged() + .flowOn(ioDispatcher) .asLiveData(viewModelScope.coroutineContext) } @@ -167,9 +172,7 @@ internal class SyncInfoViewModel @Inject constructor( } fun performLogout() { - viewModelScope.launch { - logoutUseCase() - } + viewModelScope.launch { logoutUseCase() } } fun requestNavigationToLogin() { @@ -238,7 +241,7 @@ internal class SyncInfoViewModel @Inject constructor( isProgressVisible = true, isSyncButtonEnabled = false, footerLastSyncMinutesAgo = "", - ) + ), ) private fun SyncInfo.forceImageSyncProgress() = copy( @@ -248,7 +251,7 @@ internal class SyncInfoViewModel @Inject constructor( isInstructionOfflineVisible = false, isProgressVisible = true, footerLastSyncMinutesAgo = "", - ) + ), ) private suspend fun ConfigManager.isModuleSelectionRequired() = 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 acc2ef8ba0..13d2740dd6 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 @@ -234,12 +234,15 @@ internal class ObserveSyncInfoUseCase @Inject constructor( } val project = try { - configManager.getProject(projectId) - } catch (e: Exception) { + projectId.takeUnless { it.isBlank() }?.let { configManager.getProject(it) } + } catch (_: Exception) { + // 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. null } - val isProjectRunning = - project?.state == ProjectState.RUNNING + + val isProjectRunning = project?.state == ProjectState.RUNNING val moduleCounts = if (project != null) { deviceConfig.selectedModules.map { moduleName -> ModuleCount( diff --git a/feature/dashboard/src/main/res/navigation/graph_dashboard.xml b/feature/dashboard/src/main/res/navigation/graph_dashboard.xml index 5ad73c5e46..0cbfa21964 100644 --- a/feature/dashboard/src/main/res/navigation/graph_dashboard.xml +++ b/feature/dashboard/src/main/res/navigation/graph_dashboard.xml @@ -61,11 +61,6 @@ - @@ -171,4 +166,10 @@ + + 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 2676e2b2d3..1098b55c23 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 @@ -35,12 +35,14 @@ import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test import kotlin.test.fail +@OptIn(ExperimentalCoroutinesApi::class) class SyncInfoViewModelTest { @get:Rule val rule = InstantTaskExecutorRule() @@ -222,7 +224,6 @@ class SyncInfoViewModelTest { // LiveData logoutEventLiveData tests - @OptIn(ExperimentalCoroutinesApi::class) @Test fun `should trigger logout when pre-logout sync completes successfully`() = runTest { val mockCompletedEventSyncState = mockk(relaxed = true) { @@ -249,7 +250,6 @@ class SyncInfoViewModelTest { flowCollector.cancel() } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun `should emit a logout event after the intended delay since ready to logout`() = runTest { val mockCompletedEventSyncState = mockk(relaxed = true) { @@ -278,7 +278,25 @@ class SyncInfoViewModelTest { flowCollector.cancel() } - @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `should emit a logout event when auth store is cleared`() = runTest { + val projectIdFlow = MutableStateFlow(TEST_PROJECT_ID) + every { authStore.observeSignedInProjectId() } returns projectIdFlow + createViewModel() + + var numberOfEmissions = 0 + val flowCollector = async { + viewModel.logoutEventFlow.collect { + numberOfEmissions++ + } + } + projectIdFlow.value = "" + advanceUntilIdle() + + assertThat(numberOfEmissions).isEqualTo(1) + flowCollector.cancel() + } + @Test fun `should not trigger logout when not in pre-logout mode`() = runTest { val mockCompletedEventSyncState = mockk(relaxed = true) { @@ -304,7 +322,6 @@ class SyncInfoViewModelTest { flowCollector.cancel() } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun `should not trigger logout when records still syncing`() = runTest { val mockInProgressEventSyncState = mockk(relaxed = true) { @@ -331,7 +348,6 @@ class SyncInfoViewModelTest { flowCollector.cancel() } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun `should not trigger logout when images still syncing`() = runTest { val mockCompletedEventSyncState = mockk(relaxed = true) { From e2e7841111de9fcbf4f96394848acc9862cf0787 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Thu, 18 Sep 2025 09:17:25 +0300 Subject: [PATCH 3/3] MS-1158 Reword comment about project data being inaccessible --- .../feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt | 5 ++--- .../settings/syncinfo/usecase/ObserveSyncInfoUseCase.kt | 5 ++--- 2 files changed, 4 insertions(+), 6 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 72c46faf86..019e02642b 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 @@ -148,9 +148,8 @@ internal class SyncInfoViewModel @Inject constructor( val projectState = try { configManager.getProject(authStore.signedInProjectId).state } catch (_: Exception) { - // 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. + // If the device is compromised, project data is deleted. Access attempts will throw an exception, + // effectively appearing to the user as if the project has ended. ProjectState.PROJECT_ENDED } 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 13d2740dd6..d3b21473b1 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 @@ -236,9 +236,8 @@ internal class ObserveSyncInfoUseCase @Inject constructor( val project = try { projectId.takeUnless { it.isBlank() }?.let { configManager.getProject(it) } } catch (_: Exception) { - // 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. + // If the device is compromised, project data is deleted. Access attempts will throw an exception, + // effectively appearing to the user as if the project has ended. null }