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..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 @@ -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,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) } @@ -200,7 +218,13 @@ 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() 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..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,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..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 @@ -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 @@ -83,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, challengeOptions) var isFinished by remember(key) { mutableStateOf(false) } val currentOnComplete by rememberUpdatedState(onComplete) val currentOnError by rememberUpdatedState(onError) @@ -124,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 @@ -156,6 +186,7 @@ internal fun ChallengeView( region: String, credentialsProvider: AWSCredentialsProvider?, disableStartView: Boolean, + challengeOptions: ChallengeOptions, onChallengeComplete: OnChallengeComplete, onChallengeFailed: Consumer ) { @@ -176,6 +207,7 @@ internal fun ChallengeView( region, credentialsProvider, disableStartView, + challengeOptions, onChallengeComplete = { currentOnChallengeComplete() }, onChallengeFailed = { currentOnChallengeFailed.accept(it) } ) @@ -232,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() @@ -402,6 +443,47 @@ 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() +) { + 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