Skip to content

feat(liveness): Add support for configuring the back camera for the no light challenge #242

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jun 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<AWSCredentials>?,
private val disableStartView: Boolean,
private val challengeOptions: ChallengeOptions,
private val onChallengeComplete: OnChallengeComplete,
val onChallengeFailed: Consumer<FaceLivenessDetectionException>
) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -83,9 +84,37 @@ fun FaceLivenessDetector(
disableStartView: Boolean = false,
onComplete: Action,
onError: Consumer<FaceLivenessDetectionException>
) = 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<AWSCredentials>? = null,
disableStartView: Boolean = false,
onComplete: Action,
onError: Consumer<FaceLivenessDetectionException>,
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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -156,6 +186,7 @@ internal fun ChallengeView(
region: String,
credentialsProvider: AWSCredentialsProvider<AWSCredentials>?,
disableStartView: Boolean,
challengeOptions: ChallengeOptions,
onChallengeComplete: OnChallengeComplete,
onChallengeFailed: Consumer<FaceLivenessDetectionException>
) {
Expand All @@ -176,6 +207,7 @@ internal fun ChallengeView(
region,
credentialsProvider,
disableStartView,
challengeOptions,
onChallengeComplete = { currentOnChallengeComplete() },
onChallengeFailed = { currentOnChallengeFailed.accept(it) }
)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -402,6 +443,47 @@ internal fun ChallengeView(
}
}

internal data class DetectorStateKey(
val sessionId: String,
val region: String,
val credentialsProvider: AWSCredentialsProvider<AWSCredentials>?,
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

Expand Down