From b62003fa712090dd01f338cc594981e54aaf0de3 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Thu, 9 Jul 2026 10:40:12 +0300 Subject: [PATCH 1/2] MS-1502 Refactor Life feedback fragment to have a single UI state --- .../face/capture/models/FaceDetection.kt | 1 - .../livefeedback/LiveFeedbackFragment.kt | 332 +++++------ .../screens/livefeedback/LiveFeedbackState.kt | 54 ++ ...tViewModel.kt => LiveFeedbackViewModel.kt} | 147 +++-- .../usecases/SimpleCaptureEventReporter.kt | 1 - .../screens/FaceCaptureViewModelTest.kt | 2 + ...eedbackAutoCaptureFragmentViewModelTest.kt | 533 ------------------ .../LiveFeedbackFragmentViewModelTest.kt | 360 ------------ .../livefeedback/LiveFeedbackViewModelTest.kt | 500 ++++++++++++++++ .../SimpleCaptureEventReporterTest.kt | 3 +- .../event/domain/models/FaceCaptureEvent.kt | 2 + .../src/main/res/values-am-rET/strings.xml | 2 - .../src/main/res/values-am/strings.xml | 2 - .../src/main/res/values-fr/strings.xml | 2 - .../resources/src/main/res/values/strings.xml | 2 - 15 files changed, 805 insertions(+), 1138 deletions(-) create mode 100644 face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackState.kt rename face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/{LiveFeedbackFragmentViewModel.kt => LiveFeedbackViewModel.kt} (74%) delete mode 100644 face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackAutoCaptureFragmentViewModelTest.kt delete mode 100644 face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragmentViewModelTest.kt create mode 100644 face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModelTest.kt diff --git a/face/capture/src/main/java/com/simprints/face/capture/models/FaceDetection.kt b/face/capture/src/main/java/com/simprints/face/capture/models/FaceDetection.kt index 2c5839c6fd..ba3d8cfc52 100644 --- a/face/capture/src/main/java/com/simprints/face/capture/models/FaceDetection.kt +++ b/face/capture/src/main/java/com/simprints/face/capture/models/FaceDetection.kt @@ -22,7 +22,6 @@ internal data class FaceDetection( enum class Status { VALID, VALID_CAPTURING, - BAD_QUALITY, NOFACE, OFFYAW, OFFROLL, 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 63dc10322d..6287d354e4 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 @@ -9,6 +9,7 @@ import android.provider.Settings import android.util.Size import android.view.View import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes import androidx.camera.core.CameraControl import androidx.camera.core.CameraSelector.DEFAULT_BACK_CAMERA import androidx.camera.core.ImageAnalysis @@ -26,7 +27,9 @@ import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels 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.domain.permission.PermissionStatus import com.simprints.core.tools.extentions.hasCameraFlash @@ -46,7 +49,6 @@ import com.simprints.infra.uibase.view.setCheckedWithLeftDrawable import com.simprints.infra.uibase.viewbinding.viewBinding import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import com.simprints.infra.resources.R as IDR @@ -64,7 +66,7 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) private val mainVm: FaceCaptureViewModel by activityViewModels() - private val vm: LiveFeedbackFragmentViewModel by viewModels() + private val vm: LiveFeedbackViewModel by viewModels() private val binding by viewBinding(FragmentLiveFeedbackBinding::bind) private lateinit var screenSize: Size @@ -74,6 +76,9 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) private var cameraControl: CameraControl? = null + private var isPermissionDenied = false + private var finishedHandled = false + private val validCaptureProgressColor: Int get() = ContextCompat.getColor(requireContext(), IDR.color.simprints_green_light) private val defaultCaptureProgressColor: Int @@ -108,17 +113,10 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) bindViewModel() binding.captureProgress.max = 1 // normalized progress - runBlocking { - // Value of the `isAutoCapture` affects major parts of the UI state management and MUST be updated - // before anything else is processed, therefore is has to block the rest of the initialisation. + // `isAutoCapture` affects major parts of the UI state, so resolve it before wiring the capture button + viewLifecycleOwner.lifecycleScope.launch { vm.initAutoCapture() - } - - binding.captureFeedbackBtn.setOnClickListener { - vm.startCapture() - if (vm.isAutoCapture) { - binding.captureFeedbackBtn.isClickable = false - } + setUpCaptureButton() } // Wait till the views gets its final size then init frame processor and setup the camera @@ -127,7 +125,6 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) vm.initCapture(mainVm.bioSDK, mainVm.samplesToCapture, mainVm.attemptNumber) } } - enableCaptureButtonIfAutoCapture() binding.captureInstructionsBtn.setOnClickListener { findNavController().navigateSafely( @@ -141,18 +138,28 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) isSelected = false setOnClickListener { val torchEnabled = !binding.captureFlashButton.isSelected - toggleTorche(torchEnabled) + toggleTorch(torchEnabled) } } } - private fun toggleTorche(enabled: Boolean) { + private fun setUpCaptureButton() { + binding.captureFeedbackBtn.setOnClickListener { + vm.startCapture() + toggleCaptureButtonIfAutoCapture(false) + } + toggleCaptureButtonIfAutoCapture(true) + } + + private fun toggleTorch(enabled: Boolean) { cameraControl?.enableTorch(enabled) binding.captureFlashButton.isSelected = enabled } /** Initialize CameraX, and prepare to bind the camera use cases */ private fun setUpCamera() = viewLifecycleOwner.lifecycleScope.launch { + // Permission is available: resume state-driven rendering. + isPermissionDenied = false if (::cameraExecutor.isInitialized && !cameraExecutor.isShutdown) { return@launch } @@ -214,7 +221,7 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) when { requireActivity().hasPermission(Manifest.permission.CAMERA) -> { setUpCamera() - enableCaptureButtonIfAutoCapture() + toggleCaptureButtonIfAutoCapture(true) } mainVm.shouldCheckCameraPermissions.getAndSet(false) -> { @@ -231,16 +238,14 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) } } - private fun enableCaptureButtonIfAutoCapture() { + private fun toggleCaptureButtonIfAutoCapture(enabled: Boolean) { if (vm.isAutoCapture) { - // Await until capture button is pressed - vm.holdOffAutoCapture() - binding.captureFeedbackBtn.isClickable = true + binding.captureFeedbackBtn.isClickable = enabled } } override fun onStop() { - toggleTorche(false) + toggleTorch(false) super.onStop() } @@ -251,7 +256,6 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) if (::imageAnalyzer.isInitialized) { imageAnalyzer.clearAnalyzer() } - if (::preview.isInitialized) { preview.surfaceProvider = null } @@ -259,27 +263,9 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) } private fun bindViewModel() { - vm.progressBarValue.observe(viewLifecycleOwner) { - binding.captureProgress.value = it - } - - vm.currentDetection.observe(viewLifecycleOwner) { - renderCurrentDetection(it) - } - - vm.capturingState.observe(viewLifecycleOwner) { - when (it) { - LiveFeedbackFragmentViewModel.CapturingState.NOT_STARTED -> renderCaptureNotStarted() - LiveFeedbackFragmentViewModel.CapturingState.CAPTURING -> renderCapturing() - LiveFeedbackFragmentViewModel.CapturingState.VALIDATING -> renderValidating() - LiveFeedbackFragmentViewModel.CapturingState.VALIDATION_FAILED -> renderResetAfterValidation() - LiveFeedbackFragmentViewModel.CapturingState.FINISHED -> { - mainVm.captureFinished(vm.sortedQualifyingCaptures) - findNavController().navigateSafely( - currentFragment = this, - directions = LiveFeedbackFragmentDirections.actionFaceLiveFeedbackFragmentToFaceConfirmationFragment(), - ) - } + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + vm.state.collect(::render) } } } @@ -299,180 +285,151 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) } } - private fun renderCurrentDetection(faceDetection: FaceDetection) { - if (vm.isAutoCapture && vm.capturingState.value != LiveFeedbackFragmentViewModel.CapturingState.CAPTURING) { - return - } + private fun render(state: LiveFeedbackState) { + if (isPermissionDenied) return - when (faceDetection.status) { - FaceDetection.Status.NOFACE -> renderNoFace() - FaceDetection.Status.OFFYAW -> renderFaceNotStraight() - FaceDetection.Status.OFFROLL -> renderFaceNotStraight() - FaceDetection.Status.TOOCLOSE -> renderFaceTooClose() - FaceDetection.Status.TOOFAR -> renderFaceTooFar() - FaceDetection.Status.BAD_QUALITY -> renderBadQuality() - FaceDetection.Status.VALID -> renderValidFace() - FaceDetection.Status.VALID_CAPTURING -> renderValidCapturingFace() + renderProgress(state.progress) + when (state.phase) { + LiveFeedbackState.Phase.NOT_STARTED -> { + renderOverlay(overlayWhite = false, explanationVisible = false) + renderFeedbackOnButton(state) + } + LiveFeedbackState.Phase.CAPTURING -> { + renderOverlay(overlayWhite = true, explanationVisible = true) + renderFeedbackOnButton(state) + } + LiveFeedbackState.Phase.VALIDATING -> { + renderOverlay(overlayWhite = true, explanationVisible = true) + renderValidating() + } + LiveFeedbackState.Phase.VALIDATION_FAILED -> { + renderOverlay(overlayWhite = true, explanationVisible = true) + renderValidationFailed() + } + LiveFeedbackState.Phase.FINISHED -> onCaptureFinished(state.result) } } - private fun renderCaptureNotStarted() { - binding.apply { - if (vm.isAutoCapture) { - captureFeedbackBtn.setText(IDR.string.face_capture_start_capture) - captureFeedbackBtn.isChecked = true - captureFeedbackBtn.isClickable = true - } else { - captureFeedbackBtn.setText(IDR.string.face_capture_title_previewing) - } - - captureOverlay.drawSemiTransparentTarget() - captureFeedbackTxtExplanation.setTextColor(ContextCompat.getColor(requireContext(), IDR.color.simprints_text_white)) - - captureFeedbackBtn.isVisible = true - captureFeedbackPermissionButton.isGone = true - captureFeedbackTxtExplanation.isGone = true + private fun renderProgress(progress: Progress) = with(binding.captureProgress) { + value = progress.value + progressColor = when (progress.tint) { + Progress.Tint.DEFAULT -> defaultCaptureProgressColor + Progress.Tint.VALID -> validCaptureProgressColor + Progress.Tint.VALIDATION -> validationProgressColor } + isInvisible = !progress.visible } - private fun renderCapturing() { - binding.apply { + private fun renderOverlay( + overlayWhite: Boolean, + explanationVisible: Boolean, + ) = with(binding) { + if (overlayWhite) { captureOverlay.drawWhiteTarget() captureFeedbackTxtExplanation.setTextColor(ContextCompat.getColor(requireContext(), IDR.color.simprints_blue_grey)) - captureFeedbackTxtExplanation.isVisible = true - - captureProgress.isVisible = true - captureFeedbackBtn.setText(IDR.string.face_capture_prep_begin_button_capturing) - captureFeedbackBtn.isVisible = true - captureFeedbackPermissionButton.isGone = true - setManualCaptureButtonClickable(false) + } else { + captureOverlay.drawSemiTransparentTarget() + captureFeedbackTxtExplanation.setTextColor(ContextCompat.getColor(requireContext(), IDR.color.simprints_text_white)) } - } - - private fun renderValidating() { - binding.apply { - captureOverlay.drawWhiteTarget() - - captureProgress.progressColor = validationProgressColor - captureProgress.isVisible = true - captureFeedbackBtn.setText(IDR.string.face_capture_title_validating) - captureFeedbackBtn.setCheckedWithLeftDrawable(false) - setManualCaptureButtonClickable(false) + captureFeedbackTxtExplanation.isVisible = explanationVisible + captureFeedbackBtn.isVisible = true + captureFeedbackPermissionButton.isGone = true + } - captureFeedbackTxtExplanation.isVisible = false + private fun renderFeedbackOnButton(state: LiveFeedbackState) = with(binding) { + val feedback = if (state.isAutoCapture && state.phase != LiveFeedbackState.Phase.CAPTURING) { + LiveFeedbackState.Feedback.NONE + } else { + state.feedback } - } - private fun renderResetAfterValidation() { - binding.apply { - captureProgress.isInvisible = true - captureFeedbackBtn.setText(IDR.string.face_capture_title_validating_failed) + when (feedback) { + LiveFeedbackState.Feedback.NONE -> when { + state.phase == LiveFeedbackState.Phase.CAPTURING -> + captureFeedbackBtn.setText(IDR.string.face_capture_prep_begin_button_capturing) - captureFeedbackTxtExplanation.setTextColor(ContextCompat.getColor(requireContext(), IDR.color.simprints_blue_grey)) - captureFeedbackTxtExplanation.setText(IDR.string.face_capture_error_validating_failed) - captureFeedbackTxtExplanation.isVisible = true - } - } + state.isAutoCapture -> { + captureFeedbackBtn.setText(IDR.string.face_capture_start_capture) + captureFeedbackBtn.isChecked = true + captureFeedbackBtn.isClickable = true + } - private fun renderValidFace() { - binding.apply { - if (vm.isAutoCapture) { - captureFeedbackBtn.setText(IDR.string.face_capture_prep_begin_button_capturing) - } else { - captureFeedbackBtn.setText(IDR.string.face_capture_begin_button) - setManualCaptureButtonClickable(true) + else -> captureFeedbackBtn.setText(IDR.string.face_capture_title_previewing) } - captureFeedbackTxtExplanation.text = null - captureFeedbackBtn.isVisible = true - captureFeedbackPermissionButton.isGone = true + LiveFeedbackState.Feedback.NO_FACE -> + renderInvalidFace(IDR.string.face_capture_title_no_face, IDR.string.face_capture_error_no_face) - captureFeedbackBtn.setCheckedWithLeftDrawable( - true, - ContextCompat.getDrawable(requireContext(), R.drawable.ic_checked_white_18dp), - ) - } - } + LiveFeedbackState.Feedback.LOOK_STRAIGHT -> + renderInvalidFace(IDR.string.face_capture_title_look_straight, IDR.string.face_capture_error_look_straight) - private fun renderValidCapturingFace() { - binding.apply { - captureFeedbackBtn.setText(IDR.string.face_capture_prep_begin_button_capturing) - captureFeedbackTxtExplanation.setText(IDR.string.face_capture_hold) - captureFeedbackBtn.isVisible = true - captureFeedbackPermissionButton.isGone = true - - captureFeedbackBtn.setCheckedWithLeftDrawable( - true, - ContextCompat.getDrawable(requireContext(), R.drawable.ic_checked_white_18dp), - ) - captureProgress.progressColor = validCaptureProgressColor - } - } - - private fun renderFaceTooFar() { - binding.apply { - captureFeedbackBtn.setText(IDR.string.face_capture_title_too_far) - captureFeedbackTxtExplanation.setText(IDR.string.face_capture_error_too_far) - captureFeedbackBtn.isVisible = true - captureFeedbackPermissionButton.isGone = true + LiveFeedbackState.Feedback.TOO_CLOSE -> + renderInvalidFace(IDR.string.face_capture_title_too_close, IDR.string.face_capture_error_too_close) - captureFeedbackBtn.setCheckedWithLeftDrawable(false) - setManualCaptureButtonClickable(false) - captureProgress.progressColor = defaultCaptureProgressColor - } - } + LiveFeedbackState.Feedback.TOO_FAR -> + renderInvalidFace(IDR.string.face_capture_title_too_far, IDR.string.face_capture_error_too_far) - private fun renderFaceTooClose() { - binding.apply { - captureFeedbackBtn.setText(IDR.string.face_capture_title_too_close) - captureFeedbackTxtExplanation.setText(IDR.string.face_capture_error_too_close) - captureFeedbackBtn.isVisible = true - captureFeedbackPermissionButton.isGone = true + LiveFeedbackState.Feedback.VALID -> { + if (state.isAutoCapture) { + captureFeedbackBtn.setText(IDR.string.face_capture_prep_begin_button_capturing) + } else { + captureFeedbackBtn.setText(IDR.string.face_capture_begin_button) + setManualCaptureButtonClickable(true) + } + captureFeedbackTxtExplanation.text = null + captureFeedbackBtn.setCheckedWithLeftDrawable( + true, + ContextCompat.getDrawable(requireContext(), R.drawable.ic_checked_white_18dp), + ) + } - captureFeedbackBtn.setCheckedWithLeftDrawable(false) - setManualCaptureButtonClickable(false) - captureProgress.progressColor = defaultCaptureProgressColor + LiveFeedbackState.Feedback.VALID_CAPTURING -> { + captureFeedbackBtn.setText(IDR.string.face_capture_prep_begin_button_capturing) + captureFeedbackTxtExplanation.setText(IDR.string.face_capture_hold) + captureFeedbackBtn.setCheckedWithLeftDrawable( + true, + ContextCompat.getDrawable(requireContext(), R.drawable.ic_checked_white_18dp), + ) + } } } - private fun renderNoFace() { - binding.apply { - captureFeedbackBtn.setText(IDR.string.face_capture_title_no_face) - captureFeedbackTxtExplanation.setText(IDR.string.face_capture_error_no_face) - captureFeedbackBtn.isVisible = true - captureFeedbackPermissionButton.isGone = true - - captureFeedbackBtn.setCheckedWithLeftDrawable(false) - setManualCaptureButtonClickable(false) - captureProgress.progressColor = defaultCaptureProgressColor - } + private fun renderInvalidFace( + @StringRes titleRes: Int, + @StringRes explanationRes: Int, + ) = with(binding) { + captureFeedbackBtn.setText(titleRes) + captureFeedbackTxtExplanation.setText(explanationRes) + captureFeedbackBtn.setCheckedWithLeftDrawable(false) + setManualCaptureButtonClickable(false) } - private fun renderFaceNotStraight() { - binding.apply { - captureFeedbackBtn.setText(IDR.string.face_capture_title_look_straight) - captureFeedbackTxtExplanation.setText(IDR.string.face_capture_error_look_straight) - captureFeedbackBtn.isVisible = true - captureFeedbackPermissionButton.isGone = true - - captureFeedbackBtn.setCheckedWithLeftDrawable(false) - setManualCaptureButtonClickable(false) - captureProgress.progressColor = defaultCaptureProgressColor - } + private fun renderValidating() = with(binding) { + captureOverlay.drawWhiteTarget() + captureFeedbackBtn.setText(IDR.string.face_capture_title_validating) + captureFeedbackBtn.setCheckedWithLeftDrawable(false) + setManualCaptureButtonClickable(false) + captureFeedbackTxtExplanation.isVisible = false + captureFeedbackBtn.isVisible = true + captureFeedbackPermissionButton.isGone = true } - private fun renderBadQuality() { - binding.apply { - captureFeedbackBtn.setText(IDR.string.face_capture_title_bad_quality) - captureFeedbackTxtExplanation.setText(IDR.string.face_capture_error_bad_quality) - captureFeedbackBtn.isVisible = true - captureFeedbackPermissionButton.isGone = true + private fun renderValidationFailed() = with(binding) { + captureFeedbackBtn.setText(IDR.string.face_capture_title_validating_failed) + captureFeedbackTxtExplanation.setText(IDR.string.face_capture_error_validating_failed) + captureFeedbackBtn.isVisible = true + captureFeedbackPermissionButton.isGone = true + } - captureFeedbackBtn.setCheckedWithLeftDrawable(false) - setManualCaptureButtonClickable(false) - captureProgress.progressColor = defaultCaptureProgressColor - } + private fun onCaptureFinished(result: List) { + if (finishedHandled) return + finishedHandled = true + mainVm.captureFinished(result) + findNavController().navigateSafely( + currentFragment = this, + directions = LiveFeedbackFragmentDirections.actionFaceLiveFeedbackFragmentToFaceConfirmationFragment(), + ) } private fun FragmentLiveFeedbackBinding.setManualCaptureButtonClickable(clickable: Boolean) { @@ -482,8 +439,9 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) } private fun renderNoPermission(shouldOpenSettings: Boolean) { + isPermissionDenied = true binding.apply { - captureOverlay.drawSemiTransparentTarget() + renderOverlay(overlayWhite = false, explanationVisible = true) captureFeedbackTxtExplanation.setText(IDR.string.face_capture_permission_denied) captureFeedbackBtn.isGone = true captureFeedbackPermissionButton.isVisible = true diff --git a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackState.kt b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackState.kt new file mode 100644 index 0000000000..6bb5c39484 --- /dev/null +++ b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackState.kt @@ -0,0 +1,54 @@ +package com.simprints.face.capture.screens.livefeedback + +import com.simprints.face.capture.models.FaceDetection + +/** + * Single, immutable snapshot of everything the live-feedback UI needs to render. + */ +internal data class LiveFeedbackState( + val phase: Phase, + val feedback: Feedback, + val isAutoCapture: Boolean, + val progress: Progress, + val result: List = emptyList(), +) { + /** Overall capture phase / state machine. */ + enum class Phase { NOT_STARTED, CAPTURING, VALIDATING, VALIDATION_FAILED, FINISHED } + + /** + * UI-facing guidance derived from the latest [FaceDetection]. + * Decoupled from the biometric [FaceDetection.Status] so the fragment maps it to text/visuals via a simple lookup. + */ + enum class Feedback { NONE, NO_FACE, LOOK_STRAIGHT, TOO_CLOSE, TOO_FAR, VALID, VALID_CAPTURING } + + companion object { + fun initial(isAutoCapture: Boolean = false) = LiveFeedbackState( + phase = Phase.NOT_STARTED, + feedback = Feedback.NONE, + isAutoCapture = isAutoCapture, + progress = Progress.HIDDEN, + ) + } +} + +internal data class Progress( + val value: Float, + val tint: Tint, + val visible: Boolean, +) { + enum class Tint { DEFAULT, VALID, VALIDATION } + + companion object { + val HIDDEN = Progress(value = 0f, tint = Tint.DEFAULT, visible = false) + } +} + +internal fun FaceDetection.Status.toFeedback(): LiveFeedbackState.Feedback = when (this) { + FaceDetection.Status.VALID -> LiveFeedbackState.Feedback.VALID + FaceDetection.Status.VALID_CAPTURING -> LiveFeedbackState.Feedback.VALID_CAPTURING + FaceDetection.Status.NOFACE -> LiveFeedbackState.Feedback.NO_FACE + FaceDetection.Status.OFFYAW -> LiveFeedbackState.Feedback.LOOK_STRAIGHT + FaceDetection.Status.OFFROLL -> LiveFeedbackState.Feedback.LOOK_STRAIGHT + FaceDetection.Status.TOOCLOSE -> LiveFeedbackState.Feedback.TOO_CLOSE + FaceDetection.Status.TOOFAR -> LiveFeedbackState.Feedback.TOO_FAR +} diff --git a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragmentViewModel.kt b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModel.kt similarity index 74% rename from face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragmentViewModel.kt rename to face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModel.kt index 8a98cb0c80..ff79fb1d98 100644 --- a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragmentViewModel.kt +++ b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModel.kt @@ -1,7 +1,6 @@ package com.simprints.face.capture.screens.livefeedback import android.graphics.Bitmap -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.simprints.core.DispatcherBG @@ -30,8 +29,11 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject @@ -39,7 +41,7 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.measureTimedValue @HiltViewModel -internal class LiveFeedbackFragmentViewModel @Inject constructor( +internal class LiveFeedbackViewModel @Inject constructor( private val resolveFaceBioSdk: ResolveFaceBioSdkUseCase, private val configRepository: ConfigRepository, private val eventReporter: SimpleCaptureEventReporter, @@ -63,13 +65,17 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( val userCaptures = mutableListOf() var sortedQualifyingCaptures = listOf() - val currentDetection = MutableLiveData() - val capturingState = MutableLiveData(CapturingState.NOT_STARTED) - val progressBarValue = MutableLiveData() + + /** + * The single source of truth for the whole screen. + * The fragment renders this deterministically; all transitions funnel through [emit]. + */ + private val _state = MutableStateFlow(LiveFeedbackState.initial()) + val state: StateFlow = _state.asStateFlow() var isAutoCapture: Boolean = false - var spoofCheckConfig: SpoofCheckConfiguration = SpoofCheckConfiguration.DISABLED - var spoofCheckCount: Int = 0 + private var spoofCheckConfig: SpoofCheckConfiguration = SpoofCheckConfiguration.DISABLED + private var spoofCheckCount: Int = 0 private var captureImagingStartTime: Long = 0 private var validationStartTime: Long = 0 @@ -78,9 +84,34 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( private var autoCaptureImagingDurationMillis: Long = FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_DEFAULT private lateinit var faceDetector: FaceDetector + private val phase: LiveFeedbackState.Phase + get() = _state.value.phase + + private fun emit( + phase: LiveFeedbackState.Phase = _state.value.phase, + feedback: LiveFeedbackState.Feedback = _state.value.feedback, + detectionForTint: FaceDetection? = null, + result: List = _state.value.result, + ) { + _state.update { currentState -> + currentState.copy( + phase = phase, + feedback = feedback, + isAutoCapture = isAutoCapture, + progress = computeProgress(phase, detectionForTint), + result = result, + ) + } + } + suspend fun initAutoCapture() { val config = configRepository.getProjectConfiguration() isAutoCapture = isUsingAutoCaptureUseCase(config) + if (isAutoCapture) { + // Await until capture button is pressed + holdOffAutoCapture() + } + emit() // Reset UI state with correct auto-capture value } fun initCapture( @@ -103,11 +134,11 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( fun holdOffAutoCapture() { if (isAutoCapture) { - if (capturingState.value != CapturingState.NOT_STARTED) { + if (phase != LiveFeedbackState.Phase.NOT_STARTED) { return // too late - imaging has already started } - capturingState.value = CapturingState.NOT_STARTED // reset view isAutoCaptureHeldOff = true + emit(phase = LiveFeedbackState.Phase.NOT_STARTED, feedback = LiveFeedbackState.Feedback.NONE) // reset view } } @@ -115,28 +146,24 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( if (isAutoCapture) { isAutoCaptureHeldOff = false } else { - capturingState.value = CapturingState.CAPTURING + emit(phase = LiveFeedbackState.Phase.CAPTURING) } } /** - * Processes the image - * - * @param croppedBitmap is the camera frame + * Processes the image. Called on the CameraX analyzer executor (off the main thread). */ fun process( originalBitmap: Bitmap, croppedBitmap: Bitmap, ) { // Skip processing and only update progress bar while spoof check is running - if (capturingState.value == CapturingState.VALIDATING) { - progressBarValue.postValue( - ((timeHelper.now().ms - validationStartTime).toFloat() / spoofCheckConfig.validationUiDurationMs).coerceIn(0f, 1f), - ) + if (phase == LiveFeedbackState.Phase.VALIDATING) { + emit(phase = LiveFeedbackState.Phase.VALIDATING) originalBitmap.recycle() croppedBitmap.recycle() return - } else if (capturingState.value == CapturingState.VALIDATION_FAILED) { + } else if (phase == LiveFeedbackState.Phase.VALIDATION_FAILED) { originalBitmap.recycle() croppedBitmap.recycle() return @@ -149,11 +176,14 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( faceDetection.detectionStartTime = captureStartTime faceDetection.detectionEndTime = timeHelper.now() + var newPhase = phase + var feedback = _state.value.feedback + if (isAutoCapture) { if (!isAutoCaptureHeldOff) { - currentDetection.postValue(faceDetection) - if (faceDetection.status == FaceDetection.Status.VALID && capturingState.value == CapturingState.NOT_STARTED) { - capturingState.postValue(CapturingState.CAPTURING) + feedback = faceDetection.status.toFeedback() + if (faceDetection.status == FaceDetection.Status.VALID && phase == LiveFeedbackState.Phase.NOT_STARTED) { + newPhase = LiveFeedbackState.Phase.CAPTURING captureImagingStartTime = captureStartTime.ms autoCaptureImagingTimeoutJob = viewModelScope.launch { delay(autoCaptureImagingDurationMillis) @@ -162,13 +192,12 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( } } } else { - currentDetection.postValue(faceDetection) + feedback = faceDetection.status.toFeedback() } - progressBarValue.postValue(getNormalizedCaptureProgress()) - when (capturingState.value) { - CapturingState.NOT_STARTED -> updateFallbackCaptureIfValid(faceDetection) - CapturingState.CAPTURING -> { + when (newPhase) { + LiveFeedbackState.Phase.NOT_STARTED -> updateFallbackCaptureIfValid(faceDetection) + LiveFeedbackState.Phase.CAPTURING -> { if (isAutoCapture) { if (isQualifying(faceDetection)) { updateUserCapturesWith(faceDetection) @@ -184,12 +213,31 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( else -> { // no-op } } + + emit(phase = newPhase, feedback = feedback, detectionForTint = faceDetection) } - fun getNormalizedCaptureProgress(): Float = if (isAutoCapture) { - ((timeHelper.now().ms - captureImagingStartTime).toFloat() / autoCaptureImagingDurationMillis).coerceIn(0f, 1f) - } else { - userCaptures.size.toFloat() / samplesToCapture + private fun computeProgress( + phase: LiveFeedbackState.Phase, + detection: FaceDetection?, + ): Progress = Progress( + value = normalizedProgress(phase), + tint = when { + phase == LiveFeedbackState.Phase.VALIDATING -> Progress.Tint.VALIDATION + detection?.status == FaceDetection.Status.VALID_CAPTURING -> Progress.Tint.VALID + else -> Progress.Tint.DEFAULT + }, + visible = phase == LiveFeedbackState.Phase.CAPTURING || phase == LiveFeedbackState.Phase.VALIDATING, + ) + + private fun normalizedProgress(phase: LiveFeedbackState.Phase): Float = when { + phase == LiveFeedbackState.Phase.VALIDATING -> + ((timeHelper.now().ms - validationStartTime).toFloat() / spoofCheckConfig.validationUiDurationMs).coerceIn(0f, 1f) + + isAutoCapture -> + ((timeHelper.now().ms - captureImagingStartTime).toFloat() / autoCaptureImagingDurationMillis).coerceIn(0f, 1f) + + else -> userCaptures.size.toFloat() / samplesToCapture } private fun isQualifying(faceDetection: FaceDetection): Boolean { @@ -250,20 +298,20 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( } } - private fun sendEventsAndFinish(attemptNumber: Int) { + private suspend fun sendEventsAndFinish(attemptNumber: Int) { sortedQualifyingCaptures = userCaptures .filter { isAutoCapture || it.hasValidStatus() } // Auto-capture images are pre-qualified .sortedByDescending { it.face?.quality } .ifEmpty { listOfNotNull(fallbackCapture) } sendCaptureEvents(attemptNumber) - capturingState.postValue(CapturingState.FINISHED) + emit(phase = LiveFeedbackState.Phase.FINISHED, result = sortedQualifyingCaptures) } private suspend fun runSpoofChecksOnCaptures() = withContext(bgDispatcher) { - capturingState.postValue(CapturingState.VALIDATING) spoofCheckCount++ validationStartTime = timeHelper.now().ms + emit(phase = LiveFeedbackState.Phase.VALIDATING) val duration = measureTimedValue { for ((index, bitmap) in userCaptures.map { it.original }.withIndex()) { @@ -280,21 +328,29 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( } private suspend fun showValidationErrorAndReset() { - capturingState.postValue(CapturingState.VALIDATION_FAILED) + emit(phase = LiveFeedbackState.Phase.VALIDATION_FAILED) val duration = measureTimedValue { // Still track the capture attempt events for analytics and troubleshooting sendCaptureEvents(attemptNumber) + + userCaptures.forEach { + it.original.recycle() + it.bitmap.recycle() + } + userCaptures.clear() + fallbackCapture?.original?.recycle() + fallbackCapture?.bitmap?.recycle() + fallbackCapture = null + + // Reset state + isAutoCaptureHeldOff = true + sortedQualifyingCaptures = emptyList() } val delay = maxOf(spoofCheckConfig.validationErrorUiDurationMs - duration.duration.inWholeMilliseconds, 0) Simber.i("Captures tracked in ${duration.duration}, waiting for ${delay}ms", tag = FACE_CAPTURE) if (delay > 0) delay(delay.milliseconds) - // Reset state - capturingState.postValue(CapturingState.NOT_STARTED) - isAutoCaptureHeldOff = true - userCaptures.clear() - sortedQualifyingCaptures = emptyList() - fallbackCapture = null + emit(phase = LiveFeedbackState.Phase.NOT_STARTED, feedback = LiveFeedbackState.Feedback.NONE, result = emptyList()) } private fun getFaceDetectionFromPotentialFace( @@ -327,7 +383,7 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( areaOccupied > faceTarget.areaRange.endInclusive -> FaceDetection.Status.TOOCLOSE potentialFace.yaw !in faceTarget.yawTarget -> FaceDetection.Status.OFFYAW potentialFace.roll !in faceTarget.rollTarget -> FaceDetection.Status.OFFROLL - capturingState.value == CapturingState.CAPTURING -> FaceDetection.Status.VALID_CAPTURING + phase == LiveFeedbackState.Phase.CAPTURING -> FaceDetection.Status.VALID_CAPTURING else -> FaceDetection.Status.VALID } @@ -370,9 +426,10 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( /** * Since events are saved in a blocking way in [SimpleCaptureEventReporter.addCaptureEvents], - * to speed things up this method creates multiple async jobs and run them all in parallel. + * this method fans the writes out as parallel jobs on the background dispatcher so that + * neither the main thread nor the camera pipeline is blocked. */ - private fun sendCaptureEvents(attemptNumber: Int) = runBlocking { + private suspend fun sendCaptureEvents(attemptNumber: Int) = withContext(bgDispatcher) { userCaptures .map { async { sendCaptureEvent(it, attemptNumber) } } .plus(async { sendCaptureEvent(fallbackCapture, attemptNumber) }) @@ -387,8 +444,6 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( eventReporter.addCaptureEvents(faceDetection, attemptNumber, qualityThreshold, spoofCheckConfig, isAutoCapture = isAutoCapture) } - enum class CapturingState { NOT_STARTED, CAPTURING, VALIDATING, VALIDATION_FAILED, FINISHED } - companion object { private const val VALID_ROLL_DELTA = 15f private const val VALID_YAW_DELTA = 30f diff --git a/face/capture/src/main/java/com/simprints/face/capture/usecases/SimpleCaptureEventReporter.kt b/face/capture/src/main/java/com/simprints/face/capture/usecases/SimpleCaptureEventReporter.kt index a0e23d020d..bc626f7625 100644 --- a/face/capture/src/main/java/com/simprints/face/capture/usecases/SimpleCaptureEventReporter.kt +++ b/face/capture/src/main/java/com/simprints/face/capture/usecases/SimpleCaptureEventReporter.kt @@ -91,7 +91,6 @@ internal class SimpleCaptureEventReporter @Inject constructor( FaceDetection.Status.VALID -> FaceCapturePayload.Result.VALID FaceDetection.Status.VALID_CAPTURING -> FaceCapturePayload.Result.VALID FaceDetection.Status.NOFACE -> FaceCapturePayload.Result.INVALID - FaceDetection.Status.BAD_QUALITY -> FaceCapturePayload.Result.BAD_QUALITY FaceDetection.Status.OFFYAW -> FaceCapturePayload.Result.OFF_YAW FaceDetection.Status.OFFROLL -> FaceCapturePayload.Result.OFF_ROLL FaceDetection.Status.TOOCLOSE -> FaceCapturePayload.Result.TOO_CLOSE diff --git a/face/capture/src/test/java/com/simprints/face/capture/screens/FaceCaptureViewModelTest.kt b/face/capture/src/test/java/com/simprints/face/capture/screens/FaceCaptureViewModelTest.kt index 0ef699ee2b..834872d37c 100644 --- a/face/capture/src/test/java/com/simprints/face/capture/screens/FaceCaptureViewModelTest.kt +++ b/face/capture/src/test/java/com/simprints/face/capture/screens/FaceCaptureViewModelTest.kt @@ -108,6 +108,7 @@ class FaceCaptureViewModelTest { ?.imageSavingStrategy } returns ImageSavingStrategy.NEVER + viewModel.initFaceBioSdk(mockk(), ModalitySdkType.SIM_FACE) viewModel.captureFinished(faceDetections) viewModel.flowFinished() coVerify(exactly = 0) { faceImageUseCase.invoke(any(), any()) } @@ -148,6 +149,7 @@ class FaceCaptureViewModelTest { @Test fun `Recapture requests clears capture list`() { + viewModel.initFaceBioSdk(mockk(), ModalitySdkType.SIM_FACE) viewModel.captureFinished(faceDetections) viewModel.recapture() diff --git a/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackAutoCaptureFragmentViewModelTest.kt b/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackAutoCaptureFragmentViewModelTest.kt deleted file mode 100644 index abde97d400..0000000000 --- a/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackAutoCaptureFragmentViewModelTest.kt +++ /dev/null @@ -1,533 +0,0 @@ -package com.simprints.face.capture.screens.livefeedback - -import android.graphics.Bitmap -import android.graphics.Rect -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.test.ext.junit.runners.* -import com.google.common.truth.Truth.* -import com.simprints.core.tools.time.TimeHelper -import com.simprints.core.tools.time.Timestamp -import com.simprints.face.capture.models.FaceDetection -import com.simprints.face.capture.usecases.GetSpoofCheckConfigurationUseCase -import com.simprints.face.capture.usecases.IsUsingAutoCaptureUseCase -import com.simprints.face.capture.usecases.SimpleCaptureEventReporter -import com.simprints.face.infra.basebiosdk.detection.Face -import com.simprints.face.infra.basebiosdk.detection.FaceDetector -import com.simprints.face.infra.basebiosdk.detection.SpoofCheckResult -import com.simprints.face.infra.biosdkresolver.ResolveFaceBioSdkUseCase -import com.simprints.infra.config.store.ConfigRepository -import com.simprints.infra.config.store.models.FaceConfiguration -import com.simprints.infra.config.store.models.FaceConfiguration.SpoofCheckConfiguration -import com.simprints.infra.config.store.models.ModalitySdkType -import com.simprints.testtools.common.coroutines.TestCoroutineRule -import com.simprints.testtools.common.livedata.testObserver -import io.mockk.* -import io.mockk.impl.annotations.MockK -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.advanceTimeBy -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.JsonPrimitive -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import kotlin.random.Random - -@OptIn(ExperimentalCoroutinesApi::class) -@RunWith(AndroidJUnit4::class) -internal class LiveFeedbackAutoCaptureFragmentViewModelTest { - @get:Rule - val rule = InstantTaskExecutorRule() - - @get:Rule - val testCoroutineRule = TestCoroutineRule() - - @MockK - lateinit var faceDetector: FaceDetector - - @MockK - lateinit var frame: Bitmap - - @MockK - lateinit var previewFrame: Bitmap - - @MockK - lateinit var configRepository: ConfigRepository - - @MockK - lateinit var eventReporter: SimpleCaptureEventReporter - - @MockK - lateinit var timeHelper: TimeHelper - - @MockK - private lateinit var isUsingAutoCapture: IsUsingAutoCaptureUseCase - - @MockK - private lateinit var getSpoofCheckConfiguration: GetSpoofCheckConfigurationUseCase - private lateinit var viewModel: LiveFeedbackFragmentViewModel - - @Before - fun setUp() { - MockKAnnotations.init(this, relaxed = true) - - coEvery { - configRepository - .getProjectConfiguration() - .face - ?.getSdkConfiguration(any()) - ?.qualityThreshold - } returns QUALITY_THRESHOLD - every { isUsingAutoCapture.invoke(any()) } returns true - every { getSpoofCheckConfiguration.invoke(any(), any()) } returns SpoofCheckConfiguration.DISABLED - coEvery { - configRepository.getProjectConfiguration().custom - } returns mapOf("singleQualityFallbackRequired" to JsonPrimitive(false)) - - every { timeHelper.now() } returnsMany (0..100L).map { Timestamp(it) } - justRun { previewFrame.recycle() } - val resolveFaceBioSdkUseCase = mockk { - coEvery { this@mockk.invoke(any()) } returns mockk { - every { detector } returns faceDetector - } - } - - viewModel = LiveFeedbackFragmentViewModel( - resolveFaceBioSdkUseCase, - configRepository, - eventReporter, - timeHelper, - isUsingAutoCapture, - getSpoofCheckConfiguration, - testCoroutineRule.testCoroutineDispatcher, - ) - } - - @Test - fun `Do not start capture if valid quality face detected but before start capture clicked`() = runTest { - coEvery { faceDetector.analyze(frame) } returns getFace() - val currentDetection = viewModel.currentDetection.testObserver() - val capturingState = viewModel.capturingState.testObserver() - - viewModel.initAutoCapture() - viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - viewModel.process(frame, frame) - - assertThat(currentDetection.observedValues) - .isEmpty() - assertThat(capturingState.observedValues.last()) - .isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.NOT_STARTED) - } - - @Test - fun `Do not start capture if valid quality face detected but capture held off`() = runTest { - coEvery { faceDetector.analyze(frame) } returns getFace() - val currentDetection = viewModel.currentDetection.testObserver() - val capturingState = viewModel.capturingState.testObserver() - - viewModel.initAutoCapture() - viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - viewModel.startCapture() - viewModel.holdOffAutoCapture() - viewModel.process(frame, frame) - - assertThat(currentDetection.observedValues) - .isEmpty() - assertThat(capturingState.observedValues.last()) - .isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.NOT_STARTED) - } - - @Test - fun `Start capture if valid quality face detected when start capture clicked`() = runTest { - coEvery { faceDetector.analyze(frame) } returns getFace() - val currentDetection = viewModel.currentDetection.testObserver() - val capturingState = viewModel.capturingState.testObserver() - - viewModel.initAutoCapture() - viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - viewModel.startCapture() - viewModel.process(frame, frame) - - assertThat(currentDetection.observedValues.last()?.hasValidStatus()).isEqualTo(true) - assertThat(capturingState.observedValues.last()) - .isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.CAPTURING) - } - - @Test - fun `Proceed with capture if capture attempted to be held off after it started`() = runTest { - coEvery { faceDetector.analyze(frame) } returns getFace() - val currentDetection = viewModel.currentDetection.testObserver() - val capturingState = viewModel.capturingState.testObserver() - - viewModel.initAutoCapture() - viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - viewModel.startCapture() - viewModel.process(frame, frame) - viewModel.holdOffAutoCapture() - viewModel.process(frame, frame) - - assertThat(currentDetection.observedValues.last()?.hasValidStatus()).isEqualTo(true) - assertThat(capturingState.observedValues.last()) - .isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.CAPTURING) - } - - @Test - fun `Start capture if valid quality face detected later than start capture clicked`() = runTest { - coEvery { faceDetector.analyze(frame) } returnsMany listOf( - getFace(quality = -2f), - getFace(), - ) - val currentDetection = viewModel.currentDetection.testObserver() - val capturingState = viewModel.capturingState.testObserver() - - viewModel.initAutoCapture() - viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - viewModel.startCapture() - viewModel.process(frame, frame) - viewModel.process(frame, frame) - - assertThat(currentDetection.observedValues.first()?.status) - .isEqualTo(FaceDetection.Status.VALID) - assertThat(capturingState.observedValues.first()) - .isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.NOT_STARTED) - assertThat(currentDetection.observedValues.last()?.hasValidStatus()) - .isEqualTo(true) - assertThat(capturingState.observedValues.last()) - .isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.CAPTURING) - } - - @Test - fun `Process fallback image when valid face correctly but not started capture`() = runTest { - coEvery { faceDetector.analyze(frame) } returns getFace() - - viewModel.initAutoCapture() - viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - viewModel.process(frame, frame) // a fallback image frame before the preparation delay elapses - viewModel.startCapture() - viewModel.process(frame, frame) - - val currentDetection = viewModel.currentDetection.testObserver() - assertThat(currentDetection.observedValues.last()?.hasValidStatus()).isEqualTo(true) - - coVerify { eventReporter.addFallbackCaptureEvent(any(), any()) } - } - - @Test - fun `Process valid face correctly`() = runTest { - coEvery { faceDetector.analyze(frame) } returns getFace() - - viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - viewModel.startCapture() - viewModel.process(frame, frame) - advanceTimeBy(AUTO_CAPTURE_IMAGING_DURATION_MS + 1) - - val currentDetection = viewModel.currentDetection.testObserver() - assertThat(currentDetection.observedValues.last()?.hasValidStatus()).isEqualTo(true) - - coVerify { eventReporter.addCaptureEvents(any(), any(), any(), any()) } - } - - @Test - fun `Process invalid faces correctly`() = runTest { - val smallFace: Face = getFace(Rect(0, 0, 30, 30)) - val bigFace: Face = getFace(Rect(0, 0, 80, 80)) - val yawedFace: Face = getFace(yaw = 45f) - val rolledFace: Face = getFace(roll = 45f) - val noFace = null - - every { faceDetector.analyze(frame) } returnsMany listOf( - smallFace, - bigFace, - yawedFace, - rolledFace, - noFace, - ) - - val detections = viewModel.currentDetection.testObserver() - viewModel.initAutoCapture() - viewModel.initCapture(ModalitySdkType.SIM_FACE, 2, 0) - viewModel.startCapture() - - viewModel.process(frame, frame) - viewModel.process(frame, frame) - viewModel.process(frame, frame) - viewModel.process(frame, frame) - viewModel.process(frame, frame) - viewModel.process(frame, frame) - - detections.observedValues.let { - assertThat(it[0]?.status).isEqualTo(FaceDetection.Status.TOOFAR) - assertThat(it[1]?.status).isEqualTo(FaceDetection.Status.TOOCLOSE) - assertThat(it[2]?.status).isEqualTo(FaceDetection.Status.OFFYAW) - assertThat(it[3]?.status).isEqualTo(FaceDetection.Status.OFFROLL) - assertThat(it[4]?.status).isEqualTo(FaceDetection.Status.NOFACE) - } - - coVerify(exactly = 0) { eventReporter.addCaptureEvents(any(), any(), any(), any()) } - } - - @Test - fun `Process invalid faces after single fallback correctly`() = runTest { - val validFace: Face = getFace() - val badQuality: Face = getFace(quality = -2f) - - every { faceDetector.analyze(frame) } returnsMany listOf( - badQuality, // not a fallback image due to bad quality - validFace, // fallback image - validFace, // 1st capture - badQuality, // 2nd capture - ) - - val detections = viewModel.currentDetection.testObserver() - viewModel.initAutoCapture() - viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - // fallback image frames before the preparation delay elapses - viewModel.process(frame, frame) - viewModel.process(frame, frame) - viewModel.startCapture() - viewModel.process(frame, frame) - viewModel.process(frame, frame) - - detections.observedValues.let { - // fallback image frame wasn't observed during preparation delay - assertThat(it[0]?.hasValidStatus()).isEqualTo(true) - assertThat(it[1]?.hasValidStatus()).isEqualTo(true) - } - } - - @Test - fun `Use default imaging duration when not configured`() = runTest { - coEvery { faceDetector.analyze(frame) } returns getFace() - coEvery { - configRepository.getProjectConfiguration().custom - } returns mapOf("faceAutoCaptureImagingDurationMillis" to JsonPrimitive(AUTO_CAPTURE_IMAGING_DURATION_MS)) - - val capturingState = viewModel.capturingState.testObserver() - - viewModel.initAutoCapture() - viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - viewModel.startCapture() - viewModel.process(frame, frame) - advanceTimeBy(AUTO_CAPTURE_IMAGING_DURATION_MS) - assertThat(capturingState.observedValues.last()) - .isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.CAPTURING) - - advanceTimeBy(1) - assertThat(capturingState.observedValues.last()) - .isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.FINISHED) - } - - @Test - fun `Use custom imaging duration when provided in config`() = runTest { - val configDuration = 5000L - coEvery { faceDetector.analyze(frame) } returns getFace() - coEvery { configRepository.getProjectConfiguration().custom } returns - mapOf( - "faceAutoCaptureImagingDurationMillis" to JsonPrimitive(configDuration.toInt()), - ) - - val capturingState = viewModel.capturingState.testObserver() - - viewModel.initAutoCapture() - viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - viewModel.startCapture() - viewModel.process(frame, frame) - advanceTimeBy(configDuration / 2) - assertThat(capturingState.observedValues.last()) - .isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.CAPTURING) - // Add 1ms to account for json deserialization delay - advanceTimeBy((configDuration / 2) + 1) - assertThat(capturingState.observedValues.last()) - .isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.FINISHED) - } - - @Test - fun `Auto capture spoof check Recorded finishes regardless of score`() = runTest { - val validFace = getFace() - every { getSpoofCheckConfiguration.invoke(any(), any()) } returns - getDefaultSpoofConfig() - every { faceDetector.analyze(frame) } returns validFace - coEvery { faceDetector.spoofCheck(any(), any()) } returns SpoofCheckResult(score = 0.9f) - - val capturingState = viewModel.capturingState.testObserver() - - viewModel.initAutoCapture() - viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - viewModel.startCapture() - viewModel.process(frame, frame) - - advanceUntilIdle() - - assertThat(capturingState.observedValues).contains(LiveFeedbackFragmentViewModel.CapturingState.VALIDATING) - assertThat(capturingState.observedValues.last()).isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.FINISHED) - assertThat(viewModel.sortedQualifyingCaptures).hasSize(1) - assertThat(viewModel.sortedQualifyingCaptures[0].spoofCheckResult?.score).isEqualTo(0.9f) - } - - @Test - fun `Auto capture spoof check Enforced passed finishes capture`() = runTest { - val validFace = getFace() - every { getSpoofCheckConfiguration.invoke(any(), any()) } returns getDefaultSpoofConfig(FaceConfiguration.SpoofCheckMode.ENFORCED) - every { faceDetector.analyze(frame) } returns validFace - coEvery { faceDetector.spoofCheck(any(), any()) } returns SpoofCheckResult(score = 0.1f) - - val capturingState = viewModel.capturingState.testObserver() - - viewModel.initAutoCapture() - viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - viewModel.startCapture() - viewModel.process(frame, frame) - - advanceUntilIdle() - - assertThat(capturingState.observedValues).contains(LiveFeedbackFragmentViewModel.CapturingState.VALIDATING) - assertThat(capturingState.observedValues.last()).isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.FINISHED) - assertThat(viewModel.sortedQualifyingCaptures).hasSize(1) - assertThat(viewModel.sortedQualifyingCaptures[0].spoofCheckResult?.score).isEqualTo(0.1f) - } - - @Test - fun `Auto capture spoof check Enforced failed resets state`() = runTest { - val validFace = getFace() - every { getSpoofCheckConfiguration.invoke(any(), any()) } returns getDefaultSpoofConfig(FaceConfiguration.SpoofCheckMode.ENFORCED) - every { faceDetector.analyze(frame) } returns validFace - coEvery { faceDetector.spoofCheck(any(), any()) } returns SpoofCheckResult(score = 0.9f) - - val capturingState = viewModel.capturingState.testObserver() - - viewModel.initAutoCapture() - viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - viewModel.startCapture() - viewModel.process(frame, frame) - - advanceUntilIdle() - - assertThat(capturingState.observedValues).contains(LiveFeedbackFragmentViewModel.CapturingState.VALIDATION_FAILED) - assertThat(capturingState.observedValues.last()).isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.NOT_STARTED) - assertThat(viewModel.userCaptures).isEmpty() - assertThat(viewModel.sortedQualifyingCaptures).isEmpty() - } - - @Test - fun `Auto capture spoof check Enforced failed max attempts finishes capture`() = runTest { - val validFace = getFace() - every { getSpoofCheckConfiguration.invoke(any(), any()) } returns getDefaultSpoofConfig(FaceConfiguration.SpoofCheckMode.ENFORCED) - every { faceDetector.analyze(frame) } returns validFace - coEvery { faceDetector.spoofCheck(any(), any()) } returns SpoofCheckResult(score = 0.9f) - - val capturingState = viewModel.capturingState.testObserver() - - viewModel.initAutoCapture() - viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - - viewModel.startCapture() - viewModel.process(frame, frame) - advanceUntilIdle() - - assertThat(capturingState.observedValues.last()).isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.NOT_STARTED) - - viewModel.startCapture() - viewModel.process(frame, frame) - advanceUntilIdle() - - assertThat(capturingState.observedValues.last()).isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.FINISHED) - } - - @Test - fun `Skip frame processing while validating`() = runTest { - val validFace = getFace() - every { getSpoofCheckConfiguration.invoke(any(), any()) } returns getDefaultSpoofConfig() - every { faceDetector.analyze(frame) } returns validFace - coEvery { faceDetector.spoofCheck(any(), any()) } answers { - viewModel.process(frame, frame) - SpoofCheckResult(score = 0.9f) - } - - viewModel.initAutoCapture() - viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - viewModel.startCapture() - viewModel.process(frame, frame) - - advanceUntilIdle() - - verify(exactly = 1) { faceDetector.analyze(frame) } - } - - @Test - fun `Save all valid captures without fallback image`() = runTest { - val validFace: Face = getFace() - every { faceDetector.analyze(frame) } returns validFace - - val currentDetectionObserver = viewModel.currentDetection.testObserver() - val capturingStateObserver = viewModel.capturingState.testObserver() - val samplesToKeep = 2 - viewModel.initAutoCapture() - viewModel.initCapture(ModalitySdkType.SIM_FACE, samplesToKeep, 0) - viewModel.process(frame, frame) // won't be observed during the preparation phase - viewModel.startCapture() - repeat(100) { - viewModel.process(frame, frame) - } - advanceTimeBy(AUTO_CAPTURE_IMAGING_DURATION_MS + 1) - - currentDetectionObserver.observedValues.let { - // 1st frame wasn't observed during preparation delay - assertThat(it[0]?.hasValidStatus()).isEqualTo(true) - assertThat(it[1]?.hasValidStatus()).isEqualTo(true) - } - - capturingStateObserver.observedValues.let { - assertThat(it[0]) - .isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.NOT_STARTED) - assertThat(it[1]) - .isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.CAPTURING) - assertThat(it[2]).isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.FINISHED) - } - - assertThat(viewModel.userCaptures.size).isEqualTo(samplesToKeep) - viewModel.userCaptures.let { - with(it[0]) { - assertThat(hasValidStatus()).isEqualTo(true) - assertThat(face).isEqualTo(validFace) - assertThat(isFallback).isEqualTo(false) - } - - assertThat(it[1].isFallback).isEqualTo(false) - } - - with(viewModel.sortedQualifyingCaptures) { - assertThat(size).isEqualTo(samplesToKeep) - assertThat(get(0).face).isEqualTo(validFace) - assertThat(get(0).isFallback).isEqualTo(false) - assertThat(get(1).face).isEqualTo(validFace) - assertThat(get(1).isFallback).isEqualTo(false) - } - - coVerify { eventReporter.addFallbackCaptureEvent(any(), any()) } - coVerify(exactly = 3) { eventReporter.addCaptureEvents(any(), any(), any(), any(), any()) } - } - - private fun getFace( - rect: Rect = Rect(0, 0, 60, 60), - quality: Float = 1f, - yaw: Float = 0f, - roll: Float = 0f, - ) = Face(100, 100, rect, yaw, roll, quality, Random.nextBytes(20), "format") - - private fun getDefaultSpoofConfig( - mode: FaceConfiguration.SpoofCheckMode = FaceConfiguration.SpoofCheckMode.RECORDED, - ): SpoofCheckConfiguration = SpoofCheckConfiguration( - mode = mode, - threshold = 0.5f, - maxAttempts = 2, - maxBitmapSize = 1500, - validationUiDurationMs = 1000, - validationErrorUiDurationMs = 1000, - ) - - companion object { - private const val QUALITY_THRESHOLD = -1f - private const val AUTO_CAPTURE_IMAGING_DURATION_MS = 3000L - } -} diff --git a/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragmentViewModelTest.kt b/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragmentViewModelTest.kt deleted file mode 100644 index c4c73dba72..0000000000 --- a/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragmentViewModelTest.kt +++ /dev/null @@ -1,360 +0,0 @@ -package com.simprints.face.capture.screens.livefeedback - -import android.graphics.Bitmap -import android.graphics.Rect -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.test.ext.junit.runners.* -import com.google.common.truth.Truth.* -import com.simprints.core.tools.time.TimeHelper -import com.simprints.core.tools.time.Timestamp -import com.simprints.face.capture.models.FaceDetection -import com.simprints.face.capture.usecases.GetSpoofCheckConfigurationUseCase -import com.simprints.face.capture.usecases.IsUsingAutoCaptureUseCase -import com.simprints.face.capture.usecases.SimpleCaptureEventReporter -import com.simprints.face.infra.basebiosdk.detection.Face -import com.simprints.face.infra.basebiosdk.detection.FaceDetector -import com.simprints.face.infra.basebiosdk.detection.SpoofCheckResult -import com.simprints.face.infra.biosdkresolver.ResolveFaceBioSdkUseCase -import com.simprints.infra.config.store.ConfigRepository -import com.simprints.infra.config.store.models.FaceConfiguration -import com.simprints.infra.config.store.models.FaceConfiguration.SpoofCheckConfiguration -import com.simprints.infra.config.store.models.ModalitySdkType -import com.simprints.testtools.common.coroutines.TestCoroutineRule -import com.simprints.testtools.common.livedata.testObserver -import io.mockk.* -import io.mockk.impl.annotations.MockK -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import kotlin.random.Random - -@RunWith(AndroidJUnit4::class) -internal class LiveFeedbackFragmentViewModelTest { - @get:Rule - val rule = InstantTaskExecutorRule() - - @get:Rule - val testCoroutineRule = TestCoroutineRule() - - @MockK - lateinit var faceDetector: FaceDetector - - @MockK - lateinit var frame: Bitmap - - @MockK - lateinit var previewFrame: Bitmap - - @MockK - lateinit var configRepository: ConfigRepository - - @MockK - lateinit var eventReporter: SimpleCaptureEventReporter - - @MockK - lateinit var timeHelper: TimeHelper - - @MockK - private lateinit var isUsingAutoCapture: IsUsingAutoCaptureUseCase - - @MockK - private lateinit var getSpoofCheckConfiguration: GetSpoofCheckConfigurationUseCase - private lateinit var viewModel: LiveFeedbackFragmentViewModel - - @Before - fun setUp() { - MockKAnnotations.init(this, relaxed = true) - - coEvery { - configRepository - .getProjectConfiguration() - .face - ?.getSdkConfiguration(any()) - ?.qualityThreshold - } returns QUALITY_THRESHOLD - every { isUsingAutoCapture.invoke(any()) } returns false - every { getSpoofCheckConfiguration.invoke(any(), any()) } returns FaceConfiguration.SpoofCheckConfiguration.DISABLED - - every { timeHelper.now() } returnsMany (0..100L).map { Timestamp(it) } - justRun { previewFrame.recycle() } - val resolveFaceBioSdkUseCase = mockk { - coEvery { this@mockk.invoke(any()) } returns mockk { - every { detector } returns faceDetector - } - } - - viewModel = LiveFeedbackFragmentViewModel( - resolveFaceBioSdkUseCase, - configRepository, - eventReporter, - timeHelper, - isUsingAutoCapture, - getSpoofCheckConfiguration, - testCoroutineRule.testCoroutineDispatcher, - ) - } - - @Test - fun `Process fallback image when valid face correctly but not started capture`() = runTest { - coEvery { faceDetector.analyze(frame) } returns getFace() - - viewModel.initAutoCapture() - viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - viewModel.process(frame, frame) - - val currentDetection = viewModel.currentDetection.testObserver() - assertThat(currentDetection.observedValues.last()?.status).isEqualTo(FaceDetection.Status.VALID) - - coVerify { eventReporter.addFallbackCaptureEvent(any(), any()) } - } - - @Test - fun `Process valid face correctly`() = runTest { - coEvery { faceDetector.analyze(frame) } returns getFace() - - viewModel.initAutoCapture() - viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - viewModel.process(frame, frame) - viewModel.startCapture() - viewModel.process(frame, frame) - - val currentDetection = viewModel.currentDetection.testObserver() - assertThat(currentDetection.observedValues.last()?.status).isEqualTo(FaceDetection.Status.VALID_CAPTURING) - - coVerify { eventReporter.addCaptureEvents(any(), any(), any(), any()) } - } - - @Test - fun `Process invalid faces correctly`() = runTest { - val smallFace: Face = getFace(Rect(0, 0, 30, 30)) - val bigFace: Face = getFace(Rect(0, 0, 80, 80)) - val yawedFace: Face = getFace(yaw = 45f) - val rolledFace: Face = getFace(roll = 45f) - val noFace = null - - every { faceDetector.analyze(frame) } returnsMany listOf( - smallFace, - bigFace, - yawedFace, - rolledFace, - noFace, - ) - - val detections = viewModel.currentDetection.testObserver() - viewModel.initAutoCapture() - viewModel.initCapture(ModalitySdkType.SIM_FACE, 2, 0) - - viewModel.process(frame, frame) - viewModel.process(frame, frame) - viewModel.process(frame, frame) - viewModel.process(frame, frame) - viewModel.process(frame, frame) - viewModel.process(frame, frame) - - detections.observedValues.let { - assertThat(it[0]?.status).isEqualTo(FaceDetection.Status.TOOFAR) - assertThat(it[1]?.status).isEqualTo(FaceDetection.Status.TOOCLOSE) - assertThat(it[2]?.status).isEqualTo(FaceDetection.Status.OFFYAW) - assertThat(it[3]?.status).isEqualTo(FaceDetection.Status.OFFROLL) - assertThat(it[4]?.status).isEqualTo(FaceDetection.Status.NOFACE) - } - - coVerify(exactly = 0) { eventReporter.addCaptureEvents(any(), any(), any(), any()) } - } - - @Test - fun `Process invalid faces after single fallback correctly`() = runTest { - val validFace: Face = getFace() - val badQuality: Face = getFace(quality = -2f) - - every { faceDetector.analyze(frame) } returnsMany listOf( - badQuality, - validFace, - badQuality, - ) - - val detections = viewModel.currentDetection.testObserver() - viewModel.initAutoCapture() - viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - viewModel.process(frame, frame) - viewModel.process(frame, frame) - viewModel.process(frame, frame) - - detections.observedValues.let { - assertThat(it[0]?.status).isEqualTo(FaceDetection.Status.VALID) - assertThat(it[1]?.status).isEqualTo(FaceDetection.Status.VALID) - } - } - - @Test - fun `Save all valid captures without fallback image`() = runTest { - val validFace: Face = getFace() - every { faceDetector.analyze(frame) } returns validFace - every { timeHelper.now() } returnsMany (0..100L).map { Timestamp(it) } - - val currentDetectionObserver = viewModel.currentDetection.testObserver() - val capturingStateObserver = viewModel.capturingState.testObserver() - viewModel.initAutoCapture() - viewModel.initCapture(ModalitySdkType.SIM_FACE, 2, 0) - viewModel.process(frame, frame) - viewModel.startCapture() - viewModel.process(frame, frame) - viewModel.process(frame, frame) - - currentDetectionObserver.observedValues.let { - assertThat(it[0]?.status).isEqualTo(FaceDetection.Status.VALID) - assertThat(it[1]?.status).isEqualTo(FaceDetection.Status.VALID_CAPTURING) - assertThat(it[2]?.status).isEqualTo(FaceDetection.Status.VALID_CAPTURING) - } - - capturingStateObserver.observedValues.let { - assertThat(it[0]).isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.NOT_STARTED) - assertThat(it[1]).isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.CAPTURING) - assertThat(it[2]).isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.FINISHED) - } - - assertThat(viewModel.userCaptures.size).isEqualTo(2) - viewModel.userCaptures.let { - with(it[0]) { - assertThat(status).isEqualTo(FaceDetection.Status.VALID_CAPTURING) - assertThat(face).isEqualTo(validFace) - assertThat(isFallback).isEqualTo(false) - } - - assertThat(it[1].isFallback).isEqualTo(false) - } - - with(viewModel.sortedQualifyingCaptures) { - assertThat(size).isEqualTo(2) - assertThat(get(0).face).isEqualTo(validFace) - assertThat(get(0).isFallback).isEqualTo(false) - assertThat(get(1).face).isEqualTo(validFace) - assertThat(get(1).isFallback).isEqualTo(false) - } - - coVerify { eventReporter.addFallbackCaptureEvent(any(), any()) } - coVerify(exactly = 3) { eventReporter.addCaptureEvents(any(), any(), any(), any()) } - } - - @Test - fun `Spoof check Recorded finishes capture regardless of spoof result`() = runTest { - val validFace: Face = getFace() - every { getSpoofCheckConfiguration.invoke(any(), any()) } returns getDefaultSpoofConfig() - every { faceDetector.analyze(frame) } returns validFace - coEvery { faceDetector.spoofCheck(any(), any()) } returns SpoofCheckResult(score = 0.9f) - - val capturingStateObserver = viewModel.capturingState.testObserver() - viewModel.initAutoCapture() - viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - - viewModel.process(frame, frame) - viewModel.startCapture() - viewModel.process(frame, frame) - - advanceUntilIdle() - - assertThat(capturingStateObserver.observedValues.last()).isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.FINISHED) - assertThat(viewModel.sortedQualifyingCaptures.size).isEqualTo(1) - assertThat(viewModel.sortedQualifyingCaptures[0].spoofCheckResult?.score).isEqualTo(0.9f) - } - - @Test - fun `Spoof check Enforced passed finishes capture`() = runTest { - val validFace: Face = getFace() - every { getSpoofCheckConfiguration.invoke(any(), any()) } returns getDefaultSpoofConfig(FaceConfiguration.SpoofCheckMode.ENFORCED) - every { faceDetector.analyze(frame) } returns validFace - coEvery { faceDetector.spoofCheck(any(), any()) } returns SpoofCheckResult(score = 0.1f) - - val capturingStateObserver = viewModel.capturingState.testObserver() - viewModel.initAutoCapture() - viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - - viewModel.process(frame, frame) - viewModel.startCapture() - viewModel.process(frame, frame) - - advanceUntilIdle() - - assertThat(capturingStateObserver.observedValues.last()).isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.FINISHED) - assertThat(viewModel.sortedQualifyingCaptures.size).isEqualTo(1) - assertThat(viewModel.sortedQualifyingCaptures[0].spoofCheckResult?.score).isEqualTo(0.1f) - } - - @Test - fun `Spoof check Enforced failed resets state`() = runTest { - val validFace: Face = getFace() - every { getSpoofCheckConfiguration.invoke(any(), any()) } returns getDefaultSpoofConfig(FaceConfiguration.SpoofCheckMode.ENFORCED) - every { faceDetector.analyze(frame) } returns validFace - coEvery { faceDetector.spoofCheck(any(), any()) } returns SpoofCheckResult(score = 0.9f) - - val capturingStateObserver = viewModel.capturingState.testObserver() - viewModel.initAutoCapture() - viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - - viewModel.process(frame, frame) - viewModel.startCapture() - viewModel.process(frame, frame) - - advanceUntilIdle() - - assertThat(capturingStateObserver.observedValues).contains(LiveFeedbackFragmentViewModel.CapturingState.VALIDATION_FAILED) - assertThat(capturingStateObserver.observedValues.last()).isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.NOT_STARTED) - assertThat(viewModel.sortedQualifyingCaptures.size).isEqualTo(0) - assertThat(viewModel.userCaptures.size).isEqualTo(0) - } - - @Test - fun `Spoof check Enforced failed max times finishes capture`() = runTest { - val validFace: Face = getFace() - every { getSpoofCheckConfiguration.invoke(any(), any()) } returns getDefaultSpoofConfig(FaceConfiguration.SpoofCheckMode.ENFORCED) - every { faceDetector.analyze(frame) } returns validFace - coEvery { faceDetector.spoofCheck(any(), any()) } returns SpoofCheckResult(score = 0.9f) - - val capturingStateObserver = viewModel.capturingState.testObserver() - viewModel.initAutoCapture() - viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - - // Attempt 1 - viewModel.process(frame, frame) - viewModel.startCapture() - viewModel.process(frame, frame) - - advanceUntilIdle() - - assertThat(capturingStateObserver.observedValues.last()).isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.NOT_STARTED) - - // Attempt 2 - viewModel.process(frame, frame) - viewModel.startCapture() - viewModel.process(frame, frame) - - advanceUntilIdle() - - assertThat(capturingStateObserver.observedValues.last()).isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.FINISHED) - } - - private fun getFace( - rect: Rect = Rect(0, 0, 60, 60), - quality: Float = 1f, - yaw: Float = 0f, - roll: Float = 0f, - ) = Face(100, 100, rect, yaw, roll, quality, Random.nextBytes(20), "format") - - private fun getDefaultSpoofConfig( - mode: FaceConfiguration.SpoofCheckMode = FaceConfiguration.SpoofCheckMode.RECORDED, - ): SpoofCheckConfiguration = SpoofCheckConfiguration( - mode = mode, - threshold = 0.5f, - maxAttempts = 2, - maxBitmapSize = 1500, - validationUiDurationMs = 1000, - validationErrorUiDurationMs = 1000, - ) - - companion object { - private const val QUALITY_THRESHOLD = -1f - } -} diff --git a/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModelTest.kt b/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModelTest.kt new file mode 100644 index 0000000000..3b75ba1e4c --- /dev/null +++ b/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModelTest.kt @@ -0,0 +1,500 @@ +package com.simprints.face.capture.screens.livefeedback + +import android.graphics.Bitmap +import android.graphics.Rect +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.test.ext.junit.runners.* +import com.google.common.truth.Truth.* +import com.simprints.core.tools.time.TimeHelper +import com.simprints.core.tools.time.Timestamp +import com.simprints.face.capture.usecases.GetSpoofCheckConfigurationUseCase +import com.simprints.face.capture.usecases.IsUsingAutoCaptureUseCase +import com.simprints.face.capture.usecases.SimpleCaptureEventReporter +import com.simprints.face.infra.basebiosdk.detection.Face +import com.simprints.face.infra.basebiosdk.detection.FaceDetector +import com.simprints.face.infra.basebiosdk.detection.SpoofCheckResult +import com.simprints.face.infra.biosdkresolver.ResolveFaceBioSdkUseCase +import com.simprints.infra.config.store.ConfigRepository +import com.simprints.infra.config.store.models.FaceConfiguration +import com.simprints.infra.config.store.models.FaceConfiguration.SpoofCheckConfiguration +import com.simprints.infra.config.store.models.ModalitySdkType +import com.simprints.testtools.common.coroutines.TestCoroutineRule +import io.mockk.* +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +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 org.junit.runner.RunWith +import kotlin.random.Random + +/** + * Behaviour-pinning tests for the refactored [LiveFeedbackViewModel], written from + * scratch against its single-source-of-truth [LiveFeedbackViewModel.state] API. + * + * These validate the state machine, feedback mapping and progress derivation that the + * refactored UI relies on. The pre-existing view-model tests are intentionally not reused. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +internal class LiveFeedbackViewModelTest { + @get:Rule + val rule = InstantTaskExecutorRule() + + @get:Rule + val testCoroutineRule = TestCoroutineRule() + + @MockK + lateinit var faceDetector: FaceDetector + + @MockK + lateinit var frame: Bitmap + + @MockK + lateinit var resolveFaceBioSdkUseCase: ResolveFaceBioSdkUseCase + + @MockK + lateinit var configRepository: ConfigRepository + + @MockK + lateinit var eventReporter: SimpleCaptureEventReporter + + @MockK + lateinit var timeHelper: TimeHelper + + @MockK + private lateinit var isUsingAutoCapture: IsUsingAutoCaptureUseCase + + @MockK + private lateinit var getSpoofCheckConfiguration: GetSpoofCheckConfigurationUseCase + + private lateinit var viewModel: LiveFeedbackViewModel + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + coEvery { resolveFaceBioSdkUseCase.invoke(any()) } returns mockk { + every { detector } returns faceDetector + } + coEvery { + configRepository + .getProjectConfiguration() + .face + ?.getSdkConfiguration(any()) + ?.qualityThreshold + } returns QUALITY_THRESHOLD + every { isUsingAutoCapture.invoke(any()) } returns false + every { getSpoofCheckConfiguration.invoke(any(), any()) } returns SpoofCheckConfiguration.DISABLED + + every { timeHelper.now() } returnsMany (0..1000L).map { Timestamp(it) } + justRun { frame.recycle() } + + viewModel = LiveFeedbackViewModel( + resolveFaceBioSdkUseCase, + configRepository, + eventReporter, + timeHelper, + isUsingAutoCapture, + getSpoofCheckConfiguration, + testCoroutineRule.testCoroutineDispatcher, + ) + } + + @Test + fun `initial state is NOT_STARTED with no feedback and hidden progress`() = runTest { + with(viewModel.state.value) { + assertThat(phase).isEqualTo(LiveFeedbackState.Phase.NOT_STARTED) + assertThat(feedback).isEqualTo(LiveFeedbackState.Feedback.NONE) + assertThat(isAutoCapture).isFalse() + assertThat(progress.visible).isFalse() + } + } + + @Test + fun `initAutoCapture reflects auto-capture flag in state`() = runTest { + every { isUsingAutoCapture.invoke(any()) } returns true + + viewModel.initAutoCapture() + + assertThat(viewModel.state.value.isAutoCapture).isTrue() + assertThat(viewModel.isAutoCapture).isTrue() + } + + @Test + fun `manual - valid face before start keeps NOT_STARTED, shows VALID feedback and stores fallback`() = runTest { + every { faceDetector.analyze(frame) } returns getFace() + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) + viewModel.process(frame, frame) + + with(viewModel.state.value) { + assertThat(phase).isEqualTo(LiveFeedbackState.Phase.NOT_STARTED) + assertThat(feedback).isEqualTo(LiveFeedbackState.Feedback.VALID) + assertThat(progress.visible).isFalse() + } + coVerify { eventReporter.addFallbackCaptureEvent(any(), any()) } + } + + @Test + fun `manual - starting capture moves to CAPTURING and valid frames become VALID_CAPTURING`() = runTest { + every { faceDetector.analyze(frame) } returns getFace() + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 2, 0) + viewModel.process(frame, frame) + viewModel.startCapture() + viewModel.process(frame, frame) + + with(viewModel.state.value) { + assertThat(phase).isEqualTo(LiveFeedbackState.Phase.CAPTURING) + assertThat(feedback).isEqualTo(LiveFeedbackState.Feedback.VALID_CAPTURING) + assertThat(progress.visible).isTrue() + assertThat(progress.tint).isEqualTo(Progress.Tint.VALID) + } + } + + @Test + fun `manual - invalid faces map to the correct feedback`() = runTest { + every { faceDetector.analyze(frame) } returnsMany listOf( + getFace(Rect(0, 0, 30, 30)), // too far + getFace(Rect(0, 0, 80, 80)), // too close + getFace(yaw = 45f), // off yaw + getFace(roll = 45f), // off roll + null, // no face + ) + val states = collectStates() + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 2, 0) + repeat(5) { viewModel.process(frame, frame) } + + val feedbacks = states.map { it.feedback } + assertThat(feedbacks).containsAtLeast( + LiveFeedbackState.Feedback.TOO_FAR, + LiveFeedbackState.Feedback.TOO_CLOSE, + LiveFeedbackState.Feedback.LOOK_STRAIGHT, + LiveFeedbackState.Feedback.NO_FACE, + ) + coVerify(exactly = 0) { eventReporter.addCaptureEvents(any(), any(), any(), any(), any()) } + } + + @Test + fun `manual - progress reflects captured sample ratio`() = runTest { + every { faceDetector.analyze(frame) } returns getFace() + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 2, 0) + viewModel.startCapture() + viewModel.process(frame, frame) + + assertThat(viewModel.state.value.progress.value).isEqualTo(0.5f) + } + + @Test + fun `manual - capturing enough samples finishes and publishes sorted result`() = runTest { + val validFace = getFace() + every { faceDetector.analyze(frame) } returns validFace + val states = collectStates() + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 2, 0) + viewModel.process(frame, frame) + viewModel.startCapture() + viewModel.process(frame, frame) + viewModel.process(frame, frame) + + val phases = states.map { it.phase } + assertThat(phases) + .containsAtLeast( + LiveFeedbackState.Phase.NOT_STARTED, + LiveFeedbackState.Phase.CAPTURING, + LiveFeedbackState.Phase.FINISHED, + ).inOrder() + + assertThat(viewModel.userCaptures).hasSize(2) + assertThat(viewModel.sortedQualifyingCaptures).hasSize(2) + assertThat(viewModel.state.value.result).hasSize(2) + coVerify(exactly = 3) { eventReporter.addCaptureEvents(any(), any(), any(), any(), any()) } + } + + @Test + fun `auto - does not start until start capture is pressed`() = runTest { + every { isUsingAutoCapture.invoke(any()) } returns true + every { faceDetector.analyze(frame) } returns getFace() + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) + viewModel.process(frame, frame) + + assertThat(viewModel.state.value.phase).isEqualTo(LiveFeedbackState.Phase.NOT_STARTED) + // Guidance is suppressed before imaging starts in auto-capture. + assertThat(viewModel.state.value.feedback).isEqualTo(LiveFeedbackState.Feedback.NONE) + } + + @Test + fun `auto - held off capture does not start`() = runTest { + every { isUsingAutoCapture.invoke(any()) } returns true + every { faceDetector.analyze(frame) } returns getFace() + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) + viewModel.startCapture() + viewModel.holdOffAutoCapture() + viewModel.process(frame, frame) + + assertThat(viewModel.state.value.phase).isEqualTo(LiveFeedbackState.Phase.NOT_STARTED) + } + + @Test + fun `auto - valid face after start begins CAPTURING and finishes after imaging duration`() = runTest { + every { isUsingAutoCapture.invoke(any()) } returns true + every { faceDetector.analyze(frame) } returns getFace() + val states = collectStates() + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) + viewModel.startCapture() + viewModel.process(frame, frame) + + assertThat(viewModel.state.value.phase).isEqualTo(LiveFeedbackState.Phase.CAPTURING) + + advanceTimeBy(AUTO_CAPTURE_IMAGING_DURATION_MS + 1) + + assertThat(states.map { it.phase }) + .containsAtLeast( + LiveFeedbackState.Phase.CAPTURING, + LiveFeedbackState.Phase.FINISHED, + ).inOrder() + coVerify { eventReporter.addCaptureEvents(any(), any(), any(), any(), any()) } + } + + @Test + fun `spoof RECORDED finishes regardless of score`() = runTest { + every { getSpoofCheckConfiguration.invoke(any(), any()) } returns spoofConfig() + every { faceDetector.analyze(frame) } returns getFace() + coEvery { faceDetector.spoofCheck(any(), any()) } returns SpoofCheckResult(score = 0.9f) + val states = collectStates() + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) + viewModel.process(frame, frame) + viewModel.startCapture() + viewModel.process(frame, frame) + advanceUntilIdle() + + assertThat(states.map { it.phase }).contains(LiveFeedbackState.Phase.VALIDATING) + assertThat(viewModel.state.value.phase).isEqualTo(LiveFeedbackState.Phase.FINISHED) + assertThat(viewModel.sortedQualifyingCaptures[0].spoofCheckResult?.score).isEqualTo(0.9f) + } + + @Test + fun `spoof ENFORCED passing finishes capture`() = runTest { + every { getSpoofCheckConfiguration.invoke(any(), any()) } returns spoofConfig(FaceConfiguration.SpoofCheckMode.ENFORCED) + every { faceDetector.analyze(frame) } returns getFace() + coEvery { faceDetector.spoofCheck(any(), any()) } returns SpoofCheckResult(score = 0.1f) + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) + viewModel.process(frame, frame) + viewModel.startCapture() + viewModel.process(frame, frame) + advanceUntilIdle() + + assertThat(viewModel.state.value.phase).isEqualTo(LiveFeedbackState.Phase.FINISHED) + } + + @Test + fun `spoof ENFORCED failing goes through VALIDATION_FAILED and resets to NOT_STARTED`() = runTest { + every { getSpoofCheckConfiguration.invoke(any(), any()) } returns spoofConfig(FaceConfiguration.SpoofCheckMode.ENFORCED) + every { faceDetector.analyze(frame) } returns getFace() + coEvery { faceDetector.spoofCheck(any(), any()) } returns SpoofCheckResult(score = 0.9f) + val states = collectStates() + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) + viewModel.process(frame, frame) + viewModel.startCapture() + viewModel.process(frame, frame) + advanceUntilIdle() + + assertThat(states.map { it.phase }).contains(LiveFeedbackState.Phase.VALIDATION_FAILED) + assertThat(viewModel.state.value.phase).isEqualTo(LiveFeedbackState.Phase.NOT_STARTED) + assertThat(viewModel.userCaptures).isEmpty() + assertThat(viewModel.sortedQualifyingCaptures).isEmpty() + } + + @Test + fun `spoof ENFORCED failing max attempts finishes capture`() = runTest { + every { getSpoofCheckConfiguration.invoke(any(), any()) } returns spoofConfig(FaceConfiguration.SpoofCheckMode.ENFORCED) + every { faceDetector.analyze(frame) } returns getFace() + coEvery { faceDetector.spoofCheck(any(), any()) } returns SpoofCheckResult(score = 0.9f) + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) + + // Attempt 1 + viewModel.process(frame, frame) + viewModel.startCapture() + viewModel.process(frame, frame) + advanceUntilIdle() + assertThat(viewModel.state.value.phase).isEqualTo(LiveFeedbackState.Phase.NOT_STARTED) + + // Attempt 2 reaches maxAttempts + viewModel.process(frame, frame) + viewModel.startCapture() + viewModel.process(frame, frame) + advanceUntilIdle() + assertThat(viewModel.state.value.phase).isEqualTo(LiveFeedbackState.Phase.FINISHED) + } + + @Test + fun `frames are skipped while validating and progress uses the validation tint`() = runTest { + every { getSpoofCheckConfiguration.invoke(any(), any()) } returns spoofConfig() + every { faceDetector.analyze(frame) } returns getFace() + coEvery { faceDetector.spoofCheck(any(), any()) } answers { + // A frame arriving mid-validation must not trigger another analysis. + viewModel.process(frame, frame) + assertThat(viewModel.state.value.phase).isEqualTo(LiveFeedbackState.Phase.VALIDATING) + assertThat(viewModel.state.value.progress.tint).isEqualTo(Progress.Tint.VALIDATION) + SpoofCheckResult(score = 0.1f) + } + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) + viewModel.startCapture() + viewModel.process(frame, frame) + advanceUntilIdle() + + verify(exactly = 1) { faceDetector.analyze(frame) } + } + + @Test + fun `event saving - fallback capture event is saved only once across multiple valid pre-start frames`() = runTest { + every { faceDetector.analyze(frame) } returns getFace() + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) + viewModel.process(frame, frame) + viewModel.process(frame, frame) + viewModel.process(frame, frame) + + // A single fallback event despite several valid frames, and no capture events yet. + coVerify(exactly = 1) { eventReporter.addFallbackCaptureEvent(any(), any()) } + coVerify(exactly = 0) { eventReporter.addCaptureEvents(any(), any(), any(), any(), any()) } + } + + @Test + fun `event saving - single sample capture saves one capture event and the fallback`() = runTest { + every { faceDetector.analyze(frame) } returns getFace() + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) + viewModel.process(frame, frame) // fallback frame before start + viewModel.startCapture() + viewModel.process(frame, frame) // captured sample + + // 1 captured sample + 1 fallback capture. + coVerify(exactly = 2) { eventReporter.addCaptureEvents(any(), any(), any(), any(), any()) } + coVerify(exactly = 1) { eventReporter.addFallbackCaptureEvent(any(), any()) } + } + + @Test + fun `event saving - captured samples are stored as non-fallback with one event per sample plus fallback`() = runTest { + val validFace = getFace() + every { faceDetector.analyze(frame) } returns validFace + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 2, 0) + viewModel.process(frame, frame) // fallback frame before start + viewModel.startCapture() + viewModel.process(frame, frame) + viewModel.process(frame, frame) + + assertThat(viewModel.userCaptures).hasSize(2) + assertThat(viewModel.userCaptures.none { it.isFallback }).isTrue() + with(viewModel.sortedQualifyingCaptures) { + assertThat(this).hasSize(2) + assertThat(all { it.face == validFace }).isTrue() + assertThat(none { it.isFallback }).isTrue() + } + // 2 captures + 1 fallback. + coVerify(exactly = 3) { eventReporter.addCaptureEvents(any(), any(), any(), any(), any()) } + coVerify(exactly = 1) { eventReporter.addFallbackCaptureEvent(any(), any()) } + } + + @Test + fun `event saving - falls back to the fallback capture when no captured sample qualifies`() = runTest { + every { faceDetector.analyze(frame) } returnsMany listOf( + getFace(), // valid fallback frame before start + null, // invalid captured sample (no face) + ) + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) + viewModel.process(frame, frame) // fallback frame + viewModel.startCapture() + viewModel.process(frame, frame) // invalid capture -> finishes + + with(viewModel.sortedQualifyingCaptures) { + assertThat(this).hasSize(1) + assertThat(first().isFallback).isTrue() + } + coVerify(exactly = 1) { eventReporter.addFallbackCaptureEvent(any(), any()) } + // Invalid capture + fallback are both tracked for analytics. + coVerify(exactly = 2) { eventReporter.addCaptureEvents(any(), any(), any(), any(), any()) } + } + + @Test + fun `event saving - auto capture saves an event per stored sample plus the fallback`() = runTest { + every { isUsingAutoCapture.invoke(any()) } returns true + every { faceDetector.analyze(frame) } returns getFace() + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) + viewModel.process(frame, frame) // pre-start fallback frame (held off) + viewModel.startCapture() + viewModel.process(frame, frame) // begins imaging + advanceTimeBy(AUTO_CAPTURE_IMAGING_DURATION_MS + 1) + + // 1 stored sample + 1 fallback. + coVerify(exactly = 2) { eventReporter.addCaptureEvents(any(), any(), any(), any(), any()) } + coVerify(exactly = 1) { eventReporter.addFallbackCaptureEvent(any(), any()) } + } + + private fun TestScope.collectStates(): List { + val states = mutableListOf() + backgroundScope.launch(testCoroutineRule.testCoroutineDispatcher) { + viewModel.state.toList(states) + } + return states + } + + private fun getFace( + rect: Rect = Rect(0, 0, 60, 60), + quality: Float = 1f, + yaw: Float = 0f, + roll: Float = 0f, + ) = Face(100, 100, rect, yaw, roll, quality, Random.nextBytes(20), "format") + + private fun spoofConfig(mode: FaceConfiguration.SpoofCheckMode = FaceConfiguration.SpoofCheckMode.RECORDED): SpoofCheckConfiguration = + SpoofCheckConfiguration( + mode = mode, + threshold = 0.5f, + maxAttempts = 2, + maxBitmapSize = 1500, + validationUiDurationMs = 1000, + validationErrorUiDurationMs = 1000, + ) + + companion object { + private const val QUALITY_THRESHOLD = -1f + private const val AUTO_CAPTURE_IMAGING_DURATION_MS = 3000L + } +} diff --git a/face/capture/src/test/java/com/simprints/face/capture/usecases/SimpleCaptureEventReporterTest.kt b/face/capture/src/test/java/com/simprints/face/capture/usecases/SimpleCaptureEventReporterTest.kt index a9f2ed7a5a..8593d163c7 100644 --- a/face/capture/src/test/java/com/simprints/face/capture/usecases/SimpleCaptureEventReporterTest.kt +++ b/face/capture/src/test/java/com/simprints/face/capture/usecases/SimpleCaptureEventReporterTest.kt @@ -154,9 +154,8 @@ class SimpleCaptureEventReporterTest { reporter.addCaptureEvents(getDetection(FaceDetection.Status.OFFROLL), 1, 0.5f, SpoofCheckConfiguration.DISABLED) reporter.addCaptureEvents(getDetection(FaceDetection.Status.TOOCLOSE), 1, 0.5f, SpoofCheckConfiguration.DISABLED) reporter.addCaptureEvents(getDetection(FaceDetection.Status.TOOFAR), 1, 0.5f, SpoofCheckConfiguration.DISABLED) - reporter.addCaptureEvents(getDetection(FaceDetection.Status.BAD_QUALITY), 1, 0.5f, SpoofCheckConfiguration.DISABLED) - coVerify(exactly = 6) { + coVerify(exactly = 5) { eventRepository.addOrUpdateEvent(withArg { assertThat(it).isInstanceOf(FaceCaptureEvent::class.java) }) } coVerify(exactly = 0) { diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/FaceCaptureEvent.kt b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/FaceCaptureEvent.kt index 7ed84732cd..c07a88f09f 100644 --- a/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/FaceCaptureEvent.kt +++ b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/FaceCaptureEvent.kt @@ -82,6 +82,8 @@ data class FaceCaptureEvent( enum class Result { VALID, INVALID, + + @Deprecated("Not used since 2026.3.0") BAD_QUALITY, OFF_YAW, OFF_ROLL, 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 86d3caba42..5818100d0d 100644 --- a/infra/resources/src/main/res/values-am-rET/strings.xml +++ b/infra/resources/src/main/res/values-am-rET/strings.xml @@ -185,8 +185,6 @@ ለማንሳት እየተዘጋጀ ምንም ፊት ማግኘት አልቻለም ፊቱ ክቡን መሙላቱን ያረጋግጡ - ግልጽ ያልሆነ ምስል - ይበልጥ ግልጽ የሆነ ፎቶ ለማግኘት መብራቱን ለማስተካከል ወይም ፊቱን ለማስተካከል ይሞክሩ ፊቱ በጣም ርቋል ቀረብ አድርግ(ፊት ርቋል) ፊቱ በጣም ቀርቧል diff --git a/infra/resources/src/main/res/values-am/strings.xml b/infra/resources/src/main/res/values-am/strings.xml index ac819994e4..374d03f10d 100644 --- a/infra/resources/src/main/res/values-am/strings.xml +++ b/infra/resources/src/main/res/values-am/strings.xml @@ -185,8 +185,6 @@ ለማንሳት እየተዘጋጀ ምንም ፊት ማግኘት አልቻለም ፊቱ ክቡን መሙላቱን ያረጋግጡ - ግልጽ ያልሆነ ምስል - ይበልጥ ግልጽ የሆነ ፎቶ ለማግኘት መብራቱን ለማስተካከል ወይም ፊቱን ለማስተካከል ይሞክሩ ፊቱ በጣም ርቋል ቀረብ አድርግ(ፊት ርቋል) ፊቱ በጣም ቀርቧል diff --git a/infra/resources/src/main/res/values-fr/strings.xml b/infra/resources/src/main/res/values-fr/strings.xml index 143260a1ff..8d8f87e5ef 100644 --- a/infra/resources/src/main/res/values-fr/strings.xml +++ b/infra/resources/src/main/res/values-fr/strings.xml @@ -187,8 +187,6 @@ Préparation du scan Aucun visage détecté Assurez-vous que le visage soit dans le cercle - Image floue - Essayez d\'ajuster l\'éclairage ou de repositionner le visage pour obtenir une meilleure photo. Trop loin Rapprochez-vous Trop près diff --git a/infra/resources/src/main/res/values/strings.xml b/infra/resources/src/main/res/values/strings.xml index b978d9fedf..5ec0cf0e59 100644 --- a/infra/resources/src/main/res/values/strings.xml +++ b/infra/resources/src/main/res/values/strings.xml @@ -182,8 +182,6 @@ Preparing to scan No face detected Make sure the face fills the circle - Unclear Image - Try adjusting the lighting or repositioning the face for a clearer photo Too far Move closer Too close From a902bf9670edd47387a4ef10902fc8f89b910862 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Thu, 9 Jul 2026 14:30:30 +0300 Subject: [PATCH 2/2] MS-1502 Change permission boolean to status instead --- .../livefeedback/LiveFeedbackFragment.kt | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) 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 6287d354e4..3a8102b70d 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 @@ -76,7 +76,7 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) private var cameraControl: CameraControl? = null - private var isPermissionDenied = false + private var permissionStatus: PermissionStatus = PermissionStatus.Granted private var finishedHandled = false private val validCaptureProgressColor: Int @@ -90,11 +90,8 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) private val launchPermissionRequest = registerForActivityResult( ActivityResultContracts.RequestPermission(), ) { granted -> - when (requireActivity().permissionFromResult(Manifest.permission.CAMERA, granted)) { - PermissionStatus.Granted -> setUpCamera() - PermissionStatus.Denied -> renderNoPermission(false) - PermissionStatus.DeniedNeverAskAgain -> renderNoPermission(true) - } + permissionStatus = requireActivity().permissionFromResult(Manifest.permission.CAMERA, granted) + if (permissionStatus == PermissionStatus.Granted) setUpCamera() else renderNoPermission() } override fun onViewCreated( @@ -158,8 +155,8 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) /** Initialize CameraX, and prepare to bind the camera use cases */ private fun setUpCamera() = viewLifecycleOwner.lifecycleScope.launch { - // Permission is available: resume state-driven rendering. - isPermissionDenied = false + permissionStatus = PermissionStatus.Granted + if (::cameraExecutor.isInitialized && !cameraExecutor.isShutdown) { return@launch } @@ -230,6 +227,7 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) if (requireActivity().hasPermission(Manifest.permission.CAMERA)) { setUpCamera() } else { + permissionStatus = PermissionStatus.Denied launchPermissionRequest.launch(Manifest.permission.CAMERA) } } @@ -286,7 +284,7 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) } private fun render(state: LiveFeedbackState) { - if (isPermissionDenied) return + if (permissionStatus != PermissionStatus.Granted) return renderProgress(state.progress) when (state.phase) { @@ -438,15 +436,14 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) } } - private fun renderNoPermission(shouldOpenSettings: Boolean) { - isPermissionDenied = true + private fun renderNoPermission() { binding.apply { renderOverlay(overlayWhite = false, explanationVisible = true) captureFeedbackTxtExplanation.setText(IDR.string.face_capture_permission_denied) captureFeedbackBtn.isGone = true captureFeedbackPermissionButton.isVisible = true captureFeedbackPermissionButton.setOnClickListener { - if (shouldOpenSettings) { + if (permissionStatus == PermissionStatus.DeniedNeverAskAgain) { requireActivity().startActivity( Intent( Settings.ACTION_APPLICATION_DETAILS_SETTINGS,