Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ internal data class FaceDetection(
enum class Status {
VALID,
VALID_CAPTURING,
BAD_QUALITY,
Comment thread
alexandr-simprints marked this conversation as resolved.
NOFACE,
OFFYAW,
OFFROLL,
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.simprints.face.capture.screens.livefeedback

import com.simprints.face.capture.models.FaceDetection

/**
* Single, immutable snapshot of everything the live-feedback UI needs to render.
*/
internal data class LiveFeedbackState(
val phase: Phase,
val feedback: Feedback,
val isAutoCapture: Boolean,
val progress: Progress,
val result: List<FaceDetection> = emptyList(),
) {
/** Overall capture phase / state machine. */
enum class Phase { NOT_STARTED, CAPTURING, VALIDATING, VALIDATION_FAILED, FINISHED }

/**
* UI-facing guidance derived from the latest [FaceDetection].
* Decoupled from the biometric [FaceDetection.Status] so the fragment maps it to text/visuals via a simple lookup.
*/
enum class Feedback { NONE, NO_FACE, LOOK_STRAIGHT, TOO_CLOSE, TOO_FAR, VALID, VALID_CAPTURING }

companion object {
fun initial(isAutoCapture: Boolean = false) = LiveFeedbackState(
phase = Phase.NOT_STARTED,
feedback = Feedback.NONE,
isAutoCapture = isAutoCapture,
progress = Progress.HIDDEN,
)
}
}

internal data class Progress(
val value: Float,
val tint: Tint,
val visible: Boolean,
) {
enum class Tint { DEFAULT, VALID, VALIDATION }

companion object {
val HIDDEN = Progress(value = 0f, tint = Tint.DEFAULT, visible = false)
}
}

internal fun FaceDetection.Status.toFeedback(): LiveFeedbackState.Feedback = when (this) {
FaceDetection.Status.VALID -> LiveFeedbackState.Feedback.VALID
FaceDetection.Status.VALID_CAPTURING -> LiveFeedbackState.Feedback.VALID_CAPTURING
FaceDetection.Status.NOFACE -> LiveFeedbackState.Feedback.NO_FACE
FaceDetection.Status.OFFYAW -> LiveFeedbackState.Feedback.LOOK_STRAIGHT
FaceDetection.Status.OFFROLL -> LiveFeedbackState.Feedback.LOOK_STRAIGHT
FaceDetection.Status.TOOCLOSE -> LiveFeedbackState.Feedback.TOO_CLOSE
FaceDetection.Status.TOOFAR -> LiveFeedbackState.Feedback.TOO_FAR
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.simprints.face.capture.screens.livefeedback

import android.graphics.Bitmap
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.simprints.core.DispatcherBG
Expand Down Expand Up @@ -30,16 +29,19 @@
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.measureTimedValue

@HiltViewModel
internal class LiveFeedbackFragmentViewModel @Inject constructor(
internal class LiveFeedbackViewModel @Inject constructor(
private val resolveFaceBioSdk: ResolveFaceBioSdkUseCase,
private val configRepository: ConfigRepository,
private val eventReporter: SimpleCaptureEventReporter,
Expand All @@ -63,13 +65,17 @@

val userCaptures = mutableListOf<FaceDetection>()
var sortedQualifyingCaptures = listOf<FaceDetection>()
val currentDetection = MutableLiveData<FaceDetection>()
val capturingState = MutableLiveData(CapturingState.NOT_STARTED)
val progressBarValue = MutableLiveData<Float>()

/**
* The single source of truth for the whole screen.
* The fragment renders this deterministically; all transitions funnel through [emit].
*/
private val _state = MutableStateFlow(LiveFeedbackState.initial())
val state: StateFlow<LiveFeedbackState> = _state.asStateFlow()

var isAutoCapture: Boolean = false
var spoofCheckConfig: SpoofCheckConfiguration = SpoofCheckConfiguration.DISABLED
var spoofCheckCount: Int = 0
private var spoofCheckConfig: SpoofCheckConfiguration = SpoofCheckConfiguration.DISABLED
private var spoofCheckCount: Int = 0

private var captureImagingStartTime: Long = 0
private var validationStartTime: Long = 0
Expand All @@ -78,9 +84,34 @@
private var autoCaptureImagingDurationMillis: Long = FACE_AUTO_CAPTURE_IMAGING_DURATION_MILLIS_DEFAULT
private lateinit var faceDetector: FaceDetector

private val phase: LiveFeedbackState.Phase
get() = _state.value.phase

private fun emit(
phase: LiveFeedbackState.Phase = _state.value.phase,
feedback: LiveFeedbackState.Feedback = _state.value.feedback,
detectionForTint: FaceDetection? = null,
result: List<FaceDetection> = _state.value.result,
) {
_state.update { currentState ->
currentState.copy(
phase = phase,
feedback = feedback,
isAutoCapture = isAutoCapture,
progress = computeProgress(phase, detectionForTint),
result = result,
)
}
}

suspend fun initAutoCapture() {

Check warning on line 107 in face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModel.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Classes extending "ViewModel" should not expose suspending functions.

See more on https://sonarcloud.io/project/issues?id=Simprints_Android-Simprints-ID&issues=AZ9GVVlis-lwpYkD7X3M&open=AZ9GVVlis-lwpYkD7X3M&pullRequest=1737
val config = configRepository.getProjectConfiguration()
isAutoCapture = isUsingAutoCaptureUseCase(config)
if (isAutoCapture) {
// Await until capture button is pressed
holdOffAutoCapture()
}
emit() // Reset UI state with correct auto-capture value
}

fun initCapture(
Expand All @@ -103,40 +134,36 @@

fun holdOffAutoCapture() {
if (isAutoCapture) {
if (capturingState.value != CapturingState.NOT_STARTED) {
if (phase != LiveFeedbackState.Phase.NOT_STARTED) {
return // too late - imaging has already started
}
capturingState.value = CapturingState.NOT_STARTED // reset view
isAutoCaptureHeldOff = true
emit(phase = LiveFeedbackState.Phase.NOT_STARTED, feedback = LiveFeedbackState.Feedback.NONE) // reset view
}
}

fun startCapture() {
if (isAutoCapture) {
isAutoCaptureHeldOff = false
} else {
capturingState.value = CapturingState.CAPTURING
emit(phase = LiveFeedbackState.Phase.CAPTURING)
}
}

/**
* Processes the image
*
* @param croppedBitmap is the camera frame
* Processes the image. Called on the CameraX analyzer executor (off the main thread).
*/
fun process(

Check failure on line 156 in face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/LiveFeedbackViewModel.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 20 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Simprints_Android-Simprints-ID&issues=AZ9GVVlis-lwpYkD7X3N&open=AZ9GVVlis-lwpYkD7X3N&pullRequest=1737
originalBitmap: Bitmap,
croppedBitmap: Bitmap,
) {
// Skip processing and only update progress bar while spoof check is running
if (capturingState.value == CapturingState.VALIDATING) {
progressBarValue.postValue(
((timeHelper.now().ms - validationStartTime).toFloat() / spoofCheckConfig.validationUiDurationMs).coerceIn(0f, 1f),
)
if (phase == LiveFeedbackState.Phase.VALIDATING) {
emit(phase = LiveFeedbackState.Phase.VALIDATING)
originalBitmap.recycle()
croppedBitmap.recycle()
return
} else if (capturingState.value == CapturingState.VALIDATION_FAILED) {
} else if (phase == LiveFeedbackState.Phase.VALIDATION_FAILED) {
originalBitmap.recycle()
croppedBitmap.recycle()
return
Expand All @@ -149,11 +176,14 @@
faceDetection.detectionStartTime = captureStartTime
faceDetection.detectionEndTime = timeHelper.now()

var newPhase = phase
var feedback = _state.value.feedback

if (isAutoCapture) {
if (!isAutoCaptureHeldOff) {
currentDetection.postValue(faceDetection)
if (faceDetection.status == FaceDetection.Status.VALID && capturingState.value == CapturingState.NOT_STARTED) {
capturingState.postValue(CapturingState.CAPTURING)
feedback = faceDetection.status.toFeedback()
if (faceDetection.status == FaceDetection.Status.VALID && phase == LiveFeedbackState.Phase.NOT_STARTED) {
newPhase = LiveFeedbackState.Phase.CAPTURING
captureImagingStartTime = captureStartTime.ms
autoCaptureImagingTimeoutJob = viewModelScope.launch {
delay(autoCaptureImagingDurationMillis)
Expand All @@ -162,13 +192,12 @@
}
}
} else {
currentDetection.postValue(faceDetection)
feedback = faceDetection.status.toFeedback()
}
progressBarValue.postValue(getNormalizedCaptureProgress())

when (capturingState.value) {
CapturingState.NOT_STARTED -> updateFallbackCaptureIfValid(faceDetection)
CapturingState.CAPTURING -> {
when (newPhase) {
LiveFeedbackState.Phase.NOT_STARTED -> updateFallbackCaptureIfValid(faceDetection)
LiveFeedbackState.Phase.CAPTURING -> {
if (isAutoCapture) {
if (isQualifying(faceDetection)) {
updateUserCapturesWith(faceDetection)
Expand All @@ -184,12 +213,31 @@
else -> { // no-op
}
}

emit(phase = newPhase, feedback = feedback, detectionForTint = faceDetection)
}

fun getNormalizedCaptureProgress(): Float = if (isAutoCapture) {
((timeHelper.now().ms - captureImagingStartTime).toFloat() / autoCaptureImagingDurationMillis).coerceIn(0f, 1f)
} else {
userCaptures.size.toFloat() / samplesToCapture
private fun computeProgress(
phase: LiveFeedbackState.Phase,
detection: FaceDetection?,
): Progress = Progress(
value = normalizedProgress(phase),
tint = when {
phase == LiveFeedbackState.Phase.VALIDATING -> Progress.Tint.VALIDATION
detection?.status == FaceDetection.Status.VALID_CAPTURING -> Progress.Tint.VALID
else -> Progress.Tint.DEFAULT
},
visible = phase == LiveFeedbackState.Phase.CAPTURING || phase == LiveFeedbackState.Phase.VALIDATING,
)

private fun normalizedProgress(phase: LiveFeedbackState.Phase): Float = when {
phase == LiveFeedbackState.Phase.VALIDATING ->
((timeHelper.now().ms - validationStartTime).toFloat() / spoofCheckConfig.validationUiDurationMs).coerceIn(0f, 1f)

isAutoCapture ->
((timeHelper.now().ms - captureImagingStartTime).toFloat() / autoCaptureImagingDurationMillis).coerceIn(0f, 1f)

else -> userCaptures.size.toFloat() / samplesToCapture
}

private fun isQualifying(faceDetection: FaceDetection): Boolean {
Expand Down Expand Up @@ -250,20 +298,20 @@
}
}

private fun sendEventsAndFinish(attemptNumber: Int) {
private suspend fun sendEventsAndFinish(attemptNumber: Int) {
sortedQualifyingCaptures = userCaptures
.filter { isAutoCapture || it.hasValidStatus() } // Auto-capture images are pre-qualified
.sortedByDescending { it.face?.quality }
.ifEmpty { listOfNotNull(fallbackCapture) }

sendCaptureEvents(attemptNumber)
capturingState.postValue(CapturingState.FINISHED)
emit(phase = LiveFeedbackState.Phase.FINISHED, result = sortedQualifyingCaptures)
}

private suspend fun runSpoofChecksOnCaptures() = withContext(bgDispatcher) {
capturingState.postValue(CapturingState.VALIDATING)
spoofCheckCount++
validationStartTime = timeHelper.now().ms
emit(phase = LiveFeedbackState.Phase.VALIDATING)

val duration = measureTimedValue {
for ((index, bitmap) in userCaptures.map { it.original }.withIndex()) {
Expand All @@ -280,21 +328,29 @@
}

private suspend fun showValidationErrorAndReset() {
capturingState.postValue(CapturingState.VALIDATION_FAILED)
emit(phase = LiveFeedbackState.Phase.VALIDATION_FAILED)
val duration = measureTimedValue {
// Still track the capture attempt events for analytics and troubleshooting
sendCaptureEvents(attemptNumber)

userCaptures.forEach {
it.original.recycle()
it.bitmap.recycle()
}
userCaptures.clear()
fallbackCapture?.original?.recycle()
fallbackCapture?.bitmap?.recycle()
fallbackCapture = null

// Reset state
isAutoCaptureHeldOff = true
sortedQualifyingCaptures = emptyList()
}
val delay = maxOf(spoofCheckConfig.validationErrorUiDurationMs - duration.duration.inWholeMilliseconds, 0)
Simber.i("Captures tracked in ${duration.duration}, waiting for ${delay}ms", tag = FACE_CAPTURE)
if (delay > 0) delay(delay.milliseconds)

// Reset state
capturingState.postValue(CapturingState.NOT_STARTED)
isAutoCaptureHeldOff = true
userCaptures.clear()
sortedQualifyingCaptures = emptyList()
fallbackCapture = null
emit(phase = LiveFeedbackState.Phase.NOT_STARTED, feedback = LiveFeedbackState.Feedback.NONE, result = emptyList())
}

private fun getFaceDetectionFromPotentialFace(
Expand Down Expand Up @@ -327,7 +383,7 @@
areaOccupied > faceTarget.areaRange.endInclusive -> FaceDetection.Status.TOOCLOSE
potentialFace.yaw !in faceTarget.yawTarget -> FaceDetection.Status.OFFYAW
potentialFace.roll !in faceTarget.rollTarget -> FaceDetection.Status.OFFROLL
capturingState.value == CapturingState.CAPTURING -> FaceDetection.Status.VALID_CAPTURING
phase == LiveFeedbackState.Phase.CAPTURING -> FaceDetection.Status.VALID_CAPTURING
else -> FaceDetection.Status.VALID
}

Expand Down Expand Up @@ -370,9 +426,10 @@

/**
* Since events are saved in a blocking way in [SimpleCaptureEventReporter.addCaptureEvents],
* to speed things up this method creates multiple async jobs and run them all in parallel.
* this method fans the writes out as parallel jobs on the background dispatcher so that
* neither the main thread nor the camera pipeline is blocked.
*/
private fun sendCaptureEvents(attemptNumber: Int) = runBlocking {
private suspend fun sendCaptureEvents(attemptNumber: Int) = withContext(bgDispatcher) {
userCaptures
.map { async { sendCaptureEvent(it, attemptNumber) } }
.plus(async { sendCaptureEvent(fallbackCapture, attemptNumber) })
Expand All @@ -387,8 +444,6 @@
eventReporter.addCaptureEvents(faceDetection, attemptNumber, qualityThreshold, spoofCheckConfig, isAutoCapture = isAutoCapture)
}

enum class CapturingState { NOT_STARTED, CAPTURING, VALIDATING, VALIDATION_FAILED, FINISHED }

companion object {
private const val VALID_ROLL_DELTA = 15f
private const val VALID_YAW_DELTA = 30f
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@ internal class SimpleCaptureEventReporter @Inject constructor(
FaceDetection.Status.VALID -> FaceCapturePayload.Result.VALID
FaceDetection.Status.VALID_CAPTURING -> FaceCapturePayload.Result.VALID
FaceDetection.Status.NOFACE -> FaceCapturePayload.Result.INVALID
FaceDetection.Status.BAD_QUALITY -> FaceCapturePayload.Result.BAD_QUALITY
FaceDetection.Status.OFFYAW -> FaceCapturePayload.Result.OFF_YAW
FaceDetection.Status.OFFROLL -> FaceCapturePayload.Result.OFF_ROLL
FaceDetection.Status.TOOCLOSE -> FaceCapturePayload.Result.TOO_CLOSE
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ class FaceCaptureViewModelTest {
?.imageSavingStrategy
} returns ImageSavingStrategy.NEVER

viewModel.initFaceBioSdk(mockk(), ModalitySdkType.SIM_FACE)
viewModel.captureFinished(faceDetections)
viewModel.flowFinished()
coVerify(exactly = 0) { faceImageUseCase.invoke(any(), any()) }
Expand Down Expand Up @@ -148,6 +149,7 @@ class FaceCaptureViewModelTest {

@Test
fun `Recapture requests clears capture list`() {
viewModel.initFaceBioSdk(mockk(), ModalitySdkType.SIM_FACE)
viewModel.captureFinished(faceDetections)
viewModel.recapture()

Expand Down
Loading