Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,8 @@ data class SyncInfoModuleCount(
val name: String,
val count: String = "",
)

enum class LogoutActionReason {
USER_ACTION,
PROJECT_ENDING_OR_DEVICE_COMPROMISED,
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
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
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
Expand Down Expand Up @@ -67,19 +69,19 @@ internal class SyncInfoViewModel @Inject constructor(
private val eventSyncButtonClickFlow = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
private val imageSyncButtonClickFlow = MutableSharedFlow<Unit>(extraBufferCapacity = 1)

val logoutEventFlow: Flow<LiveDataEventWithContent<Unit>> = combine(
val logoutEventFlow: Flow<LogoutActionReason?> = 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<SyncInfo> by lazy {
val dataLayerDrivenSyncInfoFlow = observeSyncInfo(isPreLogoutUpSync)
Expand All @@ -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.
Expand All @@ -107,29 +109,32 @@ 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(
eventSyncButtonResponsiveSyncInfo,
imageSyncButtonResponsiveSyncInfo,
).onStart {
emit(dataLayerDrivenSyncInfoFlow.firstOrNull() ?: SyncInfo())
}.distinctUntilChanged().flowOn(ioDispatcher)
}.distinctUntilChanged()
.flowOn(ioDispatcher)
.asLiveData(viewModelScope.coroutineContext)
}

Expand All @@ -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)
}
}
Expand All @@ -159,9 +171,7 @@ internal class SyncInfoViewModel @Inject constructor(
}

fun performLogout() {
viewModelScope.launch {
logoutUseCase()
}
viewModelScope.launch { logoutUseCase() }
}

fun requestNavigationToLogin() {
Expand Down Expand Up @@ -230,7 +240,7 @@ internal class SyncInfoViewModel @Inject constructor(
isProgressVisible = true,
isSyncButtonEnabled = false,
footerLastSyncMinutesAgo = "",
)
),
)

private fun SyncInfo.forceImageSyncProgress() = copy(
Expand All @@ -240,7 +250,7 @@ internal class SyncInfoViewModel @Inject constructor(
isInstructionOfflineVisible = false,
isProgressVisible = true,
footerLastSyncMinutesAgo = "",
)
),
)

private suspend fun ConfigManager.isModuleSelectionRequired() =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 6 additions & 5 deletions feature/dashboard/src/main/res/navigation/graph_dashboard.xml
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,6 @@
<action
android:id="@+id/action_mainFragment_to_moduleSelectionFragment"
app:destination="@id/moduleSelectionFragment" />
<action
android:id="@+id/action_mainFragment_to_requestLoginFragment"
app:destination="@id/requestLoginFragment"
app:popUpTo="@id/dashboard_navigation"
app:popUpToInclusive="true" />
<action
android:id="@+id/action_mainFragment_to_privacyNoticesFragment"
app:destination="@id/graph_privacy" />
Expand Down Expand Up @@ -171,4 +166,10 @@
</fragment>
</navigation>

<action
android:id="@+id/action_to_requestLoginFragment"
app:destination="@id/requestLoginFragment"
app:popUpTo="@id/dashboard_navigation"
app:popUpToInclusive="true" />

</navigation>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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<EventSyncState>(relaxed = true) {
Expand All @@ -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<EventSyncState>(relaxed = true) {
Expand Down Expand Up @@ -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<EventSyncState>(relaxed = true) {
Expand All @@ -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<EventSyncState>(relaxed = true) {
Expand All @@ -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<EventSyncState>(relaxed = true) {
Expand Down Expand Up @@ -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<Project> {
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()
Expand Down