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 951b3b9a8f..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 @@ -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) } @@ -140,8 +145,15 @@ 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) { + // 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 + } + + val isDownSyncAllowed = !isPreLogoutUpSync && projectState == ProjectState.RUNNING syncOrchestrator.startEventSync(isDownSyncAllowed) } } @@ -159,9 +171,7 @@ internal class SyncInfoViewModel @Inject constructor( } fun performLogout() { - viewModelScope.launch { - logoutUseCase() - } + viewModelScope.launch { logoutUseCase() } } fun requestNavigationToLogin() { @@ -230,7 +240,7 @@ internal class SyncInfoViewModel @Inject constructor( isProgressVisible = true, isSyncButtonEnabled = false, footerLastSyncMinutesAgo = "", - ) + ), ) private fun SyncInfo.forceImageSyncProgress() = copy( @@ -240,7 +250,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 978b708b8d..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 @@ -233,23 +233,33 @@ internal class ObserveSyncInfoUseCase @Inject constructor( else -> DownSyncCounts(0, isLowerBound = false) } - val project = configManager.getProject(projectId) - 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), - ), - ) + val project = try { + projectId.takeUnless { it.isBlank() }?.let { configManager.getProject(it) } + } catch (_: Exception) { + // 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 + } + + val isProjectRunning = project?.state == ProjectState.RUNNING + val moduleCounts = if (project != null) { + deviceConfig.selectedModules.map { moduleName -> + ModuleCount( + name = when (moduleName) { + is TokenizableString.Raw -> moduleName + is TokenizableString.Tokenized -> tokenizationProcessor.decrypt( + encrypted = moduleName, + tokenKeyType = TokenKeyType.ModuleId, + project, + ) + }.value, + count = enrolmentRecordRepository.count( + SubjectQuery(projectId = projectId, moduleId = moduleName), + ), + ) + } + } else { + emptyList() } val modulesCountTotal = SyncInfoModuleCount( isTotal = true, 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 19728ace50..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 @@ -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 @@ -34,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() @@ -221,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) { @@ -248,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) { @@ -277,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) { @@ -303,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) { @@ -330,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) { @@ -408,6 +425,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()