diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 77948700..21201832 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] accompanist = "0.28.0" agp = "8.7.2" -amplify = "2.27.2" +amplify = "2.29.0" appcompat = "1.6.1" androidx-core = "1.9.0" androidx-junit = "1.1.4" diff --git a/liveness/api/liveness.api b/liveness/api/liveness.api index f652df73..87271126 100644 --- a/liveness/api/liveness.api +++ b/liveness/api/liveness.api @@ -44,6 +44,13 @@ public final class com/amplifyframework/ui/liveness/model/FaceLivenessDetectionE public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V } +public final class com/amplifyframework/ui/liveness/model/FaceLivenessDetectionException$UnsupportedChallengeTypeException : com/amplifyframework/ui/liveness/model/FaceLivenessDetectionException { + public static final field $stable I + public fun ()V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + public final class com/amplifyframework/ui/liveness/model/FaceLivenessDetectionException$UserCancelledException : com/amplifyframework/ui/liveness/model/FaceLivenessDetectionException { public static final field $stable I public fun ()V @@ -51,6 +58,58 @@ public final class com/amplifyframework/ui/liveness/model/FaceLivenessDetectionE public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V } +public final class com/amplifyframework/ui/liveness/state/AttemptCounter { + public static final field $stable I + public static final field ATTEMPT_COUNT_RESET_INTERVAL_MS J + public static final field Companion Lcom/amplifyframework/ui/liveness/state/AttemptCounter$Companion; + public fun ()V + public final fun countAttempt ()V + public final fun getCount ()I +} + +public final class com/amplifyframework/ui/liveness/state/AttemptCounter$Companion { + public final fun getAttemptCount ()I + public final fun getLatestAttemptTimeStamp ()J + public final fun setAttemptCount (I)V + public final fun setLatestAttemptTimeStamp (J)V +} + +public abstract class com/amplifyframework/ui/liveness/ui/Camera { + public static final field $stable I +} + +public final class com/amplifyframework/ui/liveness/ui/Camera$Back : com/amplifyframework/ui/liveness/ui/Camera { + public static final field $stable I + public static final field INSTANCE Lcom/amplifyframework/ui/liveness/ui/Camera$Back; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/amplifyframework/ui/liveness/ui/Camera$Front : com/amplifyframework/ui/liveness/ui/Camera { + public static final field $stable I + public static final field INSTANCE Lcom/amplifyframework/ui/liveness/ui/Camera$Front; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/amplifyframework/ui/liveness/ui/ChallengeOptions { + public static final field $stable I + public fun ()V + public fun (Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovementAndLight;Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovement;)V + public synthetic fun (Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovementAndLight;Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovement;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovementAndLight; + public final fun component2 ()Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovement; + public final fun copy (Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovementAndLight;Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovement;)Lcom/amplifyframework/ui/liveness/ui/ChallengeOptions; + public static synthetic fun copy$default (Lcom/amplifyframework/ui/liveness/ui/ChallengeOptions;Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovementAndLight;Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovement;ILjava/lang/Object;)Lcom/amplifyframework/ui/liveness/ui/ChallengeOptions; + public fun equals (Ljava/lang/Object;)Z + public final fun getFaceMovement ()Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovement; + public final fun getFaceMovementAndLight ()Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovementAndLight; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class com/amplifyframework/ui/liveness/ui/ComposableSingletons$CancelChallengeButtonKt { public static final field INSTANCE Lcom/amplifyframework/ui/liveness/ui/ComposableSingletons$CancelChallengeButtonKt; public static field lambda-1 Lkotlin/jvm/functions/Function2; @@ -110,6 +169,36 @@ public final class com/amplifyframework/ui/liveness/ui/ComposableSingletons$Reco public final class com/amplifyframework/ui/liveness/ui/FaceLivenessDetectorKt { public static final fun FaceLivenessDetector (Ljava/lang/String;Ljava/lang/String;Lcom/amplifyframework/auth/AWSCredentialsProvider;ZLcom/amplifyframework/core/Action;Lcom/amplifyframework/core/Consumer;Landroidx/compose/runtime/Composer;II)V + public static final fun FaceLivenessDetector (Ljava/lang/String;Ljava/lang/String;Lcom/amplifyframework/auth/AWSCredentialsProvider;ZLcom/amplifyframework/core/Action;Lcom/amplifyframework/core/Consumer;Lcom/amplifyframework/ui/liveness/ui/ChallengeOptions;Landroidx/compose/runtime/Composer;II)V +} + +public abstract class com/amplifyframework/ui/liveness/ui/LivenessChallenge { + public static final field $stable I + public synthetic fun (Lcom/amplifyframework/ui/liveness/ui/Camera;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Lcom/amplifyframework/ui/liveness/ui/Camera;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun getCamera ()Lcom/amplifyframework/ui/liveness/ui/Camera; +} + +public final class com/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovement : com/amplifyframework/ui/liveness/ui/LivenessChallenge { + public static final field $stable I + public fun ()V + public fun (Lcom/amplifyframework/ui/liveness/ui/Camera;)V + public synthetic fun (Lcom/amplifyframework/ui/liveness/ui/Camera;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lcom/amplifyframework/ui/liveness/ui/Camera; + public final fun copy (Lcom/amplifyframework/ui/liveness/ui/Camera;)Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovement; + public static synthetic fun copy$default (Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovement;Lcom/amplifyframework/ui/liveness/ui/Camera;ILjava/lang/Object;)Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovement; + public fun equals (Ljava/lang/Object;)Z + public fun getCamera ()Lcom/amplifyframework/ui/liveness/ui/Camera; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovementAndLight : com/amplifyframework/ui/liveness/ui/LivenessChallenge { + public static final field $stable I + public static final field INSTANCE Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovementAndLight; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; } public final class com/amplifyframework/ui/liveness/ui/LivenessColorScheme { diff --git a/liveness/src/main/java/com/amplifyframework/ui/liveness/camera/LivenessCoordinator.kt b/liveness/src/main/java/com/amplifyframework/ui/liveness/camera/LivenessCoordinator.kt index 05c72c17..1febd66d 100644 --- a/liveness/src/main/java/com/amplifyframework/ui/liveness/camera/LivenessCoordinator.kt +++ b/liveness/src/main/java/com/amplifyframework/ui/liveness/camera/LivenessCoordinator.kt @@ -35,15 +35,20 @@ import com.amplifyframework.predictions.aws.AWSPredictionsPlugin import com.amplifyframework.predictions.aws.exceptions.AccessDeniedException import com.amplifyframework.predictions.aws.exceptions.FaceLivenessSessionNotFoundException import com.amplifyframework.predictions.aws.exceptions.FaceLivenessSessionTimeoutException +import com.amplifyframework.predictions.aws.exceptions.FaceLivenessUnsupportedChallengeTypeException import com.amplifyframework.predictions.aws.models.ColorChallengeResponse import com.amplifyframework.predictions.aws.models.RgbColor import com.amplifyframework.predictions.aws.options.AWSFaceLivenessSessionOptions +import com.amplifyframework.predictions.models.Challenge import com.amplifyframework.predictions.models.FaceLivenessSessionInformation import com.amplifyframework.predictions.models.VideoEvent import com.amplifyframework.ui.liveness.BuildConfig import com.amplifyframework.ui.liveness.model.FaceLivenessDetectionException import com.amplifyframework.ui.liveness.model.LivenessCheckState +import com.amplifyframework.ui.liveness.state.AttemptCounter import com.amplifyframework.ui.liveness.state.LivenessState +import com.amplifyframework.ui.liveness.ui.Camera +import com.amplifyframework.ui.liveness.ui.ChallengeOptions import com.amplifyframework.ui.liveness.util.WebSocketCloseCode import java.util.Date import java.util.concurrent.Executors @@ -65,25 +70,26 @@ internal typealias OnFreshnessColorDisplayed = ( @SuppressLint("UnsafeOptInUsageError") internal class LivenessCoordinator( val context: Context, - lifecycleOwner: LifecycleOwner, + private val lifecycleOwner: LifecycleOwner, private val sessionId: String, private val region: String, private val credentialsProvider: AWSCredentialsProvider?, - disableStartView: Boolean, + private val disableStartView: Boolean, + private val challengeOptions: ChallengeOptions, private val onChallengeComplete: OnChallengeComplete, val onChallengeFailed: Consumer ) { + private val attemptCounter = AttemptCounter() private val analysisExecutor = Executors.newSingleThreadExecutor() val livenessState = LivenessState( - sessionId, - context, - disableStartView, - this::processCaptureReady, - this::startLivenessSession, - this::processSessionError, - this::processFinalEventsSent + sessionId = sessionId, + context = context, + disableStartView = disableStartView, + onCaptureReady = this::processCaptureReady, + onSessionError = this::processSessionError, + onFinalEventsSent = this::processFinalEventsSent ) private val preview = Preview.Builder().apply { @@ -138,6 +144,15 @@ internal class LivenessCoordinator( private var disconnectEventReceived = false init { + startLivenessSession() + if (challengeOptions.hasOneCameraConfigured()) { + launchCamera(challengeOptions.faceMovementAndLight.camera) + } else { + livenessState.loadingCameraPreview = true + } + } + + private fun launchCamera(camera: Camera) { MainScope().launch { delay(5_000) if (!previewTextureView.hasReceivedUpdate) { @@ -152,17 +167,24 @@ internal class LivenessCoordinator( getCameraProvider(context).apply { if (lifecycleOwner.lifecycle.currentState != Lifecycle.State.DESTROYED) { unbindAll() - if (this.hasCamera(CameraSelector.DEFAULT_FRONT_CAMERA)) { + + val (chosenCamera, orientation) = when (camera) { + Camera.Front -> Pair(CameraSelector.DEFAULT_FRONT_CAMERA, "front") + Camera.Back -> Pair(CameraSelector.DEFAULT_BACK_CAMERA, "back") + } + + if (this.hasCamera(chosenCamera)) { bindToLifecycle( lifecycleOwner, - CameraSelector.DEFAULT_FRONT_CAMERA, + chosenCamera, preview, analysis ) } else { + livenessState.loadingCameraPreview = false val faceLivenessException = FaceLivenessDetectionException( - "A front facing camera is required but no front facing camera detected.", - "Enable a front facing camera." + "A $orientation facing camera is required but no $orientation facing camera detected.", + "Enable a $orientation facing camera." ) processSessionError(faceLivenessException, true) } @@ -172,13 +194,19 @@ internal class LivenessCoordinator( } private fun startLivenessSession() { - livenessState.livenessCheckState.value = LivenessCheckState.Initial.withConnectingMessage() + livenessState.livenessCheckState = LivenessCheckState.Initial.withConnectingMessage() + attemptCounter.countAttempt() val faceLivenessSessionInformation = FaceLivenessSessionInformation( - TARGET_WIDTH.toFloat(), - TARGET_HEIGHT.toFloat(), - "FaceMovementAndLightChallenge_1.0.0", - region + videoWidth = TARGET_WIDTH.toFloat(), + videoHeight = TARGET_HEIGHT.toFloat(), + challengeVersions = listOf( + Challenge.FaceMovementAndLightChallenge("2.0.0"), + Challenge.FaceMovementChallenge("1.0.0") + ), + region = region, + preCheckViewEnabled = !disableStartView, + attemptCount = attemptCounter.getCount() ) val faceLivenessSessionOptions = AWSFaceLivenessSessionOptions.builder().apply { @@ -190,25 +218,33 @@ internal class LivenessCoordinator( faceLivenessSessionInformation, faceLivenessSessionOptions, BuildConfig.LIVENESS_VERSION_NAME, - { livenessState.onLivenessSessionReady(it) }, + { + livenessState.onLivenessSessionReady(it) + if (!challengeOptions.hasOneCameraConfigured()) { + val foundChallenge = challengeOptions.getLivenessChallenge(it.challengeType) + launchCamera(foundChallenge.camera) + } + }, { disconnectEventReceived = true onChallengeComplete() }, { error -> - val faceLivenessException = when (error) { + val (faceLivenessException, shouldStopLivenessSession) = when (error) { is AccessDeniedException -> - FaceLivenessDetectionException.AccessDeniedException(throwable = error) + FaceLivenessDetectionException.AccessDeniedException(throwable = error) to false is FaceLivenessSessionNotFoundException -> - FaceLivenessDetectionException.SessionNotFoundException(throwable = error) + FaceLivenessDetectionException.SessionNotFoundException(throwable = error) to false is FaceLivenessSessionTimeoutException -> - FaceLivenessDetectionException.SessionTimedOutException(throwable = error) + FaceLivenessDetectionException.SessionTimedOutException(throwable = error) to false + is FaceLivenessUnsupportedChallengeTypeException -> + FaceLivenessDetectionException.UnsupportedChallengeTypeException(throwable = error) to true else -> FaceLivenessDetectionException( error.message ?: "Unknown error.", error.recoverySuggestion, error - ) + ) to false } - processSessionError(faceLivenessException, false) + processSessionError(faceLivenessException, shouldStopLivenessSession) } ) } @@ -256,8 +292,8 @@ internal class LivenessCoordinator( ) } - fun processFreshnessChallengeComplete() { - livenessState.onFreshnessComplete() + fun processLivenessCheckComplete() { + livenessState.onLivenessChallengeComplete() stopEncoder { livenessState.onFullChallengeComplete() } } diff --git a/liveness/src/main/java/com/amplifyframework/ui/liveness/ml/FaceDetector.kt b/liveness/src/main/java/com/amplifyframework/ui/liveness/ml/FaceDetector.kt index 86058581..0f4fc8cf 100755 --- a/liveness/src/main/java/com/amplifyframework/ui/liveness/ml/FaceDetector.kt +++ b/liveness/src/main/java/com/amplifyframework/ui/liveness/ml/FaceDetector.kt @@ -42,10 +42,12 @@ internal class FaceDetector(private val livenessState: LivenessState) { outputScores: Array> ): List { val detections = mutableListOf() + val faceTargetChallenge = livenessState.faceTargetChallenge ?: return emptyList() for (i in 0 until NUM_BOXES) { var score = outputScores[0][i][0] score = computeSigmoid(score) - if (score < MIN_SCORE_THRESHOLD) { + + if (score < faceTargetChallenge.faceTargetMatching.faceDetectionThreshold) { continue } @@ -159,6 +161,7 @@ internal class FaceDetector(private val livenessState: LivenessState) { scaledMouth, scaledLeftEar, scaledRightEar, + faceTargetChallenge.faceTargetMatching.targetHeightWidthRatio ) renormalizedDetections.add( Detection( @@ -183,13 +186,14 @@ internal class FaceDetector(private val livenessState: LivenessState) { nose: Landmark, mouth: Landmark, leftEar: Landmark, - rightEar: Landmark + rightEar: Landmark, + heightWidthRatio: Float ): RectF { val pupilDistance = calculatePupilDistance(leftEye, rightEye) val faceHeight = calculateFaceHeight(leftEye, rightEye, mouth) val ow = (ALPHA * pupilDistance + GAMMA * faceHeight) / 2 - val oh = GOLDEN_RATIO * ow + val oh = heightWidthRatio * ow val eyeCenterX = (leftEye.x + rightEye.x) / 2 val eyeCenterY = (leftEye.y + rightEye.y) / 2 @@ -450,7 +454,6 @@ internal class FaceDetector(private val livenessState: LivenessState) { companion object { private const val MIN_SUPPRESSION_THRESHOLD = 0.3f - private const val MIN_SCORE_THRESHOLD = 0.7f private val strides = listOf(8, 16, 16, 16) private const val ASPECT_RATIOS_SIZE = 1 private const val MIN_SCALE = 0.1484375f @@ -459,7 +462,6 @@ internal class FaceDetector(private val livenessState: LivenessState) { private const val ANCHOR_OFFSET_Y = 0.5f private const val INPUT_SIZE_HEIGHT = 128 private const val INPUT_SIZE_WIDTH = 128 - private const val GOLDEN_RATIO = 1.618f private const val ALPHA = 2.0f private const val GAMMA = 1.8f const val X_SCALE = 128f @@ -479,7 +481,6 @@ internal class FaceDetector(private val livenessState: LivenessState) { * 14, 15 - right eye tragion */ const val NUM_COORDS = 16 - const val INITIAL_FACE_DISTANCE_THRESHOLD = 0.32f fun loadModel(context: Context): Interpreter { val modelFileDescriptor = diff --git a/liveness/src/main/java/com/amplifyframework/ui/liveness/model/FaceLivenessDetectionException.kt b/liveness/src/main/java/com/amplifyframework/ui/liveness/model/FaceLivenessDetectionException.kt index 9f746097..c440aacc 100644 --- a/liveness/src/main/java/com/amplifyframework/ui/liveness/model/FaceLivenessDetectionException.kt +++ b/liveness/src/main/java/com/amplifyframework/ui/liveness/model/FaceLivenessDetectionException.kt @@ -45,6 +45,13 @@ open class FaceLivenessDetectionException( throwable: Throwable? = null ) : FaceLivenessDetectionException(message, recoverySuggestion, throwable) + class UnsupportedChallengeTypeException( + message: String = "Received an unsupported ChallengeType from the backend.", + recoverySuggestion: String = "Verify that the Challenges configured in your backend are supported by " + + "this library.", + throwable: Throwable? = null + ) : FaceLivenessDetectionException(message, recoverySuggestion, throwable) + class UserCancelledException( message: String = "User cancelled the face liveness check.", recoverySuggestion: String = "Retry the face liveness check.", diff --git a/liveness/src/main/java/com/amplifyframework/ui/liveness/model/LivenessCheckState.kt b/liveness/src/main/java/com/amplifyframework/ui/liveness/model/LivenessCheckState.kt index 0ffeccfd..913e0fba 100644 --- a/liveness/src/main/java/com/amplifyframework/ui/liveness/model/LivenessCheckState.kt +++ b/liveness/src/main/java/com/amplifyframework/ui/liveness/model/LivenessCheckState.kt @@ -19,10 +19,10 @@ import android.graphics.RectF import com.amplifyframework.ui.liveness.R import com.amplifyframework.ui.liveness.ml.FaceDetector -internal sealed class LivenessCheckState(val instructionId: Int? = null, val isActionable: Boolean = true) { - class Initial( - instructionId: Int? = null, - isActionable: Boolean = true +internal sealed class LivenessCheckState(open val instructionId: Int? = null, open val isActionable: Boolean = true) { + data class Initial( + override val instructionId: Int? = null, + override val isActionable: Boolean = true ) : LivenessCheckState(instructionId, isActionable) { companion object { fun withMoveFaceMessage() = @@ -37,7 +37,7 @@ internal sealed class LivenessCheckState(val instructionId: Int? = null, val isA Initial(R.string.amplify_ui_liveness_get_ready_center_face_label) } } - class Running(instructionId: Int? = null) : LivenessCheckState(instructionId, true) { + data class Running(override val instructionId: Int? = null) : LivenessCheckState(instructionId, true) { companion object { fun withMoveFaceMessage() = Running( R.string.amplify_ui_liveness_challenge_instruction_move_face_closer diff --git a/liveness/src/main/java/com/amplifyframework/ui/liveness/state/AttemptCounter.kt b/liveness/src/main/java/com/amplifyframework/ui/liveness/state/AttemptCounter.kt new file mode 100644 index 00000000..6ea4dfbf --- /dev/null +++ b/liveness/src/main/java/com/amplifyframework/ui/liveness/state/AttemptCounter.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package com.amplifyframework.ui.liveness.state + +class AttemptCounter { + + fun getCount() = attemptCount + + fun countAttempt() { + val timestamp = System.currentTimeMillis() + if (timestamp - latestAttemptTimeStamp > ATTEMPT_COUNT_RESET_INTERVAL_MS) { + // Reset interval has lapsed so reset the attemptCount + attemptCount = 0 + } + + attemptCount += 1 + latestAttemptTimeStamp = timestamp + } + + companion object { + const val ATTEMPT_COUNT_RESET_INTERVAL_MS = 300_000L + var attemptCount = 0 + var latestAttemptTimeStamp: Long = System.currentTimeMillis() + } +} diff --git a/liveness/src/main/java/com/amplifyframework/ui/liveness/state/LivenessState.kt b/liveness/src/main/java/com/amplifyframework/ui/liveness/state/LivenessState.kt index 793158a6..b871ea43 100644 --- a/liveness/src/main/java/com/amplifyframework/ui/liveness/state/LivenessState.kt +++ b/liveness/src/main/java/com/amplifyframework/ui/liveness/state/LivenessState.kt @@ -22,7 +22,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.amplifyframework.predictions.aws.models.ColorChallenge -import com.amplifyframework.predictions.aws.models.ColorChallengeType import com.amplifyframework.predictions.aws.models.FaceTargetChallenge import com.amplifyframework.predictions.aws.models.FaceTargetChallengeResponse import com.amplifyframework.predictions.aws.models.InitialFaceDetected @@ -47,21 +46,21 @@ internal data class LivenessState( val context: Context, val disableStartView: Boolean, val onCaptureReady: () -> Unit, - val onFaceDistanceCheckPassed: () -> Unit, val onSessionError: (FaceLivenessDetectionException, Boolean) -> Unit, val onFinalEventsSent: () -> Unit, ) { var videoViewportSize: VideoViewportSize? by mutableStateOf(null) - var livenessCheckState = mutableStateOf( + var livenessCheckState by mutableStateOf( LivenessCheckState.Initial() ) - var runningFreshness by mutableStateOf(false) + var faceMatched by mutableStateOf(false) var faceGuideRect: RectF? by mutableStateOf(null) var faceMatchPercentage: Float by mutableStateOf(0.25f) var initialFaceDistanceCheckPassed by mutableStateOf(false) var initialLocalFaceFound by mutableStateOf(false) var showingStartView by mutableStateOf(!disableStartView) + var loadingCameraPreview by mutableStateOf(false) private var initialStreamFace: InitialStreamFace? = null @VisibleForTesting @@ -78,7 +77,7 @@ internal data class LivenessState( @VisibleForTesting var readyToSendFinalEvents = false - var livenessSessionInfo: FaceLivenessSession? = null + var livenessSessionInfo: FaceLivenessSession? by mutableStateOf(null) var faceTargetChallenge: FaceTargetChallenge? by mutableStateOf(null) var colorChallenge: ColorChallenge? = null @@ -89,18 +88,18 @@ internal data class LivenessState( } fun onError(stopLivenessSession: Boolean, webSocketCloseCode: WebSocketCloseCode) { - livenessCheckState.value = LivenessCheckState.Error + livenessCheckState = LivenessCheckState.Error onDestroy(stopLivenessSession, webSocketCloseCode) } // Cleans up state when challenge is completed or cancelled. // We only send webSocketCloseCode if error encountered. fun onDestroy(stopLivenessSession: Boolean, webSocketCloseCode: WebSocketCloseCode? = null) { - livenessCheckState.value = LivenessCheckState.Error + livenessCheckState = LivenessCheckState.Error faceOvalMatchTimer?.cancel() readyForOval = false faceGuideRect = null - runningFreshness = false + faceMatched = false if (stopLivenessSession) { livenessSessionInfo?.stopSession(webSocketCloseCode?.code) } @@ -112,7 +111,6 @@ internal data class LivenessState( .filterIsInstance().firstOrNull() colorChallenge = faceLivenessSession.challenges .filterIsInstance().firstOrNull() - livenessCheckState.value = LivenessCheckState.Running() readyForOval = true } @@ -120,16 +118,16 @@ internal data class LivenessState( readyToSendFinalEvents = true } - fun onFreshnessComplete() { + fun onLivenessChallengeComplete() { val faceGuideRect = this.faceGuideRect readyForOval = false this.faceGuideRect = null - runningFreshness = false + faceMatched = false if (faceMatchOvalEnd == null) { faceMatchOvalEnd = Date().time } - livenessCheckState.value = if (faceGuideRect != null) { + livenessCheckState = if (faceGuideRect != null) { LivenessCheckState.Success(faceGuideRect) } else { LivenessCheckState.Error @@ -142,19 +140,19 @@ internal data class LivenessState( fun onFrameAvailable(): Boolean { if (showingStartView) return false - return when (val livenessCheckState = livenessCheckState.value) { + return when (val livenessCheckState = livenessCheckState) { is LivenessCheckState.Error -> false is LivenessCheckState.Initial, is LivenessCheckState.Running -> { /** - * Start freshness check if the face has matched oval (we know this if faceMatchOvalStart is not null) - * We trigger this in onFrameAvailable instead of onFrameFaceUpdate in the event the user moved the face - * away from the camera. We want to run this check on every frame if the challenge is in process. + * Start the challenge checks once the face has matched oval (we know this if faceMatchOvalStart is + * not null). We trigger this in onFrameAvailable instead of onFrameFaceUpdate in the event the user + * moved the face away from the camera. We want to run this check on every frame if the challenge is + * in process. */ - if (!runningFreshness && colorChallenge?.challengeType == - ColorChallengeType.SEQUENTIAL && + if (!faceMatched && faceMatchOvalStart?.let { (Date().time - it) > 1000 } == true ) { - runningFreshness = true + faceMatched = true } true } @@ -164,7 +162,7 @@ internal data class LivenessState( livenessSessionInfo!!.sendChallengeResponseEvent( FaceTargetChallengeResponse( - colorChallenge!!.challengeId, + livenessSessionInfo!!.challengeId, livenessCheckState.faceGuideRect, Date(faceMatchOvalStart!!), Date(faceMatchOvalEnd!!) @@ -186,10 +184,10 @@ internal data class LivenessState( } when (faceCount) { 0 -> { - if (!initialLocalFaceFound || livenessCheckState.value is LivenessCheckState.Initial) { - livenessCheckState.value = LivenessCheckState.Initial.withMoveFaceMessage() - } else if (livenessCheckState.value is LivenessCheckState.Running) { - livenessCheckState.value = LivenessCheckState.Running.withMoveFaceMessage() + if (!initialLocalFaceFound || livenessCheckState is LivenessCheckState.Initial) { + livenessCheckState = LivenessCheckState.Initial.withMoveFaceMessage() + } else if (livenessCheckState is LivenessCheckState.Running) { + livenessCheckState = LivenessCheckState.Running.withMoveFaceMessage() } } 1 -> { @@ -198,10 +196,10 @@ internal data class LivenessState( } } else -> { - if (!initialLocalFaceFound || livenessCheckState.value is LivenessCheckState.Initial) { - livenessCheckState.value = LivenessCheckState.Initial.withMultipleFaceMessage() - } else if (livenessCheckState.value is LivenessCheckState.Running) { - livenessCheckState.value = LivenessCheckState.Running.withMultipleFaceMessage() + if (!initialLocalFaceFound || livenessCheckState is LivenessCheckState.Initial) { + livenessCheckState = LivenessCheckState.Initial.withMultipleFaceMessage() + } else if (livenessCheckState is LivenessCheckState.Running) { + livenessCheckState = LivenessCheckState.Running.withMultipleFaceMessage() } } } @@ -225,12 +223,11 @@ internal data class LivenessState( leftEye, rightEye, mouth, LivenessCoordinator.TARGET_WIDTH, LivenessCoordinator.TARGET_HEIGHT ) - if (faceDistance >= FaceDetector.INITIAL_FACE_DISTANCE_THRESHOLD) { - livenessCheckState.value = + if (faceDistance >= faceTargetChallenge!!.faceTargetMatching.faceDistanceThresholdMin) { + livenessCheckState = LivenessCheckState.Initial.withMoveFaceFurtherAwayMessage() } else { initialFaceDistanceCheckPassed = true - onFaceDistanceCheckPassed() } } @@ -240,7 +237,7 @@ internal data class LivenessState( onCaptureReady() livenessSessionInfo!!.sendChallengeResponseEvent( InitialFaceDetected( - colorChallenge!!.challengeId, + livenessSessionInfo!!.challengeId, face.faceRect, Date(face.timestamp) ) @@ -278,11 +275,11 @@ internal data class LivenessState( faceOvalPosition == FaceDetector.FaceOvalPosition.MATCHED if (detectedFaceMatchedOval) { - livenessCheckState.value = LivenessCheckState.Running.withFaceOvalPosition( + livenessCheckState = LivenessCheckState.Running.withFaceOvalPosition( FaceDetector.FaceOvalPosition.MATCHED ) } else { - livenessCheckState.value = LivenessCheckState.Running.withFaceOvalPosition( + livenessCheckState = LivenessCheckState.Running.withFaceOvalPosition( faceOvalPosition ) } @@ -314,6 +311,7 @@ internal data class LivenessState( } fun onStartViewComplete() { + livenessCheckState = LivenessCheckState.Running() showingStartView = false } } diff --git a/liveness/src/main/java/com/amplifyframework/ui/liveness/ui/FaceLivenessDetector.kt b/liveness/src/main/java/com/amplifyframework/ui/liveness/ui/FaceLivenessDetector.kt index c0e6a268..4eae7dc0 100644 --- a/liveness/src/main/java/com/amplifyframework/ui/liveness/ui/FaceLivenessDetector.kt +++ b/liveness/src/main/java/com/amplifyframework/ui/liveness/ui/FaceLivenessDetector.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -55,6 +56,8 @@ import com.amplifyframework.auth.AWSCredentials import com.amplifyframework.auth.AWSCredentialsProvider import com.amplifyframework.core.Action import com.amplifyframework.core.Consumer +import com.amplifyframework.predictions.models.FaceLivenessChallengeType +import com.amplifyframework.predictions.models.FaceLivenessSession import com.amplifyframework.ui.liveness.R import com.amplifyframework.ui.liveness.camera.LivenessCoordinator import com.amplifyframework.ui.liveness.camera.OnChallengeComplete @@ -81,9 +84,37 @@ fun FaceLivenessDetector( disableStartView: Boolean = false, onComplete: Action, onError: Consumer +) = FaceLivenessDetector( + sessionId, + region, + credentialsProvider, + disableStartView, + onComplete, + onError, + ChallengeOptions() +) + +/** + * @param sessionId of challenge + * @param region AWS region to stream the video to. Current supported regions are listed in [add link here] + * @param credentialsProvider to provide custom CredentialsProvider for authentication. Default uses initialized Amplify.Auth CredentialsProvider + * @param disableStartView to bypass warmup screen. + * @param challengeOptions is the list of ChallengeOptions that are to be overridden from the default configuration + * @param onComplete callback notifying a completed challenge + * @param onError callback containing exception for cause + */ +@Composable +fun FaceLivenessDetector( + sessionId: String, + region: String, + credentialsProvider: AWSCredentialsProvider? = null, + disableStartView: Boolean = false, + onComplete: Action, + onError: Consumer, + challengeOptions: ChallengeOptions = ChallengeOptions(), ) { val scope = rememberCoroutineScope() - val key = Triple(sessionId, region, credentialsProvider) + val key = DetectorStateKey(sessionId, region, credentialsProvider) var isFinished by remember(key) { mutableStateOf(false) } val currentOnComplete by rememberUpdatedState(onComplete) val currentOnError by rememberUpdatedState(onError) @@ -122,6 +153,7 @@ fun FaceLivenessDetector( region, credentialsProvider = credentialsProvider, disableStartView, + challengeOptions = challengeOptions, onChallengeComplete = { scope.launch { // if we are already finished, we already provided a result in complete or failed @@ -154,6 +186,7 @@ internal fun ChallengeView( region: String, credentialsProvider: AWSCredentialsProvider?, disableStartView: Boolean, + challengeOptions: ChallengeOptions, onChallengeComplete: OnChallengeComplete, onChallengeFailed: Consumer ) { @@ -174,6 +207,7 @@ internal fun ChallengeView( region, credentialsProvider, disableStartView, + challengeOptions, onChallengeComplete = { currentOnChallengeComplete() }, onChallengeFailed = { currentOnChallengeFailed.accept(it) } ) @@ -230,6 +264,15 @@ internal fun ChallengeView( if (livenessState.showingStartView) { + if (livenessState.loadingCameraPreview) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .align(Alignment.Center), + strokeWidth = 2.dp, + ) + } + FaceGuide( modifier = Modifier .fillMaxSize() @@ -247,8 +290,10 @@ internal fun ChallengeView( verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - PhotosensitivityView { - showPhotosensitivityAlert.value = true + if (livenessState.livenessSessionInfo.isFaceMovementAndLightChallenge()) { + PhotosensitivityView { + showPhotosensitivityAlert.value = true + } } InstructionMessage(LivenessCheckState.Initial.withStartViewMessage()) @@ -277,7 +322,6 @@ internal fun ChallengeView( } } } else { - livenessState.faceGuideRect?.let { FaceGuide( modifier = Modifier @@ -288,23 +332,29 @@ internal fun ChallengeView( ) } - if (livenessState.runningFreshness) { - FreshnessChallenge( - key, - modifier = Modifier.fillMaxSize(), - colors = livenessState.colorChallenge!!.challengeColors, - onColorDisplayed = { currentColor, previousColor, sequenceNumber, colorStart -> - livenessCoordinator.processColorDisplayed( - currentColor, - previousColor, - sequenceNumber, - colorStart - ) - }, - onComplete = { - livenessCoordinator.processFreshnessChallengeComplete() + if (livenessState.faceMatched) { + if (livenessState.livenessSessionInfo.isFaceMovementAndLightChallenge()) { + FreshnessChallenge( + key, + modifier = Modifier.fillMaxSize(), + colors = livenessState.colorChallenge!!.challengeColors, + onColorDisplayed = { currentColor, previousColor, sequenceNumber, colorStart -> + livenessCoordinator.processColorDisplayed( + currentColor, + previousColor, + sequenceNumber, + colorStart + ) + }, + onComplete = { + livenessCoordinator.processLivenessCheckComplete() + } + ) + } else { + LaunchedEffect(key) { + livenessCoordinator.processLivenessCheckComplete() } - ) + } } livenessState.faceGuideRect?.let { @@ -343,8 +393,14 @@ internal fun ChallengeView( verticalArrangement = Arrangement.spacedBy(5.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - InstructionMessage(livenessState.livenessCheckState.value) - if (livenessState.livenessCheckState.value.instructionId == + if (shouldDisplayInstruction( + livenessState.livenessCheckState, + livenessState.livenessSessionInfo?.challengeType + ) + ) { + InstructionMessage(livenessState.livenessCheckState) + } + if (livenessState.livenessCheckState.instructionId == FaceDetector.FaceOvalPosition.TOO_FAR.instructionStringRes ) { val scaledOvalRect = livenessState.faceGuideRect?.let { @@ -377,7 +433,7 @@ internal fun ChallengeView( verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - InstructionMessage(livenessState.livenessCheckState.value) + InstructionMessage(livenessState.livenessCheckState) } } } @@ -386,3 +442,61 @@ internal fun ChallengeView( } } } + +internal data class DetectorStateKey( + val sessionId: String, + val region: String, + val credentialsProvider: AWSCredentialsProvider? +) + +data class ChallengeOptions( + val faceMovementAndLight: LivenessChallenge.FaceMovementAndLight = LivenessChallenge.FaceMovementAndLight, + val faceMovement: LivenessChallenge.FaceMovement = LivenessChallenge.FaceMovement() +) { + internal fun getLivenessChallenge(challengeType: FaceLivenessChallengeType): LivenessChallenge = + when (challengeType) { + FaceLivenessChallengeType.FaceMovementAndLightChallenge -> faceMovementAndLight + FaceLivenessChallengeType.FaceMovementChallenge -> faceMovement + } + + /** + * @return true if all of the challenge options are configured to use the same camera configuration + */ + internal fun hasOneCameraConfigured(): Boolean = + listOf( + faceMovementAndLight, + faceMovement + ).all { it.camera == faceMovementAndLight.camera } +} + +sealed class LivenessChallenge( + open val camera: Camera = Camera.Front +) { + data class FaceMovement(override val camera: Camera = Camera.Front) : LivenessChallenge( + camera = camera + ) + data object FaceMovementAndLight : LivenessChallenge() +} + +sealed class Camera { + data object Front : Camera() + data object Back : Camera() +} + +private fun FaceLivenessSession?.isFaceMovementAndLightChallenge(): Boolean = + this?.challengeType == FaceLivenessChallengeType.FaceMovementAndLightChallenge + +private fun shouldDisplayInstruction( + livenessCheckState: LivenessCheckState, + challengeType: FaceLivenessChallengeType? +): Boolean = + if (challengeType == null) { + true + } else if (livenessCheckState == + LivenessCheckState.Running.withFaceOvalPosition(FaceDetector.FaceOvalPosition.MATCHED) && + challengeType == FaceLivenessChallengeType.FaceMovementChallenge + ) { + false + } else { + true + } diff --git a/liveness/src/test/java/com/amplifyframework/ui/liveness/ml/FaceDetectorTest.kt b/liveness/src/test/java/com/amplifyframework/ui/liveness/ml/FaceDetectorTest.kt index 8e4fba71..6fcd4460 100644 --- a/liveness/src/test/java/com/amplifyframework/ui/liveness/ml/FaceDetectorTest.kt +++ b/liveness/src/test/java/com/amplifyframework/ui/liveness/ml/FaceDetectorTest.kt @@ -37,8 +37,7 @@ internal class FaceDetectorTest { private val mouth = Landmark(0.5062596f, 0.68926525f) private val leftEar = Landmark(0.78989476f, 0.5973732f) private val rightEar = Landmark(0.16585289f, 0.5668279f) - private val width = 0.65490234f - private val height = 0.49117205f + private val heightWidthRatio = 1.618f @Before fun setup() { @@ -68,7 +67,8 @@ internal class FaceDetectorTest { nose, mouth, leftEar, - rightEar + rightEar, + heightWidthRatio ) // then diff --git a/liveness/src/test/java/com/amplifyframework/ui/liveness/state/LivenessStateTest.kt b/liveness/src/test/java/com/amplifyframework/ui/liveness/state/LivenessStateTest.kt index f354924e..9f036e34 100644 --- a/liveness/src/test/java/com/amplifyframework/ui/liveness/state/LivenessStateTest.kt +++ b/liveness/src/test/java/com/amplifyframework/ui/liveness/state/LivenessStateTest.kt @@ -22,6 +22,7 @@ import com.amplifyframework.predictions.aws.models.FaceTargetChallenge import com.amplifyframework.predictions.aws.models.FaceTargetChallengeResponse import com.amplifyframework.predictions.aws.models.InitialFaceDetected import com.amplifyframework.predictions.models.ChallengeResponseEvent +import com.amplifyframework.predictions.models.FaceLivenessChallengeType import com.amplifyframework.predictions.models.FaceLivenessSession import com.amplifyframework.predictions.models.FaceLivenessSessionChallenge import com.amplifyframework.predictions.models.VideoEvent @@ -29,6 +30,7 @@ import com.amplifyframework.ui.liveness.ml.FaceDetector import com.amplifyframework.ui.liveness.model.FaceLivenessDetectionException import com.amplifyframework.ui.liveness.model.LivenessCheckState import com.amplifyframework.ui.liveness.util.WebSocketCloseCode +import io.mockk.every import io.mockk.mockk import io.mockk.verify import org.junit.Assert.assertEquals @@ -44,7 +46,6 @@ internal class LivenessStateTest { private lateinit var livenessState: LivenessState private val onCaptureReady = mockk<() -> Unit>(relaxed = true) - private val onFaceDistanceCheckPassed = mockk<() -> Unit>(relaxed = true) private val onSessionError = mockk<(FaceLivenessDetectionException, Boolean) -> Unit>(relaxed = true) private val onFinalEventsSent = mockk<() -> Unit>(relaxed = true) @@ -56,7 +57,6 @@ internal class LivenessStateTest { ApplicationProvider.getApplicationContext(), false, onCaptureReady, - onFaceDistanceCheckPassed, onSessionError, onFinalEventsSent ) @@ -71,11 +71,24 @@ internal class LivenessStateTest { ApplicationProvider.getApplicationContext(), false, onCaptureReady, - onFaceDistanceCheckPassed, onSessionError, onFinalEventsSent ) + val faceTargetChallenge = mockk(relaxed = true) + val challenges = listOf( + faceTargetChallenge + ) + val faceLivenessSession = FaceLivenessSession( + challengeId = "12345", + challengeType = FaceLivenessChallengeType.FaceMovementAndLightChallenge, + challenges = challenges, + onVideoEvent = { }, + onChallengeResponseEvent = { }, + stopLivenessSession = { } + ) + stateWithStartView.onLivenessSessionReady(faceLivenessSession) + // then assertFalse(stateWithStartView.onFrameAvailable()) stateWithStartView.onFrameFaceUpdate( @@ -108,11 +121,24 @@ internal class LivenessStateTest { ApplicationProvider.getApplicationContext(), true, onCaptureReady, - onFaceDistanceCheckPassed, onSessionError, onFinalEventsSent ) + val faceTargetChallenge = mockk(relaxed = true) + val challenges = listOf( + faceTargetChallenge + ) + val faceLivenessSession = FaceLivenessSession( + challengeId = "12345", + challengeType = FaceLivenessChallengeType.FaceMovementAndLightChallenge, + challenges = challenges, + onVideoEvent = { }, + onChallengeResponseEvent = { }, + stopLivenessSession = { } + ) + stateWithoutStartView.onLivenessSessionReady(faceLivenessSession) + // then assertTrue(stateWithoutStartView.onFrameAvailable()) assertTrue( @@ -126,21 +152,28 @@ internal class LivenessStateTest { } @Test - fun `beginning state is initial`() { - assertTrue(livenessState.livenessCheckState.value is LivenessCheckState.Initial) + fun `beginning state is running`() { + assertTrue(livenessState.livenessCheckState is LivenessCheckState.Running) } @Test fun `state is error after on error`() { livenessState.onError(true, WebSocketCloseCode.RUNTIME_ERROR) - assertTrue(livenessState.livenessCheckState.value is LivenessCheckState.Error) + assertTrue(livenessState.livenessCheckState is LivenessCheckState.Error) } @Test fun `session is stopped when stopLivenessSession is true and error occurs`() { val challenges = mockk>(relaxed = true) val stopSession = mockk<(Int?) -> Unit>(relaxed = true) - livenessState.livenessSessionInfo = FaceLivenessSession(challenges, { }, { }, stopSession) + livenessState.livenessSessionInfo = FaceLivenessSession( + challengeId = "12345", + challengeType = FaceLivenessChallengeType.FaceMovementAndLightChallenge, + challenges = challenges, + onVideoEvent = { }, + onChallengeResponseEvent = { }, + stopLivenessSession = stopSession + ) livenessState.onError(true, WebSocketCloseCode.RUNTIME_ERROR) verify(exactly = 1) { stopSession(WebSocketCloseCode.RUNTIME_ERROR.code) } } @@ -149,7 +182,14 @@ internal class LivenessStateTest { fun `proper code is sent when provided in onDestroy`() { val challenges = mockk>(relaxed = true) val stopSession = mockk<(Int?) -> Unit>(relaxed = true) - livenessState.livenessSessionInfo = FaceLivenessSession(challenges, { }, { }, stopSession) + livenessState.livenessSessionInfo = FaceLivenessSession( + challengeId = "12345", + challengeType = FaceLivenessChallengeType.FaceMovementAndLightChallenge, + challenges = challenges, + onVideoEvent = { }, + onChallengeResponseEvent = { }, + stopLivenessSession = stopSession + ) livenessState.onDestroy(true, WebSocketCloseCode.DISPOSED) verify(exactly = 1) { stopSession(WebSocketCloseCode.DISPOSED.code) } } @@ -158,7 +198,14 @@ internal class LivenessStateTest { fun `null close code is sent when no close code provided in onDestroy`() { val challenges = mockk>(relaxed = true) val stopSession = mockk<(Int?) -> Unit>(relaxed = true) - livenessState.livenessSessionInfo = FaceLivenessSession(challenges, { }, { }, stopSession) + livenessState.livenessSessionInfo = FaceLivenessSession( + challengeId = "12345", + challengeType = FaceLivenessChallengeType.FaceMovementAndLightChallenge, + challenges = challenges, + onVideoEvent = { }, + onChallengeResponseEvent = { }, + stopLivenessSession = stopSession + ) livenessState.onDestroy(true, null) verify(exactly = 1) { stopSession(null) } } @@ -167,7 +214,14 @@ internal class LivenessStateTest { fun `session is not stopped when stopLivenessSession is false and error occurs`() { val challenges = mockk>(relaxed = true) val stopSession = mockk<(Int?) -> Unit>(relaxed = true) - livenessState.livenessSessionInfo = FaceLivenessSession(challenges, { }, { }, stopSession) + livenessState.livenessSessionInfo = FaceLivenessSession( + challengeId = "12345", + challengeType = FaceLivenessChallengeType.FaceMovementAndLightChallenge, + challenges = challenges, + onVideoEvent = { }, + onChallengeResponseEvent = { }, + stopLivenessSession = stopSession + ) livenessState.onError(false, WebSocketCloseCode.RUNTIME_ERROR) verify(exactly = 0) { stopSession(any()) } } @@ -178,7 +232,14 @@ internal class LivenessStateTest { val challenges = listOf( faceTargetChallenge ) - val faceLivenessSession = FaceLivenessSession(challenges, { }, { }, { }) + val faceLivenessSession = FaceLivenessSession( + challengeId = "12345", + challengeType = FaceLivenessChallengeType.FaceMovementAndLightChallenge, + challenges = challenges, + onVideoEvent = { }, + onChallengeResponseEvent = { }, + stopLivenessSession = { } + ) livenessState.onLivenessSessionReady(faceLivenessSession) assertEquals(faceTargetChallenge, livenessState.faceTargetChallenge) } @@ -189,7 +250,14 @@ internal class LivenessStateTest { val challenges = listOf( colorChallenge ) - val faceLivenessSession = FaceLivenessSession(challenges, { }, { }, { }) + val faceLivenessSession = FaceLivenessSession( + challengeId = "12345", + challengeType = FaceLivenessChallengeType.FaceMovementAndLightChallenge, + challenges = challenges, + onVideoEvent = { }, + onChallengeResponseEvent = { }, + stopLivenessSession = { } + ) livenessState.onLivenessSessionReady(faceLivenessSession) assertEquals(colorChallenge, livenessState.colorChallenge) } @@ -198,7 +266,7 @@ internal class LivenessStateTest { fun `challenge runs after retrieving session info`() { val faceLivenessSession = mockk(relaxed = true) livenessState.onLivenessSessionReady(faceLivenessSession) - assertTrue(livenessState.livenessCheckState.value is LivenessCheckState.Running) + assertTrue(livenessState.livenessCheckState is LivenessCheckState.Running) assertTrue(livenessState.readyForOval) } @@ -209,48 +277,54 @@ internal class LivenessStateTest { } @Test - fun `freshness stops running on freshness complete`() { - livenessState.onFreshnessComplete() - assertFalse(livenessState.runningFreshness) + fun `liveness challenge execution stops running on challenge completion`() { + livenessState.onLivenessChallengeComplete() + assertFalse(livenessState.faceMatched) } @Test - fun `state is success after freshness completes and no errors occur`() { + fun `state is success after challenge completes and no errors occur`() { livenessState.faceGuideRect = mockk(relaxed = true) - livenessState.onFreshnessComplete() - assertTrue(livenessState.livenessCheckState.value is LivenessCheckState.Success) + livenessState.onLivenessChallengeComplete() + assertTrue(livenessState.livenessCheckState is LivenessCheckState.Success) } @Test - fun `state is error after freshness completes and an error occurs`() { + fun `state is error after challenge completes and an error occurs`() { livenessState.faceGuideRect = mockk(relaxed = true) livenessState.onError(false, WebSocketCloseCode.RUNTIME_ERROR) - livenessState.onFreshnessComplete() - assertTrue(livenessState.livenessCheckState.value is LivenessCheckState.Error) + livenessState.onLivenessChallengeComplete() + assertTrue(livenessState.livenessCheckState is LivenessCheckState.Error) } @Test fun `stop processing frames if in error state`() { - livenessState.livenessCheckState.value = LivenessCheckState.Error + livenessState.livenessCheckState = LivenessCheckState.Error assertFalse(livenessState.onFrameAvailable()) } @Test fun `keep processing frames if not in success or error state`() { - livenessState.livenessCheckState.value = LivenessCheckState.Running.withMoveFaceMessage() + livenessState.livenessCheckState = LivenessCheckState.Running.withMoveFaceMessage() assertTrue(livenessState.onFrameAvailable()) } @Test fun `final events are sent when ready to send final events`() { val faceGuideRect = mockk(relaxed = true) - livenessState.livenessCheckState.value = LivenessCheckState.Success(faceGuideRect) + livenessState.livenessCheckState = LivenessCheckState.Success(faceGuideRect) livenessState.readyToSendFinalEvents = true val challenges = mockk>(relaxed = true) val sendVideoEvent = mockk<(VideoEvent) -> Unit>(relaxed = true) val sendChallengeResponse = mockk<(ChallengeResponseEvent) -> Unit>(relaxed = true) - livenessState.livenessSessionInfo = - FaceLivenessSession(challenges, sendVideoEvent, sendChallengeResponse) {} + livenessState.livenessSessionInfo = FaceLivenessSession( + challengeId = "12345", + challengeType = FaceLivenessChallengeType.FaceMovementAndLightChallenge, + challenges = challenges, + onVideoEvent = sendVideoEvent, + onChallengeResponseEvent = sendChallengeResponse, + stopLivenessSession = { } + ) livenessState.colorChallenge = mockk(relaxed = true) livenessState.faceMatchOvalStart = 0L livenessState.faceMatchOvalEnd = 0L @@ -265,16 +339,16 @@ internal class LivenessStateTest { @Test fun `state is initial if no face found before running`() { livenessState.onFrameFaceCountUpdate(0) - assertTrue(livenessState.livenessCheckState.value is LivenessCheckState.Initial) + assertTrue(livenessState.livenessCheckState is LivenessCheckState.Initial) } @Test fun `state is running if no face found during challenges`() { livenessState.initialLocalFaceFound = true - livenessState.livenessCheckState.value = + livenessState.livenessCheckState = LivenessCheckState.Running.withMultipleFaceMessage() livenessState.onFrameFaceCountUpdate(0) - assertTrue(livenessState.livenessCheckState.value is LivenessCheckState.Running) + assertTrue(livenessState.livenessCheckState is LivenessCheckState.Running) } @Test @@ -286,36 +360,66 @@ internal class LivenessStateTest { @Test fun `state is initial if multiple faces detected before running`() { livenessState.onFrameFaceCountUpdate(2) - assertTrue(livenessState.livenessCheckState.value is LivenessCheckState.Initial) + assertTrue(livenessState.livenessCheckState is LivenessCheckState.Initial) } @Test fun `state is running if multiple faces found during challenges`() { livenessState.initialLocalFaceFound = true - livenessState.livenessCheckState.value = LivenessCheckState.Running.withMoveFaceMessage() + livenessState.livenessCheckState = LivenessCheckState.Running.withMoveFaceMessage() livenessState.onFrameFaceCountUpdate(2) - assertTrue(livenessState.livenessCheckState.value is LivenessCheckState.Running) + assertTrue(livenessState.livenessCheckState is LivenessCheckState.Running) } @Test fun `state is initial if face distance check fails before running`() { + val faceTargetChallenge = mockk(relaxed = true) + val challenges = listOf( + faceTargetChallenge + ) + val faceLivenessSession = FaceLivenessSession( + challengeId = "12345", + challengeType = FaceLivenessChallengeType.FaceMovementAndLightChallenge, + challenges = challenges, + onVideoEvent = { }, + onChallengeResponseEvent = { }, + stopLivenessSession = { } + ) + livenessState.onLivenessSessionReady(faceLivenessSession) + val faceRect = RectF(20f, 20f, 100f, 100f) val leftEye = FaceDetector.Landmark(25f, 40f) val rightEye = FaceDetector.Landmark(75f, 40f) val mouth = FaceDetector.Landmark(40f, 80f) livenessState.onFrameFaceUpdate(faceRect, leftEye, rightEye, mouth) - assertTrue(livenessState.livenessCheckState.value is LivenessCheckState.Initial) + assertTrue(livenessState.livenessCheckState is LivenessCheckState.Running) } @Test fun `face distance check passes when face is far enough away before running`() { + val faceTargetChallenge = mockk(relaxed = true) + val challenges = listOf( + faceTargetChallenge + ) + + every { faceTargetChallenge.faceTargetMatching.faceDistanceThresholdMin } returns 1f + + val faceLivenessSession = FaceLivenessSession( + challengeId = "12345", + challengeType = FaceLivenessChallengeType.FaceMovementAndLightChallenge, + challenges = challenges, + onVideoEvent = { }, + onChallengeResponseEvent = { }, + stopLivenessSession = { } + ) + livenessState.onLivenessSessionReady(faceLivenessSession) + val faceRect = RectF(20f, 20f, 100f, 100f) val leftEye = FaceDetector.Landmark(25f, 40f) val rightEye = FaceDetector.Landmark(75f, 40f) val mouth = FaceDetector.Landmark(40f, 80f) livenessState.onFrameFaceUpdate(faceRect, leftEye, rightEye, mouth) assertTrue(livenessState.initialFaceDistanceCheckPassed) - verify(exactly = 1) { onFaceDistanceCheckPassed() } } @Test @@ -339,7 +443,14 @@ internal class LivenessStateTest { val sendVideoEvent = mockk<(VideoEvent) -> Unit>(relaxed = true) val sendChallengeResponse = mockk<(ChallengeResponseEvent) -> Unit>(relaxed = true) livenessState.livenessSessionInfo = - FaceLivenessSession(challenges, sendVideoEvent, sendChallengeResponse) {} + FaceLivenessSession( + challengeId = "12345", + challengeType = FaceLivenessChallengeType.FaceMovementAndLightChallenge, + challenges = challenges, + onVideoEvent = sendVideoEvent, + onChallengeResponseEvent = sendChallengeResponse, + stopLivenessSession = { } + ) livenessState.colorChallenge = mockk(relaxed = true) livenessState.faceTargetChallenge = mockk(relaxed = true)