diff --git a/face/capture/src/main/java/com/simprints/face/capture/screens/FaceCaptureViewModel.kt b/face/capture/src/main/java/com/simprints/face/capture/screens/FaceCaptureViewModel.kt index dd5dd2eeea..685a3a5791 100644 --- a/face/capture/src/main/java/com/simprints/face/capture/screens/FaceCaptureViewModel.kt +++ b/face/capture/src/main/java/com/simprints/face/capture/screens/FaceCaptureViewModel.kt @@ -3,6 +3,7 @@ package com.simprints.face.capture.screens import android.app.Activity import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.simprints.core.DeviceID @@ -55,9 +56,14 @@ internal class FaceCaptureViewModel @Inject constructor( private val saveLicenseCheckEvent: SaveLicenseCheckEventUseCase, private val shouldShowInstructions: ShouldShowInstructionsScreenUseCase, @param:DeviceID private val deviceID: String, + private val savedStateHandle: SavedStateHandle, ) : ViewModel() { - // Updated in live feedback screen - var attemptNumber: Int = 0 + // Number of times the user has (re)started a capture attempt for the current capture step. + var attemptNumber: Int + get() = savedStateHandle[KEY_ATTEMPT_NUMBER] ?: 0 + private set(value) { + savedStateHandle[KEY_ATTEMPT_NUMBER] = value + } var samplesToCapture = 1 var initialised = false lateinit var bioSDK: ModalitySdkType @@ -92,6 +98,10 @@ internal class FaceCaptureViewModel @Inject constructor( activity: Activity, sdk: ModalitySdkType, ) = viewModelScope.launch { + if (::bioSDK.isInitialized && bioSDK != sdk) { + resetForNewCaptureStep() + } + if (initialised) { Simber.i("Face bio SDK already initialised", tag = FACE_CAPTURE) return@launch @@ -207,10 +217,21 @@ internal class FaceCaptureViewModel @Inject constructor( fun recapture() { Simber.i("Starting face recapture flow", tag = FACE_CAPTURE) + attemptNumber++ faceDetections = listOf() _recaptureEvent.send() } + /** + ** Resets the state that is scoped to a single capture step + **/ + private fun resetForNewCaptureStep() { + Simber.i("Resetting face capture state for a new capture step", tag = FACE_CAPTURE) + attemptNumber = 0 + initialised = false + faceDetections = emptyList() + } + private fun saveFaceDetections() { Simber.i("Saving captures to disk", tag = FACE_CAPTURE) faceDetections.forEach { saveImage(it, it.id) } @@ -241,4 +262,8 @@ internal class FaceCaptureViewModel @Inject constructor( ) { eventReporter.addCaptureConfirmationEvent(startTime, isContinue) } + + private companion object { + private const val KEY_ATTEMPT_NUMBER = "FaceCaptureViewModel.attemptNumber" + } } diff --git a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModel.kt b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModel.kt index f92179c979..cffe791556 100644 --- a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModel.kt +++ b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModel.kt @@ -52,7 +52,7 @@ internal class LiveFeedbackViewModel @Inject constructor( private val getSpoofCheckConfiguration: GetSpoofCheckConfigurationUseCase, @param:DispatcherBG private val bgDispatcher: CoroutineDispatcher, ) : ViewModel() { - private var attemptNumber: Int = 1 + private var attemptNumber: Int = 0 private var samplesToCapture: Int = 1 private var qualityThreshold: Float = 0f @@ -368,6 +368,7 @@ internal class LiveFeedbackViewModel @Inject constructor( val duration = measureTimedValue { // Still track the capture attempt events for analytics and troubleshooting sendCaptureEvents(attemptNumber) + attemptNumber++ userCaptures.forEach { it.original.recycle() diff --git a/face/capture/src/test/java/com/simprints/face/capture/screens/FaceCaptureViewModelTest.kt b/face/capture/src/test/java/com/simprints/face/capture/screens/FaceCaptureViewModelTest.kt index 834872d37c..b3c45776bf 100644 --- a/face/capture/src/test/java/com/simprints/face/capture/screens/FaceCaptureViewModelTest.kt +++ b/face/capture/src/test/java/com/simprints/face/capture/screens/FaceCaptureViewModelTest.kt @@ -1,6 +1,7 @@ package com.simprints.face.capture.screens import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.SavedStateHandle import com.google.common.truth.Truth.* import com.simprints.core.tools.time.Timestamp import com.simprints.face.capture.models.FaceDetection @@ -67,6 +68,7 @@ class FaceCaptureViewModelTest { private lateinit var shouldShowInstructionsScreen: ShouldShowInstructionsScreenUseCase private lateinit var viewModel: FaceCaptureViewModel + private lateinit var savedStateHandle: SavedStateHandle private val faceDetections = listOf( mockk(relaxed = true) { @@ -81,6 +83,7 @@ class FaceCaptureViewModelTest { coEvery { faceImageUseCase.invoke(any(), any()) } returns null every { bitmapToByteArrayUseCase.invoke(any()) } returns byteArrayOf() every { authStore.signedInProjectId } returns "projectId" + savedStateHandle = SavedStateHandle() viewModel = FaceCaptureViewModel( authStore, @@ -95,6 +98,7 @@ class FaceCaptureViewModelTest { saveLicenseCheckEvent, shouldShowInstructionsScreen, "deviceId", + savedStateHandle, ) } @@ -157,6 +161,63 @@ class FaceCaptureViewModelTest { assertThat(viewModel.getSampleDetection()).isNull() } + @Test + fun `Recapture increments attempt number on each call`() { + assertThat(viewModel.attemptNumber).isEqualTo(0) + + viewModel.recapture() + assertThat(viewModel.attemptNumber).isEqualTo(1) + + viewModel.recapture() + assertThat(viewModel.attemptNumber).isEqualTo(2) + } + + @Test + fun `Attempt number survives being restored from a SavedStateHandle`() { + viewModel.recapture() + viewModel.recapture() + assertThat(viewModel.attemptNumber).isEqualTo(2) + + val restoredViewModel = FaceCaptureViewModel( + authStore, + configRepository, + faceImageUseCase, + eventReporter, + bitmapToByteArrayUseCase, + licenseRepository, + mockk { + coEvery { this@mockk(any()).initializer } returns faceBioSdkInitializer + }, + saveLicenseCheckEvent, + shouldShowInstructionsScreen, + "deviceId", + savedStateHandle, + ) + + assertThat(restoredViewModel.attemptNumber).isEqualTo(2) + } + + @Test + fun `Attempt number and init flag reset when a capture step starts for a different SDK`() { + val license = "license" + coEvery { + licenseRepository.getCachedLicense(Vendor.RankOne) + } returns License("2133-12-30T17:32:28Z", license, LicenseVersion("1.0")) + every { faceBioSdkInitializer.tryInitWithLicense(any(), license) } returns true + coJustRun { saveLicenseCheckEvent(any(), any()) } + + viewModel.initFaceBioSdk(mockk(), ModalitySdkType.RANK_ONE) + viewModel.recapture() + viewModel.recapture() + assertThat(viewModel.attemptNumber).isEqualTo(2) + assertThat(viewModel.initialised).isTrue() + + // A new capture step is started for a different SDK within the same (Activity-scoped) ViewModel + viewModel.initFaceBioSdk(mockk(), ModalitySdkType.SIM_FACE) + + assertThat(viewModel.attemptNumber).isEqualTo(0) + } + @Test fun `Requests exit form on back press`() { viewModel.handleBackButton() diff --git a/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModelTest.kt b/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModelTest.kt index 99230a3d5d..34aa204f53 100644 --- a/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModelTest.kt +++ b/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModelTest.kt @@ -385,6 +385,34 @@ internal class LiveFeedbackViewModelTest { assertThat(viewModel.state.value.phase).isEqualTo(LiveFeedbackState.Phase.FINISHED) } + @Test + fun `spoof ENFORCED failing retry reports an incrementing attempt number`() = runTest { + every { getSpoofCheckConfiguration.invoke(any(), any()) } returns spoofConfig(FaceConfiguration.SpoofCheckMode.ENFORCED) + every { faceDetector.analyze(frame) } returns getFace() + coEvery { faceDetector.spoofCheck(any(), any()) } returns SpoofCheckResult(score = 0.9f) + val attemptNumbers = mutableListOf() + coEvery { + eventReporter.addCaptureEvents(any(), capture(attemptNumbers), any(), any(), any()) + } just runs + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 1, 0) + + // Attempt 0 fails spoof check + viewModel.process(frame, frame) + viewModel.startCapture() + viewModel.process(frame, frame) + advanceUntilIdle() + + // Attempt 1 (retry) reaches maxAttempts and finishes + viewModel.process(frame, frame) + viewModel.startCapture() + viewModel.process(frame, frame) + advanceUntilIdle() + + assertThat(attemptNumbers).containsAtLeast(0, 1) + } + @Test fun `frames are skipped while validating and progress uses the validation tint`() = runTest { every { getSpoofCheckConfiguration.invoke(any(), any()) } returns spoofConfig()