diff --git a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragment.kt b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragment.kt index ed904c887c..129dc3455e 100644 --- a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragment.kt +++ b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragment.kt @@ -117,11 +117,7 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) vm.initCapture(mainVm.bioSDK, mainVm.samplesToCapture, mainVm.attemptNumber) } } - if (vm.isAutoCapture) { - // Await until capture button is pressed - vm.holdOffAutoCapture() - binding.captureFeedbackBtn.isClickable = true - } + enableCaptureButtonIfAutoCapture() binding.captureInstructionsBtn.setOnClickListener { findNavController().navigateSafely( @@ -185,9 +181,12 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) override fun onResume() { super.onResume() - when { - requireActivity().hasPermission(Manifest.permission.CAMERA) -> setUpCamera() + requireActivity().hasPermission(Manifest.permission.CAMERA) -> { + setUpCamera() + enableCaptureButtonIfAutoCapture() + } + mainVm.shouldCheckCameraPermissions.getAndSet(false) -> { // Check permission in onResume() so that if user left the app to go to Settings // and give the permission, it's reflected when they come back to SID @@ -202,6 +201,14 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) } } + private fun enableCaptureButtonIfAutoCapture() { + if (vm.isAutoCapture) { + // Await until capture button is pressed + vm.holdOffAutoCapture() + binding.captureFeedbackBtn.isClickable = true + } + } + override fun onStop() { toggleTorche(false) // Shut down our background executor diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/base/BaseFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/base/BaseFragment.kt index baed5f8516..9a66322268 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/base/BaseFragment.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/base/BaseFragment.kt @@ -18,7 +18,7 @@ internal class BaseFragment : Fragment(R.layout.fragment_base) { if (authStore.signedInProjectId.isNotEmpty()) { findNavController().navigateSafely(this, BaseFragmentDirections.actionBaseFragmentToMainFragment()) } else { - findNavController().navigateSafely(this, BaseFragmentDirections.actionBaseFragmentToRequestLoginFragment()) + findNavController().navigateSafely(this, BaseFragmentDirections.actionToRequestLoginFragment()) } } } diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt index 3358fe7f4a..48ff415af0 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/debug/DebugFragment.kt @@ -79,7 +79,9 @@ internal class DebugFragment : Fragment(R.layout.fragment_debug) { } binding.syncStart.setOnClickListener { - syncOrchestrator.startEventSync() + lifecycleScope.launch(dispatcher) { + syncOrchestrator.startEventSync() + } } binding.syncStop.setOnClickListener { @@ -87,7 +89,9 @@ internal class DebugFragment : Fragment(R.layout.fragment_debug) { } binding.syncSchedule.setOnClickListener { - syncOrchestrator.rescheduleEventSync() + lifecycleScope.launch(dispatcher) { + syncOrchestrator.rescheduleEventSync() + } } binding.clearFirebaseToken.setOnClickListener { 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 e55a0ad2aa..c6ca37ee2d 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 @@ -19,7 +19,6 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel @@ -52,9 +51,7 @@ internal class LogoutSyncViewModel @Inject constructor( } fun logout() { - viewModelScope.launch { - logoutUseCase() - } + logoutUseCase() } private companion object { 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 5bdcd83226..06d36708ff 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 @@ -51,11 +51,5 @@ class LogoutSyncFragment : Fragment(R.layout.fragment_logout_sync) { } } } - viewModel.logoutEventLiveData.observe(viewLifecycleOwner) { - findNavController().navigateSafely( - this@LogoutSyncFragment, - R.id.action_logoutSyncFragment_to_requestLoginFragment, - ) - } } } diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/syncdecline/LogoutSyncDeclineFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/syncdecline/LogoutSyncDeclineFragment.kt index 0271f6debe..9ba9ba7918 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/syncdecline/LogoutSyncDeclineFragment.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/syncdecline/LogoutSyncDeclineFragment.kt @@ -74,7 +74,7 @@ class LogoutSyncDeclineFragment : Fragment(R.layout.fragment_logout_sync_decline viewModel.logoutEventLiveData.observe(viewLifecycleOwner) { findNavController().navigateSafely( this@LogoutSyncDeclineFragment, - LogoutSyncDeclineFragmentDirections.actionLogoutSyncDeclineFragmentToRequestLoginFragment(), + LogoutSyncDeclineFragmentDirections.actionToRequestLoginFragment(null), ) } } diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCase.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCase.kt index 0910f7cf06..7db3251a52 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCase.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/logout/usecase/LogoutUseCase.kt @@ -6,7 +6,7 @@ import com.simprints.infra.enrolment.records.repository.EnrolmentRecordRepositor import com.simprints.infra.enrolment.records.repository.local.migration.RealmToRoomMigrationFlagsStore import com.simprints.infra.sync.SyncOrchestrator import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.withContext +import kotlinx.coroutines.runBlocking import javax.inject.Inject internal class LogoutUseCase @Inject constructor( @@ -16,7 +16,8 @@ internal class LogoutUseCase @Inject constructor( private val enrolmentRecordRepository: EnrolmentRecordRepository, @DispatcherIO private val ioDispatcher: CoroutineDispatcher, ) { - suspend operator fun invoke() = withContext(ioDispatcher) { + // To prevent a race between wiping data and navigation, this use case must block the executing thread + operator fun invoke() = runBlocking(ioDispatcher) { // Cancel all background sync syncOrchestrator.cancelBackgroundWork() syncOrchestrator.deleteEventSyncInfo() diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/about/AboutFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/about/AboutFragment.kt index 60cf05f8b9..fc105b5a4b 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/about/AboutFragment.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/about/AboutFragment.kt @@ -19,9 +19,9 @@ import com.simprints.feature.dashboard.R import com.simprints.feature.dashboard.databinding.FragmentSettingsAboutBinding import com.simprints.feature.dashboard.settings.password.SettingsPasswordDialogFragment import com.simprints.infra.config.store.models.GeneralConfiguration.Modality.FINGERPRINT -import com.simprints.infra.uibase.view.applySystemBarInsets import com.simprints.infra.uibase.navigation.navigateSafely import com.simprints.infra.uibase.system.Clipboard +import com.simprints.infra.uibase.view.applySystemBarInsets import com.simprints.infra.uibase.viewbinding.viewBinding import dagger.hilt.android.AndroidEntryPoint import java.util.Locale @@ -96,7 +96,7 @@ internal class AboutFragment : PreferenceFragmentCompat() { LiveDataEventWithContentObserver { val destination = when (it) { LogoutDestination.LogoutDataSyncScreen -> AboutFragmentDirections.actionAboutFragmentToLogoutNavigation() - LogoutDestination.LoginScreen -> AboutFragmentDirections.actionAboutFragmentToRequestLoginFragment() + LogoutDestination.LoginScreen -> AboutFragmentDirections.actionToRequestLoginFragment() } findNavController().navigateSafely(this, destination) }, diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfo.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfo.kt index f506c1d115..22e7765840 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfo.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfo.kt @@ -4,6 +4,7 @@ data class SyncInfo( val isLoggedIn: Boolean = true, val isConfigurationLoadingProgressBarVisible: Boolean = false, val isLoginPromptSectionVisible: Boolean = false, + val isImageSyncSectionVisible: Boolean = false, val syncInfoSectionRecords: SyncInfoSectionRecords = SyncInfoSectionRecords(), val syncInfoSectionImages: SyncInfoSectionImages = SyncInfoSectionImages(), val syncInfoSectionModules: SyncInfoSectionModules = SyncInfoSectionModules(), @@ -84,3 +85,8 @@ data class SyncInfoModuleCount( val name: String, val count: String = "", ) + +enum class LogoutActionReason { + USER_ACTION, + PROJECT_ENDING_OR_DEVICE_COMPROMISED, +} diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoFragment.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoFragment.kt index c68d6989ac..bb5c777d72 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoFragment.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoFragment.kt @@ -12,6 +12,7 @@ import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.core.view.isGone import androidx.core.view.isInvisible +import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle @@ -22,11 +23,14 @@ import com.google.android.material.progressindicator.LinearProgressIndicator import com.simprints.core.tools.utils.TimeUtils import com.simprints.feature.dashboard.R import com.simprints.feature.dashboard.databinding.FragmentSyncInfoBinding +import com.simprints.feature.dashboard.requestlogin.LogoutReason +import com.simprints.feature.dashboard.requestlogin.RequestLoginFragmentArgs import com.simprints.feature.dashboard.settings.syncinfo.modulecount.ModuleCount import com.simprints.feature.dashboard.settings.syncinfo.modulecount.ModuleCountAdapter import com.simprints.feature.dashboard.view.ConfigurableSyncInfoFragmentContainer import com.simprints.feature.login.LoginContract import com.simprints.infra.uibase.navigation.handleResult +import com.simprints.infra.uibase.navigation.navigateSafely import com.simprints.infra.uibase.navigation.toBundle import com.simprints.infra.uibase.view.applySystemBarInsets import com.simprints.infra.uibase.view.setPulseAnimation @@ -138,8 +142,20 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.RESUMED) { - viewModel.logoutEventFlow.collect { + viewModel.logoutEventFlow.collect { reason -> viewModel.performLogout() + + val logoutReason = reason?.takeIf { it == LogoutActionReason.PROJECT_ENDING_OR_DEVICE_COMPROMISED }?.let { + LogoutReason( + title = getString(IDR.string.dashboard_sync_project_ending_alert_title), + body = getString(IDR.string.dashboard_sync_project_ending_message), + ) + } + findNavController().navigateSafely( + parentFragment, + R.id.action_to_requestLoginFragment, + RequestLoginFragmentArgs(logoutReason = logoutReason).toBundle(), + ) } } } @@ -180,7 +196,7 @@ internal class SyncInfoFragment : Fragment(R.layout.fragment_sync_info) { renderRecordsSection(syncInfo.syncInfoSectionRecords, config) // Images section - binding.layoutImagesSync.isGone = !config.isSyncInfoImageSyncVisible + binding.layoutImagesSync.isVisible = config.isSyncInfoImageSyncVisible && syncInfo.isImageSyncSectionVisible renderImagesSection(syncInfo.syncInfoSectionImages) // Modules section 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 3d2286687c..820b6d6324 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModel.kt @@ -7,7 +7,6 @@ import androidx.lifecycle.asFlow import androidx.lifecycle.asLiveData import androidx.lifecycle.viewModelScope import com.simprints.core.DispatcherIO -import com.simprints.core.livedata.LiveDataEventWithContent import com.simprints.core.tools.time.TimeHelper import com.simprints.feature.dashboard.logout.usecase.LogoutUseCase import com.simprints.feature.dashboard.settings.syncinfo.usecase.ObserveSyncInfoUseCase @@ -23,13 +22,17 @@ import com.simprints.infra.sync.SyncOrchestrator import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import javax.inject.Inject @@ -60,34 +63,97 @@ internal class SyncInfoViewModel @Inject constructor( private val imageSyncStatusFlow = syncOrchestrator.observeImageSyncStatus() - val logoutEventFlow: Flow> = combine( + private val eventSyncButtonClickFlow = MutableSharedFlow(extraBufferCapacity = 1) + private val imageSyncButtonClickFlow = MutableSharedFlow(extraBufferCapacity = 1) + + val logoutEventFlow: Flow = combine( + authStore.observeSignedInProjectId(), eventSyncStateFlow, imageSyncStatusFlow, - ) { eventSyncState, imageSyncStatus -> - val isReadyToLogOut = - isPreLogoutUpSync && eventSyncState.isSyncCompleted() && !imageSyncStatus.isSyncing - return@combine isReadyToLogOut + ) { projectId, eventSyncState, imageSyncStatus -> + when { + projectId.isEmpty() -> LogoutActionReason.PROJECT_ENDING_OR_DEVICE_COMPROMISED + isPreLogoutUpSync && eventSyncState.isSyncCompleted() && !imageSyncStatus.isSyncing -> LogoutActionReason.USER_ACTION + else -> null + } }.debounce(LOGOUT_DELAY_MILLIS) - .filter { isReadyToLogOut -> - isReadyToLogOut // only when ready - }.map { - LiveDataEventWithContent(Unit) - }.flowOn(ioDispatcher) + .filter { it != null } + .flowOn(ioDispatcher) val syncInfoLiveData: LiveData by lazy { - observeSyncInfo(isPreLogoutUpSync) + val dataLayerDrivenSyncInfoFlow = observeSyncInfo(isPreLogoutUpSync) .onStart { startInitialSyncIfRequired() syncImagesAfterEventsWhenRequired() - }.flowOn(ioDispatcher) + } + + /* + * Visual sync button responsiveness optimization + * + * The problem: data layer-driven progress visualization is simple programmatically, but can be slow in the UI. + * + * How it would work without the optimization: + * The forceEventSync and toggleImageSync invoke sync purely on the data layer, + * so the UI may remain unaware of the forced sync command until data-driven evidence of sync starts appearing. + * This may take seconds on slow devices. + * + * How it works with the optimization: + * Each forced sync, invoked by forceEventSync and toggleImageSync, immediately reshapes the flow of events: + * At first we immediately emit a sync state that is forcefully marked as in progress, for events or images separately. + * And we start ignoring sync states that happen before the true progress in data layer appears. + * Once the true progress in data layer starts, we keep showing that true progress. + * Additionally, an initial progress is emitted on start, before any forced sync invocations. To prevent getting stuck. + */ + + val eventSyncButtonResponsiveSyncInfo = eventSyncButtonClickFlow.flatMapLatest { + dataLayerDrivenSyncInfoFlow + .dropWhile { syncInfo -> + !syncInfo.syncInfoSectionRecords.isProgressVisible + }.onStart { + val initialState = syncInfoLiveData.value ?: SyncInfo() + emit(initialState.forceEventSyncProgress()) + } + } + + val imageSyncButtonResponsiveSyncInfo = imageSyncButtonClickFlow.flatMapLatest { + dataLayerDrivenSyncInfoFlow + .dropWhile { syncInfo -> + !syncInfo.syncInfoSectionImages.isProgressVisible + }.onStart { + val initialState = syncInfoLiveData.value ?: SyncInfo() + emit(initialState.forceImageSyncProgress()) + } + } + + merge( + eventSyncButtonResponsiveSyncInfo, + imageSyncButtonResponsiveSyncInfo, + ).onStart { + emit(dataLayerDrivenSyncInfoFlow.firstOrNull() ?: SyncInfo()) + }.distinctUntilChanged() + .flowOn(ioDispatcher) .asLiveData(viewModelScope.coroutineContext) } - fun forceEventSync() { + fun forceEventSync(canEmitSyncButtonClick: Boolean = true) { viewModelScope.launch { + if (canEmitSyncButtonClick) { + val isEventSyncing = eventSyncStateFlow.firstOrNull()?.isSyncInProgress() == true + if (!isEventSyncing) { + eventSyncButtonClickFlow.emit(Unit) + } + } + syncOrchestrator.stopEventSync() - val isDownSyncAllowed = - !isPreLogoutUpSync && configManager.getProject(authStore.signedInProjectId).state == ProjectState.RUNNING + val projectState = try { + configManager.getProject(authStore.signedInProjectId).state + } catch (_: Exception) { + // If the device is compromised, project data is deleted. Access attempts will throw an exception, + // effectively appearing to the user as if the project has ended. + ProjectState.PROJECT_ENDED + } + + val isDownSyncAllowed = !isPreLogoutUpSync && projectState == ProjectState.RUNNING syncOrchestrator.startEventSync(isDownSyncAllowed) } } @@ -98,15 +164,14 @@ internal class SyncInfoViewModel @Inject constructor( if (isImageSyncing) { syncOrchestrator.stopImageSync() } else { + imageSyncButtonClickFlow.emit(Unit) syncOrchestrator.startImageSync() } } } fun performLogout() { - viewModelScope.launch { - logoutUseCase() - } + logoutUseCase() } fun requestNavigationToLogin() { @@ -142,7 +207,7 @@ internal class SyncInfoViewModel @Inject constructor( else -> false } if (isForceEventSync) { - forceEventSync() + forceEventSync(canEmitSyncButtonClick = false) } } } @@ -162,6 +227,32 @@ internal class SyncInfoViewModel @Inject constructor( } } + private fun SyncInfo.forceEventSyncProgress() = copy( + syncInfoSectionRecords = syncInfoSectionRecords.copy( + counterTotalRecords = "", + counterRecordsToUpload = "", + counterRecordsToDownload = "", + isInstructionDefaultVisible = false, + isInstructionCommCarePermissionVisible = false, + isInstructionNoModulesVisible = false, + isInstructionOfflineVisible = false, + isInstructionErrorVisible = false, + isProgressVisible = true, + isSyncButtonEnabled = false, + footerLastSyncMinutesAgo = "", + ), + ) + + private fun SyncInfo.forceImageSyncProgress() = copy( + syncInfoSectionImages = syncInfoSectionImages.copy( + counterImagesToUpload = "", + isInstructionDefaultVisible = false, + isInstructionOfflineVisible = false, + isProgressVisible = true, + footerLastSyncMinutesAgo = "", + ), + ) + private suspend fun ConfigManager.isModuleSelectionRequired() = getProjectConfiguration().isModuleSelectionAvailable() && getDeviceConfiguration().selectedModules.isEmpty() diff --git a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCase.kt b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCase.kt index 978b708b8d..4652e62a35 100644 --- a/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCase.kt +++ b/feature/dashboard/src/main/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCase.kt @@ -25,6 +25,7 @@ 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.isSampleUploadEnabledInProject import com.simprints.infra.config.store.models.isSimprintsEventDownSyncAllowed import com.simprints.infra.config.store.tokenization.TokenizationProcessor import com.simprints.infra.config.sync.ConfigManager @@ -202,7 +203,7 @@ internal class ObserveSyncInfoUseCase @Inject constructor( ) ) val isSyncButtonEnabled = - (eventSyncVisibleState == OnStandby) && + (eventSyncVisibleState == OnStandby || eventSyncVisibleState == Error) && ((!isPreLogoutUpSync && isDownSyncPossible) || isEventUpSyncPossible) val projectId = authStore.signedInProjectId @@ -233,23 +234,33 @@ internal class ObserveSyncInfoUseCase @Inject constructor( else -> DownSyncCounts(0, isLowerBound = false) } - val project = configManager.getProject(projectId) - val isProjectRunning = - project.state == ProjectState.RUNNING - val moduleCounts = deviceConfig.selectedModules.map { moduleName -> - ModuleCount( - name = when (moduleName) { - is TokenizableString.Raw -> moduleName - is TokenizableString.Tokenized -> tokenizationProcessor.decrypt( - encrypted = moduleName, - tokenKeyType = TokenKeyType.ModuleId, - project, - ) - }.value, - count = enrolmentRecordRepository.count( - SubjectQuery(projectId = projectId, moduleId = moduleName), - ), - ) + val project = try { + projectId.takeUnless { it.isBlank() }?.let { configManager.getProject(it) } + } catch (_: Exception) { + // If the device is compromised, project data is deleted. Access attempts will throw an exception, + // effectively appearing to the user as if the project has ended. + null + } + + val isProjectRunning = project?.state == ProjectState.RUNNING + val moduleCounts = if (project != null) { + deviceConfig.selectedModules.map { moduleName -> + ModuleCount( + name = when (moduleName) { + is TokenizableString.Raw -> moduleName + is TokenizableString.Tokenized -> tokenizationProcessor.decrypt( + encrypted = moduleName, + tokenKeyType = TokenKeyType.ModuleId, + project, + ) + }.value, + count = enrolmentRecordRepository.count( + SubjectQuery(projectId = projectId, moduleId = moduleName), + ), + ) + } + } else { + emptyList() } val modulesCountTotal = SyncInfoModuleCount( isTotal = true, @@ -310,12 +321,13 @@ internal class ObserveSyncInfoUseCase @Inject constructor( ) val syncInfo = SyncInfo( - isLoggedIn, + isLoggedIn = isLoggedIn, isConfigurationLoadingProgressBarVisible = isRefreshing, isLoginPromptSectionVisible = isReLoginRequired && !isPreLogoutUpSync, - syncInfoSectionRecords, - syncInfoSectionImages, - syncInfoSectionModules, + isImageSyncSectionVisible = projectConfig.isSampleUploadEnabledInProject(), + syncInfoSectionRecords = syncInfoSectionRecords, + syncInfoSectionImages = syncInfoSectionImages, + syncInfoSectionModules = syncInfoSectionModules, ) return@combine9 syncInfo }.onRecordSyncComplete { diff --git a/feature/dashboard/src/main/res/navigation/graph_dashboard.xml b/feature/dashboard/src/main/res/navigation/graph_dashboard.xml index 5ad73c5e46..91eb617620 100644 --- a/feature/dashboard/src/main/res/navigation/graph_dashboard.xml +++ b/feature/dashboard/src/main/res/navigation/graph_dashboard.xml @@ -33,11 +33,6 @@ android:name="com.simprints.feature.dashboard.base.BaseFragment" android:label="BaseFragment" tools:layout="@layout/fragment_base"> - - @@ -102,11 +92,6 @@ - @@ -146,11 +131,6 @@ - @@ -162,13 +142,13 @@ android:id="@+id/logOutSyncDeclineFragment" android:name="com.simprints.feature.dashboard.logout.syncdecline.LogoutSyncDeclineFragment" android:label="LogoutSyncDeclineFragment" - tools:layout="@layout/fragment_logout_sync_decline"> - - + tools:layout="@layout/fragment_logout_sync_decline" /> + + 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 9e74d7593e..7176f73921 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 @@ -61,18 +61,4 @@ internal class LogoutSyncFragmentTest { onView(withId(R.id.logout_sync_info)).check(matches(not(isDisplayed()))) onView(withId(R.id.logoutWithoutSyncButton)).check(matches(not(isDisplayed()))) } - - @Test - fun `should navigate to requestLoginFragment when logout event received`() { - every { logoutSyncViewModel.logoutEventLiveData } returns mockk { - every { observe(any(), any()) } answers { - secondArg>().onChanged(Unit) - } - } - val navController = testNavController(R.navigation.graph_dashboard, R.id.logout_navigation) - launchFragmentInHiltContainer(navController = navController) - - assertThat(navController.currentDestination?.id) - .isEqualTo(R.id.requestLoginFragment) - } } 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 0deaaac2c6..1098b55c23 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModelTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/SyncInfoViewModelTest.kt @@ -11,6 +11,7 @@ import com.simprints.feature.dashboard.logout.usecase.LogoutUseCase import com.simprints.feature.dashboard.settings.syncinfo.usecase.ObserveSyncInfoUseCase import com.simprints.feature.login.LoginResult import com.simprints.infra.authstore.AuthStore +import com.simprints.infra.authstore.exceptions.RemoteDbNotSignedInException import com.simprints.infra.config.store.models.DeviceConfiguration import com.simprints.infra.config.store.models.GeneralConfiguration import com.simprints.infra.config.store.models.Project @@ -27,18 +28,21 @@ 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 kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule import org.junit.Test import kotlin.test.fail +@OptIn(ExperimentalCoroutinesApi::class) class SyncInfoViewModelTest { @get:Rule val rule = InstantTaskExecutorRule() @@ -220,7 +224,6 @@ class SyncInfoViewModelTest { // LiveData logoutEventLiveData tests - @OptIn(ExperimentalCoroutinesApi::class) @Test fun `should trigger logout when pre-logout sync completes successfully`() = runTest { val mockCompletedEventSyncState = mockk(relaxed = true) { @@ -247,7 +250,6 @@ class SyncInfoViewModelTest { flowCollector.cancel() } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun `should emit a logout event after the intended delay since ready to logout`() = runTest { val mockCompletedEventSyncState = mockk(relaxed = true) { @@ -276,7 +278,25 @@ class SyncInfoViewModelTest { flowCollector.cancel() } - @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `should emit a logout event when auth store is cleared`() = runTest { + val projectIdFlow = MutableStateFlow(TEST_PROJECT_ID) + every { authStore.observeSignedInProjectId() } returns projectIdFlow + createViewModel() + + var numberOfEmissions = 0 + val flowCollector = async { + viewModel.logoutEventFlow.collect { + numberOfEmissions++ + } + } + projectIdFlow.value = "" + advanceUntilIdle() + + assertThat(numberOfEmissions).isEqualTo(1) + flowCollector.cancel() + } + @Test fun `should not trigger logout when not in pre-logout mode`() = runTest { val mockCompletedEventSyncState = mockk(relaxed = true) { @@ -302,7 +322,6 @@ class SyncInfoViewModelTest { flowCollector.cancel() } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun `should not trigger logout when records still syncing`() = runTest { val mockInProgressEventSyncState = mockk(relaxed = true) { @@ -329,7 +348,6 @@ class SyncInfoViewModelTest { flowCollector.cancel() } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun `should not trigger logout when images still syncing`() = runTest { val mockCompletedEventSyncState = mockk(relaxed = true) { @@ -407,6 +425,21 @@ class SyncInfoViewModelTest { coVerify { syncOrchestrator.startEventSync(isDownSyncAllowed = false) } } + @Test + fun `should start event sync with down sync disabled event sync when logged out`() = runTest { + val mockEndingProject = mockk { + every { state } throws RemoteDbNotSignedInException("stub!") + } + coEvery { configManager.getProject(any()) } returns mockEndingProject + createViewModel() + viewModel.isPreLogoutUpSync = false + + viewModel.forceEventSync() + + coVerify { syncOrchestrator.stopEventSync() } + coVerify { syncOrchestrator.startEventSync(isDownSyncAllowed = false) } + } + @Test fun `should stop current event sync before starting new one`() = runTest { viewModel.forceEventSync() @@ -510,6 +543,126 @@ class SyncInfoViewModelTest { coVerify(exactly = 0) { syncOrchestrator.startEventSync(any()) } } + // Sync button responsiveness optimization + + @Test + fun `should immediately show event progress snapshot when forcing event sync`() = runTest { + createViewModel() + + val values = viewModel.syncInfoLiveData.getOrAwaitValues(number = 2) { + viewModel.forceEventSync() + } + + val initial = values[0] + val forced = values[1] + assertThat(initial.syncInfoSectionRecords.isProgressVisible).isFalse() + assertThat(forced.syncInfoSectionRecords.isProgressVisible).isTrue() + } + + @Test + fun `should not emit forced event progress when events already syncing`() = runTest { + val mockInProgressEventSyncState = mockk(relaxed = true) { + every { isSyncInProgress() } returns true + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockInProgressEventSyncState) + createViewModel() + + val values = viewModel.syncInfoLiveData.getOrAwaitValues(number = 1) { + viewModel.forceEventSync() + } + + val initial = values[0] + assertThat(initial.syncInfoSectionRecords.isProgressVisible).isFalse() + } + + @Test + fun `should immediately show image progress snapshot when starting image sync`() = runTest { + val mockNotSyncingImageStatus = mockk(relaxed = true) { + every { isSyncing } returns false + } + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockNotSyncingImageStatus) + createViewModel() + + val values = viewModel.syncInfoLiveData.getOrAwaitValues(number = 2) { + viewModel.toggleImageSync() + } + + val initial = values[0] + val forced = values[1] + assertThat(initial.syncInfoSectionImages.isProgressVisible).isFalse() + assertThat(forced.syncInfoSectionImages.isProgressVisible).isTrue() + } + + @Test + fun `should not emit forced image progress when stopping image sync`() = runTest { + val mockSyncingImageStatus = mockk(relaxed = true) { + every { isSyncing } returns true + } + every { syncOrchestrator.observeImageSyncStatus() } returns MutableStateFlow(mockSyncingImageStatus) + createViewModel() + + val values = viewModel.syncInfoLiveData.getOrAwaitValues(number = 1) { + viewModel.toggleImageSync() + } + + val initial = values[0] + assertThat(initial.syncInfoSectionImages.isProgressVisible).isFalse() + } + + @Test + fun `should switch from forced to data-driven event sync progress once available`() = runTest { + val base = createDefaultSyncInfo() + val dataFlow = MutableStateFlow(base) + every { observeSyncInfo(any()) } returns dataFlow + createViewModel() + + val values = viewModel.syncInfoLiveData.getOrAwaitValues(number = 3) { + viewModel.forceEventSync() + dataFlow.value = base.copy( + syncInfoSectionRecords = base.syncInfoSectionRecords.copy( + isProgressVisible = true, + counterTotalRecords = "123", + ), + ) + } + + val initial = values[0] + val forced = values[1] + val dataDriven = values[2] + assertThat(initial.syncInfoSectionRecords.isProgressVisible).isFalse() + assertThat(forced.syncInfoSectionRecords.isProgressVisible).isTrue() + assertThat(forced.syncInfoSectionRecords.counterTotalRecords).isEmpty() + assertThat(dataDriven.syncInfoSectionRecords.isProgressVisible).isTrue() + assertThat(dataDriven.syncInfoSectionRecords.counterTotalRecords).isEqualTo("123") + } + + @Test + fun `should switch from forced to data-driven image sync progress once available`() = runTest { + val base = createDefaultSyncInfo() + val dataFlow = MutableStateFlow(base) + every { observeSyncInfo(any()) } returns dataFlow + createViewModel() + + val values = viewModel.syncInfoLiveData.getOrAwaitValues(number = 3) { + viewModel.toggleImageSync() + dataFlow.value = base.copy( + syncInfoSectionImages = base.syncInfoSectionImages.copy( + isProgressVisible = true, + counterImagesToUpload = "123", + ), + ) + } + + val initial = values[0] + val forced = values[1] + val dataDriven = values[2] + assertThat(initial.syncInfoSectionImages.isProgressVisible).isFalse() + assertThat(forced.syncInfoSectionImages.isProgressVisible).isTrue() + assertThat(forced.syncInfoSectionImages.counterImagesToUpload).isEmpty() + assertThat(dataDriven.syncInfoSectionImages.isProgressVisible).isTrue() + assertThat(dataDriven.syncInfoSectionImages.counterImagesToUpload).isEqualTo("123") + } + // Other/combined UX case tests @Test diff --git a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCaseTest.kt b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCaseTest.kt index b85298f6c7..6778a0a733 100644 --- a/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCaseTest.kt +++ b/feature/dashboard/src/test/java/com/simprints/feature/dashboard/settings/syncinfo/usecase/ObserveSyncInfoUseCaseTest.kt @@ -23,6 +23,7 @@ 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.isSampleUploadEnabledInProject import com.simprints.infra.config.store.models.isSimprintsEventDownSyncAllowed import com.simprints.infra.config.store.tokenization.TokenizationProcessor import com.simprints.infra.config.sync.ConfigManager @@ -180,6 +181,7 @@ class ObserveSyncInfoUseCaseTest { every { any().isModuleSelectionAvailable() } returns false every { any().isSimprintsEventDownSyncAllowed() } returns true every { any().isCommCareEventDownSyncAllowed() } returns false + every { any().isSampleUploadEnabledInProject() } returns true every { commCarePermissionChecker.hasCommCarePermissions() } returns true } @@ -1166,6 +1168,20 @@ class ObserveSyncInfoUseCaseTest { assertThat(result.syncInfoSectionRecords.isSyncButtonEnabled).isFalse() } + @Test + fun `sync button should be enabled when sync has failed for non-CommCare and non-network reasons`() = runTest { + val mockCommCarePermissionErrorEventSyncState = mockk(relaxed = true) { + every { isSyncFailed() } returns true + every { isSyncFailedBecauseCommCarePermissionIsMissing() } returns false + } + every { eventSyncManager.getLastSyncState(any()) } returns MutableLiveData(mockCommCarePermissionErrorEventSyncState) + + createUseCase() + val result = useCase().first() + + assertThat(result.syncInfoSectionRecords.isSyncButtonEnabled).isTrue() + } + @Test fun `should calculate correct record last sync time when sync time available`() = runTest { val timestamp = Timestamp(0L) diff --git a/feature/matcher/build.gradle.kts b/feature/matcher/build.gradle.kts index e6a34a307d..64fd4d0d20 100644 --- a/feature/matcher/build.gradle.kts +++ b/feature/matcher/build.gradle.kts @@ -9,6 +9,7 @@ android { } dependencies { + implementation(project(":feature:exit-form")) implementation(project(":infra:orchestrator-data")) implementation(project(":infra:enrolment-records:repository")) diff --git a/feature/matcher/src/main/java/com/simprints/matcher/screen/MatchFragment.kt b/feature/matcher/src/main/java/com/simprints/matcher/screen/MatchFragment.kt index cb16d60914..94d61c6425 100644 --- a/feature/matcher/src/main/java/com/simprints/matcher/screen/MatchFragment.kt +++ b/feature/matcher/src/main/java/com/simprints/matcher/screen/MatchFragment.kt @@ -3,6 +3,7 @@ package com.simprints.matcher.screen import android.animation.ObjectAnimator import android.os.Bundle import android.view.View +import androidx.activity.addCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.isGone import androidx.core.view.isVisible @@ -14,15 +15,21 @@ import com.simprints.core.livedata.LiveDataEventWithContentObserver import com.simprints.core.tools.extentions.applicationSettingsIntent import com.simprints.core.tools.extentions.hasPermission import com.simprints.core.tools.extentions.permissionFromResult +import com.simprints.feature.exitform.ExitFormContract +import com.simprints.feature.exitform.ExitFormResult import com.simprints.infra.logging.LoggingConstants.CrashReportTag.ORCHESTRATION import com.simprints.infra.logging.Simber import com.simprints.infra.uibase.navigation.finishWithResult +import com.simprints.infra.uibase.navigation.handleResult +import com.simprints.infra.uibase.navigation.navigateSafely import com.simprints.infra.uibase.navigation.navigationParams import com.simprints.infra.uibase.view.applySystemBarInsets import com.simprints.infra.uibase.viewbinding.viewBinding import com.simprints.matcher.MatchParams import com.simprints.matcher.R import com.simprints.matcher.databinding.FragmentMatcherBinding +import com.simprints.matcher.screen.MatchFragment.Companion.LOADING_PROGRESS +import com.simprints.matcher.screen.MatchFragment.Companion.MATCHING_PROGRESS import com.simprints.matcher.screen.MatchViewModel.MatchState import dagger.hilt.android.AndroidEntryPoint import com.simprints.infra.resources.R as IDR @@ -56,7 +63,24 @@ internal class MatchFragment : Fragment(R.layout.fragment_matcher) { applySystemBarInsets(view) Simber.i("MatchFragment started (isFace=${params.isFaceMatch()})", tag = ORCHESTRATION) + findNavController().handleResult( + this, + R.id.matcherFragment, + ExitFormContract.DESTINATION, + ) { + val option = it.submittedOption() + if (option != null) { + findNavController().finishWithResult(this, it) + } + } + observeViewModel() + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { + findNavController().navigateSafely( + this@MatchFragment, + MatchFragmentDirections.actionGlobalRefusalFragment(), + ) + } } override fun onResume() { diff --git a/feature/matcher/src/main/res/navigation/graph_matcher.xml b/feature/matcher/src/main/res/navigation/graph_matcher.xml index f19a76f90b..ff9ba5561f 100644 --- a/feature/matcher/src/main/res/navigation/graph_matcher.xml +++ b/feature/matcher/src/main/res/navigation/graph_matcher.xml @@ -12,4 +12,9 @@ android:name="params" app:argType="com.simprints.core.domain.step.StepParams" /> + + + diff --git a/feature/setup/src/main/java/com/simprints/feature/setup/location/StoreUserLocationIntoCurrentSessionWorker.kt b/feature/setup/src/main/java/com/simprints/feature/setup/location/StoreUserLocationIntoCurrentSessionWorker.kt index 29f9546086..ed509163db 100644 --- a/feature/setup/src/main/java/com/simprints/feature/setup/location/StoreUserLocationIntoCurrentSessionWorker.kt +++ b/feature/setup/src/main/java/com/simprints/feature/setup/location/StoreUserLocationIntoCurrentSessionWorker.kt @@ -32,8 +32,8 @@ internal class StoreUserLocationIntoCurrentSessionWorker @AssistedInject constru override val tag: String = "StoreUserLocationWorker" override suspend fun doWork(): Result = withContext(dispatcher) { - crashlyticsLog("Started") showProgressNotification() + crashlyticsLog("Started") try { createLocationFlow() .filterNotNull() diff --git a/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt index 69fb54869f..534f489536 100644 --- a/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt +++ b/feature/validate-subject-pool/src/test/java/com/simprints/feature/validatepool/usecase/RunBlockingEventSyncUseCaseTest.kt @@ -9,9 +9,10 @@ import com.simprints.infra.eventsync.status.models.EventSyncWorkerType import com.simprints.infra.sync.SyncOrchestrator import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.MockKAnnotations +import io.mockk.coJustRun +import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK -import io.mockk.justRun import io.mockk.verify import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest @@ -38,7 +39,7 @@ class RunBlockingEventSyncUseCaseTest { fun setUp() { MockKAnnotations.init(this) - justRun { syncOrchestrator.startEventSync() } + coJustRun { syncOrchestrator.startEventSync() } usecase = RunBlockingEventSyncUseCase( syncManager, @@ -58,7 +59,7 @@ class RunBlockingEventSyncUseCaseTest { liveData.postValue(createSyncState("sync", EventSyncWorkerState.Succeeded)) testScheduler.advanceUntilIdle() - verify { syncOrchestrator.startEventSync() } + coVerify { syncOrchestrator.startEventSync() } verify { syncManager.getLastSyncState() } } @@ -74,7 +75,7 @@ class RunBlockingEventSyncUseCaseTest { liveData.postValue(createSyncState("sync", EventSyncWorkerState.Failed())) testScheduler.advanceUntilIdle() - verify { syncOrchestrator.startEventSync() } + coVerify { syncOrchestrator.startEventSync() } verify { syncManager.getLastSyncState() } } @@ -90,7 +91,7 @@ class RunBlockingEventSyncUseCaseTest { liveData.postValue(createSyncState("sync", EventSyncWorkerState.Cancelled)) testScheduler.advanceUntilIdle() - verify { syncOrchestrator.startEventSync() } + coVerify { syncOrchestrator.startEventSync() } verify { syncManager.getLastSyncState() } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 460e8490ff..eea04cd478 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,7 +24,7 @@ androidx_viewpager_version = "1.1.0" androidx_security_version = "1.1.0" androidx_annotation_version = "1.9.1" androidx_arch_core_version = "2.2.0" -matertial_version = "1.13.0" +material_version = "1.12.0" hilt_version = "2.56.2" hilt_androidx_version = "1.2.0" @@ -133,7 +133,7 @@ androidX-navigation-ui = { module = "androidx.navigation:navigation-ui-ktx", ver androidX-annotation-annotation = { module = "androidx.annotation:annotation", version.ref = "androidx_annotation_version" } #Support -support-material = { module = "com.google.android.material:material", version.ref = "matertial_version" } +support-material = { module = "com.google.android.material:material", version.ref = "material_version" } #WorkManager workManager-work = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx_work_version" } diff --git a/infra/auth-logic/src/main/java/com/simprints/infra/authlogic/AuthManagerImpl.kt b/infra/auth-logic/src/main/java/com/simprints/infra/authlogic/AuthManagerImpl.kt index fe9b3cf625..d6a31f0a6d 100644 --- a/infra/auth-logic/src/main/java/com/simprints/infra/authlogic/AuthManagerImpl.kt +++ b/infra/auth-logic/src/main/java/com/simprints/infra/authlogic/AuthManagerImpl.kt @@ -4,7 +4,9 @@ import com.simprints.core.domain.tokenization.asTokenizableRaw import com.simprints.infra.authlogic.authenticator.Authenticator import com.simprints.infra.authlogic.authenticator.SignerManager import javax.inject.Inject +import javax.inject.Singleton +@Singleton internal class AuthManagerImpl @Inject constructor( private val authenticator: Authenticator, private val signerManager: SignerManager, 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 de9f8f56f2..cf85f651e7 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 @@ -37,14 +37,30 @@ fun ProjectConfiguration.canSyncBiometricDataToSimprints(): Boolean = fun ProjectConfiguration.canSyncAnalyticsDataToSimprints(): Boolean = synchronization.up.simprints.kind == UpSynchronizationConfiguration.UpSynchronizationKind.ONLY_ANALYTICS -fun ProjectConfiguration.isSimprintsEventDownSyncAllowed(): Boolean = - synchronization.down.simprints != null && +fun ProjectConfiguration.isSimprintsEventDownSyncAllowed(): Boolean = synchronization.down.simprints != null && synchronization.down.simprints.frequency != Frequency.ONLY_PERIODICALLY_UP_SYNC fun ProjectConfiguration.isCommCareEventDownSyncAllowed(): Boolean = synchronization.down.commCare != null fun ProjectConfiguration.imagesUploadRequiresUnmeteredConnection(): Boolean = synchronization.up.simprints.imagesRequireUnmeteredConnection +fun ProjectConfiguration.isSampleUploadEnabledInProject(): Boolean = listOfNotNull( + face?.rankOne?.imageSavingStrategy?.let { it != FaceConfiguration.ImageSavingStrategy.NEVER }, + face?.simFace?.imageSavingStrategy?.let { it != FaceConfiguration.ImageSavingStrategy.NEVER }, + fingerprint + ?.nec + ?.vero2 + ?.imageSavingStrategy + ?.let { it != Vero2Configuration.ImageSavingStrategy.NEVER }, + fingerprint + ?.secugenSimMatcher + ?.vero2 + ?.imageSavingStrategy + ?.let { it != Vero2Configuration.ImageSavingStrategy.NEVER }, +).let { explicitStrategies -> + explicitStrategies.isNotEmpty() && explicitStrategies.any { it } +} + fun ProjectConfiguration.allowedAgeRanges(): List = listOfNotNull( face?.rankOne?.allowedAgeRange, fingerprint?.secugenSimMatcher?.allowedAgeRange, 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 6eaaddc567..3c17b34cf7 100644 --- a/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ProjectConfigurationTest.kt +++ b/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ProjectConfigurationTest.kt @@ -1,6 +1,6 @@ package com.simprints.infra.config.store.models -import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.* import com.simprints.infra.config.store.models.UpSynchronizationConfiguration.CoSyncUpSynchronizationConfiguration import com.simprints.infra.config.store.models.UpSynchronizationConfiguration.SimprintsUpSynchronizationConfiguration import com.simprints.infra.config.store.models.UpSynchronizationConfiguration.UpSynchronizationKind.ALL @@ -10,10 +10,12 @@ import com.simprints.infra.config.store.models.UpSynchronizationConfiguration.Up import com.simprints.infra.config.store.testtools.faceConfiguration import com.simprints.infra.config.store.testtools.faceSdkConfiguration import com.simprints.infra.config.store.testtools.fingerprintConfiguration +import com.simprints.infra.config.store.testtools.fingerprintSdkConfiguration import com.simprints.infra.config.store.testtools.projectConfiguration import com.simprints.infra.config.store.testtools.simprintsDownSyncConfigurationConfiguration import com.simprints.infra.config.store.testtools.simprintsUpSyncConfigurationConfiguration import com.simprints.infra.config.store.testtools.synchronizationConfiguration +import com.simprints.infra.config.store.testtools.vero2Configuration import org.junit.Test class ProjectConfigurationTest { @@ -232,7 +234,7 @@ class ProjectConfigurationTest { val config = projectConfiguration.copy( synchronization = synchronizationConfiguration.copy( down = synchronizationConfiguration.down.copy( - simprints = null + simprints = null, ), ), ) @@ -245,7 +247,7 @@ class ProjectConfigurationTest { val config = projectConfiguration.copy( synchronization = synchronizationConfiguration.copy( down = synchronizationConfiguration.down.copy( - commCare = DownSynchronizationConfiguration.CommCareDownSynchronizationConfiguration + commCare = DownSynchronizationConfiguration.CommCareDownSynchronizationConfiguration, ), ), ) @@ -258,7 +260,7 @@ class ProjectConfigurationTest { val config = projectConfiguration.copy( synchronization = synchronizationConfiguration.copy( down = synchronizationConfiguration.down.copy( - commCare = null + commCare = null, ), ), ) @@ -284,6 +286,74 @@ class ProjectConfigurationTest { } } + @Test + fun `isSampleUploadEnabledInProject should return correct based on available saving strategy`() { + data class TestData( + val rankOneStrategy: FaceConfiguration.ImageSavingStrategy? = null, + val simFaceStrategy: FaceConfiguration.ImageSavingStrategy? = null, + val secugenStrategy: Vero2Configuration.ImageSavingStrategy? = null, + val necStragery: Vero2Configuration.ImageSavingStrategy? = null, + val result: Boolean, + ) + listOf( + TestData( + rankOneStrategy = FaceConfiguration.ImageSavingStrategy.ONLY_GOOD_SCAN, + simFaceStrategy = FaceConfiguration.ImageSavingStrategy.NEVER, + result = true, + ), + TestData( + necStragery = Vero2Configuration.ImageSavingStrategy.NEVER, + secugenStrategy = Vero2Configuration.ImageSavingStrategy.ONLY_GOOD_SCAN, + result = true, + ), + TestData( + simFaceStrategy = FaceConfiguration.ImageSavingStrategy.ONLY_GOOD_SCAN, + secugenStrategy = Vero2Configuration.ImageSavingStrategy.NEVER, + result = true, + ), + TestData( + simFaceStrategy = FaceConfiguration.ImageSavingStrategy.ONLY_GOOD_SCAN, + secugenStrategy = Vero2Configuration.ImageSavingStrategy.ONLY_GOOD_SCAN, + result = true, + ), + TestData( + necStragery = Vero2Configuration.ImageSavingStrategy.ONLY_GOOD_SCAN, + result = true, + ), + TestData( + rankOneStrategy = FaceConfiguration.ImageSavingStrategy.NEVER, + simFaceStrategy = FaceConfiguration.ImageSavingStrategy.NEVER, + necStragery = Vero2Configuration.ImageSavingStrategy.NEVER, + secugenStrategy = Vero2Configuration.ImageSavingStrategy.NEVER, + result = false, + ), + TestData( + rankOneStrategy = FaceConfiguration.ImageSavingStrategy.NEVER, + necStragery = Vero2Configuration.ImageSavingStrategy.NEVER, + result = false, + ), + TestData(result = false), + ).forEach { (rankOne, simFace, secugen, nec, result) -> + assertThat( + projectConfiguration + .copy( + face = faceConfiguration.copy( + rankOne = rankOne?.let { faceSdkConfiguration.copy(imageSavingStrategy = it) }, + simFace = simFace?.let { faceSdkConfiguration.copy(imageSavingStrategy = it) }, + ), + fingerprint = fingerprintConfiguration.copy( + secugenSimMatcher = secugen?.let { + fingerprintSdkConfiguration.copy(vero2 = vero2Configuration.copy(imageSavingStrategy = it)) + }, + nec = nec?.let { + fingerprintSdkConfiguration.copy(vero2 = vero2Configuration.copy(imageSavingStrategy = it)) + }, + ), + ).isSampleUploadEnabledInProject(), + ).isEqualTo(result) + } + } + @Test fun `allowedAgeRanges returns all non-null age ranges`() { val faceAgeRange = AgeGroup(10, 20) diff --git a/infra/config-store/src/test/java/com/simprints/infra/config/store/testtools/Models.kt b/infra/config-store/src/test/java/com/simprints/infra/config/store/testtools/Models.kt index af4bdeada6..7817c2930f 100644 --- a/infra/config-store/src/test/java/com/simprints/infra/config/store/testtools/Models.kt +++ b/infra/config-store/src/test/java/com/simprints/infra/config/store/testtools/Models.kt @@ -261,20 +261,22 @@ internal val apiFingerprintConfiguration = ApiFingerprintConfiguration( nec = null, ) +internal val fingerprintSdkConfiguration = FingerprintConfiguration.FingerprintSdkConfiguration( + fingersToCapture = listOf(Finger.LEFT_3RD_FINGER), + decisionPolicy = decisionPolicy, + comparisonStrategyForVerification = FingerprintConfiguration.FingerComparisonStrategy.SAME_FINGER, + vero1 = Vero1Configuration(qualityThreshold = 10), + vero2 = vero2Configuration, + allowedAgeRange = allowedAgeRange, + verificationMatchThreshold = 42.0f, + maxCaptureAttempts = MaxCaptureAttempts(noFingerDetected = 17), +) + internal val fingerprintConfiguration = FingerprintConfiguration( allowedScanners = listOf(FingerprintConfiguration.VeroGeneration.VERO_2), allowedSDKs = listOf(FingerprintConfiguration.BioSdk.SECUGEN_SIM_MATCHER), displayHandIcons = true, - secugenSimMatcher = FingerprintConfiguration.FingerprintSdkConfiguration( - fingersToCapture = listOf(Finger.LEFT_3RD_FINGER), - decisionPolicy = decisionPolicy, - comparisonStrategyForVerification = FingerprintConfiguration.FingerComparisonStrategy.SAME_FINGER, - vero1 = Vero1Configuration(qualityThreshold = 10), - vero2 = vero2Configuration, - allowedAgeRange = allowedAgeRange, - verificationMatchThreshold = 42.0f, - maxCaptureAttempts = MaxCaptureAttempts(noFingerDetected = 17), - ), + secugenSimMatcher = fingerprintSdkConfiguration, nec = null, ) 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 ed3c37befd..f29f83f55c 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 @@ -14,7 +14,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.onStart import javax.inject.Inject +import javax.inject.Singleton +@Singleton class ConfigManager @Inject constructor( private val configRepository: ConfigRepository, private val enrolmentRecordRepository: EnrolmentRecordRepository, diff --git a/infra/core/src/main/java/com/simprints/core/domain/tokenization/TokenizableString.kt b/infra/core/src/main/java/com/simprints/core/domain/tokenization/TokenizableString.kt index f1fe94f6b2..55a1fd632e 100644 --- a/infra/core/src/main/java/com/simprints/core/domain/tokenization/TokenizableString.kt +++ b/infra/core/src/main/java/com/simprints/core/domain/tokenization/TokenizableString.kt @@ -1,10 +1,9 @@ package com.simprints.core.domain.tokenization -import android.os.Parcelable import androidx.annotation.Keep import com.simprints.core.domain.tokenization.TokenizableString.Raw import com.simprints.core.domain.tokenization.TokenizableString.Tokenized -import kotlinx.parcelize.Parcelize +import java.io.Serializable /** * Sealed class for values that might be tokenized (symmetrically encrypted). Use this wrapping @@ -15,10 +14,9 @@ import kotlinx.parcelize.Parcelize */ @Keep -sealed class TokenizableString : Parcelable { +sealed class TokenizableString : Serializable { abstract val value: String - @Parcelize data class Tokenized( override val value: String, ) : TokenizableString() { @@ -29,7 +27,6 @@ sealed class TokenizableString : Parcelable { override fun toString() = super.toString() } - @Parcelize data class Raw( override val value: String, ) : TokenizableString() { diff --git a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/EnrolmentRecordRepositoryImpl.kt b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/EnrolmentRecordRepositoryImpl.kt index 9a9a19a553..7dd4a975be 100644 --- a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/EnrolmentRecordRepositoryImpl.kt +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/EnrolmentRecordRepositoryImpl.kt @@ -20,7 +20,9 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.withContext import javax.inject.Inject +import javax.inject.Singleton +@Singleton internal class EnrolmentRecordRepositoryImpl @Inject constructor( private val remoteDataSource: EnrolmentRecordRemoteDataSource, @CommCareDataSource private val commCareDataSource: IdentityDataSource, diff --git a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationWorker.kt b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationWorker.kt index b6ed7ca510..46f0b8bbb9 100644 --- a/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationWorker.kt +++ b/infra/enrolment-records/repository/src/main/java/com/simprints/infra/enrolment/records/repository/local/migration/RealmToRoomMigrationWorker.kt @@ -6,7 +6,6 @@ import androidx.work.WorkerParameters import com.simprints.core.DispatcherIO import com.simprints.core.workers.SimCoroutineWorker import com.simprints.infra.config.store.ConfigRepository -import com.simprints.infra.config.store.models.Project import com.simprints.infra.enrolment.records.repository.domain.models.SubjectAction import com.simprints.infra.enrolment.records.repository.domain.models.SubjectQuery import com.simprints.infra.enrolment.records.repository.local.RealmEnrolmentRecordLocalDataSource @@ -38,13 +37,10 @@ internal class RealmToRoomMigrationWorker @AssistedInject constructor( override val tag: String get() = REALM_DB_MIGRATION.name - lateinit var project: Project - override suspend fun doWork(): Result = withContext(dispatcher) { - project = configRepo.getProject() + showProgressNotification() crashlyticsLog("[RealmToRoomMigrationWorker] MigrationWorker started.") try { - showProgressNotification() // 1. Check if down sync is in progress to retry latter (no need to increase the retry count) if (realmToRoomMigrationFlagsStore.isDownSyncInProgress()) { realmToRoomMigrationFlagsStore.updateStatus(MigrationStatus.NOT_STARTED) @@ -87,6 +83,7 @@ internal class RealmToRoomMigrationWorker @AssistedInject constructor( private suspend fun processRecords() { // log realm db info crashlyticsLog("[RealmToRoomMigrationWorker] ${realmDataSource.getLocalDBInfo()}") + val project = configRepo.getProject() var index = 0 realmDataSource .loadAllSubjectsInBatches(BATCH_SIZE) diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/samples/ApiEventSampleUpSyncRequestPayload.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/samples/ApiEventSampleUpSyncRequestPayload.kt index 2feaf75161..4d3c950dbc 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/samples/ApiEventSampleUpSyncRequestPayload.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/samples/ApiEventSampleUpSyncRequestPayload.kt @@ -11,7 +11,7 @@ import com.simprints.infra.eventsync.event.remote.models.fromDomainToApi internal data class ApiEventSampleUpSyncRequestPayload( override val startTime: ApiTimestamp, val endTime: ApiTimestamp?, - val requestId: String, + val requestId: String?, val sampleId: String, val size: Long, val errorType: String?, diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/down/workers/BaseEventDownSyncDownloaderWorker.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/down/workers/BaseEventDownSyncDownloaderWorker.kt index d19159bafc..2c820e119d 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/down/workers/BaseEventDownSyncDownloaderWorker.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/down/workers/BaseEventDownSyncDownloaderWorker.kt @@ -65,8 +65,8 @@ internal abstract class BaseEventDownSyncDownloaderWorker( } protected open suspend fun performDownSync(): Result = withContext(dispatcher) { - crashlyticsLog("Started") showProgressNotification() + crashlyticsLog("Started") try { val workerId = id.toString() var count = syncCache.readProgress(workerId) diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/master/EventEndSyncReporterWorker.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/master/EventEndSyncReporterWorker.kt index 6748f29aaf..e4b55c0c8e 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/master/EventEndSyncReporterWorker.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/master/EventEndSyncReporterWorker.kt @@ -31,8 +31,8 @@ internal class EventEndSyncReporterWorker @AssistedInject constructor( override val tag: String = "EventEndSyncReporter" override suspend fun doWork(): Result = withContext(dispatcher) { - crashlyticsLog("Started") showProgressNotification() + crashlyticsLog("Started") try { val syncId = inputData.getString(SYNC_ID_TO_MARK_AS_COMPLETED) Simber.d("Params: $syncId", tag = tag) diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/master/EventStartSyncReporterWorker.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/master/EventStartSyncReporterWorker.kt index f63fcb985c..da4edae79a 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/master/EventStartSyncReporterWorker.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/master/EventStartSyncReporterWorker.kt @@ -28,8 +28,8 @@ internal class EventStartSyncReporterWorker @AssistedInject constructor( override val tag: String = "EventStartSyncReporter" override suspend fun doWork(): Result = withContext(dispatcher) { - crashlyticsLog("Started") showProgressNotification() + crashlyticsLog("Started") try { val syncId = inputData.getString(SYNC_ID_STARTED) Simber.d("Params: $syncId", tag = tag) 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 541ab26bc7..9895dc6d6a 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 @@ -64,8 +64,8 @@ class EventSyncMasterWorker @AssistedInject internal constructor( } override suspend fun doWork(): Result = withContext(dispatcher) { - crashlyticsLog("Started") showProgressNotification() + crashlyticsLog("Started") try { // check if device is rooted before starting the sync securityManager.checkIfDeviceIsRooted() diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/up/workers/EventUpSyncUploaderWorker.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/up/workers/EventUpSyncUploaderWorker.kt index c7daafd996..43ee2d0976 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/up/workers/EventUpSyncUploaderWorker.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/sync/up/workers/EventUpSyncUploaderWorker.kt @@ -71,8 +71,8 @@ internal class EventUpSyncUploaderWorker @AssistedInject constructor( ?: throw IllegalArgumentException("input required") override suspend fun doWork(): Result = withContext(dispatcher) { - crashlyticsLog("Started") showProgressNotification() + crashlyticsLog("Started") try { val workerId = this@EventUpSyncUploaderWorker.id.toString() var count = eventSyncCache.readProgress(workerId) diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/samples/SampleUpSyncRequestEvent.kt b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/samples/SampleUpSyncRequestEvent.kt index 89389d57de..9f9ecbccde 100644 --- a/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/samples/SampleUpSyncRequestEvent.kt +++ b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/samples/SampleUpSyncRequestEvent.kt @@ -20,7 +20,7 @@ class SampleUpSyncRequestEvent( constructor( createdAt: Timestamp, endedAt: Timestamp, - requestId: String, + requestId: String?, sampleId: String, size: Long, errorType: String? = null, @@ -42,7 +42,7 @@ class SampleUpSyncRequestEvent( data class SampleUpSyncRequestPayload( override val createdAt: Timestamp, override val endedAt: Timestamp?, - val requestId: String, + val requestId: String?, val sampleId: String, val size: Long, val errorType: String?, 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/firebase/FirebaseSampleUploader.kt similarity index 64% rename from infra/images/src/main/java/com/simprints/infra/images/remote/firestore/FirestoreSampleUploader.kt rename to infra/images/src/main/java/com/simprints/infra/images/remote/firebase/FirebaseSampleUploader.kt index 5f100c3ab8..82276db1e3 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/firebase/FirebaseSampleUploader.kt @@ -1,14 +1,21 @@ -package com.simprints.infra.images.remote.firestore +package com.simprints.infra.images.remote.firebase import com.google.firebase.storage.FirebaseStorage import com.google.firebase.storage.StorageMetadata import com.google.firebase.storage.StorageReference +import com.google.firebase.storage.UploadTask +import com.simprints.core.tools.time.TimeHelper import com.simprints.infra.authstore.AuthStore import com.simprints.infra.config.sync.ConfigManager +import com.simprints.infra.events.EventRepository +import com.simprints.infra.events.event.domain.models.samples.SampleUpSyncRequestEvent +import com.simprints.infra.events.event.domain.models.scope.EventScopeEndCause +import com.simprints.infra.events.event.domain.models.scope.EventScopeType import com.simprints.infra.images.local.ImageLocalDataSource import com.simprints.infra.images.metadata.ImageMetadataStore import com.simprints.infra.images.model.SecuredImageRef import com.simprints.infra.images.remote.SampleUploader +import com.simprints.infra.images.usecase.SamplePathConverter import com.simprints.infra.logging.LoggingConstants.CrashReportTag.SAMPLE_UPLOAD import com.simprints.infra.logging.LoggingConstants.CrashReportTag.SYNC import com.simprints.infra.logging.Simber @@ -16,11 +23,14 @@ import kotlinx.coroutines.tasks.await import java.io.FileInputStream import javax.inject.Inject -internal class FirestoreSampleUploader @Inject constructor( +internal class FirebaseSampleUploader @Inject constructor( + private val timeHelper: TimeHelper, private val configManager: ConfigManager, private val authStore: AuthStore, private val localDataSource: ImageLocalDataSource, private val metadataStore: ImageMetadataStore, + private val samplePathUtil: SamplePathConverter, + private val eventRepository: EventRepository, ) : SampleUploader { override suspend fun uploadAllSamples( projectId: String, @@ -33,33 +43,51 @@ internal class FirestoreSampleUploader @Inject constructor( } var allImagesUploaded = true - Simber.i("Starting sample upload to Firestore") + Simber.i("Starting sample upload to Firebase storage") val bucketUrl = configManager.getProject(projectId).imageBucket val rootRef = FirebaseStorage .getInstance(firebaseApp, bucketUrl) .reference + val urlRequestScope = eventRepository.createEventScope(type = EventScopeType.SAMPLE_UP_SYNC) + 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 { + val requestStartTime = timeHelper.now() localDataSource.decryptImage(imageRef)?.let { stream -> val metadata = metadataStore.getMetadata(imageRef.relativePath) - val uploadSuccessful = uploadSample(rootRef, stream, imageRef, metadata) - if (uploadSuccessful) { + + val task = uploadSample(rootRef, stream, imageRef, metadata) + if (task.task.isSuccessful) { localDataSource.deleteImage(imageRef) metadataStore.deleteMetadata(imageRef.relativePath) } else { allImagesUploaded = false Simber.i("Failed to upload image without exception", tag = SAMPLE_UPLOAD) } + + eventRepository.addOrUpdateEvent( + scope = urlRequestScope, + event = SampleUpSyncRequestEvent( + createdAt = requestStartTime, + endedAt = timeHelper.now(), + requestId = null, + sampleId = samplePathUtil.extract(imageRef.relativePath)?.sampleId.orEmpty(), + size = task.bytesTransferred, + errorType = task.error?.javaClass?.simpleName, + ), + ) } } catch (t: Throwable) { allImagesUploaded = false Simber.e("Failed to upload images", t, tag = SYNC) } } + eventRepository.closeEventScope(urlRequestScope, EventScopeEndCause.WORKFLOW_ENDED) return allImagesUploaded } @@ -69,13 +97,13 @@ internal class FirestoreSampleUploader @Inject constructor( imageStream: FileInputStream, imageRef: SecuredImageRef, metadata: Map, - ): Boolean { + ): UploadTask.TaskSnapshot { val fileRef = imageRef.relativePath.parts .fold(rootRef) { ref, pathPart -> ref.child(pathPart) } Simber.i("Uploading ${fileRef.path.last()}", tag = SAMPLE_UPLOAD) - val uploadTask = if (metadata.isEmpty()) { + return if (metadata.isEmpty()) { fileRef.putStream(imageStream).await() } else { val storeMetadata = StorageMetadata @@ -84,6 +112,5 @@ internal class FirestoreSampleUploader @Inject constructor( .build() fileRef.putStream(imageStream, storeMetadata).await() } - return uploadTask.task.isSuccessful } } diff --git a/infra/images/src/main/java/com/simprints/infra/images/usecase/GetUploaderUseCase.kt b/infra/images/src/main/java/com/simprints/infra/images/usecase/GetUploaderUseCase.kt index 61986f9637..e6cfe2cab1 100644 --- a/infra/images/src/main/java/com/simprints/infra/images/usecase/GetUploaderUseCase.kt +++ b/infra/images/src/main/java/com/simprints/infra/images/usecase/GetUploaderUseCase.kt @@ -3,13 +3,13 @@ package com.simprints.infra.images.usecase import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.experimental import com.simprints.infra.images.remote.SampleUploader -import com.simprints.infra.images.remote.firestore.FirestoreSampleUploader +import com.simprints.infra.images.remote.firebase.FirebaseSampleUploader import com.simprints.infra.images.remote.signedurl.SignedUrlSampleUploader import javax.inject.Inject internal class GetUploaderUseCase @Inject constructor( private val configRepository: ConfigRepository, - private val firestoreUploader: FirestoreSampleUploader, + private val firestoreUploader: FirebaseSampleUploader, private val signedUrlUploader: SignedUrlSampleUploader, ) { suspend operator fun invoke(): SampleUploader = configRepository 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/firebase/FirebaseSampleUploaderTest.kt similarity index 69% rename from infra/images/src/test/java/com/simprints/infra/images/remote/firestore/FirestoreSampleUploaderTest.kt rename to infra/images/src/test/java/com/simprints/infra/images/remote/firebase/FirebaseSampleUploaderTest.kt index d1a8f1f144..6d89faac9e 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/firebase/FirebaseSampleUploaderTest.kt @@ -1,14 +1,21 @@ -package com.simprints.infra.images.remote.firestore +package com.simprints.infra.images.remote.firebase import androidx.test.ext.junit.runners.* import com.google.common.truth.Truth.* import com.google.firebase.storage.FirebaseStorage +import com.simprints.core.tools.time.TimeHelper +import com.simprints.core.tools.time.Timestamp import com.simprints.infra.authstore.AuthStore +import com.simprints.infra.config.store.models.GeneralConfiguration import com.simprints.infra.config.sync.ConfigManager +import com.simprints.infra.events.EventRepository +import com.simprints.infra.events.event.domain.models.scope.EventScope +import com.simprints.infra.events.event.domain.models.scope.EventScopeType import com.simprints.infra.images.local.ImageLocalDataSource import com.simprints.infra.images.metadata.ImageMetadataStore import com.simprints.infra.images.model.Path import com.simprints.infra.images.model.SecuredImageRef +import com.simprints.infra.images.usecase.SamplePathConverter import io.mockk.* import io.mockk.impl.annotations.MockK import kotlinx.coroutines.tasks.await @@ -21,7 +28,10 @@ import java.io.FileInputStream @Suppress("DEPRECATION") @RunWith(AndroidJUnit4::class) -class FirestoreSampleUploaderTest { +class FirebaseSampleUploaderTest { + @MockK + private lateinit var timeHelper: TimeHelper + @MockK private lateinit var configManager: ConfigManager @@ -34,10 +44,16 @@ class FirestoreSampleUploaderTest { @MockK private lateinit var metadataStore: ImageMetadataStore + @MockK + private lateinit var samplePathUtil: SamplePathConverter + + @MockK + private lateinit var eventRepository: EventRepository + @MockK private lateinit var localDataSource: ImageLocalDataSource - private lateinit var remoteDataSource: FirestoreSampleUploader + private lateinit var remoteDataSource: FirebaseSampleUploader @Before fun setup() { @@ -45,13 +61,22 @@ class FirestoreSampleUploaderTest { every { mockSecuredImageRef.relativePath.parts } returns arrayOf("Test1") - remoteDataSource = FirestoreSampleUploader( + remoteDataSource = FirebaseSampleUploader( + timeHelper = timeHelper, configManager = configManager, authStore = authStore, localDataSource = localDataSource, metadataStore = metadataStore, + samplePathUtil = samplePathUtil, + eventRepository = eventRepository, ) + every { timeHelper.now() } returns Timestamp(0L) + every { samplePathUtil.extract(any()) } returns + SamplePathConverter.PathData("sessionID", GeneralConfiguration.Modality.FACE, "sampleId") + coEvery { eventRepository.createEventScope(any(), any()) } returns mockk() + coJustRun { eventRepository.closeEventScope(any(), any()) } + // We need to mock statics and global extensions mockkStatic(FirebaseStorage::class) mockkStatic("kotlinx.coroutines.tasks.TasksKt") @@ -118,6 +143,30 @@ class FirestoreSampleUploaderTest { coVerify(exactly = 3) { metadataStore.deleteMetadata(any()) } } + @Test + fun `test upload and report all upload events`() = runTest { + setupProjectConfig() + setupStorageMock() + configureLocalImageFiles(numberOfValidFiles = 3) + + assertThat(remoteDataSource.uploadAllSamples(PROJECT_ID)).isTrue() + coVerify(exactly = 1) { eventRepository.createEventScope(EventScopeType.SAMPLE_UP_SYNC, any()) } + coVerify(exactly = 3) { eventRepository.addOrUpdateEvent(any(), any()) } + coVerify(exactly = 1) { eventRepository.closeEventScope(any(), any()) } + } + + @Test + fun `test upload failed and report all upload events`() = runTest { + setupProjectConfig() + setupStorageMock(success = false) + configureLocalImageFiles(numberOfValidFiles = 3) + + assertThat(remoteDataSource.uploadAllSamples(PROJECT_ID)).isFalse() + coVerify(exactly = 1) { eventRepository.createEventScope(EventScopeType.SAMPLE_UP_SYNC, any()) } + coVerify(exactly = 3) { eventRepository.addOrUpdateEvent(any(), any()) } + coVerify(exactly = 1) { eventRepository.closeEventScope(any(), any()) } + } + @Test fun `test failed decryption should not return success`() = runTest { setupProjectConfig() @@ -167,10 +216,18 @@ class FirestoreSampleUploaderTest { every { reference.child(any()) } returns mockk { every { path } returns "testPath" every { putStream(any()) } returns mockk { - coEvery { await().task.isSuccessful } returns success + coEvery { await() } returns mockk { + coEvery { bytesTransferred } returns 1L + coEvery { task.isSuccessful } returns success + coEvery { error } returns null + } } every { putStream(any(), any()) } returns mockk { - coEvery { await().task.isSuccessful } returns success + coEvery { await() } returns mockk { + coEvery { bytesTransferred } returns 1L + coEvery { task.isSuccessful } returns success + coEvery { error } returns null + } } } } @@ -204,7 +261,7 @@ class FirestoreSampleUploaderTest { private fun mockImage() = SecuredImageRef(Path(VALID_PATH)) - companion object { + companion object Companion { private const val VALID_PATH = "valid.txt" private const val PROJECT_ID = "projectId" } 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 0f171a4c0d..3f127229b5 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 @@ -13,11 +13,11 @@ interface SyncOrchestrator { */ fun refreshConfiguration(): Flow - fun rescheduleEventSync(withDelay: Boolean = false) + suspend fun rescheduleEventSync(withDelay: Boolean = false) fun cancelEventSync() - fun startEventSync(isDownSyncAllowed: Boolean = true) + suspend fun startEventSync(isDownSyncAllowed: Boolean = true) fun stopEventSync() diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestratorImpl.kt b/infra/sync/src/main/java/com/simprints/infra/sync/SyncOrchestratorImpl.kt index f6429d7d56..78557017f4 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 @@ -10,6 +10,7 @@ import androidx.work.workDataOf import com.simprints.core.AppScope import com.simprints.infra.authstore.AuthStore import com.simprints.infra.config.store.models.imagesUploadRequiresUnmeteredConnection +import com.simprints.infra.config.store.models.isCommCareEventDownSyncAllowed import com.simprints.infra.config.sync.ConfigManager import com.simprints.infra.eventsync.EventSyncManager import com.simprints.infra.eventsync.sync.master.EventSyncMasterWorker @@ -112,11 +113,12 @@ internal class SyncOrchestratorImpl @Inject constructor( }.map { } // Converts flow emissions to Unit value as we only care about when it happens, not the value } - override fun rescheduleEventSync(withDelay: Boolean) { + override suspend fun rescheduleEventSync(withDelay: Boolean) { workManager.schedulePeriodicWorker( - SyncConstants.EVENT_SYNC_WORK_NAME, - SyncConstants.EVENT_SYNC_WORKER_INTERVAL, + workName = SyncConstants.EVENT_SYNC_WORK_NAME, + repeatInterval = SyncConstants.EVENT_SYNC_WORKER_INTERVAL, initialDelay = if (withDelay) SyncConstants.EVENT_SYNC_WORKER_INTERVAL else 0, + constraints = getEventSyncConstraints(), tags = eventSyncManager.getPeriodicWorkTags(), ) } @@ -126,9 +128,10 @@ internal class SyncOrchestratorImpl @Inject constructor( stopEventSync() } - override fun startEventSync(isDownSyncAllowed: Boolean) { + override suspend fun startEventSync(isDownSyncAllowed: Boolean) { workManager.startWorker( - SyncConstants.EVENT_SYNC_WORK_NAME_ONE_TIME, + workName = SyncConstants.EVENT_SYNC_WORK_NAME_ONE_TIME, + constraints = getEventSyncConstraints(), tags = eventSyncManager.getOneTimeWorkTags(), inputData = workDataOf(EventSyncMasterWorker.IS_DOWN_SYNC_ALLOWED to isDownSyncAllowed), ) @@ -238,4 +241,13 @@ internal class SyncOrchestratorImpl @Inject constructor( .let { if (it) NetworkType.UNMETERED else NetworkType.CONNECTED } return Constraints.Builder().setRequiredNetworkType(networkType).build() } + + private suspend fun getEventSyncConstraints(): Constraints { + // CommCare doesn't require network connection + val networkType = configManager + .getProjectConfiguration() + .isCommCareEventDownSyncAllowed() + .let { if (it) NetworkType.NOT_REQUIRED else NetworkType.CONNECTED } + return Constraints.Builder().setRequiredNetworkType(networkType).build() + } } diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/config/worker/DeviceConfigDownSyncWorker.kt b/infra/sync/src/main/java/com/simprints/infra/sync/config/worker/DeviceConfigDownSyncWorker.kt index a136042347..5ab2de12d5 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/config/worker/DeviceConfigDownSyncWorker.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/config/worker/DeviceConfigDownSyncWorker.kt @@ -25,8 +25,8 @@ internal class DeviceConfigDownSyncWorker @AssistedInject constructor( override val tag: String = "DeviceConfigDownSync" override suspend fun doWork(): Result = withContext(dispatcher) { - crashlyticsLog("Started") showProgressNotification() + crashlyticsLog("Started") try { val state = configManager.getDeviceState() diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/config/worker/ProjectConfigDownSyncWorker.kt b/infra/sync/src/main/java/com/simprints/infra/sync/config/worker/ProjectConfigDownSyncWorker.kt index 39796fe98d..9c8f196d4e 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/config/worker/ProjectConfigDownSyncWorker.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/config/worker/ProjectConfigDownSyncWorker.kt @@ -29,8 +29,8 @@ internal class ProjectConfigDownSyncWorker @AssistedInject constructor( override val tag = "ProjectConfigDownSync" override suspend fun doWork(): Result = withContext(dispatcher) { - crashlyticsLog("Started") showProgressNotification() + crashlyticsLog("Started") try { val projectId = authStore.signedInProjectId val oldConfig = configManager.getProjectConfiguration() diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/enrolments/EnrolmentRecordWorker.kt b/infra/sync/src/main/java/com/simprints/infra/sync/enrolments/EnrolmentRecordWorker.kt index 729022d32d..1483cb0502 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/enrolments/EnrolmentRecordWorker.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/enrolments/EnrolmentRecordWorker.kt @@ -24,6 +24,7 @@ class EnrolmentRecordWorker @AssistedInject constructor( override val tag: String = "EnrolmentRecordWorker" override suspend fun doWork(): Result = withContext(dispatcher) { + showProgressNotification() crashlyticsLog("Started") try { val instructionId = 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 fdd44a8dac..18812e9736 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 @@ -32,6 +32,7 @@ internal class FileUpSyncWorker @AssistedInject constructor( override val tag: String = "FileUpSyncWorker" override suspend fun doWork(): Result = withContext(dispatcher) { + showProgressNotification() crashlyticsLog("Started") try { when { diff --git a/infra/sync/src/main/java/com/simprints/infra/sync/firmware/FirmwareFileUpdateWorker.kt b/infra/sync/src/main/java/com/simprints/infra/sync/firmware/FirmwareFileUpdateWorker.kt index dad515ac18..65c2d3f680 100644 --- a/infra/sync/src/main/java/com/simprints/infra/sync/firmware/FirmwareFileUpdateWorker.kt +++ b/infra/sync/src/main/java/com/simprints/infra/sync/firmware/FirmwareFileUpdateWorker.kt @@ -28,6 +28,7 @@ class FirmwareFileUpdateWorker @AssistedInject constructor( override val tag: String = "FirmwareFileUpdateWorker" override suspend fun doWork(): Result = withContext(dispatcher) { + showProgressNotification() crashlyticsLog("Started") try { firmwareRepository.updateStoredFirmwareFilesWithLatest()