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..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 @@ -2,20 +2,49 @@ 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.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 + .observeSignedInProjectId() + .filter { projectId -> + projectId.isEmpty() + }.distinctUntilChanged() + .map { /* Unit on every "true" */ } + .asLiveData() + + val isLogoutWithoutSyncVisibleLiveData: LiveData = combine( + eventSyncManager.getLastSyncState(useDefaultValue = true).asFlow(), + syncOrchestrator.observeImageSyncStatus(), + ) { eventSyncState, imageSyncStatus -> + !eventSyncState.isSyncCompleted() || imageSyncStatus.isSyncing + }.debounce(timeoutMillis = ANTI_JITTER_DELAY_MILLIS).asLiveData() + val settingsLocked: LiveData> get() = liveData(context = viewModelScope.coroutineContext) { emit(LiveDataEventWithContent(configManager.getProjectConfiguration().general.settingsPassword)) @@ -24,4 +53,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..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 @@ -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,22 @@ 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/main/sync/SyncFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/main/sync/SyncFragment.kt deleted file mode 100644 index 8c5d20c8a5..0000000000 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/main/sync/SyncFragment.kt +++ /dev/null @@ -1,101 +0,0 @@ -package com.simprints.feature.dashboard.main.sync - -import android.content.Intent -import android.net.Uri -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) } - } - - override fun onResume() { - super.onResume() - viewModel.onResume() - } - - 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() } - onSettingsButtonClick = { - val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { - data = Uri.fromParts("package", context.packageName, null) - } - startActivity(intent) - } - } - - private fun observeLiveData() { - viewModel.isAnySyncAllowed.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 be1d520ae2..0000000000 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/main/sync/SyncViewModel.kt +++ /dev/null @@ -1,297 +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.SyncFailedCommCarePermissionMissing -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.isCommCareEventDownSyncAllowed -import com.simprints.infra.config.store.models.isSimprintsEventDownSyncAllowed -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 isAnySyncAllowed: LiveData - get() = _isAnySyncAllowed - private val _isAnySyncAllowed = 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 onResume() { - // If last state was failed because CommCare permission is missing - - // try to sync again to check if it was given - if (syncCardLiveData.value is SyncFailedCommCarePermissionMissing) { - sync() - } - } - - 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 -> - _isAnySyncAllowed.postValue( - configuration.canSyncDataToSimprints() || - configuration.isSimprintsEventDownSyncAllowed() || - configuration.isCommCareEventDownSyncAllowed() - ) - } - 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.isSyncFailedBecauseCommCarePermissionIsMissing() -> SyncFailedCommCarePermissionMissing( - lastTimeSyncSucceed(), - ) - - syncState.isSyncFailed() -> SyncTryAgain(lastTimeSyncSucceed()) - else -> SyncProgress(lastTimeSyncSucceed(), syncState.progress, syncState.total) - } - - private suspend fun isModuleSelectionRequired() = isSimprintsDownSyncAllowed() && isSelectedModulesEmpty() && isModuleSync() - - // Simprints downsync is allowed if down.simprints is not null and its frequency is not ONLY_PERIODICALLY_UP_SYNC - private suspend fun isSimprintsDownSyncAllowed() = configManager - .getProjectConfiguration() - .synchronization.down.simprints?.let { - it.frequency != Frequency.ONLY_PERIODICALLY_UP_SYNC - } ?: false - - 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/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/SyncInfo.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfo.kt new file mode 100644 index 0000000000..f506c1d115 --- /dev/null +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfo.kt @@ -0,0 +1,86 @@ +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 isInstructionCommCarePermissionVisible: 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: String = "", +) + +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: String = "", +) + +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 = "", +) 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 2260b699dc..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 @@ -1,40 +1,61 @@ 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 import android.view.View -import android.widget.ProgressBar +import android.view.ViewGroup 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 +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 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.DownSynchronizationConfiguration.SimprintsDownSynchronizationConfiguration -import com.simprints.infra.config.store.models.ProjectConfiguration -import com.simprints.infra.config.store.models.canSyncDataToSimprints -import com.simprints.infra.config.store.models.isSimprintsEventDownSyncAllowed -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.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 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 +64,319 @@ 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.textEventSyncInstructionsNoModules.setOnClickListener { + findNavController().navigate(R.id.moduleSelectionFragment) + } + binding.syncSettingsButton.setOnClickListener { + findNavController().navigate(R.id.syncInfoFragment) } binding.syncInfoToolbar.setNavigationOnClickListener { findNavController().popBackStack() } - binding.syncInfoToolbar.setOnMenuItemClickListener { - viewModel.refreshInformation() - true + binding.syncReloginRequiredLoginButton.setOnClickListener { + viewModel.requestNavigationToLogin() } - binding.syncButton.setOnClickListener { - viewModel.forceSync() - updateSyncButton(isSyncInProgress = true) + binding.buttonSyncRecordsNow.setOnClickListener { + viewModel.forceEventSync() } - binding.syncReloginRequiredLoginButton.setOnClickListener { - viewModel.login() + binding.buttonSyncImagesNow.setOnClickListener { + viewModel.toggleImageSync() } - } - - private fun observeUI() { - viewModel.configuration.observe(viewLifecycleOwner) { - enableModuleSelectionButtonAndTabsIfNecessary(it.synchronization.down.simprints) - setupRecordsCountCards(it) + binding.textEventSyncInstructionsCommCarePermission.setOnClickListener { + startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", requireContext().packageName, null) + } + ) } - viewModel.recordsInLocal.observe(viewLifecycleOwner) { - binding.totalRecordsCount.text = it?.toString() ?: "" - setProgressBar(it, binding.totalRecordsCount, binding.totalRecordsProgress) + binding.textEventSyncInstructionsOffline.setOnClickListener { + startActivity(Intent(Settings.ACTION_WIRELESS_SETTINGS)) } - - viewModel.recordsToUpSync.observe(viewLifecycleOwner) { - binding.recordsToUploadCount.text = it?.toString() ?: "" - setProgressBar(it, binding.recordsToUploadCount, binding.recordsToUploadProgress) + 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.imagesToUpload.observe(viewLifecycleOwner) { - binding.imagesToUploadCount.text = it?.toString() ?: "" - setProgressBar(it, binding.imagesToUploadCount, binding.imagesToUploadProgress) + 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.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 observeUI() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + renderSyncInfo(SyncInfo(), syncInfoConfig) + viewModel.syncInfoLiveData.observe(viewLifecycleOwner) { syncInfo -> + renderSyncInfo(syncInfo, syncInfoConfig) + } + } } - 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 - } - 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, + LiveDataEventWithContentObserver { + viewModel.performLogout() + }, + ) } } - viewModel.loginRequestedEventLiveData.observe( - viewLifecycleOwner, - LiveDataEventWithContentObserver { loginArgs -> - findNavController().navigateSafely( - this, - R.id.action_syncInfoFragment_to_login, - loginArgs, + + 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.isGone = !config.isSyncInfoToolbarVisible + + // Config loading progress bar + binding.progressConfigRefresh.isInvisible = !syncInfo.isConfigurationLoadingProgressBarVisible + + // Sync info header + binding.syncStatusHeader.isGone = !config.isSyncInfoStatusHeaderVisible + binding.syncSettingsButton.isGone = !config.isSyncInfoStatusHeaderSettingsButtonVisible + + // Section separators + 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.isGone = !syncInfo.isLoginPromptSectionVisible + + // Records section + renderRecordsSection(syncInfo.syncInfoSectionRecords, config) + + // Images section + binding.layoutImagesSync.isGone = !config.isSyncInfoImageSyncVisible + renderImagesSection(syncInfo.syncInfoSectionImages) + + // Modules section + renderModulesSection(syncInfo.syncInfoSectionModules, config) + } + + private fun renderRecordsSection( + records: SyncInfoSectionRecords, + config: SyncInfoFragmentConfig, + ) { + // Counter - total records + binding.totalRecordsCount.isGone = records.counterTotalRecords.isBlank() + binding.totalRecordsCount.text = records.counterTotalRecords + binding.totalRecordsProgress.isGone = records.counterTotalRecords.isNotBlank() + + // Counter - records to upload + binding.layoutRecordsToDownload.isGone = !records.isCounterRecordsToDownloadVisible + binding.recordsToUploadCount.isGone = records.counterRecordsToUpload.isBlank() + binding.recordsToUploadCount.text = records.counterRecordsToUpload + binding.recordsToUploadProgress.isGone = records.counterRecordsToUpload.isNotBlank() + + // Counter - records to download + binding.recordsToDownloadCount.isGone = records.counterRecordsToDownload.isBlank() + binding.recordsToDownloadCount.text = records.counterRecordsToDownload + binding.recordsToDownloadProgress.isGone = records.counterRecordsToDownload.isNotBlank() + + // Counter - images to upload (may be combined with records) + binding.layoutComboImageCounter.isGone = !config.isSyncInfoRecordsImagesCombined + binding.comboImagesToUploadCount.isGone = records.counterImagesToUpload.isBlank() + binding.comboImagesToUploadCount.text = records.counterImagesToUpload + binding.comboImagesToUploadProgress.isGone = records.counterImagesToUpload.isNotBlank() + + // 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 + records.instructionPopupErrorInfo.configureErrorPopup() + + // Progress + binding.layoutEventSyncProgress.isInvisible = !records.isProgressVisible + 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.isGone = !isSyncButtonVisible + 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.isGone = !isFooterSyncInProgressVisible + binding.textFooterRecordLoggingOut.isGone = !records.isFooterReadyToLogOutVisible + binding.textFooterRecordSyncIncomplete.isGone = !records.isFooterSyncIncompleteVisible + binding.textFooterRecordLastSyncedWhen.isGone = !records.isFooterLastSyncTimeVisible + binding.textFooterRecordLastSyncedWhen.text = records.footerLastSyncMinutesAgo + } + + 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 updateSyncButton(isSyncInProgress: Boolean) { - binding.syncButton.text = getString( - if (isSyncInProgress) { - IDR.string.dashboard_sync_info_sync_in_progress + private fun renderImagesSection(images: SyncInfoSectionImages) { + // Counter - images to upload + binding.imagesToUploadCount.isGone = images.counterImagesToUpload.isBlank() + binding.imagesToUploadCount.text = images.counterImagesToUpload + binding.imagesToUploadProgress.isGone = images.counterImagesToUpload.isNotBlank() + + // Handle instruction visibility + binding.textImageSyncInstructionsDefault.isGone = !images.isInstructionDefaultVisible + binding.textImageSyncInstructionsOffline.isGone = !images.isInstructionOfflineVisible + + // Progress + binding.layoutImageSyncProgress.isInvisible = !images.isProgressVisible + 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 = ContextCompat.getColorStateList( + requireContext(), + if (images.isProgressVisible) { + IDR.color.button_sync_images_background_red } else { - IDR.string.dashboard_sync_info_sync_now_button + IDR.color.button_sync_images_background_default }, ) - } - private fun enableModuleSelectionButtonAndTabsIfNecessary(simprintsDownSyncConfig: SimprintsDownSynchronizationConfiguration?) { - if (viewModel.isModuleSyncAndModuleIdOptionsNotEmpty(simprintsDownSyncConfig)) { - binding.moduleSelectionButton.visibility = View.VISIBLE - binding.modulesTabHost.visibility = View.VISIBLE - } else { - binding.moduleSelectionButton.visibility = View.GONE - binding.modulesTabHost.visibility = View.GONE - } + // Footer + binding.textFooterImageLastSyncedWhen.isInvisible = !images.isFooterLastSyncTimeVisible + binding.textFooterImageLastSyncedWhen.text = images.footerLastSyncMinutesAgo } - private fun setupRecordsCountCards(configuration: ProjectConfiguration) { - if (!configuration.isSimprintsEventDownSyncAllowed()) { - binding.recordsToDownloadCardView.visibility = View.GONE + private fun renderModulesSection( + modules: SyncInfoSectionModules, + config: SyncInfoFragmentConfig, + ) { + val isModuleSectionVisible = + modules.isSectionAvailable && (config.isSyncInfoModuleListVisible || modules.moduleCounts.isEmpty()) + binding.layoutModuleSelection.isGone = !isModuleSectionVisible + binding.selectedModulesView.isGone = !config.isSyncInfoModuleListVisible + + val moduleCountsForAdapter = modules.moduleCounts.map { syncInfoModuleCount -> + ModuleCount( + name = if (syncInfoModuleCount.isTotal) { + getString(IDR.string.sync_info_total_records) + } else { + syncInfoModuleCount.name + }, + count = syncInfoModuleCount.count.toIntOrNull() ?: 0, + ) } - 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: 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") - private fun updateModuleCounts(moduleCounts: List) { - val moduleCountsArray = ArrayList().apply { - addAll(moduleCounts) - } + textView.text = progressText + } - val totalRecordsEntry = ModuleCount( - getString(IDR.string.dashboard_sync_info_total_records), - moduleCounts.sumOf { it.count }, - ) - moduleCountsArray.add(TOTAL_RECORDS_INDEX, totalRecordsEntry) + private fun getCurrentDestinationId() = + parentFragment?.takeIf { !syncInfoConfig.isSyncInfoToolbarVisible }?.id // parent if this isn't standalone + ?: id - moduleCountAdapter.submitList(moduleCountsArray) + 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/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/settings/syncinfo/SyncInfoViewModel.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt index f829970ef3..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 @@ -1,252 +1,159 @@ 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.feature.dashboard.settings.syncinfo.modulecount.ModuleCount -import com.simprints.feature.login.LoginContract +import com.simprints.core.tools.time.TimeHelper +import com.simprints.feature.dashboard.logout.usecase.LogoutUseCase +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.DownSynchronizationConfiguration -import com.simprints.infra.config.store.models.DownSynchronizationConfiguration.SimprintsDownSynchronizationConfiguration -import com.simprints.infra.config.store.models.ProjectConfiguration -import com.simprints.infra.config.store.models.SynchronizationConfiguration -import com.simprints.infra.config.store.models.TokenKeyType -import com.simprints.infra.config.store.models.isSimprintsEventDownSyncAllowed -import com.simprints.infra.config.store.tokenization.TokenizationProcessor +import com.simprints.infra.config.store.models.ProjectState +import com.simprints.infra.config.store.models.isModuleSelectionAvailable 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.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.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.launch import javax.inject.Inject @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, + observeSyncInfo: ObserveSyncInfoUseCase, + 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, - ), - ) - } - _isSyncAvailable.addSource(isConnected) { isConnectedValue -> - _isSyncAvailable.postValue( - emitSyncAvailable( - isSyncRunning = lastSyncState.value?.isSyncRunning(), - isConnected = isConnectedValue, - syncConfiguration = configuration.value?.synchronization, - ), - ) - } - _isSyncAvailable.addSource(_configuration) { config -> - _isSyncAvailable.postValue( - emitSyncAvailable( - isSyncRunning = lastSyncState.value?.isSyncRunning(), - isConnected = isConnected.value, - syncConfiguration = config.synchronization, - ), - ) - } - _isReloginRequired.addSource(lastSyncState) { lastSyncStateValue -> - _isReloginRequired.postValue(lastSyncStateValue.isSyncFailedBecauseReloginRequired()) + 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 + 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() + + fun forceEventSync() { + viewModelScope.launch { + syncOrchestrator.stopEventSync() + val isDownSyncAllowed = + !isPreLogoutUpSync && configManager.getProject(authStore.signedInProjectId).state != ProjectState.PROJECT_ENDING + syncOrchestrator.startEventSync(isDownSyncAllowed) } - viewModelScope.launch { getRecordsToUpSync() } - } - - fun refreshInformation() { - _recordsInLocal.postValue(null) - _recordsToDownSync.postValue(null) - _imagesToUpload.postValue(null) - _moduleCounts.postValue(listOf()) - load() - } - - 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) } - /** - * 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 performLogout() { + 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 + // initial actions - 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)) }, - ) + private fun startInitialSyncIfRequired() { + viewModelScope.launch { + val isRunning = eventSyncManager.getLastSyncState().value?.isSyncRunning() ?: false + val lastUpdate = eventSyncManager.getLastSyncTime() + + val isForceEventSync = when { + isPreLogoutUpSync -> true + configManager.isModuleSelectionRequired() -> false + isRunning -> false + lastUpdate == null -> true + timeHelper.msBetweenNowAndTime(lastUpdate) > RE_SYNC_TIMEOUT_MILLIS -> true + else -> false + } + if (isForceEventSync) { + forceEventSync() + } + } } - private fun emitSyncAvailable( - isSyncRunning: Boolean?, - isConnected: Boolean?, - syncConfiguration: SynchronizationConfiguration? = configuration.value?.synchronization, - ): Boolean { - if (isSyncRunning == true) return false - - val simprintsDownConfig = syncConfiguration?.down?.simprints - if (simprintsDownConfig != null && - isConnected == true && - (!isModuleSync(simprintsDownConfig) || - isModuleSyncAndModuleIdOptionsNotEmpty(simprintsDownConfig)) - ) { - return true + private fun syncImagesAfterEventsWhenRequired() { + viewModelScope.launch { + if (isPreLogoutUpSync) { + eventSyncStateFlow + .map { it.isSyncCompleted() } + .distinctUntilChanged() + .collect { isEventSyncCompleted -> + if (isEventSyncCompleted) { + syncOrchestrator.startImageSync() + } + } + } } - - val commCareDownConfig = syncConfiguration?.down?.commCare - if (commCareDownConfig != null) return true - - return false } - private fun isModuleSync(simprintsDownConfig: SimprintsDownSynchronizationConfiguration) = - simprintsDownConfig.partitionType == DownSynchronizationConfiguration.PartitionType.MODULE + private suspend fun ConfigManager.isModuleSelectionRequired() = + getProjectConfiguration().isModuleSelectionAvailable() && getDeviceConfiguration().selectedModules.isEmpty() - fun isModuleSyncAndModuleIdOptionsNotEmpty(simprintsDownConfig: SimprintsDownSynchronizationConfiguration?) = - simprintsDownConfig != null && isModuleSync(simprintsDownConfig) && simprintsDownConfig.moduleOptions.isNotEmpty() - - private suspend fun getRecordsInLocal(projectId: String): Int = enrolmentRecordRepository.count(SubjectQuery(projectId = projectId)) - - private suspend fun getRecordsToUpSync() = eventSyncManager - .countEventsToUpload(listOf(EventType.ENROLMENT_V2, EventType.ENROLMENT_V4)) - .collect { _recordsToUpSync.postValue(it) } - - private suspend fun fetchRecordsToCreateAndDeleteCount(): DownSyncCounts = - if (configManager.getProjectConfiguration().isSimprintsEventDownSyncAllowed()) { - fetchAndUpdateRecordsToDownSyncAndDeleteCount() - } else { - DownSyncCounts(0, isLowerBound = false) - } - - 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 companion object { + private const val RE_SYNC_TIMEOUT_MILLIS = 5 * 60 * 1000L + private const val LOGOUT_DELAY_MILLIS = 3000L } - - 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), - ) - } - return@map ModuleCount(name = decryptedName.value, count = count) - } } 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..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 @@ -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/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..6f3c158d68 --- /dev/null +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCase.kt @@ -0,0 +1,361 @@ +package com.simprints.feature.dashboard.settings.syncinfo.usecase + +import androidx.lifecycle.asFlow +import com.simprints.core.domain.tokenization.TokenizableString +import com.simprints.core.lifecycle.AppForegroundStateTracker +import com.simprints.core.tools.extentions.combine9 +import com.simprints.core.tools.extentions.onChange +import com.simprints.core.tools.time.TimeHelper +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 +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.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 +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.filter +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 ticker: Ticker, + 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 = combine9( + connectivityTracker.observeIsConnected().asFlow(), + authStore.observeSignedInProjectId().map(String::isNotEmpty), + configManager.observeIsProjectRefreshing(), + eventSyncStateFlow, + imageSyncStatusFlow, + configManager.observeProjectConfiguration(), + configManager.observeDeviceConfiguration(), + appForegroundStateTracker.observeAppInForeground().filter { it }, // only when going to foreground + ticker.observeTickOncePerMinute(), + ) { 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 + 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 eventLastSyncTimestamp = eventSyncManager.getLastSyncTime() ?: Timestamp(-1) + val imageLastSyncTimestamp = imageSyncStatus.lastUpdateTimeMillis?.let { + Timestamp(it) + } ?: Timestamp(-1) + + val isReLoginRequired = eventSyncState.isSyncFailedBecauseReloginRequired() + + val isModuleSelectionRequired = + !isPreLogoutUpSync && projectConfig.isModuleSelectionAvailable() && deviceConfig.selectedModules.isEmpty() + + 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 + + // 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 + isEventSyncConnectionBlocked -> OfflineError + eventSyncState.isSyncFailed() -> Error + else -> OnStandby + } + + val isEventUpSyncPossible = + projectConfig.canSyncDataToSimprints() && isOnline + val isDownSyncPossible = + (projectConfig.isSimprintsEventDownSyncAllowed() && isOnline && !isReLoginRequired) || + (projectConfig.isCommCareEventDownSyncAllowed() && !eventSyncState.isSyncFailedBecauseCommCarePermissionIsMissing()) + val isSyncButtonEnabled = + (eventSyncVisibleState == OnStandby) && + ((!isPreLogoutUpSync && isDownSyncPossible) || isEventUpSyncPossible) + + 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.isSimprintsEventDownSyncAllowed() -> try { + withTimeout(COUNT_EVENTS_TIMEOUT_MILLIS) { + countEventsToDownloadWithCaching() + } + } 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 = 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 = eventSyncVisibleState == InProgress, + progress = eventSyncProgress, + isSyncButtonVisible = !isPreLogoutUpSync || eventSyncState.isSyncFailed(), + isSyncButtonEnabled = isSyncButtonEnabled, + isSyncButtonForRetry = eventSyncState.isSyncFailed(), + isFooterSyncInProgressVisible = isPreLogoutUpSync && isEventSyncInProgress, + isFooterReadyToLogOutVisible = isPreLogoutUpSync && eventSyncState.isSyncCompleted() && !imageSyncStatus.isSyncing, + isFooterSyncIncompleteVisible = isPreLogoutUpSync && eventSyncState.isSyncFailed(), + isFooterLastSyncTimeVisible = !isPreLogoutUpSync && !eventSyncState.isSyncInProgress() && eventLastSyncTimestamp.ms >= 0, + footerLastSyncMinutesAgo = timeHelper.readableBetweenNowAndTime(eventLastSyncTimestamp), + ) + + val syncInfoSectionImages = SyncInfoSectionImages( + counterImagesToUpload = imagesToUpload?.toString().orEmpty(), + isInstructionDefaultVisible = !imageSyncStatus.isSyncing && isOnline, + isInstructionOfflineVisible = !isOnline, + isProgressVisible = imageSyncStatus.isSyncing, + progress = imageSyncProgress, + isSyncButtonEnabled = isOnline && !isReLoginRequired, + isFooterLastSyncTimeVisible = !imageSyncStatus.isSyncing && imageLastSyncTimestamp.ms >= 0, + footerLastSyncMinutesAgo = timeHelper.readableBetweenNowAndTime(imageLastSyncTimestamp), + ) + + val syncInfo = SyncInfo( + isLoggedIn, + isConfigurationLoadingProgressBarVisible = isRefreshing, + isLoginPromptSectionVisible = isReLoginRequired && !isPreLogoutUpSync, + syncInfoSectionRecords, + syncInfoSectionImages, + syncInfoSectionModules, + ) + return@combine9 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, + ) + + // 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 + } +} + +/** + * 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 : EventSyncVisibleState() + +private object InProgress : EventSyncVisibleState() + +private object CommCareError : EventSyncVisibleState() + +private object NoModulesError : EventSyncVisibleState() + +private object OfflineError : EventSyncVisibleState() + +private object Error : EventSyncVisibleState() 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..7f80829cf5 --- /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.SyncFragmentContainerView) { + config = SyncInfoFragmentConfig( + 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/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 5073dd634e..0000000000 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/views/SyncCardState.kt +++ /dev/null @@ -1,63 +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 SyncFailedCommCarePermissionMissing( - override val lastTimeSyncSucceed: String?, - ) : 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 3fba4f2f9d..0000000000 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/views/SyncCardView.kt +++ /dev/null @@ -1,237 +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 = {} - var onSettingsButtonClick: () -> 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.SyncFailedCommCarePermissionMissing -> prepareSyncFailedBecauseCommCarePermissionMissingView() - 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 - binding.syncCardCommcarePermissionMissing.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 prepareSyncFailedBecauseCommCarePermissionMissingView() { - binding.syncCardCommcarePermissionMissing.visibility = View.VISIBLE - binding.syncCardCommcarePermissionMissingButton.setOnClickListener { onSettingsButtonClick() } - } - - 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/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_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_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_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/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_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/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/layout/item_module_count.xml b/feature/dashboard/src/main/res/layout/item_module_count.xml index 3e2d139b14..d2ad51ea2b 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="42" + android:textSize="16sp" /> + 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 b94048728b..0000000000 --- a/feature/dashboard/src/main/res/layout/layout_card_sync.xml +++ /dev/null @@ -1,255 +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/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/main/res/values/attrs.xml b/feature/dashboard/src/main/res/values/attrs.xml new file mode 100644 index 0000000000..206b0b62df --- /dev/null +++ b/feature/dashboard/src/main/res/values/attrs.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + 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 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..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 @@ -1,18 +1,29 @@ 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.ProjectConfiguration import com.simprints.infra.config.store.models.SettingsPasswordConfig 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.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 import org.junit.Rule import org.junit.Test @@ -24,6 +35,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 +53,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 +74,96 @@ 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.observeSignedInProjectId() } 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.observeSignedInProjectId() } 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, lastUpdateTimeMillis = null) + val projectConfig = mockk(relaxed = true) + + setupSyncMocks(eventSyncState, imageSyncStatus, projectConfig) + + 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, lastUpdateTimeMillis = null) + val projectConfig = mockk(relaxed = true) + + setupSyncMocks(eventSyncState, imageSyncStatus, projectConfig) + + 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, lastUpdateTimeMillis = null) + val projectConfig = mockk(relaxed = true) + + setupSyncMocks(eventSyncState, imageSyncStatus, projectConfig) + + val viewModel = createViewModel() + + val result = viewModel.isLogoutWithoutSyncVisibleLiveData.getOrAwaitValue() + assertThat(result).isFalse() + } + + private fun setupSyncMocks( + eventSyncState: EventSyncState, + imageSyncStatus: ImageSyncStatus, + projectConfig: ProjectConfiguration, + ) { + 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.observeProjectConfiguration() } returns flowOf(projectConfig) + } + + 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 0ac04f0349..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 @@ -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,62 @@ 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)) - - 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 { + 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(state) + secondArg>().onChanged(false) } } + launchFragmentInHiltContainer(navController = navController) + + onView(withId(R.id.logout_sync_info)).check(matches(not(isDisplayed()))) + onView(withId(R.id.logoutWithoutSyncButton)).check(matches(not(isDisplayed()))) } - private fun mockSyncToBFSIDAllowed(allowed: Boolean) { - every { syncViewModel.isAnySyncAllowed } 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/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 69c9808e84..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.isAnySyncAllowed } 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 77a9a324e4..0000000000 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/main/sync/SyncViewModelTest.kt +++ /dev/null @@ -1,632 +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.SyncFailedCommCarePermissionMissing -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 isAnySyncAllowed correctly`() { - syncState.postValue(EventSyncState("", 0, 0, listOf(), listOf(), listOf())) - isConnected.postValue(true) - - val viewModel = initViewModel() - - assertThat(viewModel.isAnySyncAllowed.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 SyncFailedCommCarePermissionMissing card state if the sync fails because of missing CommCare permission`() { - coEvery { configManager.getDeviceConfiguration() } returns deviceConfiguration - isConnected.value = true - syncState.value = EventSyncState( - "", - 10, - 40, - listOf(), - listOf( - EventSyncState.SyncWorkerInfo( - EventSyncWorkerType.DOWNLOADER, - EventSyncWorkerState.Failed(failedBecauseCommCarePermissionMissing = true), - ), - ), - listOf(), - ) - val syncCardLiveData = initViewModel().syncCardLiveData.getOrAwaitValue() - - assertThat(syncCardLiveData).isEqualTo(SyncFailedCommCarePermissionMissing(DATE)) - } - - @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() } - } - - @Test - fun `isAnySyncAllowed should be true when canSyncDataToSimprints is true and others are false`() { - setupSyncConfiguration( - canSyncDataToSimprints = true, - isSimprintsEventDownSyncAllowed = false, - isCommCareEventDownSyncAllowed = false - ) - - val viewModel = initViewModel() - - assertThat(viewModel.isAnySyncAllowed.value).isTrue() - } - - @Test - fun `isAnySyncAllowed should be true when isSimprintsEventDownSyncAllowed is true and others are false`() { - setupSyncConfiguration( - canSyncDataToSimprints = false, - isSimprintsEventDownSyncAllowed = true, - isCommCareEventDownSyncAllowed = false - ) - - val viewModel = initViewModel() - - assertThat(viewModel.isAnySyncAllowed.value).isTrue() - } - - @Test - fun `isAnySyncAllowed should be true when isCommCareEventDownSyncAllowed is true and others are false`() { - setupSyncConfiguration( - canSyncDataToSimprints = false, - isSimprintsEventDownSyncAllowed = false, - isCommCareEventDownSyncAllowed = true - ) - - val viewModel = initViewModel() - - assertThat(viewModel.isAnySyncAllowed.value).isTrue() - } - - @Test - fun `isAnySyncAllowed should be true when multiple sync options are enabled`() { - setupSyncConfiguration( - canSyncDataToSimprints = true, - isSimprintsEventDownSyncAllowed = true, - isCommCareEventDownSyncAllowed = false - ) - - val viewModel = initViewModel() - - assertThat(viewModel.isAnySyncAllowed.value).isTrue() - } - - @Test - fun `isAnySyncAllowed should be true when all sync options are enabled`() { - setupSyncConfiguration( - canSyncDataToSimprints = true, - isSimprintsEventDownSyncAllowed = true, - isCommCareEventDownSyncAllowed = true - ) - - val viewModel = initViewModel() - - assertThat(viewModel.isAnySyncAllowed.value).isTrue() - } - - @Test - fun `isAnySyncAllowed should be false when all sync options are disabled`() { - setupSyncConfiguration( - canSyncDataToSimprints = false, - isSimprintsEventDownSyncAllowed = false, - isCommCareEventDownSyncAllowed = false - ) - - val viewModel = initViewModel() - - assertThat(viewModel.isAnySyncAllowed.value).isFalse() - } - - private fun setupSyncConfiguration( - canSyncDataToSimprints: Boolean, - isSimprintsEventDownSyncAllowed: Boolean, - isCommCareEventDownSyncAllowed: Boolean - ) { - val upSyncKind = if (canSyncDataToSimprints) ALL else UpSynchronizationConfiguration.UpSynchronizationKind.NONE - val downSimprintsConfig = if (isSimprintsEventDownSyncAllowed) { - mockk { - every { frequency } returns Frequency.PERIODICALLY_AND_ON_SESSION_START - every { partitionType } returns DownSynchronizationConfiguration.PartitionType.MODULE - } - } else null - val downCommCareConfig = if (isCommCareEventDownSyncAllowed) { - mockk() - } else null - - coEvery { configManager.getProjectConfiguration().synchronization } returns mockk { - every { up.simprints } returns SimprintsUpSynchronizationConfiguration( - kind = upSyncKind, - batchSizes = UpSynchronizationConfiguration.UpSyncBatchSizes.default(), - imagesRequireUnmeteredConnection = false, - frequency = Frequency.PERIODICALLY_AND_ON_SESSION_START, - ) - every { down.simprints } returns downSimprintsConfig - every { down.commCare } returns downCommCareConfig - } - } - - private fun initViewModel(): SyncViewModel = SyncViewModel( - eventSyncManager = eventSyncManager, - syncOrchestrator = syncOrchestrator, - connectivityTracker = connectivityTracker, - configManager = configManager, - timeHelper = timeHelper, - authStore = authStore, - logout = logoutUseCase, - recentUserActivityManager = recentUserActivityManager, - ) -} 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 f2de335a95..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 @@ -2,539 +2,636 @@ 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.livedata.LiveDataEventWithContent +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.settings.syncinfo.usecase.ObserveSyncInfoUseCase 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.TokenKeyType -import com.simprints.infra.config.store.tokenization.TokenizationProcessor +import com.simprints.infra.config.store.models.ProjectState +import com.simprints.infra.config.store.models.isModuleSelectionAvailable +import com.simprints.infra.config.store.models.isSimprintsEventDownSyncAllowed 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.ImageSyncStatus import com.simprints.infra.sync.SyncOrchestrator 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.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() @get:Rule val testCoroutineRule = TestCoroutineRule() - @MockK - private lateinit var configManager: ConfigManager - - @MockK - private lateinit var enrolmentRecordRepository: EnrolmentRecordRepository - - @MockK - private lateinit var authStore: AuthStore - - @MockK - private lateinit var connectivityTracker: ConnectivityTracker - - @MockK - private lateinit var imageRepository: ImageRepository - - @MockK - private lateinit var eventSyncManager: EventSyncManager + private val configManager = mockk() + private val authStore = mockk() + private val eventSyncManager = mockk() + private val syncOrchestrator = mockk() + private val recentUserActivityManager = mockk() + private val timeHelper = mockk() + private val observeSyncInfo = mockk() + private val logoutUseCase = mockk(relaxed = true) - @MockK - private lateinit var syncOrchestrator: SyncOrchestrator - - @MockK - lateinit var recentUserActivityManager: RecentUserActivityManager - - @MockK - private lateinit var project: Project - - @MockK(relaxed = true) - private lateinit var tokenizationProcessor: TokenizationProcessor + private lateinit var viewModel: SyncInfoViewModel - private lateinit var connectionLiveData: MutableLiveData - private lateinit var stateLiveData: MutableLiveData + 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" + val TEST_TIMESTAMP = Timestamp(1000L) + } - private lateinit var viewModel: SyncInfoViewModel + 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 { lastUpdateTimeMillis } 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() + createViewModel() + } - every { authStore.signedInProjectId } returns PROJECT_ID + 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 { 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() } 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 + + 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) + } - connectionLiveData = MutableLiveData() - every { connectivityTracker.observeIsConnected() } returns connectionLiveData + every { any().isModuleSelectionAvailable() } returns false + every { any().isSimprintsEventDownSyncAllowed() } returns true - stateLiveData = MutableLiveData() - every { eventSyncManager.getLastSyncState() } returns stateLiveData - coEvery { configManager.getProject(PROJECT_ID) } returns project - viewModel = createViewModel() + every { observeSyncInfo(any()) } returns flowOf(createDefaultSyncInfo()) } - private fun createViewModel() = SyncInfoViewModel( - configManager = configManager, - connectivityTracker = connectivityTracker, - enrolmentRecordRepository = enrolmentRecordRepository, - authStore = authStore, - imageRepository = imageRepository, - eventSyncManager = eventSyncManager, - syncOrchestrator = syncOrchestrator, - tokenizationProcessor = tokenizationProcessor, - recentUserActivityManager = recentUserActivityManager, + private fun createViewModel() { + viewModel = SyncInfoViewModel( + configManager = configManager, + authStore = authStore, + eventSyncManager = eventSyncManager, + syncOrchestrator = syncOrchestrator, + recentUserActivityManager = recentUserActivityManager, + timeHelper = timeHelper, + observeSyncInfo = observeSyncInfo, + logoutUseCase = logoutUseCase, + ) + } + + 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 = "", + ), + syncInfoSectionImages = SyncInfoSectionImages( + counterImagesToUpload = "0", + isInstructionDefaultVisible = true, + isInstructionOfflineVisible = false, + isProgressVisible = false, + progress = SyncInfoProgress(), + isSyncButtonEnabled = true, + isFooterLastSyncTimeVisible = false, + footerLastSyncMinutesAgo = "", + ), + syncInfoSectionModules = SyncInfoSectionModules( + isSectionAvailable = false, + moduleCounts = emptyList(), + ), ) - @Test - fun `should initialize the configuration live data correctly`() = runTest { - val configuration = mockk(relaxed = true) - coEvery { configManager.getProjectConfiguration() } returns configuration + // LiveData loginNavigationEventLiveData tests - viewModel.refreshInformation() + @Test + fun `should show login navigation when user requests login`() = runTest { + viewModel.requestNavigationToLogin() + val result = viewModel.loginNavigationEventLiveData.getOrAwaitValue() - assertThat(viewModel.configuration.getOrAwaitValue()).isEqualTo(configuration) + assertThat(result).isNotNull() } + // LiveData logoutEventLiveData tests + + @OptIn(ExperimentalCoroutinesApi::class) @Test - fun `should initialize the recordsInLocal live data correctly`() = runTest { - val number = 10 - coEvery { enrolmentRecordRepository.count(SubjectQuery(projectId = PROJECT_ID)) } returns number + 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 { lastUpdateTimeMillis } 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.refreshInformation() + viewModel.logoutEventLiveData.observeForever(observer) + advanceTimeBy(3100L) // after the logout delay (3000ms) - assertThat(viewModel.recordsInLocal.getOrAwaitValue()).isEqualTo(number) + assertThat(capturedValues.map { it.peekContent() }).contains(Unit) } + @OptIn(ExperimentalCoroutinesApi::class) @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 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 { lastUpdateTimeMillis } 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) + } - // upSync count collected on init, so need to rebuild for mocking to take effect - viewModel = createViewModel() + viewModel.logoutEventLiveData.observeForever(observer) + advanceTimeBy(2900L) // still during the debounce delay - assertThat(viewModel.recordsToUpSync.getOrAwaitValue()).isEqualTo(number) + 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 initialize the imagesToUpload live data correctly`() = runTest { - val number = 10 - coEvery { imageRepository.getNumberOfImagesToUpload(PROJECT_ID) } returns number + 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 { lastUpdateTimeMillis } 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.refreshInformation() + viewModel.logoutEventLiveData.observeForever(observer) + advanceTimeBy(3100L) // after the logout delay (3000ms) - assertThat(viewModel.imagesToUpload.getOrAwaitValue()).isEqualTo(number) + assertThat(capturedValues).isEmpty() } + @OptIn(ExperimentalCoroutinesApi::class) @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 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 { lastUpdateTimeMillis } returns 0 } - 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(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.refreshInformation() + viewModel.logoutEventLiveData.observeForever(observer) + advanceTimeBy(3100L) // after the logout delay (3000ms) - assertThat(viewModel.moduleCounts.getOrAwaitValue()).isEqualTo( - listOf( - ModuleCount(module1.value, numberForModule1), - ModuleCount(module2.value, numberForModule2), - ), - ) + assertThat(capturedValues).isEmpty() } + @OptIn(ExperimentalCoroutinesApi::class) @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 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 { lastUpdateTimeMillis } 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) } - coEvery { - eventSyncManager.countEventsToDownload() - } returns DownSyncCounts(15, isLowerBound = false) - viewModel.refreshInformation() + viewModel.logoutEventLiveData.observeForever(observer) + advanceTimeBy(3100L) // after the logout delay (3000ms) - assertThat(viewModel.recordsToDownSync.getOrAwaitValue()?.count).isEqualTo(15) + assertThat(capturedValues).isEmpty() } + // forceEventSync() tests + @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) - } - coEvery { - eventSyncManager.countEventsToDownload() - } throws Exception() + fun `should start event sync with down sync allowed when not pre-logout`() = runTest { + viewModel.isPreLogoutUpSync = false - viewModel.refreshInformation() + viewModel.forceEventSync() - assertThat(viewModel.recordsToDownSync.getOrAwaitValue()?.count).isEqualTo(0) + coVerify { syncOrchestrator.stopEventSync() } + coVerify { syncOrchestrator.startEventSync(isDownSyncAllowed = true) } } @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 start event sync with down sync disabled when pre-logout`() = runTest { + viewModel.isPreLogoutUpSync = true - val records = viewModel.recordsInLocal.getOrAwaitValues(3) { - viewModel.refreshInformation() + 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() - // Init, refresh and reload - assertThat(records).isEqualTo(listOf(2, null, 4)) + coVerify { syncOrchestrator.stopEventSync() } + coVerify { syncOrchestrator.startEventSync(isDownSyncAllowed = false) } } @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 stop current event sync before starting new one`() = runTest { + viewModel.forceEventSync() - coVerify(exactly = 0) { enrolmentRecordRepository.count(SubjectQuery(projectId = PROJECT_ID)) } + coVerify { syncOrchestrator.stopEventSync() } + coVerify { syncOrchestrator.startEventSync(any()) } } + // toggleImageSync() tests + @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(), - ), - ) + 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(exactly = 1) { enrolmentRecordRepository.count(SubjectQuery(projectId = PROJECT_ID)) } + coVerify { syncOrchestrator.startImageSync() } + coVerify(exactly = 0) { syncOrchestrator.stopImageSync() } } @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 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.fetchSyncInformationIfNeeded(state) - viewModel.fetchSyncInformationIfNeeded(state) + viewModel.toggleImageSync() - coVerify(exactly = 1) { enrolmentRecordRepository.count(SubjectQuery(projectId = PROJECT_ID)) } + coVerify { syncOrchestrator.stopImageSync() } + coVerify(exactly = 0) { syncOrchestrator.startImageSync() } } + // logout() tests + @Test - fun `should invoke sync manager when sync is requested`() = runTest { - viewModel.forceSync() + fun `should call logout use case when logout invoked`() = runTest { + viewModel.performLogout() - verify(exactly = 1) { syncOrchestrator.startEventSync() } - assertThat(viewModel.isSyncAvailable.getOrAwaitValue()).isEqualTo(false) + verify { logoutUseCase() } } + // requestNavigationToLogin() tests + @Test - fun `isModuleSyncAndModuleIdOptionsNotEmpty returns true only if module sync and has modules`() = runTest { - // Not module sync - assertThat( - viewModel.isModuleSyncAndModuleIdOptionsNotEmpty( - createMockSimprintsDownSyncConfig( - partitionType = DownSynchronizationConfiguration.PartitionType.USER, - ).down.simprints, - ), - ).isFalse() - // Module sync + no modules - assertThat( - viewModel.isModuleSyncAndModuleIdOptionsNotEmpty( - createMockSimprintsDownSyncConfig( - partitionType = DownSynchronizationConfiguration.PartitionType.MODULE, - ).down.simprints, - ), - ).isFalse() - // CommCare sync - assertThat( - viewModel.isModuleSyncAndModuleIdOptionsNotEmpty( - createMockCommCareDownSyncConfig().down.simprints, - ), - ).isFalse() - // Module sync + has modules - assertThat( - viewModel.isModuleSyncAndModuleIdOptionsNotEmpty( - createMockSimprintsDownSyncConfig( - partitionType = DownSynchronizationConfiguration.PartitionType.MODULE, - modules = listOf("module"), - ).down.simprints, - ), - ).isTrue() + 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 `emit correct sync availability when connection status changes`() = runTest { - coEvery { configManager.getProjectConfiguration() } returns mockk { - every { synchronization } returns createMockSimprintsDownSyncConfig( - partitionType = DownSynchronizationConfiguration.PartitionType.MODULE, - modules = listOf("module"), - ) - } - viewModel.refreshInformation() - stateLiveData.value = EventSyncState("", 0, 0, emptyList(), emptyList(), emptyList()) + fun `should emit login navigation event with signed in user ID when available`() = runTest { + viewModel.requestNavigationToLogin() - connectionLiveData.value = false - assertThat(viewModel.isSyncAvailable.getOrAwaitValue()).isFalse() + val result = viewModel.loginNavigationEventLiveData.getOrAwaitValue() - connectionLiveData.value = true - assertThat(viewModel.isSyncAvailable.getOrAwaitValue()).isTrue() + assertThat(result.userId.value).isEqualTo(TEST_USER_ID) } @Test - fun `emit correct sync availability when sync status changes`() = runTest { - coEvery { configManager.getProjectConfiguration() } returns mockk { - every { synchronization } returns createMockSimprintsDownSyncConfig( - 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(viewModel.isSyncAvailable.getOrAwaitValue()).isFalse() - - stateLiveData.value = EventSyncState( - syncId = "", - progress = 0, - total = 0, - upSyncWorkersInfo = emptyList(), - downSyncWorkersInfo = listOf( - EventSyncState.SyncWorkerInfo( - EventSyncWorkerType.DOWNLOADER, - EventSyncWorkerState.Succeeded, - ), - ), - reporterStates = listOf(), - ) - assertThat(viewModel.isSyncAvailable.getOrAwaitValue()).isTrue() + 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 `emit correct sync availability when non-module config`() = runTest { - coEvery { configManager.getProjectConfiguration() } returns mockk { - every { synchronization } returns createMockSimprintsDownSyncConfig( - partitionType = DownSynchronizationConfiguration.PartitionType.USER, - ) + fun `should trigger forceEventSync when login result is success`() = runTest { + val successResult = mockk { + every { isSuccess } returns true } - viewModel.refreshInformation() - connectionLiveData.value = true - stateLiveData.value = EventSyncState("", 0, 0, emptyList(), emptyList(), emptyList()) - assertThat(viewModel.isSyncAvailable.getOrAwaitValue()).isTrue() + viewModel.handleLoginResult(successResult) + + coVerify { syncOrchestrator.startEventSync(any()) } } @Test - fun `emit correct sync availability when module config without modules`() = runTest { - coEvery { configManager.getProjectConfiguration() } returns mockk { - every { synchronization } returns createMockSimprintsDownSyncConfig( - partitionType = DownSynchronizationConfiguration.PartitionType.MODULE, - modules = emptyList(), - ) + fun `should not trigger forceEventSync when login result is failure`() = runTest { + val failureResult = mockk { + every { isSuccess } returns false } - viewModel.refreshInformation() - connectionLiveData.value = true - stateLiveData.value = EventSyncState("", 0, 0, emptyList(), emptyList(), emptyList()) - assertThat(viewModel.isSyncAvailable.getOrAwaitValue()).isFalse() + viewModel.handleLoginResult(failureResult) + + coVerify(exactly = 0) { syncOrchestrator.startEventSync(any()) } } + // Other/combined UX case tests + @Test - fun `emit correct sync availability when CommCare sync`() = runTest { - coEvery { configManager.getProjectConfiguration() } returns mockk { - every { synchronization } returns createMockCommCareDownSyncConfig() + fun `should trigger initial sync when no previous sync history`() = runTest { + val mockIdleEventSyncState = mockk(relaxed = true) { + every { isSyncRunning() } returns false } - viewModel.refreshInformation() - stateLiveData.value = EventSyncState("", 0, 0, emptyList(), emptyList(), emptyList()) + every { eventSyncManager.getLastSyncState() } returns MutableLiveData(mockIdleEventSyncState) + coEvery { eventSyncManager.getLastSyncTime() } returns null + createViewModel() - connectionLiveData.value = false - assertThat(viewModel.isSyncAvailable.getOrAwaitValue()).isTrue() + viewModel.syncInfoLiveData.getOrAwaitValue() + + coVerify { syncOrchestrator.startEventSync(any()) } } @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 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() - assertThat(viewModel.isReloginRequired.getOrAwaitValue()).isFalse() + coVerify { syncOrchestrator.startEventSync(any()) } } @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 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() - assertThat(viewModel.isReloginRequired.getOrAwaitValue()).isTrue() + viewModel.syncInfoLiveData.getOrAwaitValue() + + coVerify(exactly = 0) { syncOrchestrator.startEventSync(any()) } } @Test - fun `calling login() sends respective event to the view`() { - viewModel.login() + 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() - val loginRequestedEvent = viewModel.loginRequestedEventLiveData.getOrAwaitValue() - assertThat(loginRequestedEvent).isNotNull() + coVerify(exactly = 0) { syncOrchestrator.startEventSync(any()) } } @Test - fun `calling handleLoginResult() triggers sync if result is success`() { - viewModel.handleLoginResult(LoginResult(true)) + 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() - verify(exactly = 1) { syncOrchestrator.startEventSync() } + coVerify(atLeast = 0) { syncOrchestrator.startEventSync(any()) } } @Test - fun `calling handleLoginResult() does not trigger sync if result is not success`() { - viewModel.handleLoginResult(LoginResult(false)) - - verify(exactly = 0) { syncOrchestrator.startEventSync() } - } - - private fun createMockSimprintsDownSyncConfig( - 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, - ), - ) - every { down.commCare }.returns(null) + 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) + } + } + 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 = 1) { syncOrchestrator.startEventSync(any()) } } - private fun createMockCommCareDownSyncConfig() = mockk { - every { down.simprints }.returns(null) - every { down.commCare }.returns(DownSynchronizationConfiguration.CommCareDownSynchronizationConfiguration) + @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()) } } } 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..f482ee39cb --- /dev/null +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCaseTest.kt @@ -0,0 +1,1445 @@ +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.lifecycle.AppForegroundStateTracker +import com.simprints.core.tools.time.TimeHelper +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 +import com.simprints.infra.config.store.models.DeviceConfiguration +import com.simprints.infra.config.store.models.DownSynchronizationConfiguration +import com.simprints.infra.config.store.models.GeneralConfiguration +import com.simprints.infra.config.store.models.Project +import com.simprints.infra.config.store.models.ProjectConfiguration +import com.simprints.infra.config.store.models.ProjectState +import com.simprints.infra.config.store.models.SynchronizationConfiguration +import com.simprints.infra.config.store.models.TokenKeyType +import com.simprints.infra.config.store.models.UpSynchronizationConfiguration +import com.simprints.infra.config.store.models.canSyncDataToSimprints +import com.simprints.infra.config.store.models.isCommCareEventDownSyncAllowed +import com.simprints.infra.config.store.models.isModuleSelectionAvailable +import com.simprints.infra.config.store.models.isSimprintsEventDownSyncAllowed +import com.simprints.infra.config.store.tokenization.TokenizationProcessor +import com.simprints.infra.config.sync.ConfigManager +import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepository +import com.simprints.infra.eventsync.EventSyncManager +import com.simprints.infra.eventsync.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.ImageSyncStatus +import com.simprints.infra.sync.SyncOrchestrator +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 +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 ticker = mockk() + private val appForegroundStateTracker = 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) + + 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 createMockSynchronizationConfiguration() + } + private val mockDeviceConfiguration = mockk(relaxed = true) { + every { selectedModules } returns emptyList() + } + private val mockProject = mockk(relaxed = true) { + every { state } returns ProjectState.RUNNING + } + private val mockEventSyncState = mockk(relaxed = true) { + every { isSyncCompleted() } returns false + every { isSyncInProgress() } returns false + every { isSyncConnecting() } returns false + 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 + 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 { lastUpdateTimeMillis } 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() } 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 { 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" + + 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 + } + + private fun createUseCase() { + useCase = ObserveSyncInfoUseCase( + configManager = configManager, + connectivityTracker = connectivityTracker, + enrolmentRecordRepository = enrolmentRecordRepository, + authStore = authStore, + imageRepository = imageRepository, + eventSyncManager = eventSyncManager, + syncOrchestrator = syncOrchestrator, + tokenizationProcessor = tokenizationProcessor, + timeHelper = timeHelper, + ticker = ticker, + appForegroundStateTracker = appForegroundStateTracker, + ) + } + + @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 minutes ago") + 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 { lastUpdateTimeMillis } returns 120_000 // 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 minutes ago") + } + + @Test + fun `should emit SyncInfo with correct syncInfoSectionModules data`() = runTest { + val mockProjectConfigWithModules = mockk { + 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)) + } + 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() } 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) + } + every { synchronization } returns createMockSynchronizationConfiguration() + } + val mockDeviceConfigWithModules = mockk { + every { selectedModules } returns listOf(TokenizableString.Raw("module_1"), TokenizableString.Raw("module_2")) + } + every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithModules) + every { configManager.observeDeviceConfiguration() } returns MutableStateFlow(mockDeviceConfigWithModules) + coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithModules + coEvery { configManager.getDeviceConfiguration() } returns mockDeviceConfigWithModules + coEvery { enrolmentRecordRepository.count(any()) } returnsMany listOf(10, 15, 25) // records total, module_1, module_2 + every { 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() + } + every { synchronization } returns createMockSynchronizationConfiguration() + } + val mockDeviceConfigWithoutModules = mockk { + every { selectedModules } returns emptyList() + } + every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithoutModules) + every { configManager.observeDeviceConfiguration() } returns MutableStateFlow(mockDeviceConfigWithoutModules) + coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithoutModules + coEvery { configManager.getDeviceConfiguration() } returns mockDeviceConfigWithoutModules + every { 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() + } + every { synchronization } returns createMockSynchronizationConfiguration() + } + 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() } returns DownSyncCounts(42, isLowerBound = false) + every { mockProjectConfigWithDownSync.isSimprintsEventDownSyncAllowed() } 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() + } + every { synchronization } returns createMockSynchronizationConfiguration() + } + 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() } throws Exception("Timeout") + every { mockProjectConfigWithDownSync.isSimprintsEventDownSyncAllowed() } 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() + } + every { synchronization } returns createMockSynchronizationConfiguration() + } + 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() } throws RuntimeException("Network error") + every { mockProjectConfigWithDownSync.isSimprintsEventDownSyncAllowed() } 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() + } + + @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 + 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 { lastUpdateTimeMillis } 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 { lastUpdateTimeMillis } 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 { synchronization } returns createMockSynchronizationConfiguration() + } + 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() + } + every { synchronization } returns mockk(relaxed = true) { + every { up } returns mockk(relaxed = true) { + every { coSync } returns mockk(relaxed = true) { + every { kind } returns UpSynchronizationConfiguration.UpSynchronizationKind.NONE + } + } + } + }, + ) + val deviceConfigFlow = MutableStateFlow( + mockk(relaxed = true) { + every { selectedModules } returns emptyList() + }, + ) // started without selected modules + every { configManager.observeDeviceConfiguration() } returns deviceConfigFlow + 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)) + every { timeHelper.readableBetweenNowAndTime(any()) } returnsMany listOf("0 minutes ago", "1 minute ago") + // MutableStateFlow of Unit won't emit another (identical) Unit, so we'll count minutes and map to Units + val timePaceFlow = MutableStateFlow(0) + every { ticker.observeTickOncePerMinute() } returns timePaceFlow.map { } + createUseCase() + + val initialResult = useCase().first() + + 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 minute ago") + } + + // 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) + every { mockProjectConfiguration.isCommCareEventDownSyncAllowed() } returns 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 `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(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 enabled when online and there is sync to Simprints`() = runTest { + every { connectivityTracker.observeIsConnected().asFlow() } returns flowOf(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() + + assertThat(result.syncInfoSectionRecords.isSyncButtonEnabled).isTrue() + } + + @Test + 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 CommCare permission error`() = runTest { + val mockCommCarePermissionErrorEventSyncState = mockk(relaxed = true) { + every { isSyncFailedBecauseCommCarePermissionIsMissing() } returns true + } + 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() + + assertThat(result.syncInfoSectionRecords.isSyncButtonEnabled).isFalse() + } + + @Test + fun `should calculate correct record last sync time when sync time available`() = runTest { + 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 minutes ago") + } + + @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 { lastUpdateTimeMillis } returns 180_000 // 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 minutes ago") + } + + @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 { lastUpdateTimeMillis } returns null + } + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockImageStatusWithoutLastSync) + createUseCase() + + val result = useCase().first() + + 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) + 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) + } + every { synchronization } returns createMockSynchronizationConfiguration() + } + val mockEmptyDeviceConfig = mockk { + every { selectedModules } returns emptyList() + } + val mockIdleEventSyncState = mockk(relaxed = true) { + every { isSyncFailed() } returns false + every { isSyncInProgress() } returns false + } + every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigRequiringModules) + every { configManager.observeDeviceConfiguration() } returns MutableStateFlow(mockEmptyDeviceConfig) + coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigRequiringModules + coEvery { configManager.getDeviceConfiguration() } returns mockEmptyDeviceConfig + every { 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() + } + + // 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 + 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) + } + every { synchronization } returns createMockSynchronizationConfiguration() + } + val mockDeviceConfigWithTokenizedModules = mockk { + every { selectedModules } returns listOf(tokenizedModule) + } + every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithModules) + every { configManager.observeDeviceConfiguration() } returns MutableStateFlow(mockDeviceConfigWithTokenizedModules) + coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithModules + coEvery { configManager.getDeviceConfiguration() } returns mockDeviceConfigWithTokenizedModules + coEvery { enrolmentRecordRepository.count(any()) } returnsMany listOf(10, 10) // total, and the module + every { + tokenizationProcessor.decrypt(tokenizedModule, TokenKeyType.ModuleId, any()) + } returns TokenizableString.Raw("decrypted_module") + every { mockProjectConfigWithModules.isModuleSelectionAvailable() } returns true + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionModules.isSectionAvailable).isTrue() + assertThat(result.syncInfoSectionModules.moduleCounts).hasSize(2) // total + the module + assertThat(result.syncInfoSectionModules.moduleCounts[1].name).isEqualTo("decrypted_module") + verify { tokenizationProcessor.decrypt(tokenizedModule, TokenKeyType.ModuleId, any()) } + } + + @Test + fun `should correctly handle raw module names`() = runTest { + val rawModule = TokenizableString.Raw("raw_module_name") + val mockProjectConfigWithModules = mockk { + every { general } returns mockk { + every { modalities } returns listOf(GeneralConfiguration.Modality.FINGERPRINT) + } + every { synchronization } returns createMockSynchronizationConfiguration() + } + val mockDeviceConfigWithRawModules = mockk { + every { selectedModules } returns listOf(rawModule) + } + every { configManager.observeProjectConfiguration() } returns MutableStateFlow(mockProjectConfigWithModules) + every { configManager.observeDeviceConfiguration() } returns MutableStateFlow(mockDeviceConfigWithRawModules) + coEvery { configManager.getProjectConfiguration() } returns mockProjectConfigWithModules + coEvery { configManager.getDeviceConfiguration() } returns mockDeviceConfigWithRawModules + coEvery { enrolmentRecordRepository.count(any()) } returnsMany listOf(10, 10) // total, and the module + every { mockProjectConfigWithModules.isModuleSelectionAvailable() } returns true + createUseCase() + + val result = useCase().first() + + assertThat(result.syncInfoSectionModules.isSectionAvailable).isTrue() + assertThat(result.syncInfoSectionModules.moduleCounts).hasSize(2) // total + the module + assertThat(result.syncInfoSectionModules.moduleCounts[1].name).isEqualTo("raw_module_name") + verify(exactly = 0) { tokenizationProcessor.decrypt(any(), any(), any()) } + } + + @Test + fun `should show CommCare permission missing when does not have permission`() = runTest { + val mockNormalEventSyncState = mockk(relaxed = true) { + 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/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..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,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.Ticker import com.simprints.core.tools.utils.EncodingUtils import com.simprints.core.tools.utils.StringTokenizer import com.simprints.testtools.unit.EncodingUtilsImplForTests @@ -42,7 +43,11 @@ object FakeCoreModule { @Provides @Singleton - fun provideTimeHelper(): TimeHelper = mockk() + fun provideTimeHelper(): TimeHelper = mockk(relaxed = true) + + @Provides + @Singleton + fun provideTicker(): Ticker = mockk(relaxed = true) @Provides @Singleton @@ -95,5 +100,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..d75ba0c93d --- /dev/null +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/tools/di/FakeSecurityModule.kt @@ -0,0 +1,25 @@ +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) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 22e4490f21..11ae3454d1 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/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..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 @@ -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 observeSignedInProjectId(): 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..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 @@ -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 observeSignedInProjectId(): StateFlow = loginInfoStore.observeSignedInProjectId() + 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..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 @@ -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 observeSignedInProjectId(): 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..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 @@ -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 `observeSignedInProjectId should return flow with initial project id value`() = runTest { + val expectedFlow = MutableStateFlow("initial-project-id") + every { loginInfoStore.observeSignedInProjectId() } returns expectedFlow + + val flow = loginManagerManagerImpl.observeSignedInProjectId() + val initialValue = flow.first() + + assertThat(initialValue).isEqualTo("initial-project-id") + verify(exactly = 1) { loginInfoStore.observeSignedInProjectId() } + } + + @Test + fun `observeSignedInProjectId should return flow with empty string when project id is empty`() = runTest { + val expectedFlow = MutableStateFlow("") + every { loginInfoStore.observeSignedInProjectId() } returns expectedFlow + + val flow = loginManagerManagerImpl.observeSignedInProjectId() + val initialValue = flow.first() + + assertThat(initialValue).isEqualTo("") + verify(exactly = 1) { loginInfoStore.observeSignedInProjectId() } + } + + @Test + fun `observeSignedInProjectId should listen to the logged in project id values`() = runTest { + val expectedValues = MutableStateFlow("project1").apply { emit("project2") } + every { loginInfoStore.observeSignedInProjectId() } returns expectedValues + + val receivedFlow = loginManagerManagerImpl.observeSignedInProjectId() + + assertThat(receivedFlow).isEqualTo(expectedValues) + verify(exactly = 1) { loginInfoStore.observeSignedInProjectId() } + } + 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..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 @@ -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 `observeSignedInProjectId should return flow with initial project id value`() = runTest { + loginInfoStoreImpl.signedInProjectId = "initial-project-id" + + val flow = loginInfoStoreImpl.observeSignedInProjectId() + val initialValue = flow.first() + + assertThat(initialValue).isEqualTo("initial-project-id") + } + + @Test + fun `observeSignedInProjectId should return flow with empty string when project id is empty`() = runTest { + loginInfoStoreImpl.signedInProjectId = "" + + val flow = loginInfoStoreImpl.observeSignedInProjectId() + val initialValue = flow.first() + + assertThat(initialValue).isEqualTo("") + } + + @Test + fun `observeSignedInProjectId should emit new values when signedInProjectId is updated`() = runTest { + val flow = loginInfoStoreImpl.observeSignedInProjectId() + 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 `observeSignedInProjectId should emit empty string when credentials are cleared`() = runTest { + loginInfoStoreImpl.signedInProjectId = "project-id" + val flow = loginInfoStoreImpl.observeSignedInProjectId() + 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..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,12 +15,14 @@ interface ConfigRepository { suspend fun getProjectConfiguration(): ProjectConfiguration - fun watchProjectConfiguration(): Flow + fun observeProjectConfiguration(): Flow suspend fun getDeviceState(): DeviceState suspend fun getDeviceConfiguration(): DeviceConfiguration + fun observeDeviceConfiguration(): 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 753bf34f2e..238d442175 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,6 +71,8 @@ internal class ConfigRepositoryImpl @Inject constructor( override suspend fun getDeviceConfiguration(): DeviceConfiguration = localDataSource.getDeviceConfiguration() + 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 e466839466..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,12 +16,14 @@ internal interface ConfigLocalDataSource { suspend fun getProjectConfiguration(): ProjectConfiguration - fun watchProjectConfiguration(): Flow + fun observeProjectConfiguration(): Flow suspend fun clearProjectConfiguration() suspend fun getDeviceConfiguration(): DeviceConfiguration + fun observeDeviceConfiguration(): 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 82298209af..7addea8850 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 observeProjectConfiguration(): 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 observeDeviceConfiguration(): 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/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 70547a5a21..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 @@ -77,3 +77,13 @@ 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() 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..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 @@ -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 @@ -247,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 kotlinx.coroutines.flow.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) @@ -312,4 +314,27 @@ class ConfigRepositoryImplTest { verify(exactly = 0) { Simber.i(any(), any()) } } + + @Test + fun `observeDeviceConfiguration 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.observeDeviceConfiguration() } returns flow { + emit(config1) + emit(config2) + } + + val emittedConfigs = configServiceImpl.observeDeviceConfiguration().toList() + + assertThat(emittedConfigs).hasSize(2) + assertThat(emittedConfigs[0]).isEqualTo(config1) + assertThat(emittedConfigs[1]).isEqualTo(config2) + 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 73a9649567..401a06587e 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 @@ -25,6 +25,7 @@ import com.simprints.infra.config.store.tokenization.TokenizationProcessor import com.simprints.testtools.common.coroutines.TestCoroutineRule import com.simprints.testtools.common.syntax.assertThrows import io.mockk.mockk +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import org.junit.After @@ -304,7 +305,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") @@ -315,7 +316,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) @@ -327,4 +328,31 @@ class ConfigLocalDataSourceImplTest { assertThat(emittedConfigs[2]).isEqualTo(config4) job.cancel() } + + @Test + fun `observeDeviceConfiguration 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.observeDeviceConfiguration().first() + + assertThat(result1).isEqualTo(config1) + + configLocalDataSourceImpl.updateDeviceConfiguration { config2 } + + val result2 = configLocalDataSourceImpl.observeDeviceConfiguration().first() + + assertThat(result2).isEqualTo(config2) + } + + @Test + fun `observeDeviceConfiguration should emit default configuration initially`() = runTest { + val result = configLocalDataSourceImpl.observeDeviceConfiguration().first() + + assertThat(result).isEqualTo(ConfigLocalDataSourceImpl.defaultDeviceConfiguration.toDomain()) + } } 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 e5fdd659c4..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 @@ -503,4 +503,117 @@ 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 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()).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 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()).isFalse() + } } 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..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 @@ -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 isProjectRefreshingFlow: MutableStateFlow = MutableStateFlow(false) + + suspend fun refreshProject(projectId: String): ProjectWithConfig { + isProjectRefreshingFlow.tryEmit(true) + try { + return configRepository.refreshProject(projectId).also { + enrolmentRecordRepository.tokenizeExistingRecords(it.project) + configSyncCache.saveUpdateTime() + realmToRoomMigrationScheduler.scheduleMigrationWorkerIfNeeded() + } + } finally { + isProjectRefreshingFlow.tryEmit(false) + } } suspend fun getProject(projectId: String): Project = try { @@ -49,12 +60,18 @@ class ConfigManager @Inject constructor( } } - fun watchProjectConfiguration(): Flow = configRepository - .watchProjectConfiguration() + fun observeIsProjectRefreshing(): Flow = isProjectRefreshingFlow.asStateFlow() + + fun observeProjectConfiguration(): Flow = configRepository + .observeProjectConfiguration() .onStart { getProjectConfiguration() } // to invoke download if empty suspend fun getDeviceConfiguration(): DeviceConfiguration = configRepository.getDeviceConfiguration() + fun observeDeviceConfiguration(): Flow = configRepository + .observeDeviceConfiguration() + .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..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 @@ -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 @@ -143,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 kotlinx.coroutines.flow.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) @@ -160,16 +166,74 @@ 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 { + 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() } assertThat(emittedConfigs).hasSize(1) assertThat(emittedConfigs[0]).isEqualTo(projectConfiguration) } + + @Test + fun `observeIsProjectRefreshing should initially emit false`() = runTest { + val isRefreshing = configManager.observeIsProjectRefreshing().first() + assertThat(isRefreshing).isFalse() + } + + @Test + fun `observeIsProjectRefreshing should emit false after refreshProject completes`() = runTest { + coEvery { configRepository.refreshProject(PROJECT_ID) } returns projectWithConfig + configManager.refreshProject(PROJECT_ID) + + val isRefreshing = configManager.observeIsProjectRefreshing().first() + + assertThat(isRefreshing).isFalse() + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `observeIsProjectRefreshing should emit true during refreshProject and false when done`() = runTest { + coEvery { configRepository.refreshProject(PROJECT_ID) } coAnswers { + delay(1000) + projectWithConfig + } + + assertThat(configManager.observeIsProjectRefreshing().first()).isFalse() // before + + launch { configManager.refreshProject(PROJECT_ID) } + advanceTimeBy(500) + + assertThat(configManager.observeIsProjectRefreshing().first()).isTrue() // during + + advanceTimeBy(1000) + + assertThat(configManager.observeIsProjectRefreshing().first()).isFalse() // after + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `observeIsProjectRefreshing should emit false even when refreshProject fails`() = runTest { + coEvery { configRepository.refreshProject(PROJECT_ID) } coAnswers { + delay(500) + throw Exception("Test exception") + } + + assertThat(configManager.observeIsProjectRefreshing().first()).isFalse() // before + + launch { + try { + configManager.refreshProject(PROJECT_ID) + } catch (e: Exception) { + // Expected + } + } + advanceTimeBy(1000) + + assertThat(configManager.observeIsProjectRefreshing().first()).isFalse() // after failure + } } 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/CoreModule.kt b/infra/core/src/main/java/com/simprints/core/CoreModule.kt index d2dc20408f..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,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.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 @@ -45,6 +47,10 @@ object CoreModule { ), ) + @Provides + @Singleton + fun provideTicker(): Ticker = TickerImpl() + @Provides @Singleton fun provideSimNetworkUtils( 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 new file mode 100644 index 0000000000..41f2e063a3 --- /dev/null +++ b/infra/core/src/main/java/com/simprints/core/tools/extentions/Flow.ext.kt @@ -0,0 +1,53 @@ +package com.simprints.core.tools.extentions + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.scan + +fun combine9( + flow1: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + flow8: Flow, + flow9: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9) -> R, +): Flow = combine(flow1, flow2, flow3, flow4, flow5, flow6, flow7, flow8, flow9) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7, + args[7] as T8, + args[8] as T9, + ) +} + +fun Flow.onChange( + comparator: (T, T) -> Boolean, + action: suspend (T) -> Unit, +) = 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/main/java/com/simprints/core/tools/time/Ticker.kt b/infra/core/src/main/java/com/simprints/core/tools/time/Ticker.kt new file mode 100644 index 0000000000..0a2802a16c --- /dev/null +++ b/infra/core/src/main/java/com/simprints/core/tools/time/Ticker.kt @@ -0,0 +1,9 @@ +package com.simprints.core.tools.time + +import androidx.annotation.Keep +import kotlinx.coroutines.flow.Flow + +@Keep +interface Ticker { + fun observeTickOncePerMinute(): Flow +} diff --git a/infra/core/src/main/java/com/simprints/core/tools/time/TickerImpl.kt b/infra/core/src/main/java/com/simprints/core/tools/time/TickerImpl.kt new file mode 100644 index 0000000000..7fb8b4eea5 --- /dev/null +++ b/infra/core/src/main/java/com/simprints/core/tools/time/TickerImpl.kt @@ -0,0 +1,19 @@ +package com.simprints.core.tools.time + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +class TickerImpl @Inject constructor() : Ticker { + override fun observeTickOncePerMinute(): Flow = flow { + while (true) { + emit(Unit) + delay(ONE_MINUTE_IN_MILLIS) + } + } + + private companion object { + private const val ONE_MINUTE_IN_MILLIS = 60 * 1000L + } +} diff --git a/infra/core/src/test/java/com/simprints/core/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 new file mode 100644 index 0000000000..279ef3c537 --- /dev/null +++ b/infra/core/src/test/java/com/simprints/core/tools/extentions/FlowExtTest.kt @@ -0,0 +1,110 @@ +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 `combine9 combines 9 flows`() = runTest { + val flow1 = flowOf(1) + val flow2 = flowOf(2) + val flow3 = flowOf(3) + val flow4 = flowOf(4) + val flow5 = flowOf(5) + val flow6 = flowOf(6) + val flow7 = flowOf(7) + val flow8 = flowOf(8) + val flow9 = flowOf(9) + + val result = combine9(flow1, flow2, flow3, flow4, flow5, flow6, flow7, flow8, flow9) { t1, t2, t3, t4, t5, t6, t7, t8, t9 -> + t1 + t2 + t3 + t4 + t5 + t6 + t7 + t8 + t9 + }.toList() + + assertThat(result).isEqualTo(listOf(1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9)) + } + + @Test + fun `onChange triggers action when comparator returns true`() = runTest { + val flow = flowOf(1, 2, 2, 3) + 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() + } +} diff --git a/infra/core/src/test/java/com/simprints/core/tools/time/TickerImplTest.kt b/infra/core/src/test/java/com/simprints/core/tools/time/TickerImplTest.kt new file mode 100644 index 0000000000..0287fa191d --- /dev/null +++ b/infra/core/src/test/java/com/simprints/core/tools/time/TickerImplTest.kt @@ -0,0 +1,70 @@ +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 TickerImplTest { + @get:Rule + val testCoroutineRule = TestCoroutineRule() + + private lateinit var tickerImpl: TickerImpl + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + + tickerImpl = TickerImpl() + } + + @Test + fun testObserveTickOncePerMinute_emitsImmediately() = runTest { + val result = tickerImpl + .observeTickOncePerMinute() + .take(1) + .toList() + + assertThat(result).hasSize(1) + assertThat(result[0]).isEqualTo(Unit) + } + + @Test + fun testObserveTickOncePerMinute_emitsMultipleTimes() = runTest { + val result = tickerImpl + .observeTickOncePerMinute() + .take(3) + .toList() + + assertThat(result).hasSize(3) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun testObserveTickOncePerMinute_waitsForCorrectTime() = runTest { + val flow = tickerImpl.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() + } +} 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..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 @@ -16,7 +16,7 @@ interface EventSyncManager { suspend fun getLastSyncTime(): Timestamp? - fun getLastSyncState(): LiveData + fun getLastSyncState(useDefaultValue: Boolean = false): LiveData suspend fun countEventsToUpload(): Flow 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 21ac5711cf..b90f7ed408 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 @@ -47,7 +48,14 @@ 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, 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 66ca884f3b..541ab26bc7 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 @@ -98,7 +98,8 @@ class EventSyncMasterWorker @AssistedInject internal constructor( ).also { Simber.d("Scheduled ${it.size} up workers", tag = tag) } } - if (configuration.isSimprintsEventDownSyncAllowed()) { + val isDownSyncAllowedInWorker = inputData.getBoolean(IS_DOWN_SYNC_ALLOWED, true) + if (configuration.isSimprintsEventDownSyncAllowed() && isDownSyncAllowedInWorker) { // TODO: Remove after all users have updated to 2025.3.0 // In versions before 2025.3.0 a bug prevented single subject down-sync scopes from being closed and uploaded. // Attempting to close any such scopes and recover at least some of the data. @@ -178,5 +179,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/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 693eb34dd2..c3e63aa6da 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 @@ -115,6 +117,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() @@ -130,7 +151,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 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 27aa0efa9b..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 @@ -151,6 +151,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 }, simprintsDownSyncWorkerBuilder = simprintsDownSyncWorkerBuilder, commCareDownSyncWorkerBuilder = commCareDownSyncWorkerBuilder, 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..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 @@ -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..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 @@ -52,7 +52,10 @@ 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..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): 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 +39,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..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 @@ -25,14 +25,20 @@ 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}") + var sampleIndex = 0 + 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, @@ -76,6 +82,7 @@ internal class SignedUrlSampleUploader @Inject constructor( break } Simber.i("Uploading ${sample.sampleId}") + progressCallback?.invoke(sampleIndex++, samplesSize) 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/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 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 35e19c068b..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 6fd3ae88b1..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 62f6c4904d..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 8ed4d1e7dc..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 9eb48b38bb..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 f081646a68..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 a1134cfd16..40c78c5480 100644 --- a/infra/resources/src/main/res/values/strings.xml +++ b/infra/resources/src/main/res/values/strings.xml @@ -316,29 +316,59 @@ 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 - You need to give permission to sync from CommCare - - 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 + Syncing from CommCare requires permission in Settings + 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… Activity: %1$s @@ -383,21 +413,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. 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..32e93be2a7 --- /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 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 new file mode 100644 index 0000000000..1c90d1bd01 --- /dev/null +++ b/infra/sync/src/main/java/com/simprints/infra/sync/ImageSyncTimestampProvider.kt @@ -0,0 +1,39 @@ +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 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 + .getLong(IMAGE_SYNC_COMPLETION_TIME_MILLIS, 0) + .takeIf { + securePrefs.contains(IMAGE_SYNC_COMPLETION_TIME_MILLIS) + } + + 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/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/SyncOrchestrator.kt b/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestrator.kt index 57d67bff67..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,10 +17,16 @@ interface SyncOrchestrator { fun cancelEventSync() - fun startEventSync() + fun startEventSync(isDownSyncAllowed: Boolean = true) 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..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 @@ -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 { @@ -124,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), ) } @@ -137,6 +140,64 @@ 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 = workManager + .getWorkInfosFlow(WorkQuery.fromUniqueWorkNames(SyncConstants.FILE_UP_SYNC_WORK_NAME)) + .associateWithIfSyncing() + .map { (workInfos, isSyncing) -> + val lastUpdateTimestamp = imageSyncTimestampProvider.getLastImageSyncTimestamp() + 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, lastUpdateTimestamp) + } + + /** + * 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.getMillisSinceLastImageSync() == 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 +224,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/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/ImageSyncTimestampProviderTest.kt b/infra/sync/src/test/java/com/simprints/infra/sync/ImageSyncTimestampProviderTest.kt new file mode 100644 index 0000000000..9f953dd5d9 --- /dev/null +++ b/infra/sync/src/test/java/com/simprints/infra/sync/ImageSyncTimestampProviderTest.kt @@ -0,0 +1,133 @@ +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 `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.getMillisSinceLastImageSync() + + assertThat(result).isNull() + } + + @Test + 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.getMillisSinceLastImageSync() + + assertThat(result).isNull() + } + + @Test + fun `getMillisSinceLastImageSync returns correct seconds when timestamp exists`() { + val lastSyncTimeMillis = 1000000L + val currentTimeMillis = 1005000L // 5 seconds later + 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.getMillisSinceLastImageSync() + + 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() + + val result = imageSyncTimestampProvider.getMillisSinceLastImageSync() + + 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") + } + } +} 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..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 @@ -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,12 +11,15 @@ 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 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.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 @@ -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,96 @@ 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.getMillisSinceLastImageSync() } returns 30_000L + every { imageSyncTimestampProvider.getLastImageSyncTimestamp() } returns 1234567890L + + val status = syncOrchestrator.observeImageSyncStatus().first() + + assertThat(status.isSyncing).isTrue() + assertThat(status.lastUpdateTimeMillis).isEqualTo(1234567890L) + } + + @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.getMillisSinceLastImageSync() } returns 120_000L + every { imageSyncTimestampProvider.getLastImageSyncTimestamp() } returns 1234567890L + + val status = syncOrchestrator.observeImageSyncStatus().first() + + assertThat(status.isSyncing).isFalse() + 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 + 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.getMillisSinceLastImageSync() } 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.getMillisSinceLastImageSync() } 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 +428,10 @@ class SyncOrchestratorImplTest { @Test fun `delegates sync info deletion`() = runTest { syncOrchestrator.deleteEventSyncInfo() - coVerify { eventSyncManager.deleteSyncInfo() } + coVerify { + eventSyncManager.deleteSyncInfo() + imageSyncTimestampProvider.clearTimestamp() + } } @Test @@ -363,6 +483,7 @@ class SyncOrchestratorImplTest { eventSyncManager, shouldScheduleFirmwareUpdate, cleanupDeprecatedWorkers, + imageSyncTimestampProvider, CoroutineScope(testCoroutineRule.testCoroutineDispatcher), ) @@ -372,6 +493,23 @@ 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" 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() } } 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..b5944a4b27 --- /dev/null +++ b/infra/ui-base/src/main/java/com/simprints/infra/uibase/view/View.ext.kt @@ -0,0 +1,33 @@ +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