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 840b97bd35..2c5839c6fd 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 @@ -3,10 +3,12 @@ package com.simprints.face.capture.models import android.graphics.Bitmap import com.simprints.core.tools.time.Timestamp import com.simprints.face.infra.basebiosdk.detection.Face +import com.simprints.face.infra.basebiosdk.detection.SpoofCheckResult import com.simprints.infra.images.model.SecuredImageRef import java.util.UUID internal data class FaceDetection( + val original: Bitmap, val bitmap: Bitmap, val face: Face?, val status: Status, @@ -15,6 +17,7 @@ internal data class FaceDetection( var isFallback: Boolean = false, var id: String = UUID.randomUUID().toString(), var detectionEndTime: Timestamp, + var spoofCheckResult: SpoofCheckResult? = null, ) { enum class Status { VALID, diff --git a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzer.kt b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzer.kt index 768bb53d1d..00490f35aa 100644 --- a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzer.kt +++ b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzer.kt @@ -11,10 +11,10 @@ internal class CropToTargetOverlayAnalyzer( private val previewRect: RectF, private val overlayWidth: Int, private val overlayHeight: Int, - private val onImageCropped: (Bitmap) -> Unit, + private val onImageCropped: (original: Bitmap, cropped: Bitmap) -> Unit, ) : ImageAnalysis.Analyzer { override fun analyze(image: ImageProxy) { - val croppedBitmap = image.use { + val (originalBitmap, croppedBitmap) = image.use { if (previewRect.isEmpty) return // Adjust overlay size to be fit-center with the image size @@ -37,15 +37,16 @@ internal class CropToTargetOverlayAnalyzer( val cropTop = offsetY + (previewRect.top * scale).toInt() val cropHeight = (previewRect.height() * scale).toInt() - Bitmap.createBitmap( - it.toBitmap(), + val imageBitmap = it.toBitmap() + imageBitmap to Bitmap.createBitmap( + imageBitmap, cropLeft, cropTop, cropWidth, cropHeight, ) } - onImageCropped(croppedBitmap) + onImageCropped(originalBitmap, croppedBitmap) } private fun getSmallerRatio( 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 9075986ece..63dc10322d 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 @@ -21,6 +21,7 @@ import androidx.camera.lifecycle.awaitInstance import androidx.core.content.ContextCompat import androidx.core.net.toUri import androidx.core.view.isGone +import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels @@ -78,6 +79,9 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) private val defaultCaptureProgressColor: Int get() = ContextCompat.getColor(requireContext(), IDR.color.simprints_blue_grey_light) + private val validationProgressColor: Int + get() = ContextCompat.getColor(requireContext(), IDR.color.simprints_orange) + private val launchPermissionRequest = registerForActivityResult( ActivityResultContracts.RequestPermission(), ) { granted -> @@ -181,7 +185,7 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) previewRect = RectF(binding.captureOverlay.circleRect), // create a new instance to avoid threading issues overlayWidth = binding.captureOverlay.width, overlayHeight = binding.captureOverlay.height, - onImageCropped = ::analyze, + onImageCropped = { original, cropped -> analyze(original, cropped) }, ) imageAnalyzer.setAnalyzer(cameraExecutor, cropAnalyzer) @@ -255,6 +259,10 @@ 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) } @@ -263,6 +271,8 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) 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( @@ -274,9 +284,12 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) } } - private fun analyze(image: Bitmap) { + private fun analyze( + original: Bitmap, + cropped: Bitmap, + ) { try { - vm.process(croppedBitmap = image) + vm.process(originalBitmap = original, croppedBitmap = cropped) } catch (t: Throwable) { Simber.e("Image analysis crashed", t, tag = FACE_CAPTURE) // Image analysis is running in bg thread @@ -287,6 +300,10 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) } private fun renderCurrentDetection(faceDetection: FaceDetection) { + if (vm.isAutoCapture && vm.capturingState.value != LiveFeedbackFragmentViewModel.CapturingState.CAPTURING) { + return + } + when (faceDetection.status) { FaceDetection.Status.NOFACE -> renderNoFace() FaceDetection.Status.OFFYAW -> renderFaceNotStraight() @@ -304,23 +321,25 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) 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 - setManualCaptureButtonClickable(false) + captureFeedbackTxtExplanation.isGone = true } } private fun renderCapturing() { binding.apply { captureOverlay.drawWhiteTarget() - captureFeedbackTxtExplanation.setTextColor( - ContextCompat.getColor(requireContext(), IDR.color.simprints_blue_grey), - ) + 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) @@ -330,6 +349,32 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) } } + 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 = false + } + } + + private fun renderResetAfterValidation() { + binding.apply { + captureProgress.isInvisible = true + captureFeedbackBtn.setText(IDR.string.face_capture_title_validating_failed) + + captureFeedbackTxtExplanation.setTextColor(ContextCompat.getColor(requireContext(), IDR.color.simprints_blue_grey)) + captureFeedbackTxtExplanation.setText(IDR.string.face_capture_error_validating_failed) + captureFeedbackTxtExplanation.isVisible = true + } + } + private fun renderValidFace() { binding.apply { if (vm.isAutoCapture) { @@ -361,7 +406,7 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) true, ContextCompat.getDrawable(requireContext(), R.drawable.ic_checked_white_18dp), ) - renderProgressBar(false) + captureProgress.progressColor = validCaptureProgressColor } } @@ -374,7 +419,7 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) captureFeedbackBtn.setCheckedWithLeftDrawable(false) setManualCaptureButtonClickable(false) - renderProgressBar(false) + captureProgress.progressColor = defaultCaptureProgressColor } } @@ -387,7 +432,7 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) captureFeedbackBtn.setCheckedWithLeftDrawable(false) setManualCaptureButtonClickable(false) - renderProgressBar(false) + captureProgress.progressColor = defaultCaptureProgressColor } } @@ -400,7 +445,7 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) captureFeedbackBtn.setCheckedWithLeftDrawable(false) setManualCaptureButtonClickable(false) - renderProgressBar(false) + captureProgress.progressColor = defaultCaptureProgressColor } } @@ -413,7 +458,7 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) captureFeedbackBtn.setCheckedWithLeftDrawable(false) setManualCaptureButtonClickable(false) - renderProgressBar(false) + captureProgress.progressColor = defaultCaptureProgressColor } } @@ -426,15 +471,10 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) captureFeedbackBtn.setCheckedWithLeftDrawable(false) setManualCaptureButtonClickable(false) - renderProgressBar(false) + captureProgress.progressColor = defaultCaptureProgressColor } } - private fun FragmentLiveFeedbackBinding.renderProgressBar(validCapture: Boolean) { - captureProgress.progressColor = if (validCapture) validCaptureProgressColor else defaultCaptureProgressColor - captureProgress.value = vm.getNormalizedProgress() - } - private fun FragmentLiveFeedbackBinding.setManualCaptureButtonClickable(clickable: Boolean) { if (!vm.isAutoCapture) { captureFeedbackBtn.isClickable = clickable 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/LiveFeedbackFragmentViewModel.kt index 9406981b72..8a98cb0c80 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/LiveFeedbackFragmentViewModel.kt @@ -4,11 +4,13 @@ import android.graphics.Bitmap import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.simprints.core.DispatcherBG import com.simprints.core.tools.extentions.area import com.simprints.core.tools.time.TimeHelper import com.simprints.face.capture.models.FaceDetection import com.simprints.face.capture.models.FaceTarget import com.simprints.face.capture.models.SymmetricTarget +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 @@ -16,19 +18,25 @@ import com.simprints.face.infra.basebiosdk.detection.FaceDetector import com.simprints.face.infra.biosdkresolver.ResolveFaceBioSdkUseCase import com.simprints.infra.config.store.ConfigRepository import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_DEFAULT +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.infra.config.store.models.experimental import com.simprints.infra.logging.LoggingConstants.CrashReportTag.FACE_CAPTURE import com.simprints.infra.logging.Simber import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.measureTimedValue @HiltViewModel internal class LiveFeedbackFragmentViewModel @Inject constructor( @@ -37,6 +45,8 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( private val eventReporter: SimpleCaptureEventReporter, private val timeHelper: TimeHelper, private val isUsingAutoCaptureUseCase: IsUsingAutoCaptureUseCase, + private val getSpoofCheckConfiguration: GetSpoofCheckConfigurationUseCase, + @param:DispatcherBG private val bgDispatcher: CoroutineDispatcher, ) : ViewModel() { private var attemptNumber: Int = 1 private var samplesToCapture: Int = 1 @@ -55,10 +65,14 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( var sortedQualifyingCaptures = listOf() val currentDetection = MutableLiveData() val capturingState = MutableLiveData(CapturingState.NOT_STARTED) + val progressBarValue = MutableLiveData() var isAutoCapture: Boolean = false + var spoofCheckConfig: SpoofCheckConfiguration = SpoofCheckConfiguration.DISABLED + var spoofCheckCount: Int = 0 private var captureImagingStartTime: Long = 0 + private var validationStartTime: Long = 0 private var isAutoCaptureHeldOff = true private var autoCaptureImagingTimeoutJob: Job? = null private var autoCaptureImagingDurationMillis: Long = FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_DEFAULT @@ -81,6 +95,7 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( faceDetector = resolveFaceBioSdk(bioSdk).detector val config = configRepository.getProjectConfiguration() + spoofCheckConfig = getSpoofCheckConfiguration(config, bioSdk) qualityThreshold = config.face?.getSdkConfiguration(bioSdk)?.qualityThreshold ?: 0f autoCaptureImagingDurationMillis = config.experimental().faceAutoCaptureImagingDurationMillis } @@ -109,11 +124,28 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( * * @param croppedBitmap is the camera frame */ - fun process(croppedBitmap: Bitmap) { + 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), + ) + originalBitmap.recycle() + croppedBitmap.recycle() + return + } else if (capturingState.value == CapturingState.VALIDATION_FAILED) { + originalBitmap.recycle() + croppedBitmap.recycle() + return + } + val captureStartTime = timeHelper.now() val potentialFace = faceDetector.analyze(croppedBitmap) - val faceDetection = getFaceDetectionFromPotentialFace(croppedBitmap, potentialFace) + val faceDetection = getFaceDetectionFromPotentialFace(originalBitmap, croppedBitmap, potentialFace) faceDetection.detectionStartTime = captureStartTime faceDetection.detectionEndTime = timeHelper.now() @@ -132,6 +164,7 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( } else { currentDetection.postValue(faceDetection) } + progressBarValue.postValue(getNormalizedCaptureProgress()) when (capturingState.value) { CapturingState.NOT_STARTED -> updateFallbackCaptureIfValid(faceDetection) @@ -153,7 +186,7 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( } } - fun getNormalizedProgress(): Float = if (isAutoCapture) { + fun getNormalizedCaptureProgress(): Float = if (isAutoCapture) { ((timeHelper.now().ms - captureImagingStartTime).toFloat() / autoCaptureImagingDurationMillis).coerceIn(0f, 1f) } else { userCaptures.size.toFloat() / samplesToCapture @@ -192,23 +225,87 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( private fun finishCapture(attemptNumber: Int) { Simber.i("Finish capture", tag = FACE_CAPTURE) viewModelScope.launch { - sortedQualifyingCaptures = userCaptures - .filter { isAutoCapture || it.hasValidStatus() } // Auto-capture images are pre-qualified - .sortedByDescending { it.face?.quality } - .ifEmpty { listOfNotNull(fallbackCapture) } + if (spoofCheckConfig == SpoofCheckConfiguration.DISABLED) { + sendEventsAndFinish(attemptNumber) + } else { + runSpoofChecksOnCaptures() - sendCaptureEvents(attemptNumber) + if (spoofCheckConfig.mode == FaceConfiguration.SpoofCheckMode.RECORDED) { + sendEventsAndFinish(attemptNumber) + } else { + val spoofCheckPassed = userCaptures.map { it.spoofCheckResult }.all { + Simber.i("Spoof check result for capture: $it", tag = FACE_CAPTURE) + it != null && it.skipReason == null && spoofCheckConfig.threshold > it.score + } - capturingState.postValue(CapturingState.FINISHED) + Simber.i("Spoof check passed: $spoofCheckPassed (check count: $spoofCheckCount)", tag = FACE_CAPTURE) + // Only attempt up to configured amount of times to prevent hard-blocking user + if (spoofCheckCount >= spoofCheckConfig.maxAttempts || spoofCheckPassed) { + sendEventsAndFinish(attemptNumber) + } else { + showValidationErrorAndReset() + } + } + } + } + } + + private 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) + } + + private suspend fun runSpoofChecksOnCaptures() = withContext(bgDispatcher) { + capturingState.postValue(CapturingState.VALIDATING) + spoofCheckCount++ + validationStartTime = timeHelper.now().ms + + val duration = measureTimedValue { + for ((index, bitmap) in userCaptures.map { it.original }.withIndex()) { + val result = faceDetector.spoofCheck(bitmap, spoofCheckConfig.maxBitmapSize) + Simber.i("Spoof result: $result", tag = FACE_CAPTURE) + userCaptures[index].spoofCheckResult = result + } + } + + // Show the UI for at least a moment for it to register with the user + val delay = maxOf(spoofCheckConfig.validationUiDurationMs - duration.duration.inWholeMilliseconds, 0) + Simber.i("Spoof check performed in ${duration.duration}, waiting for ${delay}ms", tag = FACE_CAPTURE) + if (delay > 0) delay(delay.milliseconds) + } + + private suspend fun showValidationErrorAndReset() { + capturingState.postValue(CapturingState.VALIDATION_FAILED) + val duration = measureTimedValue { + // Still track the capture attempt events for analytics and troubleshooting + sendCaptureEvents(attemptNumber) } + 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 } private fun getFaceDetectionFromPotentialFace( + original: Bitmap, bitmap: Bitmap, potentialFace: Face?, ): FaceDetection = if (potentialFace == null) { + original.recycle() bitmap.recycle() FaceDetection( + original = original, bitmap = bitmap, face = null, status = FaceDetection.Status.NOFACE, @@ -216,10 +313,11 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( detectionEndTime = timeHelper.now(), ) } else { - getFaceDetection(bitmap, potentialFace) + getFaceDetection(original, bitmap, potentialFace) } private fun getFaceDetection( + original: Bitmap, bitmap: Bitmap, potentialFace: Face, ): FaceDetection { @@ -234,6 +332,7 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( } return FaceDetection( + original = original, bitmap = bitmap, face = potentialFace, status = status, @@ -285,10 +384,10 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( attemptNumber: Int, ) { if (faceDetection == null) return - eventReporter.addCaptureEvents(faceDetection, attemptNumber, qualityThreshold, isAutoCapture = isAutoCapture) + eventReporter.addCaptureEvents(faceDetection, attemptNumber, qualityThreshold, spoofCheckConfig, isAutoCapture = isAutoCapture) } - enum class CapturingState { NOT_STARTED, CAPTURING, FINISHED } + enum class CapturingState { NOT_STARTED, CAPTURING, VALIDATING, VALIDATION_FAILED, FINISHED } companion object { private const val VALID_ROLL_DELTA = 15f diff --git a/face/capture/src/main/java/com/simprints/face/capture/usecases/GetSpoofCheckConfigurationUseCase.kt b/face/capture/src/main/java/com/simprints/face/capture/usecases/GetSpoofCheckConfigurationUseCase.kt new file mode 100644 index 0000000000..f9ca00069d --- /dev/null +++ b/face/capture/src/main/java/com/simprints/face/capture/usecases/GetSpoofCheckConfigurationUseCase.kt @@ -0,0 +1,38 @@ +package com.simprints.face.capture.usecases + +import com.simprints.face.infra.biosdkresolver.RocV3BioSdk +import com.simprints.infra.config.store.models.FaceConfiguration +import com.simprints.infra.config.store.models.ModalitySdkType +import com.simprints.infra.config.store.models.ProjectConfiguration +import com.simprints.infra.config.store.models.experimental +import javax.inject.Inject + +class GetSpoofCheckConfigurationUseCase @Inject constructor( + private val rocV3BioSdk: RocV3BioSdk, +) { + operator fun invoke( + projectConfiguration: ProjectConfiguration, + bioSdk: ModalitySdkType, + ): FaceConfiguration.SpoofCheckConfiguration { + // Only available for ROC V3 + if (bioSdk != ModalitySdkType.RANK_ONE) { + return FaceConfiguration.SpoofCheckConfiguration.DISABLED + } + val faceSdkConfig = projectConfiguration.face?.getSdkConfiguration(bioSdk) + if (faceSdkConfig == null || faceSdkConfig.version != rocV3BioSdk.version()) { + return FaceConfiguration.SpoofCheckConfiguration.DISABLED + } + + // TODO use the Face SDK configuration instead + return projectConfiguration.experimental().let { + FaceConfiguration.SpoofCheckConfiguration( + mode = it.spoofCheckMode, + threshold = it.spoofCheckThreshold, + maxAttempts = it.spoofMaxAttempts, + maxBitmapSize = it.spoofMaxBitmapSize, + validationUiDurationMs = it.spoofMinValidationUiDurationMs, + validationErrorUiDurationMs = it.spoofMinValidationErrorUiDurationMs, + ) + } + } +} 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 d345658b7a..a0e23d020d 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 @@ -5,6 +5,8 @@ import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.time.Timestamp import com.simprints.core.tools.utils.EncodingUtils import com.simprints.face.capture.models.FaceDetection +import com.simprints.face.infra.basebiosdk.detection.SpoofCheckResult +import com.simprints.infra.config.store.models.FaceConfiguration import com.simprints.infra.events.event.domain.models.BiometricReferenceCreationEvent import com.simprints.infra.events.event.domain.models.FaceCaptureBiometricsEvent import com.simprints.infra.events.event.domain.models.FaceCaptureConfirmationEvent @@ -52,17 +54,18 @@ internal class SimpleCaptureEventReporter @Inject constructor( faceDetection: FaceDetection, attempt: Int, qualityThreshold: Float, + spoofConfig: FaceConfiguration.SpoofCheckConfiguration, isAutoCapture: Boolean = false, ) { val faceCaptureEvent = FaceCaptureEvent( - faceDetection.detectionStartTime, - faceDetection.detectionEndTime, - attempt, - qualityThreshold, - mapDetectionStatusToPayloadResult(faceDetection), + startTime = faceDetection.detectionStartTime, + endTime = faceDetection.detectionEndTime, + attemptNb = attempt, + qualityThreshold = qualityThreshold, + result = mapDetectionStatusToPayloadResult(faceDetection, spoofConfig), isAutoCapture = isAutoCapture, - faceDetection.isFallback, - mapDetectionToCapturePayloadFace(faceDetection), + isFallback = faceDetection.isFallback, + face = mapDetectionToCapturePayloadFace(faceDetection = faceDetection), payloadId = faceDetection.id, ) @@ -78,19 +81,45 @@ internal class SimpleCaptureEventReporter @Inject constructor( } } - private fun mapDetectionStatusToPayloadResult(faceDetection: FaceDetection) = when (faceDetection.status) { - 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 - FaceDetection.Status.TOOFAR -> FaceCapturePayload.Result.TOO_FAR + private fun mapDetectionStatusToPayloadResult( + faceDetection: FaceDetection, + spoofConfig: FaceConfiguration.SpoofCheckConfiguration, + ) = if (isFaceSpoofed(faceDetection, spoofConfig)) { + FaceCapturePayload.Result.SPOOFED + } else { + when (faceDetection.status) { + 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 + FaceDetection.Status.TOOFAR -> FaceCapturePayload.Result.TOO_FAR + } + } + + private fun isFaceSpoofed( + faceDetection: FaceDetection, + spoofConfig: FaceConfiguration.SpoofCheckConfiguration, + ): Boolean { + if (spoofConfig.mode != FaceConfiguration.SpoofCheckMode.ENFORCED) return false + val spoofResult = faceDetection.spoofCheckResult ?: return false + if (spoofResult.skipReason != null) return false + + return spoofResult.score > spoofConfig.threshold } - private fun mapDetectionToCapturePayloadFace(faceDetection: FaceDetection) = - faceDetection.face?.let { FaceCapturePayload.Face(it.yaw, it.roll, it.quality, it.format) } + private fun mapDetectionToCapturePayloadFace(faceDetection: FaceDetection) = faceDetection.face?.let { + FaceCapturePayload.Face( + yaw = it.yaw, + roll = it.roll, + quality = it.quality, + format = it.format, + spoofScore = faceDetection.spoofCheckResult?.score, + spoofSkipReason = mapSpoofReason(faceDetection.spoofCheckResult?.skipReason), + ) + } private fun mapDetectionToCaptureBometricPayloadFace(faceDetection: FaceDetection) = faceDetection.face?.let { FaceCaptureBiometricsEvent.FaceCaptureBiometricsPayload.Face( @@ -102,6 +131,13 @@ internal class SimpleCaptureEventReporter @Inject constructor( ) }!! + private fun mapSpoofReason(reason: SpoofCheckResult.SkipReason?): FaceCapturePayload.SpoofSkipReason? = when (reason) { + SpoofCheckResult.SkipReason.IMAGE_TOO_SMALL -> FaceCapturePayload.SpoofSkipReason.IMAGE_TOO_SMALL + SpoofCheckResult.SkipReason.IOD_TOO_SMALL -> FaceCapturePayload.SpoofSkipReason.IOD_TOO_SMALL + SpoofCheckResult.SkipReason.IOD_TOO_LARGE -> FaceCapturePayload.SpoofSkipReason.IOD_TOO_LARGE + else -> null + } + fun addBiometricReferenceCreationEvents( referenceId: String, captureIds: List, diff --git a/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzerTest.kt b/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzerTest.kt index 14f2c34f5f..cf20ce2147 100644 --- a/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzerTest.kt +++ b/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/CropToTargetOverlayAnalyzerTest.kt @@ -3,13 +3,10 @@ package com.simprints.face.capture.screens.livefeedback import android.graphics.Bitmap import android.graphics.RectF import androidx.camera.core.ImageProxy -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.common.truth.Truth.assertThat -import io.mockk.MockKAnnotations -import io.mockk.every +import androidx.test.ext.junit.runners.* +import com.google.common.truth.Truth.* +import io.mockk.* import io.mockk.impl.annotations.MockK -import io.mockk.justRun -import io.mockk.verify import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -36,7 +33,8 @@ internal class CropToTargetOverlayAnalyzerTest { previewRect = RectF(200f, 200f, 200f, 200f), overlayWidth = 1000, overlayHeight = 2000, - ) { capturedBitmap = it } + onImageCropped = { _, cropped -> capturedBitmap = cropped }, + ) analyzer.analyze(imageProxy) @@ -53,7 +51,8 @@ internal class CropToTargetOverlayAnalyzerTest { previewRect = RectF(200f, 200f, 800f, 800f), overlayWidth = 1000, overlayHeight = 2000, - ) { capturedBitmap = it } + onImageCropped = { _, cropped -> capturedBitmap = cropped }, + ) analyzer.analyze(imageProxy) @@ -73,7 +72,8 @@ internal class CropToTargetOverlayAnalyzerTest { previewRect = RectF(200f, 200f, 800f, 800f), overlayWidth = 1000, overlayHeight = 2000, - ) { closedBeforeCallback = closed } + onImageCropped = { _, _ -> closedBeforeCallback = closed }, + ) analyzer.analyze(imageProxy) @@ -88,7 +88,8 @@ internal class CropToTargetOverlayAnalyzerTest { previewRect = RectF(700f, 200f, 1300f, 800f), overlayWidth = 2000, overlayHeight = 1000, - ) { capturedBitmap = it } + onImageCropped = { _, cropped -> capturedBitmap = cropped }, + ) analyzer.analyze(imageProxy) @@ -104,7 +105,8 @@ internal class CropToTargetOverlayAnalyzerTest { previewRect = RectF(200f, 200f, 800f, 800f), overlayWidth = 1000, overlayHeight = 2000, - ) { capturedBitmap = it } + onImageCropped = { _, cropped -> capturedBitmap = cropped }, + ) analyzer.analyze(imageProxy) @@ -120,7 +122,8 @@ internal class CropToTargetOverlayAnalyzerTest { previewRect = RectF(700f, 200f, 1300f, 800f), overlayWidth = 2000, overlayHeight = 1000, - ) { capturedBitmap = it } + onImageCropped = { _, cropped -> capturedBitmap = cropped }, + ) analyzer.analyze(imageProxy) 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 index 3e1cf9c9d8..6e8864fc75 100644 --- 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 @@ -4,16 +4,20 @@ 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.* +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 @@ -21,6 +25,7 @@ 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 @@ -59,6 +64,8 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { @MockK private lateinit var isUsingAutoCapture: IsUsingAutoCaptureUseCase + @MockK + private lateinit var getSpoofCheckConfiguration: GetSpoofCheckConfigurationUseCase private lateinit var viewModel: LiveFeedbackFragmentViewModel @Before @@ -73,6 +80,7 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { ?.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)) @@ -90,6 +98,8 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { eventReporter, timeHelper, isUsingAutoCapture, + getSpoofCheckConfiguration, + testCoroutineRule.testCoroutineDispatcher, ) } @@ -101,13 +111,11 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { viewModel.initAutoCapture() viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - viewModel.process(frame) + viewModel.process(frame, frame) - Truth - .assertThat(currentDetection.observedValues) + assertThat(currentDetection.observedValues) .isEmpty() - Truth - .assertThat(capturingState.observedValues.last()) + assertThat(capturingState.observedValues.last()) .isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.NOT_STARTED) } @@ -121,13 +129,11 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) viewModel.startCapture() viewModel.holdOffAutoCapture() - viewModel.process(frame) + viewModel.process(frame, frame) - Truth - .assertThat(currentDetection.observedValues) + assertThat(currentDetection.observedValues) .isEmpty() - Truth - .assertThat(capturingState.observedValues.last()) + assertThat(capturingState.observedValues.last()) .isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.NOT_STARTED) } @@ -140,11 +146,10 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { viewModel.initAutoCapture() viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) viewModel.startCapture() - viewModel.process(frame) + viewModel.process(frame, frame) - Truth.assertThat(currentDetection.observedValues.last()?.hasValidStatus()).isEqualTo(true) - Truth - .assertThat(capturingState.observedValues.last()) + assertThat(currentDetection.observedValues.last()?.hasValidStatus()).isEqualTo(true) + assertThat(capturingState.observedValues.last()) .isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.CAPTURING) } @@ -157,13 +162,12 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { viewModel.initAutoCapture() viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) viewModel.startCapture() - viewModel.process(frame) + viewModel.process(frame, frame) viewModel.holdOffAutoCapture() - viewModel.process(frame) + viewModel.process(frame, frame) - Truth.assertThat(currentDetection.observedValues.last()?.hasValidStatus()).isEqualTo(true) - Truth - .assertThat(capturingState.observedValues.last()) + assertThat(currentDetection.observedValues.last()?.hasValidStatus()).isEqualTo(true) + assertThat(capturingState.observedValues.last()) .isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.CAPTURING) } @@ -179,20 +183,16 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { viewModel.initAutoCapture() viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) viewModel.startCapture() - viewModel.process(frame) - viewModel.process(frame) + viewModel.process(frame, frame) + viewModel.process(frame, frame) - Truth - .assertThat(currentDetection.observedValues.first()?.status) + assertThat(currentDetection.observedValues.first()?.status) .isEqualTo(FaceDetection.Status.VALID) - Truth - .assertThat(capturingState.observedValues.first()) + assertThat(capturingState.observedValues.first()) .isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.NOT_STARTED) - Truth - .assertThat(currentDetection.observedValues.last()?.hasValidStatus()) + assertThat(currentDetection.observedValues.last()?.hasValidStatus()) .isEqualTo(true) - Truth - .assertThat(capturingState.observedValues.last()) + assertThat(capturingState.observedValues.last()) .isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.CAPTURING) } @@ -202,12 +202,12 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { viewModel.initAutoCapture() viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - viewModel.process(frame) // a fallback image frame before the preparation delay elapses + viewModel.process(frame, frame) // a fallback image frame before the preparation delay elapses viewModel.startCapture() - viewModel.process(frame) + viewModel.process(frame, frame) val currentDetection = viewModel.currentDetection.testObserver() - Truth.assertThat(currentDetection.observedValues.last()?.hasValidStatus()).isEqualTo(true) + assertThat(currentDetection.observedValues.last()?.hasValidStatus()).isEqualTo(true) coVerify { eventReporter.addFallbackCaptureEvent(any(), any()) } } @@ -218,11 +218,11 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) viewModel.startCapture() - viewModel.process(frame) + viewModel.process(frame, frame) advanceTimeBy(AUTO_CAPTURE_IMAGING_DURATION_MS + 1) val currentDetection = viewModel.currentDetection.testObserver() - Truth.assertThat(currentDetection.observedValues.last()?.hasValidStatus()).isEqualTo(true) + assertThat(currentDetection.observedValues.last()?.hasValidStatus()).isEqualTo(true) coVerify { eventReporter.addCaptureEvents(any(), any(), any(), any()) } } @@ -248,22 +248,22 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { viewModel.initCapture(ModalitySdkType.SIM_FACE, 2, 0) viewModel.startCapture() - viewModel.process(frame) - viewModel.process(frame) - viewModel.process(frame) - viewModel.process(frame) - viewModel.process(frame) - viewModel.process(frame) + 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 { - Truth.assertThat(it[0]?.status).isEqualTo(FaceDetection.Status.TOOFAR) - Truth.assertThat(it[1]?.status).isEqualTo(FaceDetection.Status.TOOCLOSE) - Truth.assertThat(it[2]?.status).isEqualTo(FaceDetection.Status.OFFYAW) - Truth.assertThat(it[3]?.status).isEqualTo(FaceDetection.Status.OFFROLL) - Truth.assertThat(it[4]?.status).isEqualTo(FaceDetection.Status.NOFACE) + 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()) } + coVerify(exactly = 0) { eventReporter.addCaptureEvents(any(), any(), any(), any()) } } @Test @@ -282,16 +282,16 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { viewModel.initAutoCapture() viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) // fallback image frames before the preparation delay elapses - viewModel.process(frame) - viewModel.process(frame) + viewModel.process(frame, frame) + viewModel.process(frame, frame) viewModel.startCapture() - viewModel.process(frame) - viewModel.process(frame) + viewModel.process(frame, frame) + viewModel.process(frame, frame) detections.observedValues.let { // fallback image frame wasn't observed during preparation delay - Truth.assertThat(it[0]?.hasValidStatus()).isEqualTo(true) - Truth.assertThat(it[1]?.hasValidStatus()).isEqualTo(true) + assertThat(it[0]?.hasValidStatus()).isEqualTo(true) + assertThat(it[1]?.hasValidStatus()).isEqualTo(true) } } @@ -307,15 +307,13 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { viewModel.initAutoCapture() viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) viewModel.startCapture() - viewModel.process(frame) + viewModel.process(frame, frame) advanceTimeBy(AUTO_CAPTURE_IMAGING_DURATION_MS) - Truth - .assertThat(capturingState.observedValues.last()) + assertThat(capturingState.observedValues.last()) .isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.CAPTURING) advanceTimeBy(1) - Truth - .assertThat(capturingState.observedValues.last()) + assertThat(capturingState.observedValues.last()) .isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.FINISHED) } @@ -333,18 +331,128 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { viewModel.initAutoCapture() viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) viewModel.startCapture() - viewModel.process(frame) + viewModel.process(frame, frame) advanceTimeBy(configDuration / 2) - Truth - .assertThat(capturingState.observedValues.last()) + assertThat(capturingState.observedValues.last()) .isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.CAPTURING) // Add 1ms to account for json deserialization delay advanceTimeBy((configDuration / 2) + 1) - Truth - .assertThat(capturingState.observedValues.last()) + 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() @@ -355,50 +463,48 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { val samplesToKeep = 2 viewModel.initAutoCapture() viewModel.initCapture(ModalitySdkType.SIM_FACE, samplesToKeep, 0) - viewModel.process(frame) // won't be observed during the preparation phase + viewModel.process(frame, frame) // won't be observed during the preparation phase viewModel.startCapture() repeat(100) { - viewModel.process(frame) + viewModel.process(frame, frame) } advanceTimeBy(AUTO_CAPTURE_IMAGING_DURATION_MS + 1) currentDetectionObserver.observedValues.let { // 1st frame wasn't observed during preparation delay - Truth.assertThat(it[0]?.hasValidStatus()).isEqualTo(true) - Truth.assertThat(it[1]?.hasValidStatus()).isEqualTo(true) + assertThat(it[0]?.hasValidStatus()).isEqualTo(true) + assertThat(it[1]?.hasValidStatus()).isEqualTo(true) } capturingStateObserver.observedValues.let { - Truth - .assertThat(it[0]) + assertThat(it[0]) .isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.NOT_STARTED) - Truth - .assertThat(it[1]) + assertThat(it[1]) .isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.CAPTURING) - Truth.assertThat(it[2]).isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.FINISHED) + assertThat(it[2]).isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.FINISHED) } - Truth.assertThat(viewModel.userCaptures.size).isEqualTo(samplesToKeep) + assertThat(viewModel.userCaptures.size).isEqualTo(samplesToKeep) viewModel.userCaptures.let { with(it[0]) { - Truth.assertThat(hasValidStatus()).isEqualTo(true) - Truth.assertThat(face).isEqualTo(validFace) - Truth.assertThat(isFallback).isEqualTo(false) + assertThat(hasValidStatus()).isEqualTo(true) + assertThat(face).isEqualTo(validFace) + assertThat(isFallback).isEqualTo(false) } - Truth.assertThat(it[1].isFallback).isEqualTo(false) + assertThat(it[1].isFallback).isEqualTo(false) } with(viewModel.sortedQualifyingCaptures) { - Truth.assertThat(size).isEqualTo(samplesToKeep) - Truth.assertThat(get(0).face).isEqualTo(validFace) - Truth.assertThat(get(0).isFallback).isEqualTo(false) - Truth.assertThat(get(1).face).isEqualTo(validFace) - Truth.assertThat(get(1).isFallback).isEqualTo(false) + 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()) } + coVerify(exactly = 3) { eventReporter.addCaptureEvents(any(), any(), any(), any(), any()) } } private fun getFace( @@ -406,7 +512,18 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { quality: Float = 1f, yaw: Float = 0f, roll: Float = 0f, - ) = Face(100, 100, rect, yaw, roll, quality, Random.Default.nextBytes(20), "format") + ) = 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/LiveFeedbackFragmentViewModelTest.kt b/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragmentViewModelTest.kt index 06e027db95..c4c73dba72 100644 --- 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 @@ -8,17 +8,22 @@ 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 @@ -54,6 +59,9 @@ internal class LiveFeedbackFragmentViewModelTest { @MockK private lateinit var isUsingAutoCapture: IsUsingAutoCaptureUseCase + + @MockK + private lateinit var getSpoofCheckConfiguration: GetSpoofCheckConfigurationUseCase private lateinit var viewModel: LiveFeedbackFragmentViewModel @Before @@ -68,6 +76,8 @@ internal class LiveFeedbackFragmentViewModelTest { ?.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 { @@ -82,6 +92,8 @@ internal class LiveFeedbackFragmentViewModelTest { eventReporter, timeHelper, isUsingAutoCapture, + getSpoofCheckConfiguration, + testCoroutineRule.testCoroutineDispatcher, ) } @@ -91,7 +103,7 @@ internal class LiveFeedbackFragmentViewModelTest { viewModel.initAutoCapture() viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - viewModel.process(frame) + viewModel.process(frame, frame) val currentDetection = viewModel.currentDetection.testObserver() assertThat(currentDetection.observedValues.last()?.status).isEqualTo(FaceDetection.Status.VALID) @@ -105,14 +117,14 @@ internal class LiveFeedbackFragmentViewModelTest { viewModel.initAutoCapture() viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - viewModel.process(frame) + viewModel.process(frame, frame) viewModel.startCapture() - viewModel.process(frame) + 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()) } + coVerify { eventReporter.addCaptureEvents(any(), any(), any(), any()) } } @Test @@ -135,12 +147,12 @@ internal class LiveFeedbackFragmentViewModelTest { viewModel.initAutoCapture() viewModel.initCapture(ModalitySdkType.SIM_FACE, 2, 0) - viewModel.process(frame) - viewModel.process(frame) - viewModel.process(frame) - viewModel.process(frame) - viewModel.process(frame) - viewModel.process(frame) + 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) @@ -150,7 +162,7 @@ internal class LiveFeedbackFragmentViewModelTest { assertThat(it[4]?.status).isEqualTo(FaceDetection.Status.NOFACE) } - coVerify(exactly = 0) { eventReporter.addCaptureEvents(any(), any(), any()) } + coVerify(exactly = 0) { eventReporter.addCaptureEvents(any(), any(), any(), any()) } } @Test @@ -167,9 +179,9 @@ internal class LiveFeedbackFragmentViewModelTest { val detections = viewModel.currentDetection.testObserver() viewModel.initAutoCapture() viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - viewModel.process(frame) - viewModel.process(frame) - viewModel.process(frame) + viewModel.process(frame, frame) + viewModel.process(frame, frame) + viewModel.process(frame, frame) detections.observedValues.let { assertThat(it[0]?.status).isEqualTo(FaceDetection.Status.VALID) @@ -187,10 +199,10 @@ internal class LiveFeedbackFragmentViewModelTest { val capturingStateObserver = viewModel.capturingState.testObserver() viewModel.initAutoCapture() viewModel.initCapture(ModalitySdkType.SIM_FACE, 2, 0) - viewModel.process(frame) + viewModel.process(frame, frame) viewModel.startCapture() - viewModel.process(frame) - viewModel.process(frame) + viewModel.process(frame, frame) + viewModel.process(frame, frame) currentDetectionObserver.observedValues.let { assertThat(it[0]?.status).isEqualTo(FaceDetection.Status.VALID) @@ -224,7 +236,104 @@ internal class LiveFeedbackFragmentViewModelTest { } coVerify { eventReporter.addFallbackCaptureEvent(any(), any()) } - coVerify(exactly = 3) { eventReporter.addCaptureEvents(any(), 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( @@ -234,6 +343,17 @@ internal class LiveFeedbackFragmentViewModelTest { 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/usecases/GetSpoofCheckConfigurationUseCaseTest.kt b/face/capture/src/test/java/com/simprints/face/capture/usecases/GetSpoofCheckConfigurationUseCaseTest.kt new file mode 100644 index 0000000000..ac28de2f5d --- /dev/null +++ b/face/capture/src/test/java/com/simprints/face/capture/usecases/GetSpoofCheckConfigurationUseCaseTest.kt @@ -0,0 +1,84 @@ +package com.simprints.face.capture.usecases + +import com.google.common.truth.Truth.* +import com.simprints.face.infra.biosdkresolver.RocV3BioSdk +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.infra.config.store.models.ProjectConfiguration +import io.mockk.* +import io.mockk.impl.annotations.MockK +import kotlinx.serialization.json.JsonPrimitive +import org.junit.Before +import org.junit.Test + +class GetSpoofCheckConfigurationUseCaseTest { + @MockK + private lateinit var projectConfiguration: ProjectConfiguration + + @MockK + private lateinit var faceConfiguration: FaceConfiguration + + @MockK + private lateinit var faceSdkConfiguration: FaceConfiguration.FaceSdkConfiguration + + @MockK + private lateinit var rocV3BioSdk: RocV3BioSdk + + private lateinit var useCase: GetSpoofCheckConfigurationUseCase + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + + every { rocV3BioSdk.version() } returns "3.1" + + useCase = GetSpoofCheckConfigurationUseCase(rocV3BioSdk) + } + + @Test + fun `Returns disabled when face is null`() { + every { projectConfiguration.face } returns null + val result = useCase(projectConfiguration, ModalitySdkType.RANK_ONE) + + assertThat(result).isEqualTo(SpoofCheckConfiguration.DISABLED) + } + + @Test + fun `Returns disabled when face sdk config is not ROC`() { + val result = useCase(projectConfiguration, ModalitySdkType.SIM_FACE) + + assertThat(result).isEqualTo(SpoofCheckConfiguration.DISABLED) + } + + @Test + fun `Returns disabled when face sdk config is not ROC V3`() { + every { projectConfiguration.face } returns faceConfiguration + every { faceConfiguration.getSdkConfiguration(any()) } returns faceSdkConfiguration + every { faceSdkConfiguration.version } returns "1.0" + + val result = useCase(projectConfiguration, ModalitySdkType.RANK_ONE) + + assertThat(result).isEqualTo(SpoofCheckConfiguration.DISABLED) + } + + @Test + fun `Returns config when ROC V3 is used`() { + every { projectConfiguration.face } returns faceConfiguration + every { faceConfiguration.getSdkConfiguration(any()) } returns faceSdkConfiguration + every { faceSdkConfiguration.version } returns "3.1" + every { projectConfiguration.custom } returns mapOf( + "spoofCheckMode" to JsonPrimitive("ENFORCED"), + "spoofCheckThreshold" to JsonPrimitive(0.75f), + "spoofMaxAttempts" to JsonPrimitive(3), + "spoofMaxBitmapSize" to JsonPrimitive(1000), + ) + + val result = useCase(projectConfiguration, ModalitySdkType.RANK_ONE) + + assertThat(result.mode).isEqualTo(FaceConfiguration.SpoofCheckMode.ENFORCED) + assertThat(result.threshold).isEqualTo(0.75f) + assertThat(result.maxAttempts).isEqualTo(3) + assertThat(result.maxBitmapSize).isEqualTo(1000) + } +} 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 3d57fea91a..a9f2ed7a5a 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 @@ -1,12 +1,16 @@ package com.simprints.face.capture.usecases import android.graphics.Rect -import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.* import com.simprints.core.tools.time.TimeHelper import com.simprints.core.tools.time.Timestamp import com.simprints.core.tools.utils.EncodingUtils import com.simprints.face.capture.models.FaceDetection import com.simprints.face.infra.basebiosdk.detection.Face +import com.simprints.face.infra.basebiosdk.detection.SpoofCheckResult +import com.simprints.face.infra.basebiosdk.detection.SpoofCheckResult.SkipReason +import com.simprints.infra.config.store.models.FaceConfiguration +import com.simprints.infra.config.store.models.FaceConfiguration.SpoofCheckConfiguration import com.simprints.infra.events.event.domain.models.BiometricReferenceCreationEvent import com.simprints.infra.events.event.domain.models.BiometricReferenceCreationEvent.BiometricReferenceCreationPayload import com.simprints.infra.events.event.domain.models.FaceCaptureBiometricsEvent @@ -16,11 +20,8 @@ import com.simprints.infra.events.event.domain.models.FaceFallbackCaptureEvent import com.simprints.infra.events.event.domain.models.FaceOnboardingCompleteEvent import com.simprints.infra.events.session.SessionEventRepository import com.simprints.testtools.common.coroutines.TestCoroutineRule -import io.mockk.MockKAnnotations -import io.mockk.coVerify -import io.mockk.every +import io.mockk.* import io.mockk.impl.annotations.MockK -import io.mockk.mockk import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.runTest import org.junit.Before @@ -112,7 +113,7 @@ class SimpleCaptureEventReporterTest { @Test fun `Adds capture and biometric event for valid detection`() = runTest { - reporter.addCaptureEvents(getDetection(FaceDetection.Status.VALID), 1, 0.5f) + reporter.addCaptureEvents(getDetection(FaceDetection.Status.VALID), 1, 0.5f, SpoofCheckConfiguration.DISABLED) coVerify { eventRepository.addOrUpdateEvent( @@ -130,7 +131,7 @@ class SimpleCaptureEventReporterTest { @Test fun `Adds capture and biometric event for valid_capturing detection`() = runTest { - reporter.addCaptureEvents(getDetection(FaceDetection.Status.VALID_CAPTURING), 1, 0.5f) + reporter.addCaptureEvents(getDetection(FaceDetection.Status.VALID_CAPTURING), 1, 0.5f, SpoofCheckConfiguration.DISABLED) coVerify { eventRepository.addOrUpdateEvent( @@ -148,33 +149,85 @@ class SimpleCaptureEventReporterTest { @Test fun `Adds capture and no biometric event for invalid detections`() = runTest { - reporter.addCaptureEvents(getDetection(FaceDetection.Status.NOFACE), 1, 0.5f) - reporter.addCaptureEvents(getDetection(FaceDetection.Status.OFFYAW), 1, 0.5f) - reporter.addCaptureEvents(getDetection(FaceDetection.Status.OFFROLL), 1, 0.5f) - reporter.addCaptureEvents(getDetection(FaceDetection.Status.TOOCLOSE), 1, 0.5f) - reporter.addCaptureEvents(getDetection(FaceDetection.Status.TOOFAR), 1, 0.5f) - reporter.addCaptureEvents(getDetection(FaceDetection.Status.BAD_QUALITY), 1, 0.5f) + reporter.addCaptureEvents(getDetection(FaceDetection.Status.NOFACE), 1, 0.5f, SpoofCheckConfiguration.DISABLED) + reporter.addCaptureEvents(getDetection(FaceDetection.Status.OFFYAW), 1, 0.5f, SpoofCheckConfiguration.DISABLED) + 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) { + eventRepository.addOrUpdateEvent(withArg { assertThat(it).isInstanceOf(FaceCaptureEvent::class.java) }) + } + coVerify(exactly = 0) { + eventRepository.addOrUpdateEvent(withArg { assertThat(it).isInstanceOf(FaceCaptureBiometricsEvent::class.java) }) + } + } + + @Test + fun `Correctly marks only spoofed detections when enabled spoof check`() = runTest { + // Should not be marked as SPOOFED + reporter.addCaptureEvents( + getDetection(FaceDetection.Status.VALID).copy(spoofCheckResult = SpoofCheckResult(0f, skipReason = SkipReason.IOD_TOO_SMALL)), + 1, + 0.5f, + getEnforcedSpoofConfig(), + ) + reporter.addCaptureEvents( + getDetection(FaceDetection.Status.VALID).copy(spoofCheckResult = SpoofCheckResult(0.5f)), + 1, + 0.5f, + getEnforcedSpoofConfig().copy(mode = FaceConfiguration.SpoofCheckMode.RECORDED), + ) + reporter.addCaptureEvents( + getDetection(FaceDetection.Status.VALID).copy(spoofCheckResult = SpoofCheckResult(0.1f)), + 1, + 0.5f, + getEnforcedSpoofConfig(), + ) + reporter.addCaptureEvents( + getDetection(FaceDetection.Status.VALID).copy(spoofCheckResult = null), + 1, + 0.5f, + getEnforcedSpoofConfig(), + ) + // Should be marked as SPOOFED + reporter.addCaptureEvents( + getDetection(FaceDetection.Status.VALID).copy(spoofCheckResult = SpoofCheckResult(0.9f)), + 1, + 0.5f, + getEnforcedSpoofConfig(), + ) + + coVerify(exactly = 4) { eventRepository.addOrUpdateEvent( - withArg { - assertThat(it).isInstanceOf(FaceCaptureEvent::class.java) + match { + it.payload.result == FaceCaptureEvent.FaceCapturePayload.Result.VALID }, ) } - coVerify(exactly = 0) { + coVerify(exactly = 1) { eventRepository.addOrUpdateEvent( - withArg { - assertThat(it).isInstanceOf(FaceCaptureBiometricsEvent::class.java) + match { + it.payload.result == FaceCaptureEvent.FaceCapturePayload.Result.SPOOFED }, ) } } + private fun getEnforcedSpoofConfig(): SpoofCheckConfiguration = SpoofCheckConfiguration( + mode = FaceConfiguration.SpoofCheckMode.ENFORCED, + maxAttempts = 0, + maxBitmapSize = 0, + validationUiDurationMs = 0, + validationErrorUiDurationMs = 0, + threshold = 0.3f, + ) + @Test fun `Adds capture event with correct auto-capture flag`() = runTest { listOf(true, false).forEach { isAutoCapture -> - reporter.addCaptureEvents(getDetection(FaceDetection.Status.VALID), 1, 0.5f, isAutoCapture) + reporter.addCaptureEvents(getDetection(FaceDetection.Status.VALID), 1, 0.5f, SpoofCheckConfiguration.DISABLED, isAutoCapture) coVerify { eventRepository.addOrUpdateEvent( withArg { @@ -200,8 +253,58 @@ class SimpleCaptureEventReporterTest { } } - private fun getDetection(status: FaceDetection.Status) = - FaceDetection(mockk(), getFace(), status, mockk(), Timestamp(1L), false, "id", Timestamp(1L)) + @Test + fun `Correctly maps spoof skip reasons`() = runTest { + reporter.addCaptureEvents(getSpoofedDetection(SkipReason.NOT_AVAILABLE), 1, 0.5f, SpoofCheckConfiguration.DISABLED) + // to null, + reporter.addCaptureEvents(getSpoofedDetection(SkipReason.IOD_TOO_SMALL), 1, 0.5f, SpoofCheckConfiguration.DISABLED) + // to FaceCaptureEvent.FaceCapturePayload.SpoofSkipReason.IMAGE_TOO_SMALL, + reporter.addCaptureEvents(getSpoofedDetection(SkipReason.IOD_TOO_LARGE), 1, 0.5f, SpoofCheckConfiguration.DISABLED) + // to FaceCaptureEvent.FaceCapturePayload.SpoofSkipReason.IOD_TOO_LARGE, + reporter.addCaptureEvents(getSpoofedDetection(SkipReason.IMAGE_TOO_SMALL), 1, 0.5f, SpoofCheckConfiguration.DISABLED) + // to FaceCaptureEvent.FaceCapturePayload.SpoofSkipReason.IMAGE_TOO_SMALL, + + coVerify(exactly = 1) { + eventRepository.addOrUpdateEvent( + match { + it.payload.face?.spoofSkipReason == FaceCaptureEvent.FaceCapturePayload.SpoofSkipReason.IMAGE_TOO_SMALL + }, + ) + } + coVerify(exactly = 1) { + eventRepository.addOrUpdateEvent( + match { + it.payload.face?.spoofSkipReason == FaceCaptureEvent.FaceCapturePayload.SpoofSkipReason.IOD_TOO_SMALL + }, + ) + } + coVerify(exactly = 1) { + eventRepository.addOrUpdateEvent( + match { + it.payload.face?.spoofSkipReason == FaceCaptureEvent.FaceCapturePayload.SpoofSkipReason.IOD_TOO_LARGE + }, + ) + } + coVerify(exactly = 1) { + eventRepository.addOrUpdateEvent(match { it is FaceCaptureEvent && it.payload.face?.spoofSkipReason == null }) + } + } + + private fun getSpoofedDetection(reason: SkipReason) = getDetection(FaceDetection.Status.VALID).also { + it.spoofCheckResult = SpoofCheckResult(0f, reason) + } + + private fun getDetection(status: FaceDetection.Status) = FaceDetection( + mockk(), + mockk(), + getFace(), + status, + mockk(), + Timestamp(1L), + false, + "id", + Timestamp(1L), + ) private fun getFace() = Face( 100, diff --git a/face/infra/base-bio-sdk/src/main/java/com/simprints/face/infra/basebiosdk/detection/FaceDetector.kt b/face/infra/base-bio-sdk/src/main/java/com/simprints/face/infra/basebiosdk/detection/FaceDetector.kt index 9f7beac0f9..f465ea3ddd 100644 --- a/face/infra/base-bio-sdk/src/main/java/com/simprints/face/infra/basebiosdk/detection/FaceDetector.kt +++ b/face/infra/base-bio-sdk/src/main/java/com/simprints/face/infra/basebiosdk/detection/FaceDetector.kt @@ -2,7 +2,7 @@ package com.simprints.face.infra.basebiosdk.detection import android.graphics.Bitmap -fun interface FaceDetector { +interface FaceDetector { /** * Analyze an ARGB_8888 bitmap and return the detected face data * @@ -10,4 +10,15 @@ fun interface FaceDetector { * @return Face object or null if no face is detected */ fun analyze(bitmap: Bitmap): Face? + + /** + * Perform a spoof check on an ARGB_8888 bitmap + * + * @param bitmap original captured image (ARGB_8888) + * @return Either a spoof score (lower is better) or a reason why the check was skipped + */ + fun spoofCheck( + bitmap: Bitmap, + configuredMaxSize: Int, + ): SpoofCheckResult } diff --git a/face/infra/base-bio-sdk/src/main/java/com/simprints/face/infra/basebiosdk/detection/SpoofCheckResult.kt b/face/infra/base-bio-sdk/src/main/java/com/simprints/face/infra/basebiosdk/detection/SpoofCheckResult.kt new file mode 100644 index 0000000000..d1d0086ce3 --- /dev/null +++ b/face/infra/base-bio-sdk/src/main/java/com/simprints/face/infra/basebiosdk/detection/SpoofCheckResult.kt @@ -0,0 +1,13 @@ +package com.simprints.face.infra.basebiosdk.detection + +data class SpoofCheckResult( + val score: Float, + val skipReason: SkipReason? = null, +) { + enum class SkipReason { + NOT_AVAILABLE, + IMAGE_TOO_SMALL, + IOD_TOO_SMALL, + IOD_TOO_LARGE, + } +} diff --git a/face/infra/roc-v1/src/main/java/com/simprints/face/infra/rocv1/detection/RocV1Detector.kt b/face/infra/roc-v1/src/main/java/com/simprints/face/infra/rocv1/detection/RocV1Detector.kt index 11984860e4..36392ae192 100644 --- a/face/infra/roc-v1/src/main/java/com/simprints/face/infra/rocv1/detection/RocV1Detector.kt +++ b/face/infra/roc-v1/src/main/java/com/simprints/face/infra/rocv1/detection/RocV1Detector.kt @@ -5,6 +5,7 @@ import android.graphics.Rect import com.simprints.core.ExcludedFromGeneratedTestCoverageReports 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 io.rankone.rocsdk.embedded.SWIGTYPE_p_float import io.rankone.rocsdk.embedded.SWIGTYPE_p_unsigned_char import io.rankone.rocsdk.embedded.roc @@ -46,6 +47,11 @@ class RocV1Detector @Inject constructor() : FaceDetector { } } + override fun spoofCheck( + bitmap: Bitmap, + configuredMaxSize: Int, + ) = SpoofCheckResult(0f, SpoofCheckResult.SkipReason.NOT_AVAILABLE) + override fun analyze(bitmap: Bitmap): Face? { val rocColorImage = roc_image() val rocGrayImage = roc_image() diff --git a/face/infra/roc-v3/src/main/java/com/simprints/face/infra/rocv3/detection/RocV3Detector.kt b/face/infra/roc-v3/src/main/java/com/simprints/face/infra/rocv3/detection/RocV3Detector.kt index db9fa60d09..9ebfc3e3d8 100644 --- a/face/infra/roc-v3/src/main/java/com/simprints/face/infra/rocv3/detection/RocV3Detector.kt +++ b/face/infra/roc-v3/src/main/java/com/simprints/face/infra/rocv3/detection/RocV3Detector.kt @@ -7,12 +7,15 @@ import ai.roc.rocsdk.embedded.roc_image import ai.roc.rocsdk.embedded.roc_landmark import android.graphics.Bitmap import android.graphics.Rect +import androidx.core.graphics.scale import com.simprints.core.ExcludedFromGeneratedTestCoverageReports 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 java.nio.ByteBuffer import javax.inject.Inject import javax.inject.Singleton +import kotlin.math.abs import ai.roc.rocsdk.embedded.roc as roc3 @ExcludedFromGeneratedTestCoverageReports( @@ -63,14 +66,14 @@ class RocV3Detector @Inject constructor() : FaceDetector { val yawValue = roc3.float_value(yaw) val qualityValue = roc3.float_value(quality) Face( - width, - height, - detection.boundingRect(), - yawValue, - detection.rotation, - qualityValue, - roc3.cdata(roc3.roc_cast(template), roc3.ROC_FACE_FAST_FV_SIZE.toInt()), - RANK_ONE_TEMPLATE_FORMAT_3_1, + sourceWidth = width, + sourceHeight = height, + absoluteBoundingBox = detection.boundingRect(), + yaw = yawValue, + roll = detection.rotation, + quality = qualityValue, + template = roc3.cdata(roc3.roc_cast(template), roc3.ROC_FACE_FAST_FV_SIZE.toInt()), + format = RANK_ONE_TEMPLATE_FORMAT_3_1, ) } else { null @@ -172,11 +175,126 @@ class RocV3Detector @Inject constructor() : FaceDetector { return byteBuffer } + override fun spoofCheck( + bitmap: Bitmap, + configuredMaxSize: Int, + ): SpoofCheckResult { + if (minOf(bitmap.width, bitmap.height) < SPOOF_MIN_SIZE) { + // As per documentation - smallest dimension must be at least 720px + return SpoofCheckResult(0f, SpoofCheckResult.SkipReason.IMAGE_TOO_SMALL) + } + + // Scaling image down to lower the chance that IOD is outside of requirements. The check also runs faster on smaller images. + val scaledBitmap = if (maxOf(bitmap.width, bitmap.height) > configuredMaxSize) { + val scale = configuredMaxSize.toFloat() / maxOf(bitmap.width, bitmap.height).toFloat() + bitmap.scale((bitmap.width * scale).toInt(), (bitmap.height * scale).toInt(), false) + } else { + bitmap + } + + val rocColorImage = roc_image() + val rocGrayImage = roc_image() + val byteBuffer = scaledBitmap.toByteBuffer() + roc3.roc_from_rgba( + byteBuffer.array(), + scaledBitmap.width.toLong(), + scaledBitmap.height.toLong(), + scaledBitmap.rowBytes.toLong(), + rocColorImage, + ) + roc3.roc_bgr2gray(rocColorImage, rocGrayImage) + + val detection = roc_detection() + val faceDetected = isFaceDetected(rocColorImage, detection) + + var iod = 0f + var finalScore = 0f + + if (faceDetected) { + val rightEye = roc_landmark() + val leftEye = roc_landmark() + val chin = roc_landmark() + + val landmarks = roc3.new_roc_landmark_array(roc3.roc_num_landmarks_for_pose(detection.pose)) + roc3.roc_embedded_landmark_face( + rocGrayImage, + detection, + landmarks, + rightEye, + leftEye, + chin, + null, + null, + ) + roc3.delete_roc_landmark_array(landmarks) + + iod = abs(leftEye.x - rightEye.x) + + // As per documentation - Inter-ocular distance must be in 100-320px range + if (iod in SPOOF_MIN_IOD..SPOOF_MAX_IOD) { + val embeddingSpoof = roc3.new_float() + val livenessSpoof = roc3.new_float() + + roc3.roc_embedded_represent_face( + rocColorImage, + detection, + rightEye, + leftEye, + chin, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + embeddingSpoof, + ) + val embeddingScore = roc3.float_value(embeddingSpoof) + roc3.delete_float(embeddingSpoof) + + roc3.roc_embedded_liveness( + rocGrayImage, + rightEye, + leftEye, + chin, + livenessSpoof, + ) + val livenessScore = roc3.float_value(livenessSpoof) + roc3.delete_float(livenessSpoof) + + // As per documentation - weighted average of 2 call scores with 75% weight for direct liveness result + finalScore = ((3 * livenessScore) + embeddingScore) / 4f + } + leftEye.delete() + rightEye.delete() + chin.delete() + } + roc3.roc_free_image(rocGrayImage) + roc3.roc_free_image(rocColorImage) + detection.delete() + byteBuffer.clear() + + return when { + !faceDetected -> SpoofCheckResult(finalScore, SpoofCheckResult.SkipReason.NOT_AVAILABLE) + iod < SPOOF_MIN_IOD -> SpoofCheckResult(finalScore, SpoofCheckResult.SkipReason.IOD_TOO_SMALL) + iod > SPOOF_MAX_IOD -> SpoofCheckResult(finalScore, SpoofCheckResult.SkipReason.IOD_TOO_LARGE) + else -> SpoofCheckResult(finalScore) + } + } + companion object { const val RANK_ONE_TEMPLATE_FORMAT_3_1 = "RANK_ONE_3_1" const val MAX_FACE_DETECTION = 1 const val FALSE_DETECTION_RATE = 0.1f const val RELATIVE_MIN_SIZE = 0.2f const val ABSOLUTE_MIN_SIZE = 36L + + private const val SPOOF_MIN_SIZE = 720 + private const val SPOOF_MIN_IOD = 100f + private const val SPOOF_MAX_IOD = 320f } } diff --git a/face/infra/simface/src/main/java/com/simprints/face/infra/simface/detection/SimFaceDetector.kt b/face/infra/simface/src/main/java/com/simprints/face/infra/simface/detection/SimFaceDetector.kt index acc0dbc355..29e9f8836b 100644 --- a/face/infra/simface/src/main/java/com/simprints/face/infra/simface/detection/SimFaceDetector.kt +++ b/face/infra/simface/src/main/java/com/simprints/face/infra/simface/detection/SimFaceDetector.kt @@ -4,6 +4,7 @@ import android.graphics.Bitmap import com.simprints.biometrics.simface.SimFace 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 kotlinx.coroutines.runBlocking import javax.inject.Inject @@ -32,6 +33,11 @@ class SimFaceDetector @Inject constructor( ) } + override fun spoofCheck( + bitmap: Bitmap, + configuredMaxSize: Int, + ) = SpoofCheckResult(0f, SpoofCheckResult.SkipReason.NOT_AVAILABLE) + companion object { private const val BAD_FACE_THRESHOLD = 0.1 } diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt index e31d9632b6..43273060c9 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/ExperimentalProjectConfiguration.kt @@ -1,7 +1,10 @@ package com.simprints.infra.config.store.models +import com.simprints.infra.config.store.models.FaceConfiguration.SpoofCheckMode import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.floatOrNull import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull @@ -165,6 +168,60 @@ data class ExperimentalProjectConfiguration( ?.booleanOrNull .let { it == true } + // TODO Temporary flags until configuration is added to FaceSdkConfiguration + val spoofCheckMode: SpoofCheckMode + get() = customConfig + ?.get(SPOOF_CHECK_MODE) + ?.jsonPrimitive + ?.contentOrNull + ?.let { SpoofCheckMode.fromString(it) } + ?: SpoofCheckMode.DISABLED + + // TODO Temporary flags until configuration is added to FaceSdkConfiguration + val spoofCheckThreshold: Float + get() = customConfig + ?.get(SPOOF_CHECK_THRESHOLD) + ?.jsonPrimitive + ?.floatOrNull + ?.coerceIn(0f, 1f) + ?: SPOOF_CHECK_THRESHOLD_DEFAULT + + // TODO Temporary flags until configuration is added to FaceSdkConfiguration + val spoofMaxAttempts: Int + get() = customConfig + ?.get(SPOOF_CHECK_MAX_ATTEMPTS) + ?.jsonPrimitive + ?.intOrNull + ?.coerceAtLeast(1) + ?: SPOOF_CHECK_MAX_ATTEMPTS_DEFAULT + + // TODO Temporary flags until configuration is added to FaceSdkConfiguration + val spoofMaxBitmapSize: Int + get() = customConfig + ?.get(SPOOF_CHECK_MAX_BITMAP_SIZE) + ?.jsonPrimitive + ?.intOrNull + ?.coerceAtLeast(SPOOF_CHECK_MAX_BITMAP_SIZE_MINIMUM) + ?: SPOOF_CHECK_MAX_BITMAP_SIZE_DEFAULT + + // TODO Temporary flags until configuration is added to FaceSdkConfiguration + val spoofMinValidationUiDurationMs: Int + get() = customConfig + ?.get(SPOOF_CHECK_MIN_VALIDATION_UI_DURATION_MS) + ?.jsonPrimitive + ?.intOrNull + ?.coerceAtLeast(0) + ?: SPOOF_CHECK_MIN_VALIDATION_UI_DURATION_MS_DEFAULT + + // TODO Temporary flags until configuration is added to FaceSdkConfiguration + val spoofMinValidationErrorUiDurationMs: Int + get() = customConfig + ?.get(SPOOF_CHECK_MIN_VALIDATION_ERROR_UI_DURATION_MS) + ?.jsonPrimitive + ?.intOrNull + ?.coerceAtLeast(0) + ?: SPOOF_CHECK_MIN_VALIDATION_ERROR_UI_DURATION_MS_DEFAULT + companion object { internal const val DISABLE_SUBJECT_POOL_VALIDATION = "disableSubjectPoolValidation" internal const val FACE_AUTO_CAPTURE_ENABLED = "faceAutoCaptureEnabled" @@ -223,5 +280,22 @@ data class ExperimentalProjectConfiguration( const val MINIMUM_FREE_SPACE_MB_DEFAULT = 1024 // 1GB const val USE_BALANCED_LOCATION_ACCURACY = "useBalancedLocationAccuracy" + + const val SPOOF_CHECK_MODE = "spoofCheckMode" + const val SPOOF_CHECK_THRESHOLD = "spoofCheckThreshold" + + const val SPOOF_CHECK_THRESHOLD_DEFAULT = 0.33f + + const val SPOOF_CHECK_MAX_ATTEMPTS = "spoofMaxAttempts" + const val SPOOF_CHECK_MAX_ATTEMPTS_DEFAULT = 2 + + const val SPOOF_CHECK_MAX_BITMAP_SIZE = "spoofMaxBitmapSize" + const val SPOOF_CHECK_MAX_BITMAP_SIZE_MINIMUM = 720 + const val SPOOF_CHECK_MAX_BITMAP_SIZE_DEFAULT = 1500 + + const val SPOOF_CHECK_MIN_VALIDATION_UI_DURATION_MS = "spoofMinValidationUiDurationMs" + const val SPOOF_CHECK_MIN_VALIDATION_UI_DURATION_MS_DEFAULT = 2000 + const val SPOOF_CHECK_MIN_VALIDATION_ERROR_UI_DURATION_MS = "spoofMinValidationErrorUiDurationMs" + const val SPOOF_CHECK_MIN_VALIDATION_ERROR_UI_DURATION_MS_DEFAULT = 2000 } } diff --git a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/FaceConfiguration.kt b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/FaceConfiguration.kt index 26c811c2c6..a63e68c52c 100644 --- a/infra/config-store/src/main/java/com/simprints/infra/config/store/models/FaceConfiguration.kt +++ b/infra/config-store/src/main/java/com/simprints/infra/config/store/models/FaceConfiguration.kt @@ -31,4 +31,41 @@ data class FaceConfiguration( fun shouldSaveImage() = this != NEVER } + + // TODO Will be a field in FaceSdkConfiguration later + data class SpoofCheckConfiguration( + val mode: SpoofCheckMode, + val threshold: Float, + val maxAttempts: Int, + val maxBitmapSize: Int, + val validationUiDurationMs: Int, + val validationErrorUiDurationMs: Int, + ) { + companion object { + val DISABLED = SpoofCheckConfiguration( + mode = SpoofCheckMode.DISABLED, + threshold = 0f, + maxAttempts = 0, + maxBitmapSize = 0, + validationUiDurationMs = 0, + validationErrorUiDurationMs = 0, + ) + } + } + + enum class SpoofCheckMode { + DISABLED, + ENFORCED, + RECORDED, + ; + + companion object { + // TODO Later it will be enforced by API. + fun fromString(mode: String): SpoofCheckMode = when (mode.uppercase()) { + "ENFORCED" -> ENFORCED + "RECORDED" -> RECORDED + else -> DISABLED + } + } + } } diff --git a/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ExperimentalProjectConfigurationTest.kt b/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ExperimentalProjectConfigurationTest.kt index 5897b59ec6..074447595b 100644 --- a/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ExperimentalProjectConfigurationTest.kt +++ b/infra/config-store/src/test/java/com/simprints/infra/config/store/models/ExperimentalProjectConfigurationTest.kt @@ -38,6 +38,7 @@ import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration. import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.RECORDS_DB_MIGRATION_FROM_REALM_TO_ROOM_DEFAULT_MAX_RETRIES import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.RECORDS_DB_MIGRATION_FROM_REALM_TO_ROOM_ENABLED import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration.Companion.RECORDS_DB_MIGRATION_FROM_REALM_TO_ROOM_MAX_RETRIES +import com.simprints.infra.config.store.models.FaceConfiguration.SpoofCheckMode import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive import org.junit.Test @@ -348,4 +349,82 @@ internal class ExperimentalProjectConfigurationTest { assertThat(ExperimentalProjectConfiguration(config).useBalancedLocationAccuracy).isEqualTo(result) } } + + @Test + fun `spoof check mode value parsed correctly`() { + mapOf( + emptyMap() to SpoofCheckMode.DISABLED, + mapOf(ExperimentalProjectConfiguration.SPOOF_CHECK_MODE to JsonPrimitive(null)) to SpoofCheckMode.DISABLED, + mapOf(ExperimentalProjectConfiguration.SPOOF_CHECK_MODE to JsonPrimitive("string")) to SpoofCheckMode.DISABLED, + mapOf(ExperimentalProjectConfiguration.SPOOF_CHECK_MODE to JsonPrimitive("ENFORCED")) to SpoofCheckMode.ENFORCED, + mapOf(ExperimentalProjectConfiguration.SPOOF_CHECK_MODE to JsonPrimitive("RECORDED")) to SpoofCheckMode.RECORDED, + ).forEach { (config, result) -> + assertThat(ExperimentalProjectConfiguration(config).spoofCheckMode).isEqualTo(result) + } + } + + @Test + fun `spoof check threshold value parsed correctly`() { + mapOf( + emptyMap() to 0.33f, + mapOf(ExperimentalProjectConfiguration.SPOOF_CHECK_THRESHOLD to JsonPrimitive(null)) to 0.33f, + mapOf(ExperimentalProjectConfiguration.SPOOF_CHECK_THRESHOLD to JsonPrimitive("string")) to 0.33f, + mapOf(ExperimentalProjectConfiguration.SPOOF_CHECK_THRESHOLD to JsonPrimitive(1)) to 1f, + mapOf(ExperimentalProjectConfiguration.SPOOF_CHECK_THRESHOLD to JsonPrimitive(0f)) to 0f, + ).forEach { (config, result) -> + assertThat(ExperimentalProjectConfiguration(config).spoofCheckThreshold).isEqualTo(result) + } + } + + @Test + fun `spoof check max attempts value parsed correctly`() { + mapOf( + emptyMap() to 2, + mapOf(ExperimentalProjectConfiguration.SPOOF_CHECK_MAX_ATTEMPTS to JsonPrimitive(null)) to 2, + mapOf(ExperimentalProjectConfiguration.SPOOF_CHECK_MAX_ATTEMPTS to JsonPrimitive("string")) to 2, + mapOf(ExperimentalProjectConfiguration.SPOOF_CHECK_MAX_ATTEMPTS to JsonPrimitive(1)) to 1, + mapOf(ExperimentalProjectConfiguration.SPOOF_CHECK_MAX_ATTEMPTS to JsonPrimitive(0f)) to 2, + ).forEach { (config, result) -> + assertThat(ExperimentalProjectConfiguration(config).spoofMaxAttempts).isEqualTo(result) + } + } + + @Test + fun `spoof check max bitmap size value parsed correctly`() { + mapOf( + emptyMap() to 1500, + mapOf(ExperimentalProjectConfiguration.SPOOF_CHECK_MAX_BITMAP_SIZE to JsonPrimitive(null)) to 1500, + mapOf(ExperimentalProjectConfiguration.SPOOF_CHECK_MAX_BITMAP_SIZE to JsonPrimitive("string")) to 1500, + mapOf(ExperimentalProjectConfiguration.SPOOF_CHECK_MAX_BITMAP_SIZE to JsonPrimitive(100)) to 720, + mapOf(ExperimentalProjectConfiguration.SPOOF_CHECK_MAX_BITMAP_SIZE to JsonPrimitive(0f)) to 1500, + ).forEach { (config, result) -> + assertThat(ExperimentalProjectConfiguration(config).spoofMaxBitmapSize).isEqualTo(result) + } + } + + @Test + fun `spoof check spoof ui duration parsed correctly`() { + mapOf( + emptyMap() to 2000, + mapOf(ExperimentalProjectConfiguration.SPOOF_CHECK_MIN_VALIDATION_UI_DURATION_MS to JsonPrimitive(null)) to 2000, + mapOf(ExperimentalProjectConfiguration.SPOOF_CHECK_MIN_VALIDATION_UI_DURATION_MS to JsonPrimitive("string")) to 2000, + mapOf(ExperimentalProjectConfiguration.SPOOF_CHECK_MIN_VALIDATION_UI_DURATION_MS to JsonPrimitive(100)) to 100, + mapOf(ExperimentalProjectConfiguration.SPOOF_CHECK_MIN_VALIDATION_UI_DURATION_MS to JsonPrimitive(0f)) to 2000, + ).forEach { (config, result) -> + assertThat(ExperimentalProjectConfiguration(config).spoofMinValidationUiDurationMs).isEqualTo(result) + } + } + + @Test + fun `spoof check spoof error ui duration parsed correctly`() { + mapOf( + emptyMap() to 2000, + mapOf(ExperimentalProjectConfiguration.SPOOF_CHECK_MIN_VALIDATION_ERROR_UI_DURATION_MS to JsonPrimitive(null)) to 2000, + mapOf(ExperimentalProjectConfiguration.SPOOF_CHECK_MIN_VALIDATION_ERROR_UI_DURATION_MS to JsonPrimitive("string")) to 2000, + mapOf(ExperimentalProjectConfiguration.SPOOF_CHECK_MIN_VALIDATION_ERROR_UI_DURATION_MS to JsonPrimitive(100)) to 100, + mapOf(ExperimentalProjectConfiguration.SPOOF_CHECK_MIN_VALIDATION_ERROR_UI_DURATION_MS to JsonPrimitive(0f)) to 2000, + ).forEach { (config, result) -> + assertThat(ExperimentalProjectConfiguration(config).spoofMinValidationErrorUiDurationMs).isEqualTo(result) + } + } } diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiFaceCapturePayload.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiFaceCapturePayload.kt index 28bea35423..36cc81328d 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiFaceCapturePayload.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiFaceCapturePayload.kt @@ -3,11 +3,13 @@ package com.simprints.infra.eventsync.event.remote.models import androidx.annotation.Keep import com.simprints.infra.config.store.models.TokenKeyType import com.simprints.infra.events.event.domain.models.FaceCaptureEvent.FaceCapturePayload +import com.simprints.infra.events.event.domain.models.FaceCaptureEvent.FaceCapturePayload.SpoofSkipReason import com.simprints.infra.eventsync.event.remote.models.ApiFaceCapturePayload.ApiFace import com.simprints.infra.eventsync.event.remote.models.ApiFaceCapturePayload.ApiResult.BAD_QUALITY import com.simprints.infra.eventsync.event.remote.models.ApiFaceCapturePayload.ApiResult.INVALID import com.simprints.infra.eventsync.event.remote.models.ApiFaceCapturePayload.ApiResult.OFF_ROLL import com.simprints.infra.eventsync.event.remote.models.ApiFaceCapturePayload.ApiResult.OFF_YAW +import com.simprints.infra.eventsync.event.remote.models.ApiFaceCapturePayload.ApiResult.SPOOFED import com.simprints.infra.eventsync.event.remote.models.ApiFaceCapturePayload.ApiResult.TOO_CLOSE import com.simprints.infra.eventsync.event.remote.models.ApiFaceCapturePayload.ApiResult.TOO_FAR import com.simprints.infra.eventsync.event.remote.models.ApiFaceCapturePayload.ApiResult.VALID @@ -43,6 +45,8 @@ internal data class ApiFaceCapturePayload( var roll: Float, val quality: Float, val format: String, + val spoofScore: Float? = null, + val spoofSkipReason: ApiSpoofSkipReason? = null, ) @Keep @@ -55,12 +59,28 @@ internal data class ApiFaceCapturePayload( OFF_ROLL, TOO_CLOSE, TOO_FAR, + SPOOFED, + } + + @Keep + @Serializable + enum class ApiSpoofSkipReason { + IMAGE_TOO_SMALL, + IOD_TOO_SMALL, + IOD_TOO_LARGE, } override fun getTokenizedFieldJsonPath(tokenKeyType: TokenKeyType): String? = null // this payload doesn't have tokenizable fields } -internal fun FaceCapturePayload.Face.fromDomainToApi() = ApiFace(yaw, roll, quality, format) +internal fun FaceCapturePayload.Face.fromDomainToApi() = ApiFace( + yaw = yaw, + roll = roll, + quality = quality, + format = format, + spoofScore = spoofScore, + spoofSkipReason = spoofSkipReason?.fromDomainToApi(), +) internal fun FaceCapturePayload.Result.fromDomainToApi() = when (this) { FaceCapturePayload.Result.VALID -> VALID @@ -70,4 +90,11 @@ internal fun FaceCapturePayload.Result.fromDomainToApi() = when (this) { FaceCapturePayload.Result.OFF_ROLL -> OFF_ROLL FaceCapturePayload.Result.TOO_CLOSE -> TOO_CLOSE FaceCapturePayload.Result.TOO_FAR -> TOO_FAR + FaceCapturePayload.Result.SPOOFED -> SPOOFED +} + +internal fun SpoofSkipReason.fromDomainToApi() = when (this) { + SpoofSkipReason.IMAGE_TOO_SMALL -> ApiFaceCapturePayload.ApiSpoofSkipReason.IMAGE_TOO_SMALL + SpoofSkipReason.IOD_TOO_SMALL -> ApiFaceCapturePayload.ApiSpoofSkipReason.IOD_TOO_SMALL + SpoofSkipReason.IOD_TOO_LARGE -> ApiFaceCapturePayload.ApiSpoofSkipReason.IOD_TOO_LARGE } diff --git a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/EventValidationUtils.kt b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/EventValidationUtils.kt index 895b483eba..5bd8990dce 100644 --- a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/EventValidationUtils.kt +++ b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/EventValidationUtils.kt @@ -653,7 +653,9 @@ fun validateFaceCaptureEventApiModel(json: JSONObject) { assertThat(getDouble("roll")).isNotNull() assertThat(getDouble("quality")).isNotNull() assertThat(getString("format")).isIn(listOf("RANK_ONE_1_23")) - assertThat(length()).isEqualTo(4) + assertThat(getDouble("spoofScore")).isNotNull() + assertThat(getString("spoofSkipReason")).isNotNull() + assertThat(length()).isEqualTo(6) } assertThat(length()).isEqualTo(8) diff --git a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/remote/models/face/ApiFaceCapturePayloadTest.kt b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/remote/models/face/ApiFaceCapturePayloadTest.kt index e5ee640081..637af19db7 100644 --- a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/remote/models/face/ApiFaceCapturePayloadTest.kt +++ b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/remote/models/face/ApiFaceCapturePayloadTest.kt @@ -1,6 +1,6 @@ package com.simprints.infra.eventsync.event.remote.models.face -import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.* import com.simprints.core.tools.utils.randomUUID import com.simprints.infra.events.event.domain.models.FaceCaptureEvent import com.simprints.infra.events.sampledata.FACE_TEMPLATE_FORMAT @@ -17,6 +17,7 @@ class ApiFaceCapturePayloadTest { roll = 2.3f, quality = 12f, format = FACE_TEMPLATE_FORMAT, + spoofScore = 0.5f, ) val payload = ApiFaceCapturePayload( id = randomUUID(), @@ -41,52 +42,25 @@ class ApiFaceCapturePayloadTest { } @Test - fun `should map VALID correctly`() { - val result = FaceCaptureEvent.FaceCapturePayload.Result.VALID - .fromDomainToApi() - assertThat(result).isInstanceOf(ApiFaceCapturePayload.ApiResult.VALID::class.java) + fun `should map all face capture results correctly`() { + mapOf( + FaceCaptureEvent.FaceCapturePayload.Result.VALID to ApiFaceCapturePayload.ApiResult.VALID, + FaceCaptureEvent.FaceCapturePayload.Result.INVALID to ApiFaceCapturePayload.ApiResult.INVALID, + FaceCaptureEvent.FaceCapturePayload.Result.OFF_YAW to ApiFaceCapturePayload.ApiResult.OFF_YAW, + FaceCaptureEvent.FaceCapturePayload.Result.OFF_ROLL to ApiFaceCapturePayload.ApiResult.OFF_ROLL, + FaceCaptureEvent.FaceCapturePayload.Result.TOO_CLOSE to ApiFaceCapturePayload.ApiResult.TOO_CLOSE, + FaceCaptureEvent.FaceCapturePayload.Result.TOO_FAR to ApiFaceCapturePayload.ApiResult.TOO_FAR, + FaceCaptureEvent.FaceCapturePayload.Result.BAD_QUALITY to ApiFaceCapturePayload.ApiResult.BAD_QUALITY, + ).forEach { (mappedResult, expected) -> assertThat(mappedResult.fromDomainToApi()).isEqualTo(expected) } } @Test - fun `should map INVALID correctly`() { - val result = FaceCaptureEvent.FaceCapturePayload.Result.INVALID - .fromDomainToApi() - assertThat(result).isInstanceOf(ApiFaceCapturePayload.ApiResult.INVALID::class.java) - } - - @Test - fun `should map OFF YAW correctly`() { - val result = FaceCaptureEvent.FaceCapturePayload.Result.OFF_YAW - .fromDomainToApi() - assertThat(result).isInstanceOf(ApiFaceCapturePayload.ApiResult.OFF_YAW::class.java) - } - - @Test - fun `should map OFF ROLL correctly`() { - val result = FaceCaptureEvent.FaceCapturePayload.Result.OFF_ROLL - .fromDomainToApi() - assertThat(result).isInstanceOf(ApiFaceCapturePayload.ApiResult.OFF_ROLL::class.java) - } - - @Test - fun `should map TOO CLOSE correctly`() { - val result = FaceCaptureEvent.FaceCapturePayload.Result.TOO_CLOSE - .fromDomainToApi() - assertThat(result).isInstanceOf(ApiFaceCapturePayload.ApiResult.TOO_CLOSE::class.java) - } - - @Test - fun `should map TOO FAR correctly`() { - val result = FaceCaptureEvent.FaceCapturePayload.Result.TOO_FAR - .fromDomainToApi() - assertThat(result).isInstanceOf(ApiFaceCapturePayload.ApiResult.TOO_FAR::class.java) - } - - @Test - fun `should map BAD QUALITY correctly`() { - val result = FaceCaptureEvent.FaceCapturePayload.Result.BAD_QUALITY - .fromDomainToApi() - assertThat(result).isInstanceOf(ApiFaceCapturePayload.ApiResult.BAD_QUALITY::class.java) + fun `should map all face spoof skip reasons correctly`() { + mapOf( + FaceCaptureEvent.FaceCapturePayload.SpoofSkipReason.IMAGE_TOO_SMALL to ApiFaceCapturePayload.ApiSpoofSkipReason.IMAGE_TOO_SMALL, + FaceCaptureEvent.FaceCapturePayload.SpoofSkipReason.IOD_TOO_LARGE to ApiFaceCapturePayload.ApiSpoofSkipReason.IOD_TOO_LARGE, + FaceCaptureEvent.FaceCapturePayload.SpoofSkipReason.IOD_TOO_SMALL to ApiFaceCapturePayload.ApiSpoofSkipReason.IOD_TOO_SMALL, + ).forEach { (mappedResult, expected) -> assertThat(mappedResult.fromDomainToApi()).isEqualTo(expected) } } @Test @@ -97,11 +71,15 @@ class ApiFaceCapturePayloadTest { roll = 1.0f, quality = 3.0f, format = FACE_TEMPLATE_FORMAT, + spoofScore = 0.5f, + spoofSkipReason = FaceCaptureEvent.FaceCapturePayload.SpoofSkipReason.IOD_TOO_SMALL, ).fromDomainToApi() assertThat(face).isInstanceOf(ApiFaceCapturePayload.ApiFace::class.java) assertThat(face.format).isEqualTo(FACE_TEMPLATE_FORMAT) assertThat(face.yaw).isEqualTo(2.0f) assertThat(face.roll).isEqualTo(1.0f) assertThat(face.quality).isEqualTo(3.0f) + assertThat(face.spoofScore).isEqualTo(0.5f) + assertThat(face.spoofSkipReason).isEqualTo(ApiFaceCapturePayload.ApiSpoofSkipReason.IOD_TOO_SMALL) } } diff --git a/infra/events/src/debug/java/com/simprints/infra/events/sampledata/EventFactoryUtils.kt b/infra/events/src/debug/java/com/simprints/infra/events/sampledata/EventFactoryUtils.kt index b23f6b957c..8c9c225f8e 100644 --- a/infra/events/src/debug/java/com/simprints/infra/events/sampledata/EventFactoryUtils.kt +++ b/infra/events/src/debug/java/com/simprints/infra/events/sampledata/EventFactoryUtils.kt @@ -313,7 +313,14 @@ fun createFaceCaptureEvent() = FaceCaptureEvent( result = FaceCaptureEvent.FaceCapturePayload.Result.VALID, isAutoCapture = false, isFallback = true, - face = FaceCaptureEvent.FaceCapturePayload.Face(0F, 1F, 2F, FACE_TEMPLATE_FORMAT), + face = FaceCaptureEvent.FaceCapturePayload.Face( + yaw = 0F, + roll = 1F, + quality = 2F, + format = FACE_TEMPLATE_FORMAT, + spoofScore = 0.5f, + spoofSkipReason = FaceCaptureEvent.FaceCapturePayload.SpoofSkipReason.IMAGE_TOO_SMALL, + ), ) fun createFaceFallbackCaptureEvent() = FaceFallbackCaptureEvent( 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 2148a93946..7ed84732cd 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 @@ -64,7 +64,7 @@ data class FaceCaptureEvent( ) : EventPayload() { override fun toSafeString(): String = "result: $result, attempt nr: $attemptNb, auto-capture: $isAutoCapture, fallback: $isFallback, " + - "quality: ${face?.quality}, format: ${face?.format}" + "quality: ${face?.quality}, format: ${face?.format}, spoof score: ${face?.spoofScore}" @Keep @Serializable @@ -73,6 +73,8 @@ data class FaceCaptureEvent( var roll: Float, val quality: Float, val format: String, + val spoofScore: Float? = null, + val spoofSkipReason: SpoofSkipReason? = null, ) @Keep @@ -85,6 +87,15 @@ data class FaceCaptureEvent( OFF_ROLL, TOO_CLOSE, TOO_FAR, + SPOOFED, + } + + @Keep + @Serializable + enum class SpoofSkipReason { + IMAGE_TOO_SMALL, + IOD_TOO_SMALL, + IOD_TOO_LARGE, } } diff --git a/infra/events/src/test/java/com/simprints/infra/events/event/domain/models/face/FaceCaptureEventTest.kt b/infra/events/src/test/java/com/simprints/infra/events/event/domain/models/face/FaceCaptureEventTest.kt index f25848517f..928b15462c 100644 --- a/infra/events/src/test/java/com/simprints/infra/events/event/domain/models/face/FaceCaptureEventTest.kt +++ b/infra/events/src/test/java/com/simprints/infra/events/event/domain/models/face/FaceCaptureEventTest.kt @@ -1,7 +1,7 @@ package com.simprints.infra.events.event.domain.models.face import androidx.annotation.Keep -import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.* import com.simprints.infra.events.event.domain.models.EventType import com.simprints.infra.events.event.domain.models.FaceCaptureEvent import com.simprints.infra.events.sampledata.FACE_TEMPLATE_FORMAT @@ -12,16 +12,23 @@ import org.junit.Test class FaceCaptureEventTest { @Test fun create_FaceCaptureEvent() { - val faceArg = FaceCaptureEvent.FaceCapturePayload.Face(0F, 1F, 2F, FACE_TEMPLATE_FORMAT) + val faceArg = FaceCaptureEvent.FaceCapturePayload.Face( + yaw = 0F, + roll = 1F, + quality = 2F, + format = FACE_TEMPLATE_FORMAT, + spoofScore = 0.5f, + spoofSkipReason = FaceCaptureEvent.FaceCapturePayload.SpoofSkipReason.IMAGE_TOO_SMALL, + ) val event = FaceCaptureEvent( - SampleDefaults.CREATED_AT, - SampleDefaults.ENDED_AT, - 0, - 1F, - FaceCaptureEvent.FaceCapturePayload.Result.VALID, - false, - true, - faceArg, + startTime = SampleDefaults.CREATED_AT, + endTime = SampleDefaults.ENDED_AT, + attemptNb = 0, + qualityThreshold = 1F, + result = FaceCaptureEvent.FaceCapturePayload.Result.VALID, + isAutoCapture = false, + isFallback = true, + face = faceArg, ) assertThat(event.id).isNotNull() diff --git a/infra/resources/src/main/res/values/strings.xml b/infra/resources/src/main/res/values/strings.xml index ed545595d7..b978d9fedf 100644 --- a/infra/resources/src/main/res/values/strings.xml +++ b/infra/resources/src/main/res/values/strings.xml @@ -190,6 +190,9 @@ Move back Not looking straight Make sure the person is looking straight at the camera + Verifying your scan + Unable to verify + Please recapture and ensure a real person is in front of the camera Tap to capture Capturing… Please grant the camera permission