From f6d617474e7a3f90c9fa1795e30b6204fa748cc3 Mon Sep 17 00:00:00 2001 From: Vincent Tran Date: Fri, 20 Sep 2024 14:04:24 -0700 Subject: [PATCH 1/5] feat(liveness): Add support for configuring the back camera for the no light challenge --- .../ui/liveness/camera/LivenessCoordinator.kt | 37 +++++++++++--- .../ui/liveness/ml/FaceDetector.kt | 1 - .../ui/liveness/state/LivenessState.kt | 1 + .../ui/liveness/ui/FaceLivenessDetector.kt | 49 +++++++++++++++++++ 4 files changed, 81 insertions(+), 7 deletions(-) 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 a7ca5cfb..a20faf1c 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 @@ -47,6 +47,8 @@ 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 @@ -68,11 +70,12 @@ 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?, private val disableStartView: Boolean, + private val challengeOptions: ChallengeOptions, private val onChallengeComplete: OnChallengeComplete, val onChallengeFailed: Consumer ) { @@ -142,6 +145,14 @@ internal class LivenessCoordinator( 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) { @@ -156,20 +167,28 @@ 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) } + livenessState.loadingCameraPreview = false } } } @@ -200,7 +219,13 @@ internal class LivenessCoordinator( faceLivenessSessionInformation, faceLivenessSessionOptions, BuildConfig.LIVENESS_VERSION_NAME, - { livenessState.onLivenessSessionReady(it) }, + { + livenessState.onLivenessSessionReady(it) + if (!challengeOptions.hasOneCameraConfigured()) { + val foundChallenge = challengeOptions.getOptions(it.challengeType) + launchCamera(foundChallenge.camera) + } + }, { disconnectEventReceived = true onChallengeComplete() 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 68cf13f3..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 @@ -18,7 +18,6 @@ package com.amplifyframework.ui.liveness.ml import android.content.Context import android.graphics.RectF import androidx.annotation.VisibleForTesting -import com.amplifyframework.predictions.aws.models.FaceTargetChallenge import com.amplifyframework.predictions.aws.models.FaceTargetMatchingParameters import com.amplifyframework.ui.liveness.R import com.amplifyframework.ui.liveness.camera.LivenessCoordinator.Companion.TARGET_HEIGHT 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 b835f42b..2ae36cab 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 @@ -60,6 +60,7 @@ internal data class LivenessState( var initialLocalFaceFound by mutableStateOf(false) var showingStartView by mutableStateOf(!disableStartView) + var loadingCameraPreview by mutableStateOf(false) private var initialStreamFace: InitialStreamFace? = null @VisibleForTesting 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 aa574e50..046f3d33 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 @@ -72,6 +73,7 @@ import kotlinx.coroutines.launch * @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 */ @@ -81,6 +83,7 @@ fun FaceLivenessDetector( region: String, credentialsProvider: AWSCredentialsProvider? = null, disableStartView: Boolean = false, + challengeOptions: ChallengeOptions = ChallengeOptions(), onComplete: Action, onError: Consumer ) { @@ -124,6 +127,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 @@ -156,6 +160,7 @@ internal fun ChallengeView( region: String, credentialsProvider: AWSCredentialsProvider?, disableStartView: Boolean, + challengeOptions: ChallengeOptions, onChallengeComplete: OnChallengeComplete, onChallengeFailed: Consumer ) { @@ -176,6 +181,7 @@ internal fun ChallengeView( region, credentialsProvider, disableStartView, + challengeOptions, onChallengeComplete = { currentOnChallengeComplete() }, onChallengeFailed = { currentOnChallengeFailed.accept(it) } ) @@ -232,6 +238,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() @@ -402,6 +417,40 @@ internal fun ChallengeView( } } +data class ChallengeOptions( + val faceMovementAndLight: LivenessChallenge.FaceMovementAndLight = LivenessChallenge.FaceMovementAndLight, + val faceMovement: LivenessChallenge.FaceMovement = LivenessChallenge.FaceMovement() +) { + fun getOptions(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 + */ + fun hasOneCameraConfigured(): Boolean = + listOf( + faceMovementAndLight, + faceMovement + ).all { it.camera == faceMovementAndLight.camera } +} + +sealed class LivenessChallenge( + val camera: Camera = Camera.Front +) { + class FaceMovement(camera: Camera = Camera.Front) : LivenessChallenge( + camera = camera + ) + object FaceMovementAndLight : LivenessChallenge() +} + +sealed class Camera { + object Front : Camera() + object Back : Camera() +} + private fun FaceLivenessSession?.isFaceMovementAndLightChallenge(): Boolean = this?.challengeType == FaceLivenessChallengeType.FaceMovementAndLightChallenge From 2f3acabee65fa806c6b543cb5663379d025bd346 Mon Sep 17 00:00:00 2001 From: Vincent Tran Date: Tue, 24 Jun 2025 11:32:32 -0700 Subject: [PATCH 2/5] update based on PR comments --- .../ui/liveness/camera/LivenessCoordinator.kt | 3 +- .../ui/liveness/ui/FaceLivenessDetector.kt | 44 +++++++++++++++---- 2 files changed, 36 insertions(+), 11 deletions(-) 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 a20faf1c..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 @@ -188,7 +188,6 @@ internal class LivenessCoordinator( ) processSessionError(faceLivenessException, true) } - livenessState.loadingCameraPreview = false } } } @@ -222,7 +221,7 @@ internal class LivenessCoordinator( { livenessState.onLivenessSessionReady(it) if (!challengeOptions.hasOneCameraConfigured()) { - val foundChallenge = challengeOptions.getOptions(it.challengeType) + val foundChallenge = challengeOptions.getLivenessChallenge(it.challengeType) launchCamera(foundChallenge.camera) } }, 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 046f3d33..eb183190 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 @@ -73,7 +73,6 @@ import kotlinx.coroutines.launch * @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 */ @@ -83,9 +82,36 @@ fun FaceLivenessDetector( region: String, credentialsProvider: AWSCredentialsProvider? = null, disableStartView: Boolean = false, - challengeOptions: ChallengeOptions = ChallengeOptions(), 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) @@ -421,7 +447,7 @@ data class ChallengeOptions( val faceMovementAndLight: LivenessChallenge.FaceMovementAndLight = LivenessChallenge.FaceMovementAndLight, val faceMovement: LivenessChallenge.FaceMovement = LivenessChallenge.FaceMovement() ) { - fun getOptions(challengeType: FaceLivenessChallengeType): LivenessChallenge = + internal fun getLivenessChallenge(challengeType: FaceLivenessChallengeType): LivenessChallenge = when (challengeType) { FaceLivenessChallengeType.FaceMovementAndLightChallenge -> faceMovementAndLight FaceLivenessChallengeType.FaceMovementChallenge -> faceMovement @@ -430,7 +456,7 @@ data class ChallengeOptions( /** * @return true if all of the challenge options are configured to use the same camera configuration */ - fun hasOneCameraConfigured(): Boolean = + internal fun hasOneCameraConfigured(): Boolean = listOf( faceMovementAndLight, faceMovement @@ -438,17 +464,17 @@ data class ChallengeOptions( } sealed class LivenessChallenge( - val camera: Camera = Camera.Front + open val camera: Camera = Camera.Front ) { - class FaceMovement(camera: Camera = Camera.Front) : LivenessChallenge( + data class FaceMovement(override val camera: Camera = Camera.Front) : LivenessChallenge( camera = camera ) - object FaceMovementAndLight : LivenessChallenge() + data object FaceMovementAndLight : LivenessChallenge() } sealed class Camera { - object Front : Camera() - object Back : Camera() + data object Front : Camera() + data object Back : Camera() } private fun FaceLivenessSession?.isFaceMovementAndLightChallenge(): Boolean = From 4abfc5e3f44b645634c60ec01ad8e2114fecfdb7 Mon Sep 17 00:00:00 2001 From: Vincent Tran Date: Tue, 24 Jun 2025 12:18:22 -0700 Subject: [PATCH 3/5] Updated detectorview key to be a list and added challenge options to it --- .../com/amplifyframework/ui/liveness/ui/FaceLivenessDetector.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 eb183190..1c434254 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 @@ -114,7 +114,7 @@ fun FaceLivenessDetector( challengeOptions: ChallengeOptions = ChallengeOptions(), ) { val scope = rememberCoroutineScope() - val key = Triple(sessionId, region, credentialsProvider) + val key = listOf(sessionId, region, credentialsProvider, challengeOptions) var isFinished by remember(key) { mutableStateOf(false) } val currentOnComplete by rememberUpdatedState(onComplete) val currentOnError by rememberUpdatedState(onError) From de67b061530331247766d6f84d8788700a16c35c Mon Sep 17 00:00:00 2001 From: Vincent Tran Date: Tue, 24 Jun 2025 12:20:12 -0700 Subject: [PATCH 4/5] fix ktlint issue --- .../com/amplifyframework/ui/liveness/state/LivenessState.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 2ae36cab..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 @@ -60,7 +60,7 @@ internal data class LivenessState( var initialLocalFaceFound by mutableStateOf(false) var showingStartView by mutableStateOf(!disableStartView) - var loadingCameraPreview by mutableStateOf(false) + var loadingCameraPreview by mutableStateOf(false) private var initialStreamFace: InitialStreamFace? = null @VisibleForTesting From 5ddeedbcfd734186a36ee61a0b5599641f89b454 Mon Sep 17 00:00:00 2001 From: Vincent Tran Date: Tue, 24 Jun 2025 12:57:53 -0700 Subject: [PATCH 5/5] Change the list to a data class --- .../ui/liveness/ui/FaceLivenessDetector.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 1c434254..181e9acf 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 @@ -114,7 +114,7 @@ fun FaceLivenessDetector( challengeOptions: ChallengeOptions = ChallengeOptions(), ) { val scope = rememberCoroutineScope() - val key = listOf(sessionId, region, credentialsProvider, challengeOptions) + val key = DetectorStateKey(sessionId, region, credentialsProvider, challengeOptions) var isFinished by remember(key) { mutableStateOf(false) } val currentOnComplete by rememberUpdatedState(onComplete) val currentOnError by rememberUpdatedState(onError) @@ -443,6 +443,13 @@ internal fun ChallengeView( } } +internal data class DetectorStateKey( + val sessionId: String, + val region: String, + val credentialsProvider: AWSCredentialsProvider?, + val challengeOptions: ChallengeOptions +) + data class ChallengeOptions( val faceMovementAndLight: LivenessChallenge.FaceMovementAndLight = LivenessChallenge.FaceMovementAndLight, val faceMovement: LivenessChallenge.FaceMovement = LivenessChallenge.FaceMovement()