diff --git a/authenticator-screenshots/build.gradle.kts b/authenticator-screenshots/build.gradle.kts index fcc8ee49..6af0e191 100644 --- a/authenticator-screenshots/build.gradle.kts +++ b/authenticator-screenshots/build.gradle.kts @@ -21,10 +21,16 @@ plugins { android { namespace = "com.amplifyframework.ui.authenticator.screenshots" + + compileOptions { + isCoreLibraryDesugaringEnabled = true + } } dependencies { implementation(libs.bundles.compose) implementation(libs.test.mockk) implementation(projects.authenticator) + + coreLibraryDesugaring(libs.android.desugar) } diff --git a/build-logic/plugins/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/plugins/src/main/kotlin/AndroidLibraryConventionPlugin.kt index 96815a82..c97afd55 100644 --- a/build-logic/plugins/src/main/kotlin/AndroidLibraryConventionPlugin.kt +++ b/build-logic/plugins/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -101,8 +101,8 @@ class AndroidLibraryConventionPlugin : Plugin { lint { warningsAsErrors = true abortOnError = true - enable += listOf("UnusedResources", "NewerVersionAvailable") - disable += listOf("GradleDependency") + enable += listOf("UnusedResources") + disable += listOf("GradleDependency", "NewerVersionAvailable", "AndroidGradlePluginVersion") } compileOptions { 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 f9e7ecfa..de127989 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 @@ -69,8 +69,9 @@ internal class LivenessCoordinator( private val sessionId: String, private val region: String, private val credentialsProvider: AWSCredentialsProvider?, + disableStartView: Boolean, private val onChallengeComplete: OnChallengeComplete, - val onChallengeFailed: Consumer, + val onChallengeFailed: Consumer ) { private val analysisExecutor = Executors.newSingleThreadExecutor() @@ -78,6 +79,7 @@ internal class LivenessCoordinator( val livenessState = LivenessState( sessionId, context, + disableStartView, this::processCaptureReady, this::startLivenessSession, this::processSessionError, @@ -270,10 +272,10 @@ internal class LivenessCoordinator( fun destroy(context: Context) { // Destroy all resources so a new coordinator can safely be created - // livenessWebSocket.destroy() encoder.stop { encoder.destroy() } + livenessState.onDestroy(true) unbindCamera(context) analysisExecutor.shutdown() } 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 becf7e74..d4dea31f 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,8 +19,11 @@ 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) { - class Initial(instructionId: Int? = null) : LivenessCheckState(instructionId) { +internal sealed class LivenessCheckState(val instructionId: Int? = null, val isActionable: Boolean = true) { + class Initial( + instructionId: Int? = null, + isActionable: Boolean = true + ) : LivenessCheckState(instructionId, isActionable) { companion object { fun withMoveFaceMessage() = Initial(R.string.amplify_ui_liveness_challenge_instruction_move_face) @@ -29,10 +32,12 @@ internal sealed class LivenessCheckState(val instructionId: Int? = null) { fun withMoveFaceFurtherAwayMessage() = Initial(R.string.amplify_ui_liveness_challenge_instruction_move_face_further) fun withConnectingMessage() = - Initial(R.string.amplify_ui_liveness_challenge_connecting) + Initial(R.string.amplify_ui_liveness_challenge_connecting, false) + fun withStartViewMessage() = + Initial(R.string.amplify_ui_liveness_get_ready_center_face_label) } } - class Running(instructionId: Int? = null) : LivenessCheckState(instructionId) { + class Running(instructionId: Int? = null) : LivenessCheckState(instructionId, true) { companion object { fun withMoveFaceMessage() = Running( R.string.amplify_ui_liveness_challenge_instruction_move_face_closer @@ -44,7 +49,7 @@ internal sealed class LivenessCheckState(val instructionId: Int? = null) { Running(faceOvalPosition.instructionStringRes) } } - object Error : LivenessCheckState() + object Error : LivenessCheckState(isActionable = false) class Success(val faceGuideRect: RectF) : - LivenessCheckState(R.string.amplify_ui_liveness_challenge_verifying) + LivenessCheckState(R.string.amplify_ui_liveness_challenge_verifying, false) } 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 94a3c0b5..e47e8e9b 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 @@ -36,6 +36,7 @@ import com.amplifyframework.ui.liveness.model.LivenessCheckState import com.amplifyframework.ui.liveness.ui.helper.VideoViewportSize import java.util.Date import java.util.Timer +import java.util.TimerTask import kotlin.concurrent.schedule internal data class InitialStreamFace(val faceRect: RectF, val timestamp: Long) @@ -43,10 +44,11 @@ internal data class InitialStreamFace(val faceRect: RectF, val timestamp: Long) internal data class LivenessState( val sessionId: String, val context: Context, + val disableStartView: Boolean, val onCaptureReady: () -> Unit, val onFaceDistanceCheckPassed: () -> Unit, val onSessionError: (FaceLivenessDetectionException, Boolean) -> Unit, - val onFinalEventsSent: () -> Unit + val onFinalEventsSent: () -> Unit, ) { var videoViewportSize: VideoViewportSize? by mutableStateOf(null) var livenessCheckState = mutableStateOf( @@ -58,13 +60,15 @@ internal data class LivenessState( var initialFaceDistanceCheckPassed by mutableStateOf(false) var initialLocalFaceFound by mutableStateOf(false) + var showingStartView by mutableStateOf(!disableStartView) + private var initialStreamFace: InitialStreamFace? = null @VisibleForTesting var faceMatchOvalStart: Long? = null @VisibleForTesting var faceMatchOvalEnd: Long? = null private var initialFaceOvalIou = -1f - private var faceOvalMatchTimerStarted = false + private var faceOvalMatchTimer: TimerTask? = null private var detectedFaceMatchedOval = false @VisibleForTesting @@ -85,6 +89,12 @@ internal data class LivenessState( fun onError(stopLivenessSession: Boolean) { livenessCheckState.value = LivenessCheckState.Error + onDestroy(stopLivenessSession) + } + + // Cleans up state when challenge is completed or cancelled + fun onDestroy(stopLivenessSession: Boolean) { + faceOvalMatchTimer?.cancel() readyForOval = false faceGuideRect = null runningFreshness = false @@ -127,28 +137,44 @@ internal data class LivenessState( * @return true if FrameAnalyzer should continue processing the frame */ fun onFrameAvailable(): Boolean { - val livenessCheckState = livenessCheckState.value - if (livenessCheckState == LivenessCheckState.Error) return false - if (livenessCheckState !is LivenessCheckState.Success) return true - - if (readyToSendFinalEvents) { - readyToSendFinalEvents = false - - livenessSessionInfo!!.sendChallengeResponseEvent( - FaceTargetChallengeResponse( - colorChallenge!!.challengeId, - livenessCheckState.faceGuideRect, - Date(faceMatchOvalStart!!), - Date(faceMatchOvalEnd!!) - ) - ) + if (showingStartView) return false + + return when (val livenessCheckState = livenessCheckState.value) { + 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. + */ + if (!runningFreshness && colorChallenge?.challengeType == + ColorChallengeType.SEQUENTIAL && + faceMatchOvalStart?.let { (Date().time - it) > 1000 } == true + ) { + runningFreshness = true + } + true + } + is LivenessCheckState.Success -> { + if (readyToSendFinalEvents) { + readyToSendFinalEvents = false + + livenessSessionInfo!!.sendChallengeResponseEvent( + FaceTargetChallengeResponse( + colorChallenge!!.challengeId, + livenessCheckState.faceGuideRect, + Date(faceMatchOvalStart!!), + Date(faceMatchOvalEnd!!) + ) + ) - // Send empty video event to signal we're done sending video - livenessSessionInfo!!.sendVideoEvent(VideoEvent(ByteArray(0), Date())) - onFinalEventsSent() + // Send empty video event to signal we're done sending video + livenessSessionInfo!!.sendVideoEvent(VideoEvent(ByteArray(0), Date())) + onFinalEventsSent() + } + false + } } - - return false } fun onFrameFaceCountUpdate(faceCount: Int) { @@ -178,12 +204,19 @@ internal data class LivenessState( } } + /** + * returns true if face update inspect, false if thrown away + */ fun onFrameFaceUpdate( faceRect: RectF, leftEye: FaceDetector.Landmark, rightEye: FaceDetector.Landmark, mouth: FaceDetector.Landmark - ) { + ): Boolean { + if (showingStartView) { + return false + } + if (!initialFaceDistanceCheckPassed) { val faceDistance = FaceDetector.calculateFaceDistance( leftEye, rightEye, mouth, @@ -261,29 +294,25 @@ internal data class LivenessState( // Start timer and then timeout if the detected face doesn't match // the oval after a period of time - if (!detectedFaceMatchedOval && !faceOvalMatchTimerStarted) { - faceOvalMatchTimerStarted = true - Timer().schedule(faceTargetChallenge!!.faceTargetMatching.ovalFitTimeout.toLong()) { - if (!detectedFaceMatchedOval && faceGuideRect != null) { - readyForOval = false - val timeoutError = - FaceLivenessDetectionException( - "Face did not match oval within time limit." - ) - onSessionError(timeoutError, true) + if (!detectedFaceMatchedOval && faceOvalMatchTimer == null) { + faceOvalMatchTimer = + Timer().schedule(faceTargetChallenge!!.faceTargetMatching.ovalFitTimeout.toLong()) { + if (!detectedFaceMatchedOval && faceGuideRect != null) { + readyForOval = false + val timeoutError = + FaceLivenessDetectionException( + "Face did not match oval within time limit." + ) + onSessionError(timeoutError, true) + } + cancel() } - faceOvalMatchTimerStarted = false - cancel() - } - } - - // Start freshness check if it's not already started and face is in oval - if (!runningFreshness && colorChallenge?.challengeType == - ColorChallengeType.SEQUENTIAL && - faceOvalPosition == FaceDetector.FaceOvalPosition.MATCHED - ) { - runningFreshness = true } } + return true + } + + fun onStartViewComplete() { + showingStartView = false } } diff --git a/liveness/src/main/java/com/amplifyframework/ui/liveness/ui/FaceGuide.kt b/liveness/src/main/java/com/amplifyframework/ui/liveness/ui/FaceGuide.kt index 7c432904..6287b45b 100644 --- a/liveness/src/main/java/com/amplifyframework/ui/liveness/ui/FaceGuide.kt +++ b/liveness/src/main/java/com/amplifyframework/ui/liveness/ui/FaceGuide.kt @@ -40,7 +40,8 @@ import com.amplifyframework.ui.liveness.ui.helper.VideoViewportSize internal fun FaceGuide( modifier: Modifier, faceGuideRect: RectF?, - videoViewportSize: VideoViewportSize + videoViewportSize: VideoViewportSize, + backgroundColor: Color = Color.White ) { val scaledBoundingRect = faceGuideRect?.let { @@ -50,7 +51,7 @@ internal fun FaceGuide( Canvas(modifier.graphicsLayer(alpha = 0.99f)) { drawRect( - color = Color.White, + color = backgroundColor, size = size ) 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 c61ef898..55a2f7b1 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 @@ -26,9 +26,11 @@ import androidx.compose.foundation.layout.height 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.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -46,16 +48,19 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import com.amplifyframework.auth.AWSCredentials import com.amplifyframework.auth.AWSCredentialsProvider import com.amplifyframework.core.Action import com.amplifyframework.core.Consumer +import com.amplifyframework.ui.liveness.R import com.amplifyframework.ui.liveness.camera.LivenessCoordinator import com.amplifyframework.ui.liveness.camera.OnChallengeComplete 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.ui.helper.VideoViewportSize import com.amplifyframework.ui.liveness.util.hasCameraPermission import kotlinx.coroutines.launch @@ -79,7 +84,6 @@ fun FaceLivenessDetector( ) { val scope = rememberCoroutineScope() val key = Triple(sessionId, region, credentialsProvider) - var showReadyView by remember(key) { mutableStateOf(!disableStartView) } var isFinished by remember(key) { mutableStateOf(false) } val currentOnComplete by rememberUpdatedState(onComplete) val currentOnError by rememberUpdatedState(onError) @@ -111,33 +115,28 @@ fun FaceLivenessDetector( // Locks portrait orientation for duration of challenge and resets on complete LockPortraitOrientation { resetOrientation -> Surface(color = MaterialTheme.colorScheme.background) { - if (showReadyView) { - GetReadyView { - showReadyView = false - } - } else { - AlwaysOnMaxBrightnessScreen() - ChallengeView( - key = key, - sessionId = sessionId, - region, - credentialsProvider = credentialsProvider, - onChallengeComplete = { - scope.launch { - isFinished = true - resetOrientation() - currentOnComplete.call() - } - }, - onChallengeFailed = { - scope.launch { - isFinished = true - resetOrientation() - currentOnError.accept(it) - } + AlwaysOnMaxBrightnessScreen() + ChallengeView( + key = key, + sessionId = sessionId, + region, + credentialsProvider = credentialsProvider, + disableStartView, + onChallengeComplete = { + scope.launch { + isFinished = true + resetOrientation() + currentOnComplete.call() } - ) - } + }, + onChallengeFailed = { + scope.launch { + isFinished = true + resetOrientation() + currentOnError.accept(it) + } + } + ) } } } @@ -148,6 +147,7 @@ internal fun ChallengeView( sessionId: String, region: String, credentialsProvider: AWSCredentialsProvider?, + disableStartView: Boolean, onChallengeComplete: OnChallengeComplete, onChallengeFailed: Consumer ) { @@ -157,6 +157,7 @@ internal fun ChallengeView( var coordinator by remember { mutableStateOf(null) } val currentOnChallengeComplete by rememberUpdatedState(onChallengeComplete) val currentOnChallengeFailed by rememberUpdatedState(onChallengeFailed) + val showPhotosensitivityAlert = remember { mutableStateOf(false) } DisposableEffect(key) { coordinator = LivenessCoordinator( @@ -165,6 +166,7 @@ internal fun ChallengeView( sessionId, region, credentialsProvider, + disableStartView, onChallengeComplete = { currentOnChallengeComplete() }, onChallengeFailed = { currentOnChallengeFailed.accept(it) } ) @@ -178,7 +180,9 @@ internal fun ChallengeView( val livenessState = livenessCoordinator.livenessState val localDensity = LocalDensity.current - val backgroundColor = if (livenessState.faceGuideRect != null) { + val backgroundColor = if (livenessState.showingStartView) { + MaterialTheme.colorScheme.background + } else if (livenessState.faceGuideRect != null) { Color.White } else { Color.Black @@ -209,106 +213,157 @@ internal fun ChallengeView( ) } - livenessState.faceGuideRect?.let { + if (livenessState.showingStartView) { + FaceGuide( modifier = Modifier .fillMaxSize() .align(Alignment.Center), - faceGuideRect = it, - videoViewportSize = videoViewportSize + // positioned based on 480x640 preview and sized as specified by science + faceGuideRect = RectF(120f, 126f, 360f, 514f), + videoViewportSize = videoViewportSize, + backgroundColor = MaterialTheme.colorScheme.background ) - } - 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() + Column( + modifier = Modifier + .padding(16.dp) + .align(Alignment.TopCenter), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + PhotosensitivityView { + showPhotosensitivityAlert.value = true } - ) - } - livenessState.faceGuideRect?.let { - RecordingIndicator( + InstructionMessage(LivenessCheckState.Initial.withStartViewMessage()) + } + + Box( modifier = Modifier - .align(Alignment.TopStart) - .padding(16.dp) - ) - } + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.BottomCenter + ) { + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + livenessState.onStartViewComplete() + } + ) { + Text(stringResource(R.string.amplify_ui_liveness_get_ready_begin_check)) + } + } - CancelChallengeButton( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(16.dp) - ) { - livenessCoordinator.processSessionError( - FaceLivenessDetectionException.UserCancelledException(), - true - ) - } + if (showPhotosensitivityAlert.value) { + PhotosensitivityAlert { + showPhotosensitivityAlert.value = false + } + } + } else { - Box( - modifier = Modifier - .size(videoViewportSize.viewportDpSize) - .align(Alignment.Center) - ) { - if (livenessState.faceGuideRect != null) { - Box( + livenessState.faceGuideRect?.let { + FaceGuide( modifier = Modifier - .align(Alignment.TopCenter) - .fillMaxWidth() - .padding(24.dp), - contentAlignment = Alignment.TopCenter - ) { - Column( - verticalArrangement = Arrangement.spacedBy(5.dp), - horizontalAlignment = Alignment.CenterHorizontally + .fillMaxSize() + .align(Alignment.Center), + faceGuideRect = it, + videoViewportSize = videoViewportSize + ) + } + + 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() + } + ) + } + + livenessState.faceGuideRect?.let { + RecordingIndicator( + modifier = Modifier + .align(Alignment.TopStart) + .padding(16.dp) + ) + } + + CancelChallengeButton( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(16.dp) + ) { + livenessCoordinator.processSessionError( + FaceLivenessDetectionException.UserCancelledException(), + true + ) + } + + Box( + modifier = Modifier + .size(videoViewportSize.viewportDpSize) + .align(Alignment.Center) + ) { + if (livenessState.faceGuideRect != null) { + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.TopCenter ) { - InstructionMessage(livenessState.livenessCheckState.value, true) - if (livenessState.livenessCheckState.value.instructionId == - FaceDetector.FaceOvalPosition.TOO_FAR.instructionStringRes + Column( + verticalArrangement = Arrangement.spacedBy(5.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - val scaledOvalRect = livenessState.faceGuideRect?.let { - videoViewportSize.getScaledBoundingRect(it) - } ?: RectF() - val progressWidth = with(LocalDensity.current) { - ((scaledOvalRect.right - scaledOvalRect.left) * 0.6f).toDp() + InstructionMessage(livenessState.livenessCheckState.value) + if (livenessState.livenessCheckState.value.instructionId == + FaceDetector.FaceOvalPosition.TOO_FAR.instructionStringRes + ) { + val scaledOvalRect = livenessState.faceGuideRect?.let { + videoViewportSize.getScaledBoundingRect(it) + } ?: RectF() + val progressWidth = with(LocalDensity.current) { + ((scaledOvalRect.right - scaledOvalRect.left) * 0.6f).toDp() + } + LinearProgressIndicator( + progress = livenessState.faceMatchPercentage, + modifier = Modifier + .clip(MaterialTheme.shapes.small) + .width(progressWidth) + .height(12.dp), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surface + ) } - LinearProgressIndicator( - progress = livenessState.faceMatchPercentage, - modifier = Modifier - .clip(MaterialTheme.shapes.small) - .width(progressWidth) - .height(12.dp), - color = MaterialTheme.colorScheme.primary, - trackColor = MaterialTheme.colorScheme.surface - ) } } - } - } else { - Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .padding(16.dp), - contentAlignment = Alignment.BottomCenter - ) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally + } else { + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.TopCenter ) { - InstructionMessage(livenessState.livenessCheckState.value) + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + InstructionMessage(livenessState.livenessCheckState.value) + } } } } diff --git a/liveness/src/main/java/com/amplifyframework/ui/liveness/ui/GetReadyView.kt b/liveness/src/main/java/com/amplifyframework/ui/liveness/ui/GetReadyView.kt deleted file mode 100644 index f29649a7..00000000 --- a/liveness/src/main/java/com/amplifyframework/ui/liveness/ui/GetReadyView.kt +++ /dev/null @@ -1,348 +0,0 @@ -/* - * Copyright 2023 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.ui - -import android.content.res.Configuration.UI_MODE_NIGHT_YES -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.outlined.Info -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Outline -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.heading -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import com.amplifyframework.ui.liveness.R - -@Composable -internal fun GetReadyView(readyButtonOnClick: () -> Unit) { - val showPhotosensitivityAlert = remember { mutableStateOf(false) } - - Column( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .background(MaterialTheme.colorScheme.background) - .padding(16.dp) - ) { - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .weight(1f), - ) { - Text( - text = stringResource(R.string.amplify_ui_liveness_get_ready_page_title), - modifier = Modifier.semantics { heading() }, - color = MaterialTheme.colorScheme.onBackground, - style = MaterialTheme.typography.titleLarge, - ) - - Spacer(modifier = Modifier.size(8.dp)) - - Text( - text = stringResource(R.string.amplify_ui_liveness_get_ready_page_description), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onBackground, - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.errorContainer) - .padding(start = 12.dp, top = 12.dp, bottom = 12.dp) - ) { - Column( - modifier = Modifier - .padding(end = 6.dp) - .weight(1f) - ) { - Text( - text = stringResource( - R.string.amplify_ui_liveness_get_ready_photosensitivity_title - ), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onErrorContainer, - ) - Text( - text = stringResource( - R.string.amplify_ui_liveness_get_ready_photosensitivity_description - ), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer, - ) - } - IconButton( - onClick = { showPhotosensitivityAlert.value = true } - ) { - Icon( - imageVector = Icons.Outlined.Info, - contentDescription = stringResource( - /* ktlint-disable max-line-length */ - R.string.amplify_ui_liveness_get_ready_a11y_photosensitivity_icon_content_description - /* ktlint-enable max-line-length */ - ), - - tint = MaterialTheme.colorScheme.onErrorContainer - ) - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = stringResource(R.string.amplify_ui_liveness_get_ready_steps_title), - color = MaterialTheme.colorScheme.onBackground, - style = MaterialTheme.typography.titleMedium - ) - - Spacer(modifier = Modifier.height(16.dp)) - - BoxWithConstraints { - if (maxWidth < 316.dp) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - FaceOvalInstructions() - } - } else { - Row( - modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), - horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.Start) - ) { - FaceOvalInstructions() - } - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - Step(1, stringResource(R.string.amplify_ui_liveness_get_ready_step_1)) - Step(2, stringResource(R.string.amplify_ui_liveness_get_ready_step_2)) - Step(3, stringResource(R.string.amplify_ui_liveness_get_ready_step_3)) - } - - Button( - modifier = Modifier.fillMaxWidth(), - onClick = readyButtonOnClick - ) { - Text(stringResource(R.string.amplify_ui_liveness_get_ready_begin_check)) - } - } - - if (showPhotosensitivityAlert.value) { - AlertDialog( - title = { - Text( - stringResource( - R.string.amplify_ui_liveness_get_ready_photosensitivity_dialog_title - ) - ) - }, - text = { - Text( - stringResource( - R.string.amplify_ui_liveness_get_ready_photosensitivity_dialog_description - ) - ) - }, - onDismissRequest = { showPhotosensitivityAlert.value = false }, - confirmButton = { - TextButton( - onClick = { showPhotosensitivityAlert.value = false } - ) { - Text( - stringResource( - R.string.amplify_ui_liveness_get_ready_photosensitivity_dialog_dismiss - ) - ) - } - } - ) - } -} - -@Composable -private fun FaceOvalInstructions() { - val successColor = Color(0xFF365E3D) - val successBackgroundTextColor = Color(0xFFD6F5DB) - val errorColor = Color(0xFF660000) - val errorBackgroundTextColor = Color(0xFFF5BCBC) - FaceInstructionBox( - imageResource = R.drawable.amplify_ui_liveness_face_oval_good_fit, - icon = Icons.Filled.Check, - iconContentDescriptionResource = - R.string.amplify_ui_liveness_get_ready_a11y_good_face_fit_icon_content_description, - color = successColor, - textResource = R.string.amplify_ui_liveness_get_ready_good_fit, - textBackgroundColor = successBackgroundTextColor - ) - FaceInstructionBox( - imageResource = R.drawable.amplify_ui_liveness_face_oval_too_far, - icon = Icons.Filled.Close, - iconContentDescriptionResource = - R.string.amplify_ui_liveness_get_ready_a11y_wrong_face_fit_icon_content_description, - color = errorColor, - textResource = R.string.amplify_ui_liveness_get_ready_too_far, - textBackgroundColor = errorBackgroundTextColor - ) -} - -@Composable -private fun FaceInstructionBox( - imageResource: Int, - icon: ImageVector, - iconContentDescriptionResource: Int, - color: Color, - textResource: Int, - textBackgroundColor: Color -) { - Column( - modifier = Modifier - .fillMaxHeight() - ) { - Box( - modifier = Modifier - .border(1.dp, color) - .size(150.dp) - .background(Color.White) - ) { - Image( - painter = painterResource(id = imageResource), - contentDescription = - stringResource( - R.string.amplify_ui_liveness_get_ready_a11y_illustration_content_description - ), - contentScale = ContentScale.Fit, - alignment = Alignment.Center, - modifier = Modifier - .align(Alignment.Center) - ) - Icon( - imageVector = icon, - contentDescription = stringResource(iconContentDescriptionResource), - tint = Color.White, - modifier = Modifier.background(color) - ) - } - Text( - text = stringResource(textResource), - modifier = Modifier - .background(textBackgroundColor) - .width(150.dp) - .padding(4.dp) - .fillMaxHeight(), - color = color - ) - } -} - -private class Oval : Shape { - override fun createOutline( - size: Size, - layoutDirection: LayoutDirection, - density: Density - ): Outline { - val path = Path().apply { - addOval( - Rect( - left = size.width * 0.19f, - top = 0f, - right = size.width * 0.81f, - bottom = size.height - ) - ) - } - return Outline.Generic(path = path) - } -} - -@Composable -private fun Step(stepNumber: Int, body: String) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp) - ) { - Text( - text = "$stepNumber.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onBackground - ) - - Spacer(modifier = Modifier.width(8.dp)) - - Text( - text = body, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onBackground - ) - } -} - -@Preview -@Preview(uiMode = UI_MODE_NIGHT_YES) -@Composable -private fun GetReadyViewPreview() { - LivenessPreviewContainer { - GetReadyView {} - } -} diff --git a/liveness/src/main/java/com/amplifyframework/ui/liveness/ui/InstructionMessage.kt b/liveness/src/main/java/com/amplifyframework/ui/liveness/ui/InstructionMessage.kt index f38fc46f..e5bc812c 100644 --- a/liveness/src/main/java/com/amplifyframework/ui/liveness/ui/InstructionMessage.kt +++ b/liveness/src/main/java/com/amplifyframework/ui/liveness/ui/InstructionMessage.kt @@ -43,12 +43,11 @@ import com.amplifyframework.ui.liveness.ml.FaceDetector import com.amplifyframework.ui.liveness.model.LivenessCheckState @Composable internal fun InstructionMessage( - livenessCheckState: LivenessCheckState, - isFaceOvalInstruction: Boolean = false + livenessCheckState: LivenessCheckState ) { val instructionText = livenessCheckState.instructionId?.let { stringResource(it) } ?: return val showProgress = livenessCheckState is LivenessCheckState.Success - if (isFaceOvalInstruction) { + if (livenessCheckState.isActionable) { FaceOvalInstructionMessage(message = instructionText) } else { InstructionMessage(message = instructionText, showProgress = showProgress) @@ -90,18 +89,23 @@ private fun InstructionMessage( private fun FaceOvalInstructionMessage( message: String ) { - val backgroundColor = if ( - message == stringResource(FaceDetector.FaceOvalPosition.TOO_CLOSE.instructionStringRes) - ) { + + val isTooClose = message == stringResource(FaceDetector.FaceOvalPosition.TOO_CLOSE.instructionStringRes) + val isInitialCenterFace = + LivenessCheckState.Initial.withStartViewMessage().instructionId?.let { stringResource(it) == message } == true + + val backgroundColor = if (isTooClose) { MaterialTheme.colorScheme.error + } else if (isInitialCenterFace) { + MaterialTheme.colorScheme.background } else { MaterialTheme.colorScheme.primary } - val textColor = if ( - message == stringResource(FaceDetector.FaceOvalPosition.TOO_CLOSE.instructionStringRes) - ) { + val textColor = if (isTooClose) { MaterialTheme.colorScheme.onError + } else if (isInitialCenterFace) { + MaterialTheme.colorScheme.onBackground } else { MaterialTheme.colorScheme.onPrimary } diff --git a/liveness/src/main/java/com/amplifyframework/ui/liveness/ui/LivenessColorScheme.kt b/liveness/src/main/java/com/amplifyframework/ui/liveness/ui/LivenessColorScheme.kt index 7b0df8af..0f5069b7 100644 --- a/liveness/src/main/java/com/amplifyframework/ui/liveness/ui/LivenessColorScheme.kt +++ b/liveness/src/main/java/com/amplifyframework/ui/liveness/ui/LivenessColorScheme.kt @@ -57,8 +57,8 @@ object LivenessColorScheme { onSurface = Color(0xFF0D1926), error = Color(0xFF950404), onError = Color.White, - errorContainer = Color(0xFFF5D9BC), - onErrorContainer = Color(0xFF663300) + errorContainer = Color(0xFFB8CEF9), + onErrorContainer = Color(0xFF002266) ) /** Liveness Dark [ColorScheme] overrides Material3 defaults as necessary */ @@ -71,8 +71,8 @@ object LivenessColorScheme { onSurface = Color.White, error = Color(0xFFEF8F8F), onError = Color(0xFF0D1926), - errorContainer = Color(0xFF663300), - onErrorContainer = Color(0xFFEFBF8F), + errorContainer = Color(0xFF043495), + onErrorContainer = Color(0xFFE6EEFE), ) } } diff --git a/liveness/src/main/java/com/amplifyframework/ui/liveness/ui/PhotosensitivityView.kt b/liveness/src/main/java/com/amplifyframework/ui/liveness/ui/PhotosensitivityView.kt new file mode 100644 index 00000000..c0272155 --- /dev/null +++ b/liveness/src/main/java/com/amplifyframework/ui/liveness/ui/PhotosensitivityView.kt @@ -0,0 +1,125 @@ +/* + * Copyright 2023 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.ui + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.amplifyframework.ui.liveness.R + +@Composable +internal fun PhotosensitivityAlert(onDismiss: () -> Unit) { + AlertDialog( + title = { + Text( + stringResource( + R.string.amplify_ui_liveness_get_ready_photosensitivity_dialog_title + ) + ) + }, + text = { + Text( + stringResource( + R.string.amplify_ui_liveness_get_ready_photosensitivity_dialog_description + ) + ) + }, + onDismissRequest = { onDismiss() }, + confirmButton = { + TextButton( + onClick = { onDismiss() } + ) { + Text( + stringResource( + R.string.amplify_ui_liveness_get_ready_photosensitivity_dialog_dismiss + ) + ) + } + } + ) +} + +@Composable +internal fun PhotosensitivityView(infoClicked: () -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.errorContainer) + .padding(start = 12.dp, top = 12.dp, bottom = 12.dp) + ) { + Column( + modifier = Modifier + .padding(end = 6.dp) + .weight(1f) + ) { + Text( + text = stringResource( + R.string.amplify_ui_liveness_get_ready_photosensitivity_title + ), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + text = stringResource( + R.string.amplify_ui_liveness_get_ready_photosensitivity_description + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + IconButton( + onClick = { infoClicked() } + ) { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = stringResource( + /* ktlint-disable max-line-length */ + R.string.amplify_ui_liveness_get_ready_a11y_photosensitivity_icon_content_description + /* ktlint-enable max-line-length */ + ), + + tint = MaterialTheme.colorScheme.onErrorContainer + ) + } + } +} + +@Preview +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun GetReadyViewPreview() { + LivenessPreviewContainer { + PhotosensitivityView {} + } +} diff --git a/liveness/src/main/res/values/strings.xml b/liveness/src/main/res/values/strings.xml index a3b01fe6..5692258c 100644 --- a/liveness/src/main/res/values/strings.xml +++ b/liveness/src/main/res/values/strings.xml @@ -15,31 +15,21 @@ --> - Liveness Check - You will go through a face verification process to prove that you are a real person. Your screen\'s brightness will temporarily be set to 100\% for highest accuracy. - Photosensitivity Warning - This check displays colored lights. Use caution if you are photosensitive. + Center your face + Photosensitivity warning + This check flashes different colors. Use caution if you are photosensitive Photosensitivity Information Photosensitivity warning - A small percentage of individuals may experience epileptic seizures when exposed to colored lights. Use caution if you, or anyone in your family, have an epileptic condition. + Some people may experience epileptic seizures when exposed to colored lights. Use caution if you, or anyone in your family, have an epileptic condition. Got it - Follow these instructions to complete the check: - Illustration of face - Face fills the oval - Face does not fill the oval - Good fit - Too far - When an oval appears, follow the instructions to fit your face in it. - Make sure your face is not covered with sunglasses or a mask. - Move to a well-lit place that is not in direct sunlight. - Begin check + Start video check REC Hold still Move back Move closer Move face in front of camera - Ensure only one face is in front of camera - Connecting… + Only one face per check + Connecting Verifying Cancel Challenge \ No newline at end of file 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 0dbc5ffd..8f992bca 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 @@ -53,11 +53,75 @@ internal class LivenessStateTest { livenessState = LivenessState( "1234", ApplicationProvider.getApplicationContext(), + false, onCaptureReady, onFaceDistanceCheckPassed, onSessionError, onFinalEventsSent ) + livenessState.onStartViewComplete() + } + + @Test + fun `start view blocks state from proceeding`() { + // given + val stateWithStartView = LivenessState( + "1234", + ApplicationProvider.getApplicationContext(), + false, + onCaptureReady, + onFaceDistanceCheckPassed, + onSessionError, + onFinalEventsSent + ) + + // then + assertFalse(stateWithStartView.onFrameAvailable()) + stateWithStartView.onFrameFaceUpdate( + RectF(0f, 0f, 1f, 1f), + FaceDetector.Landmark(0f, 0f), + FaceDetector.Landmark(1f, 0f), + FaceDetector.Landmark(1f, 1f) + ) + + // when + stateWithStartView.onStartViewComplete() + + // then + assertTrue(stateWithStartView.onFrameAvailable()) + assertTrue( + stateWithStartView.onFrameFaceUpdate( + RectF(0f, 0f, 0f, 0f), + FaceDetector.Landmark(0f, 0f), + FaceDetector.Landmark(0f, 0f), + FaceDetector.Landmark(0f, 0f) + ) + ) + } + + @Test + fun `disabling start view immediately starts processing`() { + // given + val stateWithoutStartView = LivenessState( + "1234", + ApplicationProvider.getApplicationContext(), + true, + onCaptureReady, + onFaceDistanceCheckPassed, + onSessionError, + onFinalEventsSent + ) + + // then + assertTrue(stateWithoutStartView.onFrameAvailable()) + assertTrue( + stateWithoutStartView.onFrameFaceUpdate( + RectF(0f, 0f, 0f, 0f), + FaceDetector.Landmark(0f, 0f), + FaceDetector.Landmark(0f, 0f), + FaceDetector.Landmark(0f, 0f) + ) + ) } @Test diff --git a/samples/liveness/app/src/main/java/com/amplifyframework/ui/sample/liveness/ui/ResultScreen.kt b/samples/liveness/app/src/main/java/com/amplifyframework/ui/sample/liveness/ui/ResultScreen.kt index 9968e488..143a08ff 100644 --- a/samples/liveness/app/src/main/java/com/amplifyframework/ui/sample/liveness/ui/ResultScreen.kt +++ b/samples/liveness/app/src/main/java/com/amplifyframework/ui/sample/liveness/ui/ResultScreen.kt @@ -270,6 +270,10 @@ private fun ResultsView(sessionId: String, ) } } + if (!isLive) { + Spacer(Modifier.height(8.dp)) + TipView() + } } Button( @@ -318,6 +322,53 @@ private fun getDisplayError(error: FaceLivenessDetectionException): DisplayError } } +@Preview +@Composable +private fun TipView() { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(16.dp) + ) { + Text( + text = stringResource(id = R.string.result_tip_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Column(Modifier.padding(start = 8.dp)) { + Tip(1, stringResource(id = R.string.result_tip_1)) + Spacer(modifier = Modifier.height(8.dp)) + Tip(2, stringResource(id = R.string.result_tip_2)) + } + } +} + +@Composable +private fun Tip(tipNumber: Int, body: String) { + Row( + modifier = Modifier + .fillMaxWidth() + ) { + Text( + text = "$tipNumber.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground + ) + } +} + private fun formattedConfidenceScore(confidenceScore: Float): String { var truncatedConfidenceScore = floor(confidenceScore * 10000) / 10000 truncatedConfidenceScore = min(truncatedConfidenceScore, 99.9999f) diff --git a/samples/liveness/app/src/main/res/values/strings.xml b/samples/liveness/app/src/main/res/values/strings.xml index 23614cde..b75827ec 100644 --- a/samples/liveness/app/src/main/res/values/strings.xml +++ b/samples/liveness/app/src/main/res/values/strings.xml @@ -42,4 +42,7 @@ Create Liveness Session Grant Camera Permission Open app settings to grant camera permission. + Tips to pass the video check: + Avoid very bright lighting conditions, such as direct sunlight. + Remove sunglasses, mask, hat, or anything blocking your face. \ No newline at end of file