From ef7442dec0e3275f74a763e095c3fe8a958f8dd6 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Wed, 1 Jul 2026 10:28:24 +0300 Subject: [PATCH 01/13] MS-1491 Add temporary experimental config --- .../ExperimentalProjectConfiguration.kt | 24 +++++++++++++++ .../config/store/models/FaceConfiguration.kt | 29 +++++++++++++++++++ .../ExperimentalProjectConfigurationTest.kt | 27 +++++++++++++++++ 3 files changed, 80 insertions(+) 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..118b247654 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,23 @@ 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 + ?: SPOOF_CHECK_THRESHOLD_DEFAULT + companion object { internal const val DISABLE_SUBJECT_POOL_VALIDATION = "disableSubjectPoolValidation" internal const val FACE_AUTO_CAPTURE_ENABLED = "faceAutoCaptureEnabled" @@ -223,5 +243,9 @@ 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 } } 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..78508912f0 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,33 @@ data class FaceConfiguration( fun shouldSaveImage() = this != NEVER } + + // TODO Will be a field in FaceSdkConfiguration later + data class SpoofCheckConfiguration( + val mode: SpoofCheckMode, + val threshold: Float, + ) { + companion object { + val DISABLED = SpoofCheckConfiguration( + mode = SpoofCheckMode.DISABLED, + threshold = 0f, + ) + } + } + + 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..139e062be6 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,30 @@ 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) + } + } } From 837b86a48f60e77eea1c0750136ae0cbdc878a5d Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Wed, 1 Jul 2026 11:47:08 +0300 Subject: [PATCH 02/13] MS-1491 Add spoof configuration to LiveFeedbackFragment --- .../LiveFeedbackFragmentViewModel.kt | 5 ++ .../GetSpoofCheckConfigurationUseCase.kt | 34 ++++++++ ...eedbackAutoCaptureFragmentViewModelTest.kt | 6 ++ .../LiveFeedbackFragmentViewModelTest.kt | 8 ++ .../GetSpoofCheckConfigurationUseCaseTest.kt | 80 +++++++++++++++++++ 5 files changed, 133 insertions(+) create mode 100644 face/capture/src/main/java/com/simprints/face/capture/usecases/GetSpoofCheckConfigurationUseCase.kt create mode 100644 face/capture/src/test/java/com/simprints/face/capture/usecases/GetSpoofCheckConfigurationUseCaseTest.kt 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..121e690f7b 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 @@ -9,6 +9,7 @@ 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,6 +17,7 @@ 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.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 @@ -37,6 +39,7 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( private val eventReporter: SimpleCaptureEventReporter, private val timeHelper: TimeHelper, private val isUsingAutoCaptureUseCase: IsUsingAutoCaptureUseCase, + private val getSpoofCheckConfiguration: GetSpoofCheckConfigurationUseCase, ) : ViewModel() { private var attemptNumber: Int = 1 private var samplesToCapture: Int = 1 @@ -57,6 +60,7 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( val capturingState = MutableLiveData(CapturingState.NOT_STARTED) var isAutoCapture: Boolean = false + var spoofCheckConfig: SpoofCheckConfiguration = SpoofCheckConfiguration.DISABLED private var captureImagingStartTime: Long = 0 private var isAutoCaptureHeldOff = true @@ -81,6 +85,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 } 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..5606cd7493 --- /dev/null +++ b/face/capture/src/main/java/com/simprints/face/capture/usecases/GetSpoofCheckConfigurationUseCase.kt @@ -0,0 +1,34 @@ +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, + ) + } + } +} 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..9d1dcdf8b7 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 @@ -8,12 +8,14 @@ import com.google.common.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.biosdkresolver.ResolveFaceBioSdkUseCase import com.simprints.infra.config.store.ConfigRepository +import com.simprints.infra.config.store.models.FaceConfiguration import com.simprints.infra.config.store.models.ModalitySdkType import com.simprints.testtools.common.coroutines.TestCoroutineRule import com.simprints.testtools.common.livedata.testObserver @@ -59,6 +61,8 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { @MockK private lateinit var isUsingAutoCapture: IsUsingAutoCaptureUseCase + @MockK + private lateinit var getSpoofCheckConfiguration: GetSpoofCheckConfigurationUseCase private lateinit var viewModel: LiveFeedbackFragmentViewModel @Before @@ -73,6 +77,7 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { ?.qualityThreshold } returns QUALITY_THRESHOLD every { isUsingAutoCapture.invoke(any()) } returns true + every { getSpoofCheckConfiguration.invoke(any(), any()) } returns FaceConfiguration.SpoofCheckConfiguration.DISABLED coEvery { configRepository.getProjectConfiguration().custom } returns mapOf("singleQualityFallbackRequired" to JsonPrimitive(false)) @@ -90,6 +95,7 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { eventReporter, timeHelper, isUsingAutoCapture, + getSpoofCheckConfiguration, ) } 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..b7b4686c8c 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,12 +8,14 @@ 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.biosdkresolver.ResolveFaceBioSdkUseCase import com.simprints.infra.config.store.ConfigRepository +import com.simprints.infra.config.store.models.FaceConfiguration import com.simprints.infra.config.store.models.ModalitySdkType import com.simprints.testtools.common.coroutines.TestCoroutineRule import com.simprints.testtools.common.livedata.testObserver @@ -54,6 +56,9 @@ internal class LiveFeedbackFragmentViewModelTest { @MockK private lateinit var isUsingAutoCapture: IsUsingAutoCaptureUseCase + + @MockK + private lateinit var getSpoofCheckConfiguration: GetSpoofCheckConfigurationUseCase private lateinit var viewModel: LiveFeedbackFragmentViewModel @Before @@ -68,6 +73,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 +89,7 @@ internal class LiveFeedbackFragmentViewModelTest { eventReporter, timeHelper, isUsingAutoCapture, + getSpoofCheckConfiguration, ) } 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..a92e3631c2 --- /dev/null +++ b/face/capture/src/test/java/com/simprints/face/capture/usecases/GetSpoofCheckConfigurationUseCaseTest.kt @@ -0,0 +1,80 @@ +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), + ) + + val result = useCase(projectConfiguration, ModalitySdkType.RANK_ONE) + + assertThat(result.mode).isEqualTo(FaceConfiguration.SpoofCheckMode.ENFORCED) + assertThat(result.threshold).isEqualTo(0.75f) + } +} From dd14e55b0e478d8b3d150ad3ed5451badb8918eb Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Thu, 2 Jul 2026 15:39:14 +0300 Subject: [PATCH 03/13] MS-1491 Add spoof check to face detector API --- .../basebiosdk/detection/FaceDetector.kt | 10 +- .../basebiosdk/detection/SpoofCheckResult.kt | 13 +++ .../infra/rocv1/detection/RocV1Detector.kt | 3 + .../infra/rocv3/detection/RocV3Detector.kt | 109 ++++++++++++++++++ .../simface/detection/SimFaceDetector.kt | 3 + 5 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 face/infra/base-bio-sdk/src/main/java/com/simprints/face/infra/basebiosdk/detection/SpoofCheckResult.kt 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..e7f3f606f5 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,12 @@ 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): 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..1dc9344f24 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,8 @@ class RocV1Detector @Inject constructor() : FaceDetector { } } + override fun spoofCheck(bitmap: Bitmap) = 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..0499620072 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 @@ -10,9 +10,11 @@ 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 java.nio.ByteBuffer import javax.inject.Inject import javax.inject.Singleton +import kotlin.math.abs import ai.roc.rocsdk.embedded.roc as roc3 @ExcludedFromGeneratedTestCoverageReports( @@ -172,11 +174,118 @@ class RocV3Detector @Inject constructor() : FaceDetector { return byteBuffer } + // TODO verify results + override fun spoofCheck(bitmap: Bitmap): 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) + } + + val rocColorImage = roc_image() + val rocGrayImage = roc_image() + val byteBuffer = bitmap.toByteBuffer() + roc3.roc_from_rgba( + byteBuffer.array(), + bitmap.width.toLong(), + bitmap.height.toLong(), + bitmap.rowBytes.toLong(), + rocColorImage, + ) + roc3.roc_bgr2gray(rocColorImage, rocGrayImage) + + val detection = roc_detection() + val faceDetected = isFaceDetected(rocColorImage, detection) + + var iod = 0f + var finalScore = 0f + + val quality = roc3.new_float() // TODO remove later + + 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, + quality, + 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..9c44c9cecb 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,8 @@ class SimFaceDetector @Inject constructor( ) } + override fun spoofCheck(bitmap: Bitmap) = SpoofCheckResult(0f, SpoofCheckResult.SkipReason.NOT_AVAILABLE) + companion object { private const val BAD_FACE_THRESHOLD = 0.1 } From 7830eef38fffad0324c8c39f50d13695615d914d Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Thu, 2 Jul 2026 15:46:22 +0300 Subject: [PATCH 04/13] MS-1491 Pass original bitmap from cropper to image processor --- .../face/capture/models/FaceDetection.kt | 1 + .../CropToTargetOverlayAnalyzer.kt | 8 ++-- .../livefeedback/LiveFeedbackFragment.kt | 9 ++-- .../LiveFeedbackFragmentViewModel.kt | 13 +++-- .../CropToTargetOverlayAnalyzerTest.kt | 27 ++++++----- ...eedbackAutoCaptureFragmentViewModelTest.kt | 48 +++++++++---------- .../LiveFeedbackFragmentViewModelTest.kt | 30 ++++++------ .../SimpleCaptureEventReporterTest.kt | 20 +++++--- 8 files changed, 88 insertions(+), 68 deletions(-) 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..5eae1d030a 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 @@ -7,6 +7,7 @@ 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, 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..5092a10a8f 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,7 +37,7 @@ internal class CropToTargetOverlayAnalyzer( val cropTop = offsetY + (previewRect.top * scale).toInt() val cropHeight = (previewRect.height() * scale).toInt() - Bitmap.createBitmap( + image.toBitmap() to Bitmap.createBitmap( it.toBitmap(), cropLeft, cropTop, @@ -45,7 +45,7 @@ internal class CropToTargetOverlayAnalyzer( 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..ba8e494a4d 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 @@ -181,7 +181,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) @@ -274,9 +274,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 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 121e690f7b..8f5b3d2649 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 @@ -114,11 +114,14 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( * * @param croppedBitmap is the camera frame */ - fun process(croppedBitmap: Bitmap) { + fun process( + originalBitmap: Bitmap, + croppedBitmap: Bitmap, + ) { 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() @@ -209,11 +212,13 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( } private fun getFaceDetectionFromPotentialFace( + original: Bitmap, bitmap: Bitmap, potentialFace: Face?, ): FaceDetection = if (potentialFace == null) { bitmap.recycle() FaceDetection( + original = original, bitmap = bitmap, face = null, status = FaceDetection.Status.NOFACE, @@ -221,10 +226,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 { @@ -239,6 +245,7 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( } return FaceDetection( + original = original, bitmap = bitmap, face = potentialFace, status = status, 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 9d1dcdf8b7..5fe7f82ec6 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 @@ -107,7 +107,7 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { viewModel.initAutoCapture() viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) - viewModel.process(frame) + viewModel.process(frame, frame) Truth .assertThat(currentDetection.observedValues) @@ -127,7 +127,7 @@ 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) @@ -146,7 +146,7 @@ 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 @@ -163,9 +163,9 @@ 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 @@ -185,8 +185,8 @@ 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) @@ -208,9 +208,9 @@ 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) @@ -224,7 +224,7 @@ 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() @@ -254,12 +254,12 @@ 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) @@ -288,11 +288,11 @@ 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 @@ -313,7 +313,7 @@ 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()) @@ -339,7 +339,7 @@ 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()) @@ -361,10 +361,10 @@ 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) 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 b7b4686c8c..a37e609288 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 @@ -99,7 +99,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) @@ -113,9 +113,9 @@ 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) @@ -143,12 +143,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) @@ -175,9 +175,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) @@ -195,10 +195,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) 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..e9c652e35c 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,7 +1,7 @@ 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 @@ -16,11 +16,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 @@ -200,8 +197,17 @@ class SimpleCaptureEventReporterTest { } } - private fun getDetection(status: FaceDetection.Status) = - FaceDetection(mockk(), getFace(), status, mockk(), Timestamp(1L), false, "id", Timestamp(1L)) + private fun getDetection(status: FaceDetection.Status) = FaceDetection( + mockk(), + mockk(), + getFace(), + status, + mockk(), + Timestamp(1L), + false, + "id", + Timestamp(1L), + ) private fun getFace() = Face( 100, From b310b842979942891c18dc7ae5878dd62de741a2 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Thu, 2 Jul 2026 17:02:07 +0300 Subject: [PATCH 05/13] MS-1491 Add basic live data spoof checking and UI state handling --- .../face/capture/models/FaceDetection.kt | 2 + .../livefeedback/LiveFeedbackFragment.kt | 33 ++- .../LiveFeedbackFragmentViewModel.kt | 81 ++++++- ...eedbackAutoCaptureFragmentViewModelTest.kt | 219 +++++++++++++----- .../LiveFeedbackFragmentViewModelTest.kt | 116 ++++++++++ .../resources/src/main/res/values/strings.xml | 3 + 6 files changed, 386 insertions(+), 68 deletions(-) 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 5eae1d030a..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,6 +3,7 @@ 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 @@ -16,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/LiveFeedbackFragment.kt b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragment.kt index ba8e494a4d..cdbd0e6b39 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 @@ -263,6 +264,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( @@ -312,6 +315,8 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) } captureOverlay.drawSemiTransparentTarget() + captureFeedbackTxtExplanation.setTextColor(ContextCompat.getColor(requireContext(), IDR.color.simprints_text_white)) + captureFeedbackBtn.isVisible = true captureFeedbackPermissionButton.isGone = true setManualCaptureButtonClickable(false) @@ -321,9 +326,7 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) 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)) captureProgress.isVisible = true captureFeedbackBtn.setText(IDR.string.face_capture_prep_begin_button_capturing) @@ -333,6 +336,30 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) } } + private fun renderValidating() { + binding.apply { + captureOverlay.drawWhiteTarget() + captureFeedbackTxtExplanation.setTextColor(ContextCompat.getColor(requireContext(), IDR.color.simprints_blue_grey)) + + captureProgress.isInvisible = 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.isVisible = true + captureFeedbackTxtExplanation.setText(IDR.string.face_capture_error_validating_failed) + } + } + private fun renderValidFace() { binding.apply { if (vm.isAutoCapture) { 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 8f5b3d2649..29c9ae6fc2 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,6 +4,7 @@ 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 @@ -17,20 +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( @@ -40,6 +46,7 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( 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 @@ -61,6 +68,7 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( var isAutoCapture: Boolean = false var spoofCheckConfig: SpoofCheckConfiguration = SpoofCheckConfiguration.DISABLED + var spoofCheckCount: Int = 0 private var captureImagingStartTime: Long = 0 private var isAutoCaptureHeldOff = true @@ -118,6 +126,11 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( originalBitmap: Bitmap, croppedBitmap: Bitmap, ) { + if (capturingState.value == CapturingState.VALIDATING || capturingState.value == CapturingState.VALIDATION_FAILED) { + // Skip processing while spoof check is running + return + } + val captureStartTime = timeHelper.now() val potentialFace = faceDetector.analyze(croppedBitmap) @@ -200,15 +213,66 @@ 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() + + 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 + } + + Simber.i("Spoof check passed: $spoofCheckPassed (check count: $spoofCheckCount)", tag = FACE_CAPTURE) + // Only attempt up to MAX_SPOOF_CHECK_ATTEMPTS times to prevent hard-blocking user + if (spoofCheckCount >= MAX_SPOOF_CHECK_ATTEMPTS || 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) + sendCaptureEvents(attemptNumber) + capturingState.postValue(CapturingState.FINISHED) + } - capturingState.postValue(CapturingState.FINISHED) + private suspend fun runSpoofChecksOnCaptures() = withContext(bgDispatcher) { + capturingState.postValue(CapturingState.VALIDATING) + spoofCheckCount++ + + val duration = measureTimedValue { + for ((index, bitmap) in userCaptures.map { it.original }.withIndex()) { + userCaptures[index].spoofCheckResult = faceDetector.spoofCheck(bitmap) + } } + + // Show the UI for at least a moment for it to register with the user + val delay = maxOf(MIN_SPOOF_CHECK_UI_TIME_MS - 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) + delay(MIN_SPOOF_CHECK_UI_TIME_MS.milliseconds) // Allow for error message to be shown + + // Reset state + userCaptures.clear() + sortedQualifyingCaptures = emptyList() + fallbackCapture = null + capturingState.postValue(CapturingState.NOT_STARTED) } private fun getFaceDetectionFromPotentialFace( @@ -300,10 +364,13 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( eventReporter.addCaptureEvents(faceDetection, attemptNumber, qualityThreshold, 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 private const val VALID_YAW_DELTA = 30f + + private const val MIN_SPOOF_CHECK_UI_TIME_MS = 2000 + private const val MAX_SPOOF_CHECK_ATTEMPTS = 2 } } 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 5fe7f82ec6..f0bb09387e 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,7 +4,7 @@ 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 @@ -13,6 +13,7 @@ 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 @@ -23,6 +24,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 @@ -96,6 +98,7 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { timeHelper, isUsingAutoCapture, getSpoofCheckConfiguration, + testCoroutineRule.testCoroutineDispatcher, ) } @@ -109,11 +112,9 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) 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) } @@ -129,11 +130,9 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { viewModel.holdOffAutoCapture() 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) } @@ -148,9 +147,8 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { viewModel.startCapture() 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) } @@ -167,9 +165,8 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { viewModel.holdOffAutoCapture() 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) } @@ -188,17 +185,13 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { 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) } @@ -213,7 +206,7 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { 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()) } } @@ -228,7 +221,7 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { 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()) } } @@ -262,11 +255,11 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { 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()) } @@ -296,8 +289,8 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { 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) } } @@ -315,13 +308,11 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { viewModel.startCapture() 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) } @@ -341,16 +332,130 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { viewModel.startCapture() 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 + FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.RECORDED, 0.5f) + every { faceDetector.analyze(frame) } returns validFace + coEvery { faceDetector.spoofCheck(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 + FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f) + every { faceDetector.analyze(frame) } returns validFace + coEvery { faceDetector.spoofCheck(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 + FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f) + every { faceDetector.analyze(frame) } returns validFace + coEvery { faceDetector.spoofCheck(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 + FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f) + every { faceDetector.analyze(frame) } returns validFace + coEvery { faceDetector.spoofCheck(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 + FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f) + every { faceDetector.analyze(frame) } returns validFace + coEvery { faceDetector.spoofCheck(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() @@ -370,37 +475,35 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { 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()) } @@ -412,7 +515,7 @@ 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") 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 a37e609288..6740237253 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 @@ -13,6 +13,7 @@ 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 @@ -21,6 +22,7 @@ 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 @@ -90,6 +92,7 @@ internal class LiveFeedbackFragmentViewModelTest { timeHelper, isUsingAutoCapture, getSpoofCheckConfiguration, + testCoroutineRule.testCoroutineDispatcher, ) } @@ -235,6 +238,119 @@ internal class LiveFeedbackFragmentViewModelTest { coVerify(exactly = 3) { eventReporter.addCaptureEvents(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 FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.RECORDED, 0.5f) + every { faceDetector.analyze(frame) } returns validFace + coEvery { faceDetector.spoofCheck(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 + FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f) + every { faceDetector.analyze(frame) } returns validFace + coEvery { faceDetector.spoofCheck(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 FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f) + every { faceDetector.analyze(frame) } returns validFace + coEvery { faceDetector.spoofCheck(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 FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f) + every { faceDetector.analyze(frame) } returns validFace + coEvery { faceDetector.spoofCheck(any()) } returns SpoofCheckResult(score = 0.9f) + + val capturingStateObserver = viewModel.capturingState.testObserver() + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) + + // Attempt 1 + viewModel.process(frame, frame) + viewModel.startCapture() + viewModel.process(frame, frame) + + advanceUntilIdle() + + assertThat(capturingStateObserver.observedValues.last()).isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.NOT_STARTED) + + // Attempt 2 + viewModel.process(frame, frame) + viewModel.startCapture() + viewModel.process(frame, frame) + + advanceUntilIdle() + + assertThat(capturingStateObserver.observedValues.last()).isEqualTo(LiveFeedbackFragmentViewModel.CapturingState.FINISHED) + } + private fun getFace( rect: Rect = Rect(0, 0, 60, 60), quality: Float = 1f, 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 From 3811bb280fc76edd609a5fae0635291193a75285 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Mon, 6 Jul 2026 15:45:20 +0300 Subject: [PATCH 06/13] MS-1491 Add spoof check result tracking to the capture event --- .../LiveFeedbackFragmentViewModel.kt | 8 ++- .../usecases/SimpleCaptureEventReporter.kt | 34 +++++++--- .../SimpleCaptureEventReporterTest.kt | 43 ++++++++++++ .../remote/models/ApiFaceCapturePayload.kt | 26 +++++++- .../eventsync/event/EventValidationUtils.kt | 4 +- .../models/face/ApiFaceCapturePayloadTest.kt | 66 +++++++------------ .../events/sampledata/EventFactoryUtils.kt | 9 ++- .../event/domain/models/FaceCaptureEvent.kt | 12 +++- .../models/face/FaceCaptureEventTest.kt | 27 +++++--- 9 files changed, 161 insertions(+), 68 deletions(-) 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 29c9ae6fc2..74b4a65365 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 @@ -266,7 +266,13 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( private suspend fun showValidationErrorAndReset() { capturingState.postValue(CapturingState.VALIDATION_FAILED) - delay(MIN_SPOOF_CHECK_UI_TIME_MS.milliseconds) // Allow for error message to be shown + val duration = measureTimedValue { + // Still track the capture attempt events for analytics and troubleshooting + sendCaptureEvents(attemptNumber) + } + val delay = maxOf(MIN_SPOOF_CHECK_UI_TIME_MS - 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 userCaptures.clear() 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..6ea5c0ce52 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,7 @@ 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.events.event.domain.models.BiometricReferenceCreationEvent import com.simprints.infra.events.event.domain.models.FaceCaptureBiometricsEvent import com.simprints.infra.events.event.domain.models.FaceCaptureConfirmationEvent @@ -55,14 +56,14 @@ internal class SimpleCaptureEventReporter @Inject constructor( 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), isAutoCapture = isAutoCapture, - faceDetection.isFallback, - mapDetectionToCapturePayloadFace(faceDetection), + isFallback = faceDetection.isFallback, + face = mapDetectionToCapturePayloadFace(faceDetection = faceDetection), payloadId = faceDetection.id, ) @@ -89,8 +90,16 @@ internal class SimpleCaptureEventReporter @Inject constructor( FaceDetection.Status.TOOFAR -> FaceCapturePayload.Result.TOO_FAR } - 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 +111,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/usecases/SimpleCaptureEventReporterTest.kt b/face/capture/src/test/java/com/simprints/face/capture/usecases/SimpleCaptureEventReporterTest.kt index e9c652e35c..5caf13a94f 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 @@ -7,6 +7,8 @@ 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.events.event.domain.models.BiometricReferenceCreationEvent import com.simprints.infra.events.event.domain.models.BiometricReferenceCreationEvent.BiometricReferenceCreationPayload import com.simprints.infra.events.event.domain.models.FaceCaptureBiometricsEvent @@ -197,6 +199,47 @@ class SimpleCaptureEventReporterTest { } } + @Test + fun `Correctly maps spoof skip reasons`() = runTest { + reporter.addCaptureEvents(getSpoofedDetection(SkipReason.NOT_AVAILABLE), 1, 0.5f) + // to null, + reporter.addCaptureEvents(getSpoofedDetection(SkipReason.IOD_TOO_SMALL), 1, 0.5f) + // to FaceCaptureEvent.FaceCapturePayload.SpoofSkipReason.IMAGE_TOO_SMALL, + reporter.addCaptureEvents(getSpoofedDetection(SkipReason.IOD_TOO_LARGE), 1, 0.5f) + // to FaceCaptureEvent.FaceCapturePayload.SpoofSkipReason.IOD_TOO_LARGE, + reporter.addCaptureEvents(getSpoofedDetection(SkipReason.IMAGE_TOO_SMALL), 1, 0.5f) + // 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(), 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..ebf123f4b4 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,6 +3,7 @@ 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 @@ -43,6 +44,8 @@ internal data class ApiFaceCapturePayload( var roll: Float, val quality: Float, val format: String, + val spoofScore: Float? = null, + val spoofSkipReason: ApiSpoofSkipReason? = null, ) @Keep @@ -57,10 +60,25 @@ internal data class ApiFaceCapturePayload( TOO_FAR, } + @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 @@ -71,3 +89,9 @@ internal fun FaceCapturePayload.Result.fromDomainToApi() = when (this) { FaceCapturePayload.Result.TOO_CLOSE -> TOO_CLOSE FaceCapturePayload.Result.TOO_FAR -> TOO_FAR } + +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..a97062a559 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 @@ -86,6 +88,14 @@ data class FaceCaptureEvent( TOO_CLOSE, TOO_FAR, } + + @Keep + @Serializable + enum class SpoofSkipReason { + IMAGE_TOO_SMALL, + IOD_TOO_SMALL, + IOD_TOO_LARGE, + } } companion object { 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() From 298abef7ff6058a75cfe720b8eba723ff776e401 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Mon, 6 Jul 2026 16:18:05 +0300 Subject: [PATCH 07/13] MS-1491 Move the max attempt into spoof check configuration --- .../livefeedback/LiveFeedbackFragmentViewModel.kt | 5 ++--- .../usecases/GetSpoofCheckConfigurationUseCase.kt | 1 + .../LiveFeedbackAutoCaptureFragmentViewModelTest.kt | 10 +++++----- .../LiveFeedbackFragmentViewModelTest.kt | 8 ++++---- .../models/ExperimentalProjectConfiguration.kt | 12 ++++++++++++ .../infra/config/store/models/FaceConfiguration.kt | 2 ++ .../models/ExperimentalProjectConfigurationTest.kt | 13 +++++++++++++ 7 files changed, 39 insertions(+), 12 deletions(-) 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 74b4a65365..87d378a888 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 @@ -227,8 +227,8 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( } Simber.i("Spoof check passed: $spoofCheckPassed (check count: $spoofCheckCount)", tag = FACE_CAPTURE) - // Only attempt up to MAX_SPOOF_CHECK_ATTEMPTS times to prevent hard-blocking user - if (spoofCheckCount >= MAX_SPOOF_CHECK_ATTEMPTS || spoofCheckPassed) { + // Only attempt up to configured amount of times to prevent hard-blocking user + if (spoofCheckCount >= spoofCheckConfig.maxAttempts || spoofCheckPassed) { sendEventsAndFinish(attemptNumber) } else { showValidationErrorAndReset() @@ -377,6 +377,5 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( private const val VALID_YAW_DELTA = 30f private const val MIN_SPOOF_CHECK_UI_TIME_MS = 2000 - private const val MAX_SPOOF_CHECK_ATTEMPTS = 2 } } 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 index 5606cd7493..f1f4547b0b 100644 --- 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 @@ -28,6 +28,7 @@ class GetSpoofCheckConfigurationUseCase @Inject constructor( FaceConfiguration.SpoofCheckConfiguration( mode = it.spoofCheckMode, threshold = it.spoofCheckThreshold, + maxAttempts = it.spoofMaxAttempts, ) } } 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 f0bb09387e..bea996fdc4 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 @@ -344,7 +344,7 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { fun `Auto capture spoof check Recorded finishes regardless of score`() = runTest { val validFace = getFace() every { getSpoofCheckConfiguration.invoke(any(), any()) } returns - FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.RECORDED, 0.5f) + FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.RECORDED, 0.5f, 2) every { faceDetector.analyze(frame) } returns validFace coEvery { faceDetector.spoofCheck(any()) } returns SpoofCheckResult(score = 0.9f) @@ -367,7 +367,7 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { fun `Auto capture spoof check Enforced passed finishes capture`() = runTest { val validFace = getFace() every { getSpoofCheckConfiguration.invoke(any(), any()) } returns - FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f) + FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f, 2) every { faceDetector.analyze(frame) } returns validFace coEvery { faceDetector.spoofCheck(any()) } returns SpoofCheckResult(score = 0.1f) @@ -390,7 +390,7 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { fun `Auto capture spoof check Enforced failed resets state`() = runTest { val validFace = getFace() every { getSpoofCheckConfiguration.invoke(any(), any()) } returns - FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f) + FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f, 2) every { faceDetector.analyze(frame) } returns validFace coEvery { faceDetector.spoofCheck(any()) } returns SpoofCheckResult(score = 0.9f) @@ -413,7 +413,7 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { fun `Auto capture spoof check Enforced failed max attempts finishes capture`() = runTest { val validFace = getFace() every { getSpoofCheckConfiguration.invoke(any(), any()) } returns - FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f) + FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f, 2) every { faceDetector.analyze(frame) } returns validFace coEvery { faceDetector.spoofCheck(any()) } returns SpoofCheckResult(score = 0.9f) @@ -439,7 +439,7 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { fun `Skip frame processing while validating`() = runTest { val validFace = getFace() every { getSpoofCheckConfiguration.invoke(any(), any()) } returns - FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f) + FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f, 2) every { faceDetector.analyze(frame) } returns validFace coEvery { faceDetector.spoofCheck(any()) } answers { viewModel.process(frame, frame) 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 6740237253..cb4c5aa615 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 @@ -246,7 +246,7 @@ internal class LiveFeedbackFragmentViewModelTest { any(), any(), ) - } returns FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.RECORDED, 0.5f) + } returns FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.RECORDED, 0.5f, 2) every { faceDetector.analyze(frame) } returns validFace coEvery { faceDetector.spoofCheck(any()) } returns SpoofCheckResult(score = 0.9f) @@ -269,7 +269,7 @@ internal class LiveFeedbackFragmentViewModelTest { fun `Spoof check Enforced passed finishes capture`() = runTest { val validFace: Face = getFace() every { getSpoofCheckConfiguration.invoke(any(), any()) } returns - FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f) + FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f, 2) every { faceDetector.analyze(frame) } returns validFace coEvery { faceDetector.spoofCheck(any()) } returns SpoofCheckResult(score = 0.1f) @@ -296,7 +296,7 @@ internal class LiveFeedbackFragmentViewModelTest { any(), any(), ) - } returns FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f) + } returns FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f, 2) every { faceDetector.analyze(frame) } returns validFace coEvery { faceDetector.spoofCheck(any()) } returns SpoofCheckResult(score = 0.9f) @@ -324,7 +324,7 @@ internal class LiveFeedbackFragmentViewModelTest { any(), any(), ) - } returns FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f) + } returns FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f, 2) every { faceDetector.analyze(frame) } returns validFace coEvery { faceDetector.spoofCheck(any()) } returns SpoofCheckResult(score = 0.9f) 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 118b247654..f118167ed9 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 @@ -185,6 +185,14 @@ data class ExperimentalProjectConfiguration( ?.floatOrNull ?: 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 + ?: SPOOF_CHECK_MAX_ATTEMPTS_DEFAULT + companion object { internal const val DISABLE_SUBJECT_POOL_VALIDATION = "disableSubjectPoolValidation" internal const val FACE_AUTO_CAPTURE_ENABLED = "faceAutoCaptureEnabled" @@ -246,6 +254,10 @@ data class ExperimentalProjectConfiguration( 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 } } 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 78508912f0..57af8f3fec 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 @@ -36,11 +36,13 @@ data class FaceConfiguration( data class SpoofCheckConfiguration( val mode: SpoofCheckMode, val threshold: Float, + val maxAttempts: Int, ) { companion object { val DISABLED = SpoofCheckConfiguration( mode = SpoofCheckMode.DISABLED, threshold = 0f, + maxAttempts = 0, ) } } 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 139e062be6..227a3bccbd 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 @@ -375,4 +375,17 @@ internal class ExperimentalProjectConfigurationTest { 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 0, + ).forEach { (config, result) -> + assertThat(ExperimentalProjectConfiguration(config).spoofMaxAttempts).isEqualTo(result) + } + } } From 510a8fa90bd8ac06a8b43904fcaec143ca16cc8c Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Tue, 7 Jul 2026 12:57:36 +0300 Subject: [PATCH 08/13] MS-1491 Scale down images that are over configurable limit to ensure IOD stays in the required range --- .../LiveFeedbackFragmentViewModel.kt | 4 +- .../GetSpoofCheckConfigurationUseCase.kt | 1 + ...eedbackAutoCaptureFragmentViewModelTest.kt | 36 +++++++++------- .../LiveFeedbackFragmentViewModelTest.kt | 42 ++++++++---------- .../GetSpoofCheckConfigurationUseCaseTest.kt | 4 ++ .../basebiosdk/detection/FaceDetector.kt | 5 ++- .../infra/rocv1/detection/RocV1Detector.kt | 5 ++- .../infra/rocv3/detection/RocV3Detector.kt | 43 +++++++++++++------ .../simface/detection/SimFaceDetector.kt | 5 ++- .../ExperimentalProjectConfiguration.kt | 15 +++++++ .../config/store/models/FaceConfiguration.kt | 2 + .../ExperimentalProjectConfigurationTest.kt | 15 ++++++- 12 files changed, 120 insertions(+), 57 deletions(-) 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 87d378a888..8de497ad8d 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 @@ -254,7 +254,9 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( val duration = measureTimedValue { for ((index, bitmap) in userCaptures.map { it.original }.withIndex()) { - userCaptures[index].spoofCheckResult = faceDetector.spoofCheck(bitmap) + val result = faceDetector.spoofCheck(bitmap, spoofCheckConfig.maxBitmapSize) + Simber.i("Spoof result: $result", tag = FACE_CAPTURE) + userCaptures[index].spoofCheckResult = result } } 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 index f1f4547b0b..4b8c6c58dd 100644 --- 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 @@ -29,6 +29,7 @@ class GetSpoofCheckConfigurationUseCase @Inject constructor( mode = it.spoofCheckMode, threshold = it.spoofCheckThreshold, maxAttempts = it.spoofMaxAttempts, + maxBitmapSize = it.spoofMaxBitmapSize, ) } } 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 bea996fdc4..735c925aaf 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 @@ -17,6 +17,7 @@ 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 @@ -79,7 +80,7 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { ?.qualityThreshold } returns QUALITY_THRESHOLD every { isUsingAutoCapture.invoke(any()) } returns true - every { getSpoofCheckConfiguration.invoke(any(), any()) } returns FaceConfiguration.SpoofCheckConfiguration.DISABLED + every { getSpoofCheckConfiguration.invoke(any(), any()) } returns SpoofCheckConfiguration.DISABLED coEvery { configRepository.getProjectConfiguration().custom } returns mapOf("singleQualityFallbackRequired" to JsonPrimitive(false)) @@ -344,9 +345,9 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { fun `Auto capture spoof check Recorded finishes regardless of score`() = runTest { val validFace = getFace() every { getSpoofCheckConfiguration.invoke(any(), any()) } returns - FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.RECORDED, 0.5f, 2) + getDefaultSpoofConfig() every { faceDetector.analyze(frame) } returns validFace - coEvery { faceDetector.spoofCheck(any()) } returns SpoofCheckResult(score = 0.9f) + coEvery { faceDetector.spoofCheck(any(), any()) } returns SpoofCheckResult(score = 0.9f) val capturingState = viewModel.capturingState.testObserver() @@ -366,10 +367,9 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { @Test fun `Auto capture spoof check Enforced passed finishes capture`() = runTest { val validFace = getFace() - every { getSpoofCheckConfiguration.invoke(any(), any()) } returns - FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f, 2) + every { getSpoofCheckConfiguration.invoke(any(), any()) } returns getDefaultSpoofConfig(FaceConfiguration.SpoofCheckMode.ENFORCED) every { faceDetector.analyze(frame) } returns validFace - coEvery { faceDetector.spoofCheck(any()) } returns SpoofCheckResult(score = 0.1f) + coEvery { faceDetector.spoofCheck(any(), any()) } returns SpoofCheckResult(score = 0.1f) val capturingState = viewModel.capturingState.testObserver() @@ -389,10 +389,9 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { @Test fun `Auto capture spoof check Enforced failed resets state`() = runTest { val validFace = getFace() - every { getSpoofCheckConfiguration.invoke(any(), any()) } returns - FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f, 2) + every { getSpoofCheckConfiguration.invoke(any(), any()) } returns getDefaultSpoofConfig(FaceConfiguration.SpoofCheckMode.ENFORCED) every { faceDetector.analyze(frame) } returns validFace - coEvery { faceDetector.spoofCheck(any()) } returns SpoofCheckResult(score = 0.9f) + coEvery { faceDetector.spoofCheck(any(), any()) } returns SpoofCheckResult(score = 0.9f) val capturingState = viewModel.capturingState.testObserver() @@ -412,10 +411,9 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { @Test fun `Auto capture spoof check Enforced failed max attempts finishes capture`() = runTest { val validFace = getFace() - every { getSpoofCheckConfiguration.invoke(any(), any()) } returns - FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f, 2) + every { getSpoofCheckConfiguration.invoke(any(), any()) } returns getDefaultSpoofConfig(FaceConfiguration.SpoofCheckMode.ENFORCED) every { faceDetector.analyze(frame) } returns validFace - coEvery { faceDetector.spoofCheck(any()) } returns SpoofCheckResult(score = 0.9f) + coEvery { faceDetector.spoofCheck(any(), any()) } returns SpoofCheckResult(score = 0.9f) val capturingState = viewModel.capturingState.testObserver() @@ -438,10 +436,9 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { @Test fun `Skip frame processing while validating`() = runTest { val validFace = getFace() - every { getSpoofCheckConfiguration.invoke(any(), any()) } returns - FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f, 2) + every { getSpoofCheckConfiguration.invoke(any(), any()) } returns getDefaultSpoofConfig() every { faceDetector.analyze(frame) } returns validFace - coEvery { faceDetector.spoofCheck(any()) } answers { + coEvery { faceDetector.spoofCheck(any(), any()) } answers { viewModel.process(frame, frame) SpoofCheckResult(score = 0.9f) } @@ -517,6 +514,15 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { 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, + ) + companion object { private const val QUALITY_THRESHOLD = -1f private const val AUTO_CAPTURE_IMAGING_DURATION_MS = 3000L diff --git a/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragmentViewModelTest.kt b/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragmentViewModelTest.kt index cb4c5aa615..8e6001493b 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 @@ -17,6 +17,7 @@ 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 @@ -241,14 +242,9 @@ internal class LiveFeedbackFragmentViewModelTest { @Test fun `Spoof check Recorded finishes capture regardless of spoof result`() = runTest { val validFace: Face = getFace() - every { - getSpoofCheckConfiguration.invoke( - any(), - any(), - ) - } returns FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.RECORDED, 0.5f, 2) + every { getSpoofCheckConfiguration.invoke(any(), any()) } returns getDefaultSpoofConfig() every { faceDetector.analyze(frame) } returns validFace - coEvery { faceDetector.spoofCheck(any()) } returns SpoofCheckResult(score = 0.9f) + coEvery { faceDetector.spoofCheck(any(), any()) } returns SpoofCheckResult(score = 0.9f) val capturingStateObserver = viewModel.capturingState.testObserver() viewModel.initAutoCapture() @@ -268,10 +264,9 @@ internal class LiveFeedbackFragmentViewModelTest { @Test fun `Spoof check Enforced passed finishes capture`() = runTest { val validFace: Face = getFace() - every { getSpoofCheckConfiguration.invoke(any(), any()) } returns - FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f, 2) + every { getSpoofCheckConfiguration.invoke(any(), any()) } returns getDefaultSpoofConfig(FaceConfiguration.SpoofCheckMode.ENFORCED) every { faceDetector.analyze(frame) } returns validFace - coEvery { faceDetector.spoofCheck(any()) } returns SpoofCheckResult(score = 0.1f) + coEvery { faceDetector.spoofCheck(any(), any()) } returns SpoofCheckResult(score = 0.1f) val capturingStateObserver = viewModel.capturingState.testObserver() viewModel.initAutoCapture() @@ -291,14 +286,9 @@ internal class LiveFeedbackFragmentViewModelTest { @Test fun `Spoof check Enforced failed resets state`() = runTest { val validFace: Face = getFace() - every { - getSpoofCheckConfiguration.invoke( - any(), - any(), - ) - } returns FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f, 2) + every { getSpoofCheckConfiguration.invoke(any(), any()) } returns getDefaultSpoofConfig(FaceConfiguration.SpoofCheckMode.ENFORCED) every { faceDetector.analyze(frame) } returns validFace - coEvery { faceDetector.spoofCheck(any()) } returns SpoofCheckResult(score = 0.9f) + coEvery { faceDetector.spoofCheck(any(), any()) } returns SpoofCheckResult(score = 0.9f) val capturingStateObserver = viewModel.capturingState.testObserver() viewModel.initAutoCapture() @@ -319,14 +309,9 @@ internal class LiveFeedbackFragmentViewModelTest { @Test fun `Spoof check Enforced failed max times finishes capture`() = runTest { val validFace: Face = getFace() - every { - getSpoofCheckConfiguration.invoke( - any(), - any(), - ) - } returns FaceConfiguration.SpoofCheckConfiguration(FaceConfiguration.SpoofCheckMode.ENFORCED, 0.5f, 2) + every { getSpoofCheckConfiguration.invoke(any(), any()) } returns getDefaultSpoofConfig(FaceConfiguration.SpoofCheckMode.ENFORCED) every { faceDetector.analyze(frame) } returns validFace - coEvery { faceDetector.spoofCheck(any()) } returns SpoofCheckResult(score = 0.9f) + coEvery { faceDetector.spoofCheck(any(), any()) } returns SpoofCheckResult(score = 0.9f) val capturingStateObserver = viewModel.capturingState.testObserver() viewModel.initAutoCapture() @@ -358,6 +343,15 @@ 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, + ) + 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 index a92e3631c2..ac28de2f5d 100644 --- 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 @@ -70,11 +70,15 @@ class GetSpoofCheckConfigurationUseCaseTest { 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/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 e7f3f606f5..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 @@ -17,5 +17,8 @@ interface FaceDetector { * @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): SpoofCheckResult + fun spoofCheck( + bitmap: Bitmap, + configuredMaxSize: Int, + ): SpoofCheckResult } 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 1dc9344f24..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 @@ -47,7 +47,10 @@ class RocV1Detector @Inject constructor() : FaceDetector { } } - override fun spoofCheck(bitmap: Bitmap) = SpoofCheckResult(0f, SpoofCheckResult.SkipReason.NOT_AVAILABLE) + override fun spoofCheck( + bitmap: Bitmap, + configuredMaxSize: Int, + ) = SpoofCheckResult(0f, SpoofCheckResult.SkipReason.NOT_AVAILABLE) override fun analyze(bitmap: Bitmap): Face? { val rocColorImage = 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 0499620072..6a2b766523 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,10 +7,12 @@ 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 com.simprints.infra.logging.Simber import java.nio.ByteBuffer import javax.inject.Inject import javax.inject.Singleton @@ -65,14 +67,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 @@ -175,20 +177,30 @@ class RocV3Detector @Inject constructor() : FaceDetector { } // TODO verify results - override fun spoofCheck(bitmap: Bitmap): SpoofCheckResult { + 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) } + val maxSize = configuredMaxSize + val scaledBitmap = if (bitmap.width > maxSize) { + bitmap.scale(maxSize, (bitmap.height * maxSize.toFloat() / bitmap.width).toInt(), false) + } else { + bitmap + } + val rocColorImage = roc_image() val rocGrayImage = roc_image() - val byteBuffer = bitmap.toByteBuffer() + val byteBuffer = scaledBitmap.toByteBuffer() roc3.roc_from_rgba( byteBuffer.array(), - bitmap.width.toLong(), - bitmap.height.toLong(), - bitmap.rowBytes.toLong(), + scaledBitmap.width.toLong(), + scaledBitmap.height.toLong(), + scaledBitmap.rowBytes.toLong(), rocColorImage, ) roc3.roc_bgr2gray(rocColorImage, rocGrayImage) @@ -269,6 +281,11 @@ class RocV3Detector @Inject constructor() : FaceDetector { detection.delete() byteBuffer.clear() + Simber.i( + "!!! width: ${scaledBitmap.width}, IOD: $iod, quality: ${roc3.float_value(quality)}, finalScore: $finalScore", + tag = "FACE_CAPTURE", + ) + return when { !faceDetected -> SpoofCheckResult(finalScore, SpoofCheckResult.SkipReason.NOT_AVAILABLE) iod < SPOOF_MIN_IOD -> SpoofCheckResult(finalScore, SpoofCheckResult.SkipReason.IOD_TOO_SMALL) 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 9c44c9cecb..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 @@ -33,7 +33,10 @@ class SimFaceDetector @Inject constructor( ) } - override fun spoofCheck(bitmap: Bitmap) = SpoofCheckResult(0f, SpoofCheckResult.SkipReason.NOT_AVAILABLE) + 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 f118167ed9..4ffc455af0 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 @@ -183,6 +183,7 @@ data class ExperimentalProjectConfiguration( ?.get(SPOOF_CHECK_THRESHOLD) ?.jsonPrimitive ?.floatOrNull + ?.coerceIn(0f, 1f) ?: SPOOF_CHECK_THRESHOLD_DEFAULT // TODO Temporary flags until configuration is added to FaceSdkConfiguration @@ -191,8 +192,18 @@ data class ExperimentalProjectConfiguration( ?.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 + companion object { internal const val DISABLE_SUBJECT_POOL_VALIDATION = "disableSubjectPoolValidation" internal const val FACE_AUTO_CAPTURE_ENABLED = "faceAutoCaptureEnabled" @@ -259,5 +270,9 @@ data class ExperimentalProjectConfiguration( 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 } } 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 57af8f3fec..839dbb65be 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 @@ -37,12 +37,14 @@ data class FaceConfiguration( val mode: SpoofCheckMode, val threshold: Float, val maxAttempts: Int, + val maxBitmapSize: Int, ) { companion object { val DISABLED = SpoofCheckConfiguration( mode = SpoofCheckMode.DISABLED, threshold = 0f, maxAttempts = 0, + maxBitmapSize = 0, ) } } 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 227a3bccbd..c7e73e2d9b 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 @@ -383,9 +383,22 @@ internal class ExperimentalProjectConfigurationTest { 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 0, + 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) + } + } } From 1d0cc828ebad2e761c634ab3f16146c69a7bd558 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Tue, 7 Jul 2026 14:16:44 +0300 Subject: [PATCH 09/13] MS-1491 Display progress bar while running spoof checks (also in auto-capture mode) --- .../livefeedback/LiveFeedbackFragment.kt | 40 ++++++++++++------- .../LiveFeedbackFragmentViewModel.kt | 18 +++++++-- 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragment.kt b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackFragment.kt index cdbd0e6b39..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 @@ -79,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 -> @@ -256,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) } @@ -293,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() @@ -310,6 +321,7 @@ 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) } @@ -319,7 +331,7 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) captureFeedbackBtn.isVisible = true captureFeedbackPermissionButton.isGone = true - setManualCaptureButtonClickable(false) + captureFeedbackTxtExplanation.isGone = true } } @@ -327,6 +339,7 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) binding.apply { captureOverlay.drawWhiteTarget() captureFeedbackTxtExplanation.setTextColor(ContextCompat.getColor(requireContext(), IDR.color.simprints_blue_grey)) + captureFeedbackTxtExplanation.isVisible = true captureProgress.isVisible = true captureFeedbackBtn.setText(IDR.string.face_capture_prep_begin_button_capturing) @@ -339,9 +352,10 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) private fun renderValidating() { binding.apply { captureOverlay.drawWhiteTarget() - captureFeedbackTxtExplanation.setTextColor(ContextCompat.getColor(requireContext(), IDR.color.simprints_blue_grey)) - captureProgress.isInvisible = true + captureProgress.progressColor = validationProgressColor + captureProgress.isVisible = true + captureFeedbackBtn.setText(IDR.string.face_capture_title_validating) captureFeedbackBtn.setCheckedWithLeftDrawable(false) setManualCaptureButtonClickable(false) @@ -355,8 +369,9 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) captureProgress.isInvisible = true captureFeedbackBtn.setText(IDR.string.face_capture_title_validating_failed) - captureFeedbackTxtExplanation.isVisible = true + captureFeedbackTxtExplanation.setTextColor(ContextCompat.getColor(requireContext(), IDR.color.simprints_blue_grey)) captureFeedbackTxtExplanation.setText(IDR.string.face_capture_error_validating_failed) + captureFeedbackTxtExplanation.isVisible = true } } @@ -391,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 } } @@ -404,7 +419,7 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) captureFeedbackBtn.setCheckedWithLeftDrawable(false) setManualCaptureButtonClickable(false) - renderProgressBar(false) + captureProgress.progressColor = defaultCaptureProgressColor } } @@ -417,7 +432,7 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) captureFeedbackBtn.setCheckedWithLeftDrawable(false) setManualCaptureButtonClickable(false) - renderProgressBar(false) + captureProgress.progressColor = defaultCaptureProgressColor } } @@ -430,7 +445,7 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) captureFeedbackBtn.setCheckedWithLeftDrawable(false) setManualCaptureButtonClickable(false) - renderProgressBar(false) + captureProgress.progressColor = defaultCaptureProgressColor } } @@ -443,7 +458,7 @@ internal class LiveFeedbackFragment : Fragment(R.layout.fragment_live_feedback) captureFeedbackBtn.setCheckedWithLeftDrawable(false) setManualCaptureButtonClickable(false) - renderProgressBar(false) + captureProgress.progressColor = defaultCaptureProgressColor } } @@ -456,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 8de497ad8d..e7799d499d 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 @@ -65,12 +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 @@ -126,8 +128,13 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( originalBitmap: Bitmap, croppedBitmap: Bitmap, ) { - if (capturingState.value == CapturingState.VALIDATING || capturingState.value == CapturingState.VALIDATION_FAILED) { - // Skip processing while spoof check is running + // Skip processing and only update progress bar while spoof check is running + if (capturingState.value == CapturingState.VALIDATING) { + progressBarValue.postValue( + ((timeHelper.now().ms - validationStartTime).toFloat() / MIN_SPOOF_CHECK_UI_TIME_MS).coerceIn(0f, 1f), + ) + return + } else if (capturingState.value == CapturingState.VALIDATION_FAILED) { return } @@ -153,6 +160,7 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( } else { currentDetection.postValue(faceDetection) } + progressBarValue.postValue(getNormalizedCaptureProgress()) when (capturingState.value) { CapturingState.NOT_STARTED -> updateFallbackCaptureIfValid(faceDetection) @@ -174,7 +182,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 @@ -251,6 +259,7 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( 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()) { @@ -277,10 +286,11 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( if (delay > 0) delay(delay.milliseconds) // Reset state + capturingState.postValue(CapturingState.NOT_STARTED) + isAutoCaptureHeldOff = true userCaptures.clear() sortedQualifyingCaptures = emptyList() fallbackCapture = null - capturingState.postValue(CapturingState.NOT_STARTED) } private fun getFaceDetectionFromPotentialFace( From 61f89696ca153d13cb504d3c36a833051e38614c Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Tue, 7 Jul 2026 15:26:28 +0300 Subject: [PATCH 10/13] MS-1491 Add custom config for spoof check UI timings --- .../LiveFeedbackFragmentViewModel.kt | 8 +++--- .../GetSpoofCheckConfigurationUseCase.kt | 2 ++ ...eedbackAutoCaptureFragmentViewModelTest.kt | 2 ++ .../LiveFeedbackFragmentViewModelTest.kt | 2 ++ .../ExperimentalProjectConfiguration.kt | 23 ++++++++++++++++ .../config/store/models/FaceConfiguration.kt | 4 +++ .../ExperimentalProjectConfigurationTest.kt | 26 +++++++++++++++++++ 7 files changed, 62 insertions(+), 5 deletions(-) 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 e7799d499d..1abd45be86 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 @@ -131,7 +131,7 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( // Skip processing and only update progress bar while spoof check is running if (capturingState.value == CapturingState.VALIDATING) { progressBarValue.postValue( - ((timeHelper.now().ms - validationStartTime).toFloat() / MIN_SPOOF_CHECK_UI_TIME_MS).coerceIn(0f, 1f), + ((timeHelper.now().ms - validationStartTime).toFloat() / spoofCheckConfig.validationUiDurationMs).coerceIn(0f, 1f), ) return } else if (capturingState.value == CapturingState.VALIDATION_FAILED) { @@ -270,7 +270,7 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( } // Show the UI for at least a moment for it to register with the user - val delay = maxOf(MIN_SPOOF_CHECK_UI_TIME_MS - duration.duration.inWholeMilliseconds, 0) + 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) } @@ -281,7 +281,7 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( // Still track the capture attempt events for analytics and troubleshooting sendCaptureEvents(attemptNumber) } - val delay = maxOf(MIN_SPOOF_CHECK_UI_TIME_MS - duration.duration.inWholeMilliseconds, 0) + 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) @@ -387,7 +387,5 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( companion object { private const val VALID_ROLL_DELTA = 15f private const val VALID_YAW_DELTA = 30f - - private const val MIN_SPOOF_CHECK_UI_TIME_MS = 2000 } } 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 index 4b8c6c58dd..f9ca00069d 100644 --- 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 @@ -30,6 +30,8 @@ class GetSpoofCheckConfigurationUseCase @Inject constructor( threshold = it.spoofCheckThreshold, maxAttempts = it.spoofMaxAttempts, maxBitmapSize = it.spoofMaxBitmapSize, + validationUiDurationMs = it.spoofMinValidationUiDurationMs, + validationErrorUiDurationMs = it.spoofMinValidationErrorUiDurationMs, ) } } 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 735c925aaf..e47bfa977a 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 @@ -521,6 +521,8 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { threshold = 0.5f, maxAttempts = 2, maxBitmapSize = 1500, + validationUiDurationMs = 1000, + validationErrorUiDurationMs = 1000, ) companion object { 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 8e6001493b..e55dba3ca5 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 @@ -350,6 +350,8 @@ internal class LiveFeedbackFragmentViewModelTest { threshold = 0.5f, maxAttempts = 2, maxBitmapSize = 1500, + validationUiDurationMs = 1000, + validationErrorUiDurationMs = 1000, ) companion object { 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 4ffc455af0..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 @@ -204,6 +204,24 @@ data class ExperimentalProjectConfiguration( ?.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" @@ -274,5 +292,10 @@ data class ExperimentalProjectConfiguration( 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 839dbb65be..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 @@ -38,6 +38,8 @@ data class FaceConfiguration( val threshold: Float, val maxAttempts: Int, val maxBitmapSize: Int, + val validationUiDurationMs: Int, + val validationErrorUiDurationMs: Int, ) { companion object { val DISABLED = SpoofCheckConfiguration( @@ -45,6 +47,8 @@ data class FaceConfiguration( threshold = 0f, maxAttempts = 0, maxBitmapSize = 0, + validationUiDurationMs = 0, + validationErrorUiDurationMs = 0, ) } } 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 c7e73e2d9b..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 @@ -401,4 +401,30 @@ internal class ExperimentalProjectConfigurationTest { 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) + } + } } From 9f461ee948651ff21d4b63706e206d995c79cc35 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Tue, 7 Jul 2026 16:26:10 +0300 Subject: [PATCH 11/13] MS-1491 Remove debugging helpers from detector --- .../face/infra/rocv3/detection/RocV3Detector.kt | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) 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 6a2b766523..1a79e4292d 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 @@ -12,7 +12,6 @@ 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 com.simprints.infra.logging.Simber import java.nio.ByteBuffer import javax.inject.Inject import javax.inject.Singleton @@ -176,7 +175,6 @@ class RocV3Detector @Inject constructor() : FaceDetector { return byteBuffer } - // TODO verify results override fun spoofCheck( bitmap: Bitmap, configuredMaxSize: Int, @@ -186,9 +184,8 @@ class RocV3Detector @Inject constructor() : FaceDetector { return SpoofCheckResult(0f, SpoofCheckResult.SkipReason.IMAGE_TOO_SMALL) } - val maxSize = configuredMaxSize - val scaledBitmap = if (bitmap.width > maxSize) { - bitmap.scale(maxSize, (bitmap.height * maxSize.toFloat() / bitmap.width).toInt(), false) + val scaledBitmap = if (bitmap.width > configuredMaxSize) { + bitmap.scale(configuredMaxSize, (bitmap.height * configuredMaxSize.toFloat() / bitmap.width).toInt(), false) } else { bitmap } @@ -211,8 +208,6 @@ class RocV3Detector @Inject constructor() : FaceDetector { var iod = 0f var finalScore = 0f - val quality = roc3.new_float() // TODO remove later - if (faceDetected) { val rightEye = roc_landmark() val leftEye = roc_landmark() @@ -245,7 +240,7 @@ class RocV3Detector @Inject constructor() : FaceDetector { leftEye, chin, null, - quality, + null, null, null, null, @@ -281,11 +276,6 @@ class RocV3Detector @Inject constructor() : FaceDetector { detection.delete() byteBuffer.clear() - Simber.i( - "!!! width: ${scaledBitmap.width}, IOD: $iod, quality: ${roc3.float_value(quality)}, finalScore: $finalScore", - tag = "FACE_CAPTURE", - ) - return when { !faceDetected -> SpoofCheckResult(finalScore, SpoofCheckResult.SkipReason.NOT_AVAILABLE) iod < SPOOF_MIN_IOD -> SpoofCheckResult(finalScore, SpoofCheckResult.SkipReason.IOD_TOO_SMALL) From ea62fcdba603afba65ff00e848495f26b05a169f Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Tue, 7 Jul 2026 17:02:38 +0300 Subject: [PATCH 12/13] MS-1491 Bitmap handling fixes --- .../screens/livefeedback/CropToTargetOverlayAnalyzer.kt | 5 +++-- .../screens/livefeedback/LiveFeedbackFragmentViewModel.kt | 5 +++++ .../simprints/face/infra/rocv3/detection/RocV3Detector.kt | 6 ++++-- 3 files changed, 12 insertions(+), 4 deletions(-) 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 5092a10a8f..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 @@ -37,8 +37,9 @@ internal class CropToTargetOverlayAnalyzer( val cropTop = offsetY + (previewRect.top * scale).toInt() val cropHeight = (previewRect.height() * scale).toInt() - image.toBitmap() to Bitmap.createBitmap( - it.toBitmap(), + val imageBitmap = it.toBitmap() + imageBitmap to Bitmap.createBitmap( + imageBitmap, cropLeft, cropTop, cropWidth, 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 1abd45be86..bf186d03fb 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 @@ -133,8 +133,12 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( 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 } @@ -298,6 +302,7 @@ internal class LiveFeedbackFragmentViewModel @Inject constructor( bitmap: Bitmap, potentialFace: Face?, ): FaceDetection = if (potentialFace == null) { + original.recycle() bitmap.recycle() FaceDetection( original = original, 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 1a79e4292d..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 @@ -184,8 +184,10 @@ class RocV3Detector @Inject constructor() : FaceDetector { return SpoofCheckResult(0f, SpoofCheckResult.SkipReason.IMAGE_TOO_SMALL) } - val scaledBitmap = if (bitmap.width > configuredMaxSize) { - bitmap.scale(configuredMaxSize, (bitmap.height * configuredMaxSize.toFloat() / bitmap.width).toInt(), false) + // 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 } From 467fefa3b6310acfc9714e9b9bda3c6411bdbbc1 Mon Sep 17 00:00:00 2001 From: Sergejs Luhmirins Date: Wed, 8 Jul 2026 13:35:58 +0300 Subject: [PATCH 13/13] MS-1491 Mark spoofed captures in the capture event payload result --- .../LiveFeedbackFragmentViewModel.kt | 2 +- .../usecases/SimpleCaptureEventReporter.kt | 40 ++++++--- ...eedbackAutoCaptureFragmentViewModelTest.kt | 4 +- .../LiveFeedbackFragmentViewModelTest.kt | 6 +- .../SimpleCaptureEventReporterTest.kt | 90 +++++++++++++++---- .../remote/models/ApiFaceCapturePayload.kt | 3 + .../event/domain/models/FaceCaptureEvent.kt | 1 + 7 files changed, 112 insertions(+), 34 deletions(-) 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 bf186d03fb..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 @@ -384,7 +384,7 @@ 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, VALIDATING, VALIDATION_FAILED, FINISHED } 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 6ea5c0ce52..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 @@ -6,6 +6,7 @@ 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 @@ -53,6 +54,7 @@ internal class SimpleCaptureEventReporter @Inject constructor( faceDetection: FaceDetection, attempt: Int, qualityThreshold: Float, + spoofConfig: FaceConfiguration.SpoofCheckConfiguration, isAutoCapture: Boolean = false, ) { val faceCaptureEvent = FaceCaptureEvent( @@ -60,7 +62,7 @@ internal class SimpleCaptureEventReporter @Inject constructor( endTime = faceDetection.detectionEndTime, attemptNb = attempt, qualityThreshold = qualityThreshold, - result = mapDetectionStatusToPayloadResult(faceDetection), + result = mapDetectionStatusToPayloadResult(faceDetection, spoofConfig), isAutoCapture = isAutoCapture, isFallback = faceDetection.isFallback, face = mapDetectionToCapturePayloadFace(faceDetection = faceDetection), @@ -79,15 +81,33 @@ 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 { 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 e47bfa977a..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 @@ -263,7 +263,7 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { 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 @@ -504,7 +504,7 @@ internal class LiveFeedbackAutoCaptureFragmentViewModelTest { } 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( 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 e55dba3ca5..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 @@ -124,7 +124,7 @@ internal class LiveFeedbackFragmentViewModelTest { 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 @@ -162,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 @@ -236,7 +236,7 @@ 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 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 5caf13a94f..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 @@ -9,6 +9,8 @@ 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 @@ -111,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( @@ -129,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( @@ -147,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 { @@ -201,13 +255,13 @@ class SimpleCaptureEventReporterTest { @Test fun `Correctly maps spoof skip reasons`() = runTest { - reporter.addCaptureEvents(getSpoofedDetection(SkipReason.NOT_AVAILABLE), 1, 0.5f) + reporter.addCaptureEvents(getSpoofedDetection(SkipReason.NOT_AVAILABLE), 1, 0.5f, SpoofCheckConfiguration.DISABLED) // to null, - reporter.addCaptureEvents(getSpoofedDetection(SkipReason.IOD_TOO_SMALL), 1, 0.5f) + 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) + 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) + reporter.addCaptureEvents(getSpoofedDetection(SkipReason.IMAGE_TOO_SMALL), 1, 0.5f, SpoofCheckConfiguration.DISABLED) // to FaceCaptureEvent.FaceCapturePayload.SpoofSkipReason.IMAGE_TOO_SMALL, coVerify(exactly = 1) { 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 ebf123f4b4..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 @@ -9,6 +9,7 @@ import com.simprints.infra.eventsync.event.remote.models.ApiFaceCapturePayload.A 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 @@ -58,6 +59,7 @@ internal data class ApiFaceCapturePayload( OFF_ROLL, TOO_CLOSE, TOO_FAR, + SPOOFED, } @Keep @@ -88,6 +90,7 @@ 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) { 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 a97062a559..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 @@ -87,6 +87,7 @@ data class FaceCaptureEvent( OFF_ROLL, TOO_CLOSE, TOO_FAR, + SPOOFED, } @Keep