diff --git a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/AutoCaptureAttemptRecorder.kt b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/AutoCaptureAttemptRecorder.kt new file mode 100644 index 0000000000..7a9a916a24 --- /dev/null +++ b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/AutoCaptureAttemptRecorder.kt @@ -0,0 +1,85 @@ +package com.simprints.face.capture.screens.livefeedback + +import androidx.lifecycle.SavedStateHandle +import com.simprints.core.tools.extensions.area +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.SimpleCaptureEventReporter +import com.simprints.infra.config.store.models.ModalitySdkType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Owns the auto-capture attempt in progress: starts it, feeds it frames, persists it for process + * death and reports it as a [com.simprints.infra.events.event.domain.models.FaceCaptureAttemptEvent]. + */ +internal class AutoCaptureAttemptRecorder( + private val savedStateHandle: SavedStateHandle, + private val eventReporter: SimpleCaptureEventReporter, + private val timeHelper: TimeHelper, + private val scope: CoroutineScope, +) { + private var bioSdk: String? = null + + @Volatile + var activeAttempt: AutoCaptureAttemptStats? = null + private set + + init { + reportAttemptPersistedBeforeProcessDeath() + } + + fun setBioSdk(sdk: ModalitySdkType) { + bioSdk = sdk.name + } + + fun start(attemptNb: Int) { + // Report any unfinished previous attempt as abandoned + finish() + activeAttempt = bioSdk + ?.let { AutoCaptureAttemptStats(attemptNb = attemptNb, bioSdk = it, startTime = timeHelper.now()) } + .also { savedStateHandle[KEY_ATTEMPT_STATS] = it?.snapshot() } + } + + fun record( + attempt: AutoCaptureAttemptStats, + faceDetection: FaceDetection, + frameTime: Timestamp, + frameWidth: Int, + frameHeight: Int, + ) { + attempt.recordFrame( + detection = faceDetection, + areaOccupied = faceDetection.face?.relativeBoundingBox?.area(), + frameTime = frameTime, + frameWidth = frameWidth, + frameHeight = frameHeight, + ) + attempt.snapshotIfDue()?.let { snapshot -> + scope.launch { + // The attempt may have been finished while this write was queued + if (activeAttempt === attempt) savedStateHandle[KEY_ATTEMPT_STATS] = snapshot + } + } + } + + // Removes the saved-state key synchronously: onCleared runs after viewModelScope is cancelled + fun finish() { + val attempt = activeAttempt ?: return + activeAttempt = null + eventReporter.addCaptureAttemptEvent(attempt.toEvent(timeHelper.now())) + savedStateHandle.remove(KEY_ATTEMPT_STATS) + } + + private fun reportAttemptPersistedBeforeProcessDeath() { + val snapshot = savedStateHandle.get(KEY_ATTEMPT_STATS) ?: return + savedStateHandle.remove(KEY_ATTEMPT_STATS) + val restored = AutoCaptureAttemptStats.restore(snapshot) ?: return + eventReporter.addRecoveredCaptureAttemptEvent(restored.toEvent(endTime = restored.lastFrameTime)) + } + + companion object { + internal const val KEY_ATTEMPT_STATS = "face_auto_capture_attempt_stats" + } +} diff --git a/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/AutoCaptureAttemptStats.kt b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/AutoCaptureAttemptStats.kt new file mode 100644 index 0000000000..ea92abeaad --- /dev/null +++ b/face/capture/src/main/java/com/simprints/face/capture/screens/livefeedback/AutoCaptureAttemptStats.kt @@ -0,0 +1,287 @@ +package com.simprints.face.capture.screens.livefeedback + +import com.simprints.core.tools.time.Timestamp +import com.simprints.core.tools.utils.randomUUID +import com.simprints.face.capture.models.FaceDetection +import com.simprints.infra.events.event.domain.models.FaceCaptureAttemptEvent +import com.simprints.infra.events.event.domain.models.FaceCaptureAttemptEvent.FaceCaptureAttemptPayload.RejectionMetric +import com.simprints.infra.events.event.domain.models.FaceCaptureAttemptEvent.FaceCaptureAttemptPayload.RejectionStats +import com.simprints.infra.events.event.domain.models.FaceCaptureAttemptEvent.FaceCaptureAttemptPayload.RunStatus +import com.simprints.infra.events.event.domain.models.FaceCaptureAttemptEvent.FaceCaptureAttemptPayload.StatusRun +import com.simprints.infra.events.event.domain.models.FaceCaptureAttemptEvent.FaceCaptureAttemptPayload.ValueStats +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlin.random.Random + +/** + * Accumulates auto-capture attempt diagnostics into a [FaceCaptureAttemptEvent]. + * Not thread-safe: confine it to the analyser executor. + */ +internal class AutoCaptureAttemptStats private constructor( + private val state: State, + private val random: Random, +) { + constructor( + attemptNb: Int, + bioSdk: String, + startTime: Timestamp, + random: Random = Random.Default, + ) : this(State(attemptNb = attemptNb, bioSdk = bioSdk, startTime = startTime), random) + + val attemptNb: Int get() = state.attemptNb + val startTime: Timestamp get() = state.startTime + + val lastFrameTime: Timestamp get() = state.lastFrameTime ?: state.startTime + + private var hasFramesSinceSnapshot = false + + private var lastSnapshotMs: Long? = null + + @Synchronized + fun recordFrame( + detection: FaceDetection, + areaOccupied: Float?, + frameTime: Timestamp, + frameWidth: Int, + frameHeight: Int, + ) { + val offsetMs = (frameTime.ms - state.startTime.ms).coerceAtLeast(0L) + val runStatus = detection.status.toRunStatus() + + state.totalFramesAnalysed++ + state.lastFrameTime = frameTime + if (state.targetFrameWidth == null) { + state.targetFrameWidth = frameWidth + state.targetFrameHeight = frameHeight + } + + detection.face?.quality?.let { state.qualityAllFrames.add(it, random) } + + if (runStatus == RunStatus.VALID) { + state.validFrameCount++ + if (state.timeToFirstValidMs == null) state.timeToFirstValidMs = offsetMs + } else { + val reason = state.reasons.getOrPut(runStatus) { ReasonState(firstSeenMs = offsetMs) } + reason.count++ + decidingValue(detection, areaOccupied)?.let { reason.values.add(it, random) } + } + + advanceRuns(runStatus, offsetMs) + hasFramesSinceSnapshot = true + } + + @Synchronized + fun toEvent(endTime: Timestamp): FaceCaptureAttemptEvent { + val endOffsetMs = (endTime.ms - state.startTime.ms).coerceAtLeast(0L) + closeOpenRun(endOffsetMs) + + return FaceCaptureAttemptEvent( + startTime = state.startTime, + endTime = endTime, + attemptNb = state.attemptNb, + bioSdk = state.bioSdk, + targetFrameWidth = state.targetFrameWidth, + targetFrameHeight = state.targetFrameHeight, + totalFramesAnalysed = state.totalFramesAnalysed, + validFrameCount = state.validFrameCount, + timeToFirstValidMs = state.timeToFirstValidMs, + rejectionStats = RejectionStats( + invalid = state.reasons[RunStatus.INVALID]?.toMetric(), + badQuality = state.reasons[RunStatus.BAD_QUALITY]?.toMetric(), + offYaw = state.reasons[RunStatus.OFF_YAW]?.toMetric(), + offRoll = state.reasons[RunStatus.OFF_ROLL]?.toMetric(), + tooClose = state.reasons[RunStatus.TOO_CLOSE]?.toMetric(), + tooFar = state.reasons[RunStatus.TOO_FAR]?.toMetric(), + ), + qualityAllFrames = state.qualityAllFrames.toValueStats(), + statusRuns = state.closedRuns.map { StatusRun(it.status, it.frameCount, it.durationMs) }, + statusRunsTruncated = state.statusRunsTruncated, + id = state.eventId, + ) + } + + @Synchronized + fun snapshot(): String { + hasFramesSinceSnapshot = false + lastSnapshotMs = state.lastFrameTime?.ms + return json.encodeToString(State.serializer(), state) + } + + /** Snapshot on the first frame, then at most once per [SNAPSHOT_INTERVAL_MS] of frame time. */ + @Synchronized + fun snapshotIfDue(): String? { + if (!hasFramesSinceSnapshot) return null + val nowMs = state.lastFrameTime?.ms ?: return null + val last = lastSnapshotMs + if (last != null && nowMs - last < SNAPSHOT_INTERVAL_MS) return null + return snapshot() + } + + private fun advanceRuns( + status: RunStatus, + offsetMs: Long, + ) { + val open = state.openRun + if (open != null && open.status == status) { + open.frameCount++ + return + } + if (open != null) closeRun(open, endOffsetMs = offsetMs) + state.openRun = OpenRun(status = status, startMs = offsetMs, frameCount = 1) + } + + private fun closeOpenRun(endOffsetMs: Long) { + state.openRun?.let { closeRun(it, endOffsetMs) } + state.openRun = null + } + + private fun closeRun( + run: OpenRun, + endOffsetMs: Long, + ) { + val durationMs = (endOffsetMs - run.startMs).coerceAtLeast(0L) + + state.reasons[run.status]?.let { reason -> + if (durationMs > reason.longestRunMs) reason.longestRunMs = durationMs + } + + if (state.closedRuns.size < FaceCaptureAttemptEvent.MAX_STATUS_RUNS) { + state.closedRuns.add(ClosedRun(run.status, run.frameCount, durationMs)) + } else { + state.statusRunsTruncated = true + } + } + + private fun decidingValue( + detection: FaceDetection, + areaOccupied: Float?, + ): Float? = when (detection.status) { + FaceDetection.Status.TOOFAR, + FaceDetection.Status.TOOCLOSE, + -> areaOccupied + + FaceDetection.Status.OFFYAW -> detection.face?.yaw + FaceDetection.Status.OFFROLL -> detection.face?.roll + FaceDetection.Status.BAD_QUALITY -> detection.face?.quality + FaceDetection.Status.NOFACE, + FaceDetection.Status.VALID, + FaceDetection.Status.VALID_CAPTURING, + -> null + } + + private fun ReasonState.toMetric(): RejectionMetric { + val stats = values.toValueStats() + return RejectionMetric( + count = count, + firstSeenMs = firstSeenMs, + longestRunMs = longestRunMs, + minValue = stats?.minValue, + maxValue = stats?.maxValue, + medianValue = stats?.medianValue, + ) + } + + @Serializable + private class State( + // Persisted so an attempt recovered after process death keeps its event id + val eventId: String = randomUUID(), + val attemptNb: Int, + val bioSdk: String, + val startTime: Timestamp, + var lastFrameTime: Timestamp? = null, + var targetFrameWidth: Int? = null, + var targetFrameHeight: Int? = null, + var totalFramesAnalysed: Int = 0, + var validFrameCount: Int = 0, + var timeToFirstValidMs: Long? = null, + val reasons: MutableMap = mutableMapOf(), + val qualityAllFrames: BoundedSamples = BoundedSamples(), + val closedRuns: MutableList = mutableListOf(), + var openRun: OpenRun? = null, + var statusRunsTruncated: Boolean = false, + ) + + @Serializable + private class ReasonState( + val firstSeenMs: Long, + var count: Int = 0, + var longestRunMs: Long = 0L, + val values: BoundedSamples = BoundedSamples(), + ) + + /** Reservoir sample (Algorithm R): exact min/max, estimated median beyond [MAX_VALUE_SAMPLES]. */ + @Serializable + private class BoundedSamples( + var seen: Int = 0, + var minValue: Float? = null, + var maxValue: Float? = null, + val samples: MutableList = mutableListOf(), + ) { + fun add( + value: Float, + random: Random, + ) { + seen++ + val min = minValue + val max = maxValue + if (min == null || value < min) minValue = value + if (max == null || value > max) maxValue = value + if (samples.size < MAX_VALUE_SAMPLES) { + samples.add(value) + } else { + val slot = random.nextInt(seen) + if (slot < MAX_VALUE_SAMPLES) samples[slot] = value + } + } + + fun toValueStats(): ValueStats? { + val min = minValue ?: return null + val max = maxValue ?: return null + val sorted = samples.sorted() + val mid = sorted.size / 2 + val median = if (sorted.size % 2 == 0) (sorted[mid - 1] + sorted[mid]) / 2f else sorted[mid] + return ValueStats(minValue = min, maxValue = max, medianValue = median) + } + } + + @Serializable + private class OpenRun( + val status: RunStatus, + val startMs: Long, + var frameCount: Int, + ) + + @Serializable + private class ClosedRun( + val status: RunStatus, + val frameCount: Int, + val durationMs: Long, + ) + + companion object { + internal const val MAX_VALUE_SAMPLES = 128 + internal const val SNAPSHOT_INTERVAL_MS = 1_000L + + private val json = Json { encodeDefaults = true } + + fun restore( + snapshot: String, + random: Random = Random.Default, + ): AutoCaptureAttemptStats? = runCatching { + AutoCaptureAttemptStats(json.decodeFromString(State.serializer(), snapshot), random) + }.getOrNull() + + fun FaceDetection.Status.toRunStatus(): RunStatus = when (this) { + FaceDetection.Status.VALID, + FaceDetection.Status.VALID_CAPTURING, + -> RunStatus.VALID + + FaceDetection.Status.NOFACE -> RunStatus.INVALID + FaceDetection.Status.BAD_QUALITY -> RunStatus.BAD_QUALITY + FaceDetection.Status.OFFYAW -> RunStatus.OFF_YAW + FaceDetection.Status.OFFROLL -> RunStatus.OFF_ROLL + FaceDetection.Status.TOOCLOSE -> RunStatus.TOO_CLOSE + FaceDetection.Status.TOOFAR -> RunStatus.TOO_FAR + } + } +} 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 041787e52c..9371e3d098 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 @@ -1,6 +1,7 @@ package com.simprints.face.capture.screens.livefeedback import android.graphics.Bitmap +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.simprints.core.DispatcherBG @@ -57,6 +58,7 @@ internal class LiveFeedbackViewModel @Inject constructor( private val getSpoofCheckConfiguration: GetSpoofCheckConfigurationUseCase, private val captureAttemptTracker: CaptureAttemptTracker, @param:DispatcherBG private val bgDispatcher: CoroutineDispatcher, + private val savedStateHandle: SavedStateHandle, ) : ViewModel() { private var samplesToCapture: Int = 1 private var qualityThreshold: Float = 0f @@ -97,6 +99,8 @@ internal class LiveFeedbackViewModel @Inject constructor( private lateinit var faceDetector: FaceDetector private var hasAutoRequestedPermission = false + private val attemptRecorder = AutoCaptureAttemptRecorder(savedStateHandle, eventReporter, timeHelper, viewModelScope) + private val phase: LiveFeedbackState.Phase get() = state.value.phase @@ -162,6 +166,7 @@ internal class LiveFeedbackViewModel @Inject constructor( ) { Simber.i("Initialise face detection", tag = FACE_CAPTURE) this.samplesToCapture = samplesToCapture + attemptRecorder.setBioSdk(bioSdk) viewModelScope.launch { faceDetector = resolveFaceBioSdk(bioSdk).detector @@ -184,6 +189,8 @@ internal class LiveFeedbackViewModel @Inject constructor( return // too late - imaging has already started } isAutoCaptureHeldOff = true + // End the attempt at the pause so paused time is not counted as a rejection + attemptRecorder.finish() emit(phase = LiveFeedbackState.Phase.NOT_STARTED, feedback = LiveFeedbackState.Feedback.NONE) // reset view } } @@ -192,6 +199,7 @@ internal class LiveFeedbackViewModel @Inject constructor( // Single path for every new capture attempt captureAttemptTracker.onNewCaptureAttemptStarted() if (isAutoCapture) { + attemptRecorder.start(captureAttemptTracker.attemptNumber) isAutoCaptureHeldOff = false } else { emit(phase = LiveFeedbackState.Phase.CAPTURING) @@ -238,20 +246,34 @@ internal class LiveFeedbackViewModel @Inject constructor( return } + val attempt = attemptRecorder.activeAttempt val captureStartTime = timeHelper.now() + val targetFrameWidth = croppedBitmap.width + val targetFrameHeight = croppedBitmap.height val potentialFace = faceDetector.analyze(croppedBitmap) val faceDetection = getFaceDetectionFromPotentialFace(originalBitmap, croppedBitmap, potentialFace) faceDetection.detectionStartTime = captureStartTime faceDetection.detectionEndTime = timeHelper.now() + // Read once so frame recording and the imaging-start decision agree + val isHeldOff = isAutoCaptureHeldOff + val isCurrentAttempt = attempt === attemptRecorder.activeAttempt + if (isAutoCapture && !isHeldOff && isCurrentAttempt && attempt != null) { + attemptRecorder.record(attempt, faceDetection, captureStartTime, targetFrameWidth, targetFrameHeight) + } + var newPhase = phase var feedback = state.value.feedback if (isAutoCapture) { - if (!isAutoCaptureHeldOff) { + if (!isHeldOff) { feedback = faceDetection.status.toFeedback() - if (faceDetection.status == FaceDetection.Status.VALID && phase == LiveFeedbackState.Phase.NOT_STARTED) { + if ( + faceDetection.status == FaceDetection.Status.VALID && + phase == LiveFeedbackState.Phase.NOT_STARTED && + isCurrentAttempt + ) { newPhase = LiveFeedbackState.Phase.CAPTURING captureImagingStartTime = captureStartTime.ms autoCaptureImagingTimeoutJob = viewModelScope.launch { @@ -348,6 +370,7 @@ internal class LiveFeedbackViewModel @Inject constructor( */ private fun finishCapture(attemptNumber: Int) { Simber.i("Finish capture", tag = FACE_CAPTURE) + attemptRecorder.finish() viewModelScope.launch { enrichCapturesWithAgeAndGender() if (spoofCheckConfig.mode == SpoofCheckMode.DISABLED) { @@ -535,6 +558,16 @@ internal class LiveFeedbackViewModel @Inject constructor( eventReporter.addCaptureEvents(faceDetection, attemptNumber, qualityThreshold, spoofCheckConfig, isAutoCapture = isAutoCapture) } + override fun onCleared() { + attemptRecorder.finish() + super.onCleared() + } + + companion object { + private const val VALID_ROLL_DELTA = 15f + private const val VALID_YAW_DELTA = 30f + } + enum class PermissionAction { RequestCameraPermission, OpenAppSettings, 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 7ba7db2c58..59c44a3943 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 @@ -8,6 +8,7 @@ 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.FaceCaptureAttemptEvent import com.simprints.infra.events.event.domain.models.FaceCaptureBiometricsEvent import com.simprints.infra.events.event.domain.models.FaceCaptureConfirmationEvent import com.simprints.infra.events.event.domain.models.FaceCaptureConfirmationEvent.FaceCaptureConfirmationPayload.Result @@ -50,6 +51,17 @@ internal class SimpleCaptureEventReporter @Inject constructor( eventRepository.addOrUpdateEvent(FaceFallbackCaptureEvent(startTime, endTime)) } + fun addCaptureAttemptEvent(event: FaceCaptureAttemptEvent) = sessionCoroutineScope.launch { + eventRepository.addOrUpdateEvent(event) + } + + // Saved state can predate the attempt's completion, so never overwrite an already saved attempt + fun addRecoveredCaptureAttemptEvent(event: FaceCaptureAttemptEvent) = sessionCoroutineScope.launch { + if (eventRepository.getEventsInCurrentSession().none { it.id == event.id }) { + eventRepository.addOrUpdateEvent(event) + } + } + suspend fun addCaptureEvents( faceDetection: FaceDetection, attempt: Int, diff --git a/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/AutoCaptureAttemptStatsTest.kt b/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/AutoCaptureAttemptStatsTest.kt new file mode 100644 index 0000000000..4947fb22b8 --- /dev/null +++ b/face/capture/src/test/java/com/simprints/face/capture/screens/livefeedback/AutoCaptureAttemptStatsTest.kt @@ -0,0 +1,308 @@ +package com.simprints.face.capture.screens.livefeedback + +import android.graphics.Bitmap +import android.graphics.Rect +import com.google.common.truth.Truth.* +import com.simprints.core.tools.time.Timestamp +import com.simprints.face.capture.models.FaceDetection +import com.simprints.face.infra.basebiosdk.detection.Face +import com.simprints.infra.events.event.domain.models.FaceCaptureAttemptEvent +import com.simprints.infra.events.event.domain.models.FaceCaptureAttemptEvent.FaceCaptureAttemptPayload.RunStatus +import io.mockk.mockk +import org.junit.Before +import org.junit.Test +import kotlin.random.Random + +class AutoCaptureAttemptStatsTest { + private val bitmap: Bitmap = mockk(relaxed = true) + private lateinit var stats: AutoCaptureAttemptStats + + @Before + fun setUp() { + stats = AutoCaptureAttemptStats( + attemptNb = 2, + bioSdk = "RANK_ONE", + startTime = Timestamp(START_MS), + random = Random(SEED), + ) + } + + @Test + fun `empty attempt builds an event with zero counts and no runs`() { + val payload = stats.toEvent(Timestamp(START_MS + 500)).payload + + assertThat(payload.attemptNb).isEqualTo(2) + assertThat(payload.bioSdk).isEqualTo("RANK_ONE") + assertThat(payload.createdAt).isEqualTo(Timestamp(START_MS)) + assertThat(payload.endedAt).isEqualTo(Timestamp(START_MS + 500)) + assertThat(payload.totalFramesAnalysed).isEqualTo(0) + assertThat(payload.validFrameCount).isEqualTo(0) + assertThat(payload.timeToFirstValidMs).isNull() + assertThat(payload.targetFrameWidth).isNull() + assertThat(payload.qualityAllFrames).isNull() + assertThat(payload.statusRuns).isEmpty() + assertThat(payload.statusRunsTruncated).isFalse() + assertThat(payload.rejectionStats.tooFar).isNull() + } + + @Test + fun `counts frames per reason and records frame dimensions from the first frame`() { + record(FaceDetection.Status.TOOFAR, area = 0.12f, atMs = 0) + record(FaceDetection.Status.TOOFAR, area = 0.18f, atMs = 100) + record(FaceDetection.Status.OFFYAW, yaw = -41f, atMs = 200) + record(FaceDetection.Status.VALID, atMs = 300) + record(FaceDetection.Status.VALID_CAPTURING, atMs = 400) + + val payload = stats.toEvent(Timestamp(START_MS + 500)).payload + + assertThat(payload.totalFramesAnalysed).isEqualTo(5) + assertThat(payload.validFrameCount).isEqualTo(2) + assertThat(payload.timeToFirstValidMs).isEqualTo(300L) + assertThat(payload.targetFrameWidth).isEqualTo(FRAME_W) + assertThat(payload.targetFrameHeight).isEqualTo(FRAME_H) + assertThat(payload.rejectionStats.tooFar?.count).isEqualTo(2) + assertThat(payload.rejectionStats.offYaw?.count).isEqualTo(1) + assertThat(payload.rejectionStats.badQuality).isNull() + } + + @Test + fun `records the value of the check that failed, with min max and median`() { + record(FaceDetection.Status.TOOFAR, area = 0.18f, atMs = 0) + record(FaceDetection.Status.TOOFAR, area = 0.12f, atMs = 50) + record(FaceDetection.Status.TOOFAR, area = 0.15f, atMs = 100) + record(FaceDetection.Status.OFFROLL, roll = 22f, atMs = 150) + record(FaceDetection.Status.OFFROLL, roll = -30f, atMs = 200) + record(FaceDetection.Status.BAD_QUALITY, quality = 0.3f, atMs = 250) + + val rs = stats.toEvent(Timestamp(START_MS + 300)).payload.rejectionStats + + with(rs.tooFar!!) { + assertThat(minValue).isEqualTo(0.12f) + assertThat(maxValue).isEqualTo(0.18f) + assertThat(medianValue).isEqualTo(0.15f) + } + with(rs.offRoll!!) { + assertThat(minValue).isEqualTo(-30f) + assertThat(maxValue).isEqualTo(22f) + assertThat(medianValue).isEqualTo(-4f) + } + assertThat(rs.badQuality?.medianValue).isEqualTo(0.3f) + } + + @Test + fun `invalid frames carry no value statistics`() { + record(FaceDetection.Status.NOFACE, atMs = 0) + record(FaceDetection.Status.NOFACE, atMs = 100) + + val invalid = stats + .toEvent(Timestamp(START_MS + 200)) + .payload.rejectionStats.invalid!! + + assertThat(invalid.count).isEqualTo(2) + assertThat(invalid.minValue).isNull() + assertThat(invalid.maxValue).isNull() + assertThat(invalid.medianValue).isNull() + } + + @Test + fun `quality across all frames includes frames rejected for geometry`() { + record(FaceDetection.Status.TOOFAR, area = 0.1f, quality = 0.9f, atMs = 0) + record(FaceDetection.Status.OFFYAW, yaw = 40f, quality = 0.5f, atMs = 100) + record(FaceDetection.Status.NOFACE, atMs = 200) + record(FaceDetection.Status.VALID, quality = 0.7f, atMs = 300) + + val q = stats.toEvent(Timestamp(START_MS + 400)).payload.qualityAllFrames!! + + assertThat(q.minValue).isEqualTo(0.5f) + assertThat(q.maxValue).isEqualTo(0.9f) + assertThat(q.medianValue).isEqualTo(0.7f) + } + + @Test + fun `firstSeenMs and longestRunMs are measured from attempt start`() { + record(FaceDetection.Status.TOOFAR, area = 0.1f, atMs = 0) + record(FaceDetection.Status.TOOFAR, area = 0.1f, atMs = 100) + record(FaceDetection.Status.TOOFAR, area = 0.1f, atMs = 200) + record(FaceDetection.Status.OFFYAW, yaw = 40f, atMs = 300) + record(FaceDetection.Status.TOOFAR, area = 0.1f, atMs = 450) + record(FaceDetection.Status.VALID, atMs = 500) + + val payload = stats.toEvent(Timestamp(START_MS + 900)).payload + + with(payload.rejectionStats.tooFar!!) { + assertThat(firstSeenMs).isEqualTo(0L) + assertThat(longestRunMs).isEqualTo(300L) + assertThat(count).isEqualTo(4) + } + with(payload.rejectionStats.offYaw!!) { + assertThat(firstSeenMs).isEqualTo(300L) + assertThat(longestRunMs).isEqualTo(150L) + } + assertThat(payload.timeToFirstValidMs).isEqualTo(500L) + } + + @Test + fun `status runs are run-length encoded in order with frame counts and durations`() { + record(FaceDetection.Status.TOOFAR, area = 0.1f, atMs = 0) + record(FaceDetection.Status.TOOFAR, area = 0.1f, atMs = 100) + record(FaceDetection.Status.OFFYAW, yaw = 40f, atMs = 300) + record(FaceDetection.Status.VALID, atMs = 500) + record(FaceDetection.Status.VALID_CAPTURING, atMs = 600) + record(FaceDetection.Status.VALID_CAPTURING, atMs = 700) + + val runs = stats.toEvent(Timestamp(START_MS + 1000)).payload.statusRuns + + assertThat(runs.map { it.status }).containsExactly(RunStatus.TOO_FAR, RunStatus.OFF_YAW, RunStatus.VALID).inOrder() + assertThat(runs.map { it.frameCount }).containsExactly(2, 1, 3).inOrder() + assertThat(runs.map { it.durationMs }).containsExactly(300L, 200L, 500L).inOrder() + } + + @Test + fun `status runs are capped and the truncation flag is set, while counts keep accumulating`() { + val max = FaceCaptureAttemptEvent.MAX_STATUS_RUNS + repeat(max + 50) { i -> + if (i % 2 == 0) { + record(FaceDetection.Status.TOOFAR, area = 0.1f, atMs = i * 10L) + } else { + record(FaceDetection.Status.NOFACE, atMs = i * 10L) + } + } + + val payload = stats.toEvent(Timestamp(START_MS + (max + 50) * 10L)).payload + + assertThat(payload.statusRuns).hasSize(max) + assertThat(payload.statusRunsTruncated).isTrue() + assertThat(payload.totalFramesAnalysed).isEqualTo(max + 50) + assertThat((payload.rejectionStats.tooFar?.count ?: 0) + (payload.rejectionStats.invalid?.count ?: 0)) + .isEqualTo(max + 50) + } + + @Test + fun `snapshot and restore preserve the attempt across process death`() { + record(FaceDetection.Status.TOOFAR, area = 0.12f, atMs = 0) + record(FaceDetection.Status.OFFYAW, yaw = 40f, quality = 0.8f, atMs = 200) + record(FaceDetection.Status.VALID, quality = 0.9f, atMs = 400) + + val restored = AutoCaptureAttemptStats.restore(stats.snapshot())!! + + assertThat(restored.attemptNb).isEqualTo(2) + assertThat(restored.startTime).isEqualTo(Timestamp(START_MS)) + assertThat(restored.lastFrameTime).isEqualTo(Timestamp(START_MS + 400)) + + val fromRestored = restored.toEvent(Timestamp(START_MS + 600)).payload + val fromOriginal = stats.toEvent(Timestamp(START_MS + 600)).payload + + assertThat(fromRestored.totalFramesAnalysed).isEqualTo(fromOriginal.totalFramesAnalysed) + assertThat(fromRestored.rejectionStats).isEqualTo(fromOriginal.rejectionStats) + assertThat(fromRestored.statusRuns).isEqualTo(fromOriginal.statusRuns) + assertThat(fromRestored.qualityAllFrames).isEqualTo(fromOriginal.qualityAllFrames) + assertThat(fromRestored.timeToFirstValidMs).isEqualTo(400L) + } + + @Test + fun `restored attempt keeps the original event id`() { + record(FaceDetection.Status.TOOFAR, area = 0.12f, atMs = 0) + val restored = AutoCaptureAttemptStats.restore(stats.snapshot())!! + + assertThat(restored.toEvent(Timestamp(START_MS + 100)).id).isEqualTo(stats.toEvent(Timestamp(START_MS + 100)).id) + } + + @Test + fun `restore returns null for a corrupt snapshot`() { + assertThat(AutoCaptureAttemptStats.restore("not json")).isNull() + } + + @Test + fun `value samples are bounded while min and max stay exact and the median is estimated`() { + val n = MANY_FRAMES + val areas = (0 until n).map { it / n.toFloat() }.shuffled(Random(SEED)) + areas.forEachIndexed { i, area -> record(FaceDetection.Status.TOOFAR, area = area, atMs = i * 33L) } + + val tooFar = stats + .toEvent(Timestamp(START_MS + n * 33L)) + .payload.rejectionStats.tooFar!! + + assertThat(tooFar.count).isEqualTo(n) + assertThat(tooFar.minValue).isEqualTo(0f) + assertThat(tooFar.maxValue).isWithin(0.0001f).of((n - 1) / n.toFloat()) + assertThat(tooFar.medianValue).isWithin(0.1f).of(0.5f) + } + + @Test + fun `snapshot size stays bounded for a long attempt with a status flickering every frame`() { + val n = MANY_FRAMES + repeat(n) { i -> + if (i % 2 == 0) { + record(FaceDetection.Status.TOOFAR, area = i / n.toFloat(), quality = i / n.toFloat(), atMs = i * 33L) + } else { + record(FaceDetection.Status.OFFYAW, yaw = i.toFloat(), quality = i / n.toFloat(), atMs = i * 33L) + } + } + + val snapshot = stats.snapshot() + + assertThat(snapshot.length).isLessThan(60_000) + val restored = AutoCaptureAttemptStats.restore(snapshot)!! + val payload = restored.toEvent(Timestamp(START_MS + n * 33L)).payload + assertThat(payload.totalFramesAnalysed).isEqualTo(n) + assertThat(payload.statusRuns).hasSize(FaceCaptureAttemptEvent.MAX_STATUS_RUNS) + assertThat(payload.statusRunsTruncated).isTrue() + } + + @Test + fun `snapshotIfDue persists the first frame, then throttles by frame time regardless of status changes`() { + assertThat(stats.snapshotIfDue()).isNull() + + record(FaceDetection.Status.TOOFAR, area = 0.1f, atMs = 0) + assertThat(stats.snapshotIfDue()).isNotNull() + assertThat(stats.snapshotIfDue()).isNull() + + record(FaceDetection.Status.NOFACE, atMs = 100) + record(FaceDetection.Status.TOOFAR, area = 0.1f, atMs = 200) + record(FaceDetection.Status.NOFACE, atMs = 300) + assertThat(stats.snapshotIfDue()).isNull() + + record(FaceDetection.Status.NOFACE, atMs = AutoCaptureAttemptStats.SNAPSHOT_INTERVAL_MS) + assertThat(stats.snapshotIfDue()).isNotNull() + assertThat(stats.snapshotIfDue()).isNull() + } + + private fun record( + status: FaceDetection.Status, + area: Float? = null, + yaw: Float = 0f, + roll: Float = 0f, + quality: Float = 1f, + atMs: Long, + ) { + val face = if (status == FaceDetection.Status.NOFACE) { + null + } else { + Face(100, 100, Rect(0, 0, 60, 60), yaw, roll, quality, ByteArray(0), "format") + } + val detection = FaceDetection( + original = bitmap, + bitmap = bitmap, + face = face, + status = status, + detectionStartTime = Timestamp(START_MS + atMs), + detectionEndTime = Timestamp(START_MS + atMs), + ) + stats.recordFrame( + detection = detection, + areaOccupied = area, + frameTime = Timestamp(START_MS + atMs), + frameWidth = FRAME_W, + frameHeight = FRAME_H, + ) + } + + companion object { + private const val START_MS = 1_000_000L + private const val FRAME_W = 556 + private const val FRAME_H = 556 + private const val SEED = 42 + + private const val MANY_FRAMES = 2_000 + } +} 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 21e4f1a6a8..dfe9807a99 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 @@ -3,6 +3,8 @@ package com.simprints.face.capture.screens.livefeedback import android.graphics.Bitmap import android.graphics.Rect import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModelStore import androidx.test.ext.junit.runners.* import com.google.common.truth.Truth.* import com.simprints.core.domain.permission.PermissionStatus @@ -21,6 +23,7 @@ import com.simprints.infra.config.store.models.ExperimentalProjectConfiguration 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.events.event.domain.models.FaceCaptureAttemptEvent import com.simprints.testtools.common.coroutines.TestCoroutineRule import io.mockk.* import io.mockk.impl.annotations.MockK @@ -83,6 +86,8 @@ internal class LiveFeedbackViewModelTest { private val testCaptureAttemptTracker = CaptureAttemptTracker() + private lateinit var savedStateHandle: SavedStateHandle + @Before fun setUp() { MockKAnnotations.init(this, relaxed = true) @@ -101,6 +106,7 @@ internal class LiveFeedbackViewModelTest { every { timeHelper.now() } returnsMany (0..1000L).map { Timestamp(it) } justRun { frame.recycle() } + savedStateHandle = SavedStateHandle() viewModel = LiveFeedbackViewModel( resolveFaceBioSdkUseCase, @@ -111,6 +117,7 @@ internal class LiveFeedbackViewModelTest { getSpoofCheckConfiguration, testCaptureAttemptTracker, testCoroutineRule.testCoroutineDispatcher, + savedStateHandle, ) } @@ -765,7 +772,7 @@ internal class LiveFeedbackViewModelTest { viewModel.initCapture(ModalitySdkType.SIM_FACE, 1) viewModel.process(frame, frame) // pre-start fallback frame (held off) viewModel.startCapture() - viewModel.process(frame, frame) // begins imaging + viewModel.process(frame, frame) advanceTimeBy(AUTO_CAPTURE_IMAGING_DURATION_MS + 1) // 1 stored sample + 1 fallback. @@ -773,6 +780,316 @@ internal class LiveFeedbackViewModelTest { coVerify(exactly = 1) { eventReporter.addFallbackCaptureEvent(any(), any()) } } + @Test + fun `attempt event - auto capture emits one attempt event on finish with per-reason counts`() = runTest { + every { isUsingAutoCapture.invoke(any()) } returns true + every { faceDetector.analyze(frame) } returnsMany listOf( + getFace(Rect(0, 0, 30, 30)), + getFace(yaw = 45f), + getFace(), + ) + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 1) + viewModel.startCapture() + repeat(3) { viewModel.process(frame, frame) } + advanceTimeBy(AUTO_CAPTURE_IMAGING_DURATION_MS + 1) + + val slot = slot() + verify(exactly = 1) { eventReporter.addCaptureAttemptEvent(capture(slot)) } + with(slot.captured.payload) { + assertThat(attemptNb).isEqualTo(0) + assertThat(bioSdk).isEqualTo("SIM_FACE") + assertThat(totalFramesAnalysed).isEqualTo(3) + assertThat(validFrameCount).isEqualTo(1) + assertThat(timeToFirstValidMs).isNotNull() + assertThat(rejectionStats.tooFar?.count).isEqualTo(1) + assertThat(rejectionStats.offYaw?.count).isEqualTo(1) + assertThat(rejectionStats.tooFar?.minValue).isWithin(0.001f).of(0.09f) + assertThat(rejectionStats.offYaw?.minValue).isEqualTo(45f) + assertThat(statusRuns.map { it.status }) + .containsExactly( + FaceCaptureAttemptEvent.FaceCaptureAttemptPayload.RunStatus.TOO_FAR, + FaceCaptureAttemptEvent.FaceCaptureAttemptPayload.RunStatus.OFF_YAW, + FaceCaptureAttemptEvent.FaceCaptureAttemptPayload.RunStatus.VALID, + ).inOrder() + assertThat(qualityAllFrames).isNotNull() + } + } + + @Test + fun `attempt event - frames analysed before the capture button are not part of the attempt`() = runTest { + every { isUsingAutoCapture.invoke(any()) } returns true + every { faceDetector.analyze(frame) } returns getFace() + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.RANK_ONE, 1) + viewModel.process(frame, frame) + viewModel.process(frame, frame) + viewModel.startCapture() + viewModel.process(frame, frame) + advanceTimeBy(AUTO_CAPTURE_IMAGING_DURATION_MS + 1) + + val slot = slot() + verify(exactly = 1) { eventReporter.addCaptureAttemptEvent(capture(slot)) } + assertThat(slot.captured.payload.totalFramesAnalysed).isEqualTo(1) + assertThat(slot.captured.payload.bioSdk).isEqualTo("RANK_ONE") + } + + @Test + fun `attempt event - manual capture emits no attempt event`() = runTest { + every { faceDetector.analyze(frame) } returns getFace() + val states = collectStates() + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 1) + viewModel.startCapture() + viewModel.process(frame, frame) + advanceUntilIdle() + + // process() emits CAPTURING after finishCapture(), so check collected phases + assertThat(states.map { it.phase }).contains(LiveFeedbackState.Phase.FINISHED) + coVerify(atLeast = 1) { eventReporter.addCaptureEvents(any(), any(), any(), any(), any()) } + verify(exactly = 0) { eventReporter.addCaptureAttemptEvent(any()) } + } + + @Test + fun `attempt event - leaving the screen with an attempt that never reached a valid frame emits it as abandoned`() = runTest { + every { isUsingAutoCapture.invoke(any()) } returns true + every { faceDetector.analyze(frame) } returns getFace(Rect(0, 0, 30, 30)) + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 1) + viewModel.startCapture() + repeat(4) { viewModel.process(frame, frame) } + advanceUntilIdle() + verify(exactly = 0) { eventReporter.addCaptureAttemptEvent(any()) } + + ViewModelStore().apply { + put("vm", viewModel) + clear() + } + + val slot = slot() + verify(exactly = 1) { eventReporter.addCaptureAttemptEvent(capture(slot)) } + with(slot.captured.payload) { + assertThat(totalFramesAnalysed).isEqualTo(4) + assertThat(validFrameCount).isEqualTo(0) + assertThat(timeToFirstValidMs).isNull() + assertThat(rejectionStats.tooFar?.count).isEqualTo(4) + } + } + + @Test + fun `attempt event - starting a new attempt flushes an unfinished one as abandoned`() = runTest { + every { isUsingAutoCapture.invoke(any()) } returns true + every { faceDetector.analyze(frame) } returns getFace(Rect(0, 0, 30, 30)) + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 1) + viewModel.startCapture() + viewModel.process(frame, frame) + viewModel.holdOffAutoCapture() + viewModel.startCapture() + + val slot = slot() + verify(exactly = 1) { eventReporter.addCaptureAttemptEvent(capture(slot)) } + assertThat(slot.captured.payload.attemptNb).isEqualTo(0) + assertThat(slot.captured.payload.totalFramesAnalysed).isEqualTo(1) + } + + @Test + fun `attempt event - a frame whose analysis started before the capture button is not part of the attempt`() = runTest { + every { isUsingAutoCapture.invoke(any()) } returns true + var calls = 0 + every { faceDetector.analyze(frame) } answers { + if (calls++ == 0) viewModel.startCapture() + getFace(Rect(0, 0, 30, 30)) + } + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 1) + viewModel.process(frame, frame) + viewModel.process(frame, frame) + ViewModelStore().apply { + put("vm", viewModel) + clear() + } + + val slot = slot() + verify(exactly = 1) { eventReporter.addCaptureAttemptEvent(capture(slot)) } + assertThat(slot.captured.payload.totalFramesAnalysed).isEqualTo(1) + } + + @Test + fun `attempt event - a frame in flight when a new attempt starts is attributed to neither attempt`() = runTest { + every { isUsingAutoCapture.invoke(any()) } returns true + var calls = 0 + every { faceDetector.analyze(frame) } answers { + if (calls++ == 1) { + viewModel.holdOffAutoCapture() + viewModel.startCapture() + } + getFace(Rect(0, 0, 30, 30)) + } + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 1) + viewModel.startCapture() + viewModel.process(frame, frame) + viewModel.process(frame, frame) + + val slot = slot() + verify(exactly = 1) { eventReporter.addCaptureAttemptEvent(capture(slot)) } + assertThat(slot.captured.payload.attemptNb).isEqualTo(0) + assertThat(slot.captured.payload.totalFramesAnalysed).isEqualTo(1) + + val persisted = AutoCaptureAttemptStats.restore(savedStateHandle[AutoCaptureAttemptRecorder.KEY_ATTEMPT_STATS]!!)!! + assertThat(persisted.attemptNb).isEqualTo(1) + assertThat(persisted.toEvent(Timestamp(0L)).payload.totalFramesAnalysed).isEqualTo(0) + } + + @Test + fun `attempt event - starting an attempt persists it before any frame is analysed`() = runTest { + every { isUsingAutoCapture.invoke(any()) } returns true + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 1) + viewModel.startCapture() + + val persisted = AutoCaptureAttemptStats.restore(savedStateHandle[AutoCaptureAttemptRecorder.KEY_ATTEMPT_STATS]!!) + assertThat(persisted?.attemptNb).isEqualTo(0) + } + + @Test + fun `attempt event - ends when imaging ends, not after spoof validation`() = runTest { + every { isUsingAutoCapture.invoke(any()) } returns true + every { getSpoofCheckConfiguration.invoke(any(), any()) } returns spoofConfig() + every { faceDetector.analyze(frame) } returns getFace() + coEvery { faceDetector.spoofCheck(any(), any()) } returns SpoofCheckResult(score = 0.1f) + every { timeHelper.now() } answers { Timestamp(testScheduler.currentTime) } + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 1) + viewModel.startCapture() + viewModel.process(frame, frame) + advanceUntilIdle() + + val slot = slot() + verify(exactly = 1) { eventReporter.addCaptureAttemptEvent(capture(slot)) } + with(slot.captured.payload) { + assertThat(endedAt).isEqualTo(Timestamp(AUTO_CAPTURE_IMAGING_DURATION_MS)) + assertThat(statusRuns.sumOf { it.durationMs }).isEqualTo(AUTO_CAPTURE_IMAGING_DURATION_MS) + } + } + + @Test + fun `attempt event - a valid frame whose analysis started before the capture button does not start imaging`() = runTest { + every { isUsingAutoCapture.invoke(any()) } returns true + var calls = 0 + every { faceDetector.analyze(frame) } answers { + if (calls++ == 0) viewModel.startCapture() + getFace() + } + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 1) + viewModel.process(frame, frame) + assertThat(viewModel.state.value.phase).isEqualTo(LiveFeedbackState.Phase.NOT_STARTED) + + viewModel.process(frame, frame) + assertThat(viewModel.state.value.phase).isEqualTo(LiveFeedbackState.Phase.CAPTURING) + advanceTimeBy(AUTO_CAPTURE_IMAGING_DURATION_MS + 1) + + val slot = slot() + verify(exactly = 1) { eventReporter.addCaptureAttemptEvent(capture(slot)) } + assertThat(slot.captured.payload.totalFramesAnalysed).isEqualTo(1) + assertThat(slot.captured.payload.validFrameCount).isEqualTo(1) + } + + @Test + fun `attempt event - leaving the screen during validation keeps the imaging end time`() = runTest { + every { isUsingAutoCapture.invoke(any()) } returns true + every { getSpoofCheckConfiguration.invoke(any(), any()) } returns spoofConfig() + every { faceDetector.analyze(frame) } returns getFace() + coEvery { faceDetector.spoofCheck(any(), any()) } returns SpoofCheckResult(score = 0.1f) + every { timeHelper.now() } answers { Timestamp(testScheduler.currentTime) } + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 1) + viewModel.startCapture() + viewModel.process(frame, frame) + advanceTimeBy((AUTO_CAPTURE_IMAGING_DURATION_MS + 500).milliseconds) + assertThat(viewModel.state.value.phase).isEqualTo(LiveFeedbackState.Phase.VALIDATING) + ViewModelStore().apply { + put("vm", viewModel) + clear() + } + + val slot = slot() + verify(exactly = 1) { eventReporter.addCaptureAttemptEvent(capture(slot)) } + assertThat(slot.captured.payload.endedAt).isEqualTo(Timestamp(AUTO_CAPTURE_IMAGING_DURATION_MS)) + } + + @Test + fun `attempt event - pausing before imaging ends the attempt at the pause`() = runTest { + every { isUsingAutoCapture.invoke(any()) } returns true + every { faceDetector.analyze(frame) } returns getFace(Rect(0, 0, 30, 30)) + var nowMs = 0L + every { timeHelper.now() } answers { Timestamp(nowMs) } + + viewModel.initAutoCapture() + viewModel.initCapture(ModalitySdkType.SIM_FACE, 1) + viewModel.startCapture() + nowMs = 100 + viewModel.process(frame, frame) + nowMs = 200 + viewModel.holdOffAutoCapture() + + val slot = slot() + verify(exactly = 1) { eventReporter.addCaptureAttemptEvent(capture(slot)) } + assertThat(slot.captured.payload.endedAt).isEqualTo(Timestamp(200)) + assertThat( + slot.captured.payload.rejectionStats.tooFar + ?.longestRunMs, + ).isEqualTo(100) + assertThat(savedStateHandle.contains(AutoCaptureAttemptRecorder.KEY_ATTEMPT_STATS)).isFalse() + + nowMs = 5_000 + viewModel.startCapture() + verify(exactly = 1) { eventReporter.addCaptureAttemptEvent(any()) } + } + + @Test + fun `attempt event - an attempt persisted before process death is emitted when the view model is recreated`() = runTest { + val persisted = AutoCaptureAttemptStats( + attemptNb = 3, + bioSdk = "RANK_ONE", + startTime = Timestamp(10L), + ) + val handle = SavedStateHandle(mapOf(AutoCaptureAttemptRecorder.KEY_ATTEMPT_STATS to persisted.snapshot())) + + LiveFeedbackViewModel( + resolveFaceBioSdkUseCase, + configRepository, + eventReporter, + timeHelper, + isUsingAutoCapture, + getSpoofCheckConfiguration, + testCaptureAttemptTracker, + testCoroutineRule.testCoroutineDispatcher, + handle, + ) + + val slot = slot() + verify(exactly = 1) { eventReporter.addRecoveredCaptureAttemptEvent(capture(slot)) } + verify(exactly = 0) { eventReporter.addCaptureAttemptEvent(any()) } + assertThat(slot.captured.id).isEqualTo(persisted.toEvent(Timestamp(10L)).id) + assertThat(slot.captured.payload.attemptNb).isEqualTo(3) + assertThat(slot.captured.payload.createdAt).isEqualTo(Timestamp(10L)) + assertThat(handle.contains(AutoCaptureAttemptRecorder.KEY_ATTEMPT_STATS)).isFalse() + } + private fun TestScope.collectStates(): List { val states = mutableListOf() backgroundScope.launch(testCoroutineRule.testCoroutineDispatcher) { 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 c7ab4ea0c7..6f0381364a 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 @@ -13,6 +13,7 @@ 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.FaceCaptureAttemptEvent import com.simprints.infra.events.event.domain.models.FaceCaptureBiometricsEvent import com.simprints.infra.events.event.domain.models.FaceCaptureConfirmationEvent import com.simprints.infra.events.event.domain.models.FaceCaptureEvent @@ -99,6 +100,35 @@ class SimpleCaptureEventReporterTest { } } + @Test + fun `Adds capture attempt event`() = runTest { + val event = attemptEvent() + + reporter.addCaptureAttemptEvent(event) + + coVerify { eventRepository.addOrUpdateEvent(event) } + } + + @Test + fun `Adds recovered capture attempt event when the session does not have it`() = runTest { + val event = attemptEvent() + coEvery { eventRepository.getEventsInCurrentSession() } returns listOf(attemptEvent(id = "other")) + + reporter.addRecoveredCaptureAttemptEvent(event) + + coVerify { eventRepository.addOrUpdateEvent(event) } + } + + @Test + fun `Skips recovered capture attempt event already saved in the session`() = runTest { + val event = attemptEvent() + coEvery { eventRepository.getEventsInCurrentSession() } returns listOf(attemptEvent()) + + reporter.addRecoveredCaptureAttemptEvent(event) + + coVerify(exactly = 0) { eventRepository.addOrUpdateEvent(any()) } + } + @Test fun `Adds fallback capture event`() = runTest { reporter.addFallbackCaptureEvent(Timestamp(1L), Timestamp(1L)) @@ -355,4 +385,21 @@ class SimpleCaptureEventReporterTest { ) } } + + private fun attemptEvent(id: String = "attempt-id") = FaceCaptureAttemptEvent( + startTime = Timestamp(1L), + endTime = Timestamp(2L), + attemptNb = 0, + bioSdk = "RANK_ONE", + targetFrameWidth = 556, + targetFrameHeight = 556, + totalFramesAnalysed = 0, + validFrameCount = 0, + timeToFirstValidMs = null, + rejectionStats = FaceCaptureAttemptEvent.FaceCaptureAttemptPayload.RejectionStats(), + qualityAllFrames = null, + statusRuns = emptyList(), + statusRunsTruncated = false, + id = id, + ) } diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiEventPayload.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiEventPayload.kt index a637d17370..c41fc24452 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiEventPayload.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiEventPayload.kt @@ -63,6 +63,7 @@ import com.simprints.infra.events.event.domain.models.EventType.EXTERNAL_CREDENT import com.simprints.infra.events.event.domain.models.EventType.EXTERNAL_CREDENTIAL_SEARCH import com.simprints.infra.events.event.domain.models.EventType.EXTERNAL_CREDENTIAL_SELECTION import com.simprints.infra.events.event.domain.models.EventType.FACE_CAPTURE +import com.simprints.infra.events.event.domain.models.EventType.FACE_CAPTURE_ATTEMPT import com.simprints.infra.events.event.domain.models.EventType.FACE_CAPTURE_BIOMETRICS import com.simprints.infra.events.event.domain.models.EventType.FACE_CAPTURE_CONFIRMATION import com.simprints.infra.events.event.domain.models.EventType.FACE_FALLBACK_CAPTURE @@ -88,6 +89,7 @@ import com.simprints.infra.events.event.domain.models.ExternalCredentialCaptureV import com.simprints.infra.events.event.domain.models.ExternalCredentialConfirmationEvent.ExternalCredentialConfirmationPayload import com.simprints.infra.events.event.domain.models.ExternalCredentialSearchEvent.ExternalCredentialSearchPayload import com.simprints.infra.events.event.domain.models.ExternalCredentialSelectionEvent.ExternalCredentialSelectionPayload +import com.simprints.infra.events.event.domain.models.FaceCaptureAttemptEvent import com.simprints.infra.events.event.domain.models.FaceCaptureBiometricsEvent import com.simprints.infra.events.event.domain.models.FaceCaptureConfirmationEvent.FaceCaptureConfirmationPayload import com.simprints.infra.events.event.domain.models.FaceCaptureEvent @@ -153,6 +155,7 @@ internal fun EventPayload.fromDomainToApi(): ApiEventPayload = when (this.type) FACE_ONBOARDING_COMPLETE -> ApiFaceOnboardingCompletePayload(this as FaceOnboardingCompletePayload) FACE_FALLBACK_CAPTURE -> ApiFaceFallbackCapturePayload(this as FaceFallbackCapturePayload) FACE_CAPTURE -> ApiFaceCapturePayload(this as FaceCaptureEvent.FaceCapturePayload) + FACE_CAPTURE_ATTEMPT -> ApiFaceCaptureAttemptPayload(this as FaceCaptureAttemptEvent.FaceCaptureAttemptPayload) FACE_CAPTURE_CONFIRMATION -> ApiFaceCaptureConfirmationPayload(this as FaceCaptureConfirmationPayload) SCANNER_FIRMWARE_UPDATE -> ApiScannerFirmwareUpdatePayload(this as ScannerFirmwareUpdatePayload) CALLOUT_CONFIRMATION -> ApiCalloutPayloadV2(this as ConfirmationCalloutEventV2.ConfirmationCalloutPayload) @@ -301,6 +304,10 @@ internal object ApiEventPayloadSerializer : KSerializer { encoder.encodeSerializableValue(ApiExternalCredentialSelectionPayload.serializer(), value) } + is ApiFaceCaptureAttemptPayload -> { + encoder.encodeSerializableValue(ApiFaceCaptureAttemptPayload.serializer(), value) + } + is ApiFaceCaptureBiometricsPayload -> { encoder.encodeSerializableValue(ApiFaceCaptureBiometricsPayload.serializer(), value) } diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiEventPayloadType.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiEventPayloadType.kt index 1d148a7241..d3fec8e4be 100644 --- a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiEventPayloadType.kt +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiEventPayloadType.kt @@ -39,6 +39,7 @@ import com.simprints.infra.events.event.domain.models.EventType.EXTERNAL_CREDENT import com.simprints.infra.events.event.domain.models.EventType.EXTERNAL_CREDENTIAL_SEARCH import com.simprints.infra.events.event.domain.models.EventType.EXTERNAL_CREDENTIAL_SELECTION import com.simprints.infra.events.event.domain.models.EventType.FACE_CAPTURE +import com.simprints.infra.events.event.domain.models.EventType.FACE_CAPTURE_ATTEMPT import com.simprints.infra.events.event.domain.models.EventType.FACE_CAPTURE_BIOMETRICS import com.simprints.infra.events.event.domain.models.EventType.FACE_CAPTURE_CONFIRMATION import com.simprints.infra.events.event.domain.models.EventType.FACE_FALLBACK_CAPTURE @@ -89,6 +90,7 @@ internal enum class ApiEventPayloadType { FaceOnboardingComplete, FaceFallbackCapture, FaceCapture, + FaceCaptureAttempt, FaceCaptureBiometrics, FaceCaptureConfirmation, EventDownSyncRequest, @@ -177,6 +179,8 @@ internal fun EventType.fromDomainToApi(): ApiEventPayloadType = when (this) { FINGERPRINT_CAPTURE_BIOMETRICS -> ApiEventPayloadType.FingerprintCaptureBiometrics + FACE_CAPTURE_ATTEMPT -> ApiEventPayloadType.FaceCaptureAttempt + FACE_CAPTURE_BIOMETRICS -> ApiEventPayloadType.FaceCaptureBiometrics EVENT_DOWN_SYNC_REQUEST -> ApiEventPayloadType.EventDownSyncRequest @@ -231,6 +235,7 @@ internal fun ApiEventPayloadType.fromApiToDomain(): EventType = when (this) { ApiEventPayloadType.FaceCapture -> FACE_CAPTURE ApiEventPayloadType.FaceCaptureConfirmation -> FACE_CAPTURE_CONFIRMATION ApiEventPayloadType.FingerprintCaptureBiometrics -> FINGERPRINT_CAPTURE_BIOMETRICS + ApiEventPayloadType.FaceCaptureAttempt -> FACE_CAPTURE_ATTEMPT ApiEventPayloadType.FaceCaptureBiometrics -> FACE_CAPTURE_BIOMETRICS ApiEventPayloadType.EventDownSyncRequest -> EVENT_DOWN_SYNC_REQUEST ApiEventPayloadType.EventUpSyncRequest -> EVENT_UP_SYNC_REQUEST diff --git a/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiFaceCaptureAttemptPayload.kt b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiFaceCaptureAttemptPayload.kt new file mode 100644 index 0000000000..17d067aa9c --- /dev/null +++ b/infra/event-sync/src/main/java/com/simprints/infra/eventsync/event/remote/models/ApiFaceCaptureAttemptPayload.kt @@ -0,0 +1,133 @@ +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.FaceCaptureAttemptEvent.FaceCaptureAttemptPayload +import kotlinx.serialization.Serializable + +@Keep +@Serializable +internal data class ApiFaceCaptureAttemptPayload( + override val startTime: ApiTimestamp, + val endTime: ApiTimestamp?, + val attemptNb: Int, + val bioSdk: String, + val targetFrameWidth: Int?, + val targetFrameHeight: Int?, + val totalFramesAnalysed: Int, + val validFrameCount: Int, + val timeToFirstValidMs: Long?, + val rejectionStats: ApiRejectionStats, + val qualityAllFrames: ApiValueStats?, + val statusRuns: List, + val statusRunsTruncated: Boolean?, +) : ApiEventPayload() { + constructor(domainPayload: FaceCaptureAttemptPayload) : this( + startTime = domainPayload.createdAt.fromDomainToApi(), + endTime = domainPayload.endedAt?.fromDomainToApi(), + attemptNb = domainPayload.attemptNb, + bioSdk = domainPayload.bioSdk, + targetFrameWidth = domainPayload.targetFrameWidth, + targetFrameHeight = domainPayload.targetFrameHeight, + totalFramesAnalysed = domainPayload.totalFramesAnalysed, + validFrameCount = domainPayload.validFrameCount, + timeToFirstValidMs = domainPayload.timeToFirstValidMs, + rejectionStats = domainPayload.rejectionStats.fromDomainToApi(), + qualityAllFrames = domainPayload.qualityAllFrames?.fromDomainToApi(), + statusRuns = domainPayload.statusRuns.map { it.fromDomainToApi() }, + // Absent means false + statusRunsTruncated = domainPayload.statusRunsTruncated.takeIf { it }, + ) + + @Keep + @Serializable + data class ApiRejectionStats( + val invalid: ApiRejectionMetric? = null, + val badQuality: ApiRejectionMetric? = null, + val offYaw: ApiRejectionMetric? = null, + val offRoll: ApiRejectionMetric? = null, + val tooClose: ApiRejectionMetric? = null, + val tooFar: ApiRejectionMetric? = null, + ) + + @Keep + @Serializable + data class ApiRejectionMetric( + val count: Int, + val firstSeenMs: Long, + val longestRunMs: Long, + val minValue: Float? = null, + val maxValue: Float? = null, + val medianValue: Float? = null, + ) + + @Keep + @Serializable + data class ApiValueStats( + val minValue: Float, + val maxValue: Float, + val medianValue: Float, + ) + + @Keep + @Serializable + data class ApiStatusRun( + val status: ApiRunStatus, + val frameCount: Int, + val durationMs: Long, + ) + + /** Same names as [ApiFaceCapturePayload.ApiResult], so the two events read alike. */ + @Keep + enum class ApiRunStatus { + VALID, + INVALID, + BAD_QUALITY, + OFF_YAW, + OFF_ROLL, + TOO_CLOSE, + TOO_FAR, + } + + override fun getTokenizedFieldJsonPath(tokenKeyType: TokenKeyType): String? = null +} + +internal fun FaceCaptureAttemptPayload.RejectionStats.fromDomainToApi() = ApiFaceCaptureAttemptPayload.ApiRejectionStats( + invalid = invalid?.fromDomainToApi(), + badQuality = badQuality?.fromDomainToApi(), + offYaw = offYaw?.fromDomainToApi(), + offRoll = offRoll?.fromDomainToApi(), + tooClose = tooClose?.fromDomainToApi(), + tooFar = tooFar?.fromDomainToApi(), +) + +internal fun FaceCaptureAttemptPayload.RejectionMetric.fromDomainToApi() = ApiFaceCaptureAttemptPayload.ApiRejectionMetric( + count = count, + firstSeenMs = firstSeenMs, + longestRunMs = longestRunMs, + minValue = minValue, + maxValue = maxValue, + medianValue = medianValue, +) + +internal fun FaceCaptureAttemptPayload.ValueStats.fromDomainToApi() = ApiFaceCaptureAttemptPayload.ApiValueStats( + minValue = minValue, + maxValue = maxValue, + medianValue = medianValue, +) + +internal fun FaceCaptureAttemptPayload.StatusRun.fromDomainToApi() = ApiFaceCaptureAttemptPayload.ApiStatusRun( + status = status.fromDomainToApi(), + frameCount = frameCount, + durationMs = durationMs, +) + +internal fun FaceCaptureAttemptPayload.RunStatus.fromDomainToApi() = when (this) { + FaceCaptureAttemptPayload.RunStatus.VALID -> ApiFaceCaptureAttemptPayload.ApiRunStatus.VALID + FaceCaptureAttemptPayload.RunStatus.INVALID -> ApiFaceCaptureAttemptPayload.ApiRunStatus.INVALID + FaceCaptureAttemptPayload.RunStatus.BAD_QUALITY -> ApiFaceCaptureAttemptPayload.ApiRunStatus.BAD_QUALITY + FaceCaptureAttemptPayload.RunStatus.OFF_YAW -> ApiFaceCaptureAttemptPayload.ApiRunStatus.OFF_YAW + FaceCaptureAttemptPayload.RunStatus.OFF_ROLL -> ApiFaceCaptureAttemptPayload.ApiRunStatus.OFF_ROLL + FaceCaptureAttemptPayload.RunStatus.TOO_CLOSE -> ApiFaceCaptureAttemptPayload.ApiRunStatus.TOO_CLOSE + FaceCaptureAttemptPayload.RunStatus.TOO_FAR -> ApiFaceCaptureAttemptPayload.ApiRunStatus.TOO_FAR +} 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 5bd8990dce..0d4f92b59c 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 @@ -628,6 +628,59 @@ fun validateFaceFallbackCaptureEventApiModel(json: JSONObject) { } } +fun validateFaceCaptureAttemptEventApiModel(json: JSONObject) { + validateCommonParams(json, "FaceCaptureAttempt", 0) + with(json.getJSONObject("payload")) { + validateTimestamp(getJSONObject("startTime")) + validateTimestamp(getJSONObject("endTime")) + assertThat(getInt("attemptNb")).isEqualTo(0) + assertThat(getString("bioSdk")).isIn(listOf("RANK_ONE", "SIM_FACE")) + assertThat(getInt("targetFrameWidth")).isEqualTo(556) + assertThat(getInt("targetFrameHeight")).isEqualTo(556) + assertThat(getInt("totalFramesAnalysed")).isEqualTo(47) + assertThat(getInt("validFrameCount")).isEqualTo(15) + assertThat(getLong("timeToFirstValidMs")).isEqualTo(950L) + + with(getJSONObject("rejectionStats")) { + with(getJSONObject("tooFar")) { + assertThat(getInt("count")).isEqualTo(12) + assertThat(getLong("firstSeenMs")).isEqualTo(0L) + assertThat(getLong("longestRunMs")).isEqualTo(400L) + assertThat(getDouble("minValue")).isWithin(0.001).of(0.12) + assertThat(getDouble("maxValue")).isWithin(0.001).of(0.18) + assertThat(getDouble("medianValue")).isWithin(0.001).of(0.15) + assertThat(length()).isEqualTo(6) + } + assertThat(has("offYaw")).isTrue() + assertThat(has("invalid")).isFalse() + assertThat(has("badQuality")).isFalse() + assertThat(length()).isEqualTo(2) + } + + with(getJSONObject("qualityAllFrames")) { + assertThat(getDouble("minValue")).isWithin(0.001).of(0.41) + assertThat(getDouble("maxValue")).isWithin(0.001).of(0.97) + assertThat(getDouble("medianValue")).isWithin(0.001).of(0.83) + assertThat(length()).isEqualTo(3) + } + + with(getJSONArray("statusRuns")) { + assertThat(length()).isEqualTo(3) + with(getJSONObject(0)) { + assertThat(getString("status")).isEqualTo("TOO_FAR") + assertThat(getInt("frameCount")).isEqualTo(12) + assertThat(getLong("durationMs")).isEqualTo(400L) + assertThat(length()).isEqualTo(3) + } + assertThat(getJSONObject(1).getString("status")).isEqualTo("OFF_YAW") + assertThat(getJSONObject(2).getString("status")).isEqualTo("VALID") + } + + assertThat(has("statusRunsTruncated")).isFalse() + assertThat(length()).isEqualTo(12) + } +} + fun validateFaceCaptureEventApiModel(json: JSONObject) { validateCommonParams(json, "FaceCapture", 4) with(json.getJSONObject("payload")) { diff --git a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/usecases/MapDomainEventToApiUseCaseTest.kt b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/usecases/MapDomainEventToApiUseCaseTest.kt index 89464b88e4..179a48fd06 100644 --- a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/usecases/MapDomainEventToApiUseCaseTest.kt +++ b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/event/usecases/MapDomainEventToApiUseCaseTest.kt @@ -36,6 +36,7 @@ import com.simprints.infra.events.sampledata.createExternalCredentialCaptureValu import com.simprints.infra.events.sampledata.createExternalCredentialConfirmationEvent import com.simprints.infra.events.sampledata.createExternalCredentialSearchEvent import com.simprints.infra.events.sampledata.createExternalCredentialSelectionEvent +import com.simprints.infra.events.sampledata.createFaceCaptureAttemptEvent import com.simprints.infra.events.sampledata.createFaceCaptureBiometricsEvent import com.simprints.infra.events.sampledata.createFaceCaptureConfirmationEvent import com.simprints.infra.events.sampledata.createFaceCaptureEvent @@ -92,6 +93,7 @@ import com.simprints.infra.eventsync.event.validateExternalCredentialCaptureValu import com.simprints.infra.eventsync.event.validateExternalCredentialConfirmationApiModel import com.simprints.infra.eventsync.event.validateExternalCredentialSearchApiModel import com.simprints.infra.eventsync.event.validateExternalCredentialSelectionEventApiModel +import com.simprints.infra.eventsync.event.validateFaceCaptureAttemptEventApiModel import com.simprints.infra.eventsync.event.validateFaceCaptureBiometricsEventApiModel import com.simprints.infra.eventsync.event.validateFaceCaptureConfirmationEventApiModel import com.simprints.infra.eventsync.event.validateFaceCaptureEventApiModel @@ -486,6 +488,15 @@ internal class MapDomainEventToApiUseCaseTest { validateFaceOnboardingCompleteEventApiModel(json) } + @Test + fun validate_FaceCaptureAttemptEventApiModel() { + val event = createFaceCaptureAttemptEvent() + val apiEvent = useCase(event, project) + val json = JSONObject(SimJson.encodeToString(apiEvent)) + + validateFaceCaptureAttemptEventApiModel(json) + } + @Test fun validate_FaceFallbackCaptureEventApiModel() { val event = createFaceFallbackCaptureEvent() @@ -753,6 +764,7 @@ internal class MapDomainEventToApiUseCaseTest { FaceCaptureConfirmation -> validate_FaceCaptureConfirmationEventApiModel() FingerprintCaptureBiometrics -> validate_FingerprintCaptureBiometricsEventApiModel() FaceCaptureBiometrics -> validate_FaceCaptureBiometricsEventApiModel() + FaceCaptureAttempt -> validate_FaceCaptureAttemptEventApiModel() EventDownSyncRequest -> validate_DownSyncRequestEventApiModel() EventUpSyncRequest -> validate_UpSyncRequestEventApiModel() SampleUpSyncRequest -> validate_SampleUpSyncRequestEventApiModel() diff --git a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/testtools/RemoteTestingHelper.kt b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/testtools/RemoteTestingHelper.kt index 1fd07d64f9..4922a4328f 100644 --- a/infra/event-sync/src/test/java/com/simprints/infra/eventsync/testtools/RemoteTestingHelper.kt +++ b/infra/event-sync/src/test/java/com/simprints/infra/eventsync/testtools/RemoteTestingHelper.kt @@ -16,6 +16,7 @@ internal class RemoteTestingHelper { ApiEventPayloadType.IntentParsing, ApiEventPayloadType.CompletionCheck, ApiEventPayloadType.FaceOnboardingComplete, ApiEventPayloadType.FaceFallbackCapture, ApiEventPayloadType.FaceCapture, ApiEventPayloadType.FaceCaptureConfirmation, ApiEventPayloadType.FingerprintCaptureBiometrics, ApiEventPayloadType.FaceCaptureBiometrics, + ApiEventPayloadType.FaceCaptureAttempt, ApiEventPayloadType.EventDownSyncRequest, ApiEventPayloadType.EventUpSyncRequest, ApiEventPayloadType.LicenseCheck, ApiEventPayloadType.AgeGroupSelection, ApiEventPayloadType.BiometricReferenceCreation, ApiEventPayloadType.SampleUpSyncRequest, ApiEventPayloadType.EnrolmentUpdate, ApiEventPayloadType.ExternalCredentialSelection, 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 8c9c225f8e..b0d1be3c49 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 @@ -50,6 +50,7 @@ import com.simprints.infra.events.event.domain.models.ExternalCredentialConfirma import com.simprints.infra.events.event.domain.models.ExternalCredentialConfirmationEvent.ExternalCredentialConfirmationResult import com.simprints.infra.events.event.domain.models.ExternalCredentialSearchEvent import com.simprints.infra.events.event.domain.models.ExternalCredentialSelectionEvent +import com.simprints.infra.events.event.domain.models.FaceCaptureAttemptEvent import com.simprints.infra.events.event.domain.models.FaceCaptureBiometricsEvent import com.simprints.infra.events.event.domain.models.FaceCaptureConfirmationEvent import com.simprints.infra.events.event.domain.models.FaceCaptureConfirmationEvent.FaceCaptureConfirmationPayload.Result.CONTINUE @@ -328,6 +329,59 @@ fun createFaceFallbackCaptureEvent() = FaceFallbackCaptureEvent( ENDED_AT, ) +fun createFaceCaptureAttemptEvent() = FaceCaptureAttemptEvent( + startTime = CREATED_AT, + endTime = ENDED_AT, + attemptNb = 0, + bioSdk = "RANK_ONE", + targetFrameWidth = 556, + targetFrameHeight = 556, + totalFramesAnalysed = 47, + validFrameCount = 15, + timeToFirstValidMs = 950L, + rejectionStats = FaceCaptureAttemptEvent.FaceCaptureAttemptPayload.RejectionStats( + tooFar = FaceCaptureAttemptEvent.FaceCaptureAttemptPayload.RejectionMetric( + count = 12, + firstSeenMs = 0L, + longestRunMs = 400L, + minValue = 0.12f, + maxValue = 0.18f, + medianValue = 0.15f, + ), + offYaw = FaceCaptureAttemptEvent.FaceCaptureAttemptPayload.RejectionMetric( + count = 20, + firstSeenMs = 400L, + longestRunMs = 550L, + minValue = -41.0f, + maxValue = 36.5f, + medianValue = -34.0f, + ), + ), + qualityAllFrames = FaceCaptureAttemptEvent.FaceCaptureAttemptPayload.ValueStats( + minValue = 0.41f, + maxValue = 0.97f, + medianValue = 0.83f, + ), + statusRuns = listOf( + FaceCaptureAttemptEvent.FaceCaptureAttemptPayload.StatusRun( + FaceCaptureAttemptEvent.FaceCaptureAttemptPayload.RunStatus.TOO_FAR, + 12, + 400L, + ), + FaceCaptureAttemptEvent.FaceCaptureAttemptPayload.StatusRun( + FaceCaptureAttemptEvent.FaceCaptureAttemptPayload.RunStatus.OFF_YAW, + 20, + 550L, + ), + FaceCaptureAttemptEvent.FaceCaptureAttemptPayload.StatusRun( + FaceCaptureAttemptEvent.FaceCaptureAttemptPayload.RunStatus.VALID, + 15, + 1650L, + ), + ), + statusRunsTruncated = false, +) + fun createFaceOnboardingCompleteEvent() = FaceOnboardingCompleteEvent( CREATED_AT, ENDED_AT, diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/Event.kt b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/Event.kt index de9ba3cab3..7e104f8bfb 100644 --- a/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/Event.kt +++ b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/Event.kt @@ -114,6 +114,7 @@ sealed class Event { ExternalCredentialConfirmationEvent.serializer() is ExternalCredentialSearchEvent -> ExternalCredentialSearchEvent.serializer() is ExternalCredentialSelectionEvent -> ExternalCredentialSelectionEvent.serializer() + is FaceCaptureAttemptEvent -> FaceCaptureAttemptEvent.serializer() is FaceCaptureBiometricsEvent -> FaceCaptureBiometricsEvent.serializer() is FaceCaptureConfirmationEvent -> FaceCaptureConfirmationEvent.serializer() is FaceCaptureEvent -> FaceCaptureEvent.serializer() diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/EventType.kt b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/EventType.kt index b8f3437f1b..fc2fe8f500 100644 --- a/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/EventType.kt +++ b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/EventType.kt @@ -132,6 +132,9 @@ enum class EventType { // key added: FACE_CAPTURE_BIOMETRICS_KEY FACE_CAPTURE_BIOMETRICS, + // key added: FACE_CAPTURE_ATTEMPT_KEY + FACE_CAPTURE_ATTEMPT, + // key added: FACE_CAPTURE_CONFIRMATION_KEY FACE_CAPTURE_CONFIRMATION, @@ -199,6 +202,7 @@ enum class EventType { const val FACE_FALLBACK_CAPTURE_KEY = "FACE_FALLBACK_CAPTURE" const val FACE_CAPTURE_KEY = "FACE_CAPTURE" const val FACE_CAPTURE_BIOMETRICS_KEY = "FACE_CAPTURE_BIOMETRICS" + const val FACE_CAPTURE_ATTEMPT_KEY = "FACE_CAPTURE_ATTEMPT" const val FACE_CAPTURE_CONFIRMATION_KEY = "FACE_CAPTURE_CONFIRMATION" const val AUTHENTICATION_KEY = "AUTHENTICATION" const val CONSENT_KEY = "CONSENT" diff --git a/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/FaceCaptureAttemptEvent.kt b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/FaceCaptureAttemptEvent.kt new file mode 100644 index 0000000000..95944e49cf --- /dev/null +++ b/infra/events/src/main/java/com/simprints/infra/events/event/domain/models/FaceCaptureAttemptEvent.kt @@ -0,0 +1,139 @@ +package com.simprints.infra.events.event.domain.models + +import androidx.annotation.Keep +import com.simprints.core.tools.time.Timestamp +import com.simprints.core.tools.utils.randomUUID +import com.simprints.infra.events.event.domain.models.EventType.Companion.FACE_CAPTURE_ATTEMPT_KEY +import com.simprints.infra.events.event.domain.models.EventType.FACE_CAPTURE_ATTEMPT +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** One per auto-capture attempt, including abandoned ones. Not emitted in manual capture mode. */ +@Keep +@Serializable +@SerialName(FACE_CAPTURE_ATTEMPT_KEY) +data class FaceCaptureAttemptEvent( + override val id: String = randomUUID(), + override val payload: FaceCaptureAttemptPayload, + override val type: EventType, + override var scopeId: String? = null, + override var projectId: String? = null, +) : Event() { + constructor( + startTime: Timestamp, + endTime: Timestamp, + attemptNb: Int, + bioSdk: String, + targetFrameWidth: Int?, + targetFrameHeight: Int?, + totalFramesAnalysed: Int, + validFrameCount: Int, + timeToFirstValidMs: Long?, + rejectionStats: FaceCaptureAttemptPayload.RejectionStats, + qualityAllFrames: FaceCaptureAttemptPayload.ValueStats?, + statusRuns: List, + statusRunsTruncated: Boolean, + id: String = randomUUID(), + ) : this( + id, + FaceCaptureAttemptPayload( + createdAt = startTime, + endedAt = endTime, + eventVersion = EVENT_VERSION, + attemptNb = attemptNb, + bioSdk = bioSdk, + targetFrameWidth = targetFrameWidth, + targetFrameHeight = targetFrameHeight, + totalFramesAnalysed = totalFramesAnalysed, + validFrameCount = validFrameCount, + timeToFirstValidMs = timeToFirstValidMs, + rejectionStats = rejectionStats, + qualityAllFrames = qualityAllFrames, + statusRuns = statusRuns, + statusRunsTruncated = statusRunsTruncated, + ), + FACE_CAPTURE_ATTEMPT, + ) + + @Keep + @Serializable + data class FaceCaptureAttemptPayload( + override val createdAt: Timestamp, + override var endedAt: Timestamp?, + override val eventVersion: Int, + val attemptNb: Int, + val bioSdk: String, + /** Cropped frame passed to the detector, not the camera resolution. */ + val targetFrameWidth: Int? = null, + val targetFrameHeight: Int? = null, + val totalFramesAnalysed: Int, + val validFrameCount: Int, + val timeToFirstValidMs: Long? = null, + val rejectionStats: RejectionStats, + val qualityAllFrames: ValueStats? = null, + val statusRuns: List, + val statusRunsTruncated: Boolean = false, + override val type: EventType = FACE_CAPTURE_ATTEMPT, + ) : EventPayload() { + override fun toSafeString(): String = + "attempt nr: $attemptNb, sdk: $bioSdk, frames: $totalFramesAnalysed, valid: $validFrameCount, " + + "first valid ms: $timeToFirstValidMs, runs: ${statusRuns.size}, truncated: $statusRunsTruncated" + + /** Null entries mean the reason did not occur. */ + @Keep + @Serializable + data class RejectionStats( + val invalid: RejectionMetric? = null, + val badQuality: RejectionMetric? = null, + val offYaw: RejectionMetric? = null, + val offRoll: RejectionMetric? = null, + val tooClose: RejectionMetric? = null, + val tooFar: RejectionMetric? = null, + ) + + @Keep + @Serializable + data class RejectionMetric( + val count: Int, + val firstSeenMs: Long, + val longestRunMs: Long, + /** Area ratio, signed degrees or quality score depending on the reason. */ + val minValue: Float? = null, + val maxValue: Float? = null, + val medianValue: Float? = null, + ) + + @Keep + @Serializable + data class ValueStats( + val minValue: Float, + val maxValue: Float, + val medianValue: Float, + ) + + @Keep + @Serializable + data class StatusRun( + val status: RunStatus, + val frameCount: Int, + val durationMs: Long, + ) + + @Keep + @Serializable + enum class RunStatus { + VALID, + INVALID, + BAD_QUALITY, + OFF_YAW, + OFF_ROLL, + TOO_CLOSE, + TOO_FAR, + } + } + + companion object { + const val EVENT_VERSION = 0 + const val MAX_STATUS_RUNS = 300 + } +} diff --git a/infra/events/src/test/java/com/simprints/infra/events/event/domain/models/EventPayloadTest.kt b/infra/events/src/test/java/com/simprints/infra/events/event/domain/models/EventPayloadTest.kt index 120f44a9bf..f05d018d21 100644 --- a/infra/events/src/test/java/com/simprints/infra/events/event/domain/models/EventPayloadTest.kt +++ b/infra/events/src/test/java/com/simprints/infra/events/event/domain/models/EventPayloadTest.kt @@ -137,6 +137,21 @@ class EventPayloadTest { face = FaceCaptureEvent.FaceCapturePayload.Face(0F, 1F, 2F, FACE_TEMPLATE_FORMAT), ), FaceFallbackCaptureEvent(CREATED_AT, ENDED_AT), + FaceCaptureAttemptEvent( + startTime = CREATED_AT, + endTime = ENDED_AT, + attemptNb = 0, + bioSdk = "RANK_ONE", + targetFrameWidth = 556, + targetFrameHeight = 556, + totalFramesAnalysed = 1, + validFrameCount = 1, + timeToFirstValidMs = 0L, + rejectionStats = FaceCaptureAttemptEvent.FaceCaptureAttemptPayload.RejectionStats(), + qualityAllFrames = null, + statusRuns = emptyList(), + statusRunsTruncated = false, + ), FaceOnboardingCompleteEvent(CREATED_AT, ENDED_AT), FingerprintCaptureBiometricsEvent( createdAt = CREATED_AT, diff --git a/infra/events/src/test/java/com/simprints/infra/events/event/domain/models/face/FaceCaptureAttemptEventTest.kt b/infra/events/src/test/java/com/simprints/infra/events/event/domain/models/face/FaceCaptureAttemptEventTest.kt new file mode 100644 index 0000000000..62a21b1c43 --- /dev/null +++ b/infra/events/src/test/java/com/simprints/infra/events/event/domain/models/face/FaceCaptureAttemptEventTest.kt @@ -0,0 +1,59 @@ +package com.simprints.infra.events.event.domain.models.face + +import androidx.annotation.Keep +import com.google.common.truth.Truth.* +import com.simprints.infra.events.event.domain.models.Event +import com.simprints.infra.events.event.domain.models.EventType.FACE_CAPTURE_ATTEMPT +import com.simprints.infra.events.event.domain.models.FaceCaptureAttemptEvent +import com.simprints.infra.events.event.domain.models.FaceCaptureAttemptEvent.Companion.EVENT_VERSION +import com.simprints.infra.events.event.domain.models.FaceCaptureAttemptEvent.FaceCaptureAttemptPayload.RunStatus +import com.simprints.infra.events.sampledata.SampleDefaults.CREATED_AT +import com.simprints.infra.events.sampledata.SampleDefaults.ENDED_AT +import com.simprints.infra.events.sampledata.createFaceCaptureAttemptEvent +import com.simprints.infra.serialization.SimJson +import org.junit.Test + +@Keep +class FaceCaptureAttemptEventTest { + @Test + fun create_FaceCaptureAttemptEvent() { + val event = createFaceCaptureAttemptEvent() + + assertThat(event.id).isNotNull() + assertThat(event.type).isEqualTo(FACE_CAPTURE_ATTEMPT) + with(event.payload) { + assertThat(createdAt).isEqualTo(CREATED_AT) + assertThat(endedAt).isEqualTo(ENDED_AT) + assertThat(eventVersion).isEqualTo(EVENT_VERSION) + assertThat(type).isEqualTo(FACE_CAPTURE_ATTEMPT) + assertThat(attemptNb).isEqualTo(0) + assertThat(totalFramesAnalysed).isEqualTo(47) + assertThat(validFrameCount).isEqualTo(15) + assertThat(timeToFirstValidMs).isEqualTo(950L) + assertThat(rejectionStats.tooFar?.count).isEqualTo(12) + assertThat(rejectionStats.badQuality).isNull() + assertThat(statusRuns.map { it.status }).containsExactly(RunStatus.TOO_FAR, RunStatus.OFF_YAW, RunStatus.VALID).inOrder() + assertThat(statusRunsTruncated).isFalse() + } + } + + @Test + fun `round-trips through JSON as a polymorphic Event`() { + val original = createFaceCaptureAttemptEvent() + + val json = original.toJson() + val decoded: Event = SimJson.decodeFromString(json) + + assertThat(decoded).isInstanceOf(FaceCaptureAttemptEvent::class.java) + assertThat((decoded as FaceCaptureAttemptEvent).payload).isEqualTo(original.payload) + } + + @Test + fun `safe string carries no biometric data`() { + val safe = createFaceCaptureAttemptEvent().payload.toSafeString() + + assertThat(safe).isEqualTo( + "attempt nr: 0, sdk: RANK_ONE, frames: 47, valid: 15, first valid ms: 950, runs: 3, truncated: false", + ) + } +} diff --git a/infra/events/src/test/java/com/simprints/infra/events/event/local/models/DbEventTest.kt b/infra/events/src/test/java/com/simprints/infra/events/event/local/models/DbEventTest.kt index 998ee892c8..33e1a3c448 100644 --- a/infra/events/src/test/java/com/simprints/infra/events/event/local/models/DbEventTest.kt +++ b/infra/events/src/test/java/com/simprints/infra/events/event/local/models/DbEventTest.kt @@ -5,6 +5,7 @@ package com.simprints.infra.events.event.local.models import com.google.common.truth.Truth.assertThat import com.simprints.infra.events.event.domain.models.AuthenticationEvent.AuthenticationPayload +import com.simprints.infra.events.event.domain.models.FaceCaptureAttemptEvent import com.simprints.infra.events.sampledata.* import org.junit.Test @@ -112,6 +113,15 @@ class DbEventTest { assertThat(original).isEqualTo(transformed) } + @Test + fun convert_FaceCaptureAttemptEvent() { + val original = createFaceCaptureAttemptEvent() + val transformed = original.fromDomainToDb().fromDbToDomain() + + assertThat(original).isEqualTo(transformed) + assertThat((transformed as FaceCaptureAttemptEvent).payload).isEqualTo(original.payload) + } + @Test fun convert_FaceFallbackCaptureEvent() { val original = createFaceFallbackCaptureEvent() diff --git a/infra/events/src/test/resources/all-events/face_capture_attempt_v0.json b/infra/events/src/test/resources/all-events/face_capture_attempt_v0.json new file mode 100644 index 0000000000..283fc8a25f --- /dev/null +++ b/infra/events/src/test/resources/all-events/face_capture_attempt_v0.json @@ -0,0 +1,51 @@ +{ + "id": "3b5e9a14-77cd-49f1-8a2e-6c0b4d9f1e23", + "type": "FACE_CAPTURE_ATTEMPT", + "labels": { + "projectId": "TEST6Oai41ps1pBNrzBL", + "sessionId": "e35c39f9-b81e-48f2-97e7-46ecc8399bb4", + "deviceId": "f2fd8393c0a0be67" + }, + "payload": { + "type": "FACE_CAPTURE_ATTEMPT", + "eventVersion": 0, + "createdAt": 6781345670, + "endedAt": 6781348270, + "attemptNb": 0, + "bioSdk": "RANK_ONE", + "targetFrameWidth": 556, + "targetFrameHeight": 556, + "totalFramesAnalysed": 47, + "validFrameCount": 15, + "timeToFirstValidMs": 950, + "rejectionStats": { + "tooFar": { + "count": 12, + "firstSeenMs": 0, + "longestRunMs": 400, + "minValue": 0.12, + "maxValue": 0.18, + "medianValue": 0.15 + }, + "offYaw": { + "count": 20, + "firstSeenMs": 400, + "longestRunMs": 550, + "minValue": -41.0, + "maxValue": 36.5, + "medianValue": -34.0 + } + }, + "qualityAllFrames": { + "minValue": 0.41, + "maxValue": 0.97, + "medianValue": 0.83 + }, + "statusRuns": [ + { "status": "TOO_FAR", "frameCount": 12, "durationMs": 400 }, + { "status": "OFF_YAW", "frameCount": 20, "durationMs": 550 }, + { "status": "VALID", "frameCount": 15, "durationMs": 1650 } + ], + "statusRunsTruncated": false + } +}