Skip to content

Commit

Permalink
feat(predictions): Add support for a no light freshness challenge (#2841
Browse files Browse the repository at this point in the history
)
  • Loading branch information
vincetran authored Jul 2, 2024
1 parent 0b0c15b commit f37e5b0
Show file tree
Hide file tree
Showing 14 changed files with 839 additions and 93 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
* 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.predictions.aws.exceptions

import com.amplifyframework.annotations.InternalAmplifyApi
import com.amplifyframework.predictions.PredictionsException

@InternalAmplifyApi
class FaceLivenessUnsupportedChallengeTypeException internal constructor(
message: String = "Received an unsupported ChallengeType from the backend.",
cause: Throwable? = null,
recoverySuggestion: String = "Verify that the Challenges configured in your backend are supported by the " +
"frontend code (e.g. Amplify UI)"
) : PredictionsException(message, cause, recoverySuggestion)
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,22 @@ import com.amplifyframework.predictions.PredictionsException
import com.amplifyframework.predictions.aws.BuildConfig
import com.amplifyframework.predictions.aws.exceptions.AccessDeniedException
import com.amplifyframework.predictions.aws.exceptions.FaceLivenessSessionNotFoundException
import com.amplifyframework.predictions.aws.exceptions.FaceLivenessUnsupportedChallengeTypeException
import com.amplifyframework.predictions.aws.models.liveness.BoundingBox
import com.amplifyframework.predictions.aws.models.liveness.ClientChallenge
import com.amplifyframework.predictions.aws.models.liveness.ClientSessionInformationEvent
import com.amplifyframework.predictions.aws.models.liveness.ColorDisplayed
import com.amplifyframework.predictions.aws.models.liveness.FaceMovementAndLightClientChallenge
import com.amplifyframework.predictions.aws.models.liveness.FaceMovementClientChallenge
import com.amplifyframework.predictions.aws.models.liveness.FreshnessColor
import com.amplifyframework.predictions.aws.models.liveness.InitialFace
import com.amplifyframework.predictions.aws.models.liveness.InvalidSignatureException
import com.amplifyframework.predictions.aws.models.liveness.LivenessResponseStream
import com.amplifyframework.predictions.aws.models.liveness.SessionInformation
import com.amplifyframework.predictions.aws.models.liveness.TargetFace
import com.amplifyframework.predictions.aws.models.liveness.VideoEvent
import com.amplifyframework.predictions.models.Challenge
import com.amplifyframework.predictions.models.FaceLivenessChallengeType
import com.amplifyframework.predictions.models.FaceLivenessSessionInformation
import com.amplifyframework.util.UserAgent
import java.net.URI
Expand Down Expand Up @@ -73,12 +77,16 @@ internal class LivenessWebSocket(
val credentialsProvider: CredentialsProvider,
val endpoint: String,
val region: String,
val sessionInformation: FaceLivenessSessionInformation,
val clientSessionInformation: FaceLivenessSessionInformation,
val livenessVersion: String?,
val onSessionInformationReceived: Consumer<SessionInformation>,
val onSessionResponseReceived: Consumer<SessionResponse>,
val onErrorReceived: Consumer<PredictionsException>,
val onComplete: Action
) {
internal data class SessionResponse(
val faceLivenessSession: SessionInformation,
val livenessChallengeType: FaceLivenessChallengeType
)

private val signer = AWSV4Signer()
private var credentials: Credentials? = null
Expand All @@ -94,6 +102,7 @@ internal class LivenessWebSocket(
@VisibleForTesting
internal var webSocket: WebSocket? = null
internal val challengeId = UUID.randomUUID().toString()
var challengeType: FaceLivenessChallengeType? = null
private var initialDetectedFace: BoundingBox? = null
private var faceDetectedStart = 0L
private var videoStartTimestamp = 0L
Expand Down Expand Up @@ -145,10 +154,34 @@ internal class LivenessWebSocket(
try {
when (val response = LivenessEventStream.decode(bytes, json)) {
is LivenessResponseStream.Event -> {
if (response.serverSessionInformationEvent != null) {
onSessionInformationReceived.accept(
response.serverSessionInformationEvent.sessionInformation
)
if (response.challengeEvent != null) {
challengeType = response.challengeEvent.challengeType
} else if (response.serverSessionInformationEvent != null) {

val clientRequestedOldLightChallenge = clientSessionInformation.challengeVersions
.any { it == Challenge.FaceMovementAndLightChallenge("1.0.0") }

if (challengeType == null && clientRequestedOldLightChallenge) {
// For the 1.0.0 version of FaceMovementAndLight challenge, backend doesn't send a
// ChallengeEvent so we need to manually check and set it if that specific challenge
// was requested.
challengeType = FaceLivenessChallengeType.FaceMovementAndLightChallenge
}

// If challengeType hasn't been initialized by this point it's because server sent an
// unsupported challenge type so return an error to the client and close the web socket.
val resolvedChallengeType = challengeType
if (resolvedChallengeType == null) {
webSocketError = FaceLivenessUnsupportedChallengeTypeException()
destroy(UNSUPPORTED_CHALLENGE_CLOSURE_STATUS_CODE)
} else {
onSessionResponseReceived.accept(
SessionResponse(
response.serverSessionInformationEvent.sessionInformation,
resolvedChallengeType
)
)
}
} else if (response.disconnectionEvent != null) {
this@LivenessWebSocket.webSocket?.close(
NORMAL_SOCKET_CLOSURE_STATUS_CODE,
Expand Down Expand Up @@ -358,16 +391,26 @@ internal class LivenessWebSocket(
// Send initial ClientSessionInformationEvent
videoStartTimestamp = adjustedDate(videoStartTime)
initialDetectedFace = BoundingBox(
left = initialFaceRect.left / sessionInformation.videoWidth,
top = initialFaceRect.top / sessionInformation.videoHeight,
height = initialFaceRect.height() / sessionInformation.videoHeight,
width = initialFaceRect.width() / sessionInformation.videoWidth
left = initialFaceRect.left / clientSessionInformation.videoWidth,
top = initialFaceRect.top / clientSessionInformation.videoHeight,
height = initialFaceRect.height() / clientSessionInformation.videoHeight,
width = initialFaceRect.width() / clientSessionInformation.videoWidth
)
faceDetectedStart = adjustedDate(videoStartTime)
val clientInfoEvent =
ClientSessionInformationEvent(
challenge = ClientChallenge(
faceMovementAndLightChallenge = FaceMovementAndLightClientChallenge(

val resolvedChallengeType = challengeType
if (resolvedChallengeType == null) {
onErrorReceived.accept(
PredictionsException(
"Failed to send an initial face detected event",
AmplifyException.TODO_RECOVERY_SUGGESTION
)
)
} else {
val clientInfoEvent =
ClientSessionInformationEvent(
challenge = buildClientChallenge(
challengeType = resolvedChallengeType,
challengeId = challengeId,
initialFace = InitialFace(
boundingBox = initialDetectedFace!!,
Expand All @@ -376,14 +419,23 @@ internal class LivenessWebSocket(
videoStartTimestamp = videoStartTimestamp
)
)
)
sendClientInfoEvent(clientInfoEvent)
sendClientInfoEvent(clientInfoEvent)
}
}

fun sendFinalEvent(targetFaceRect: RectF, faceMatchedStart: Long, faceMatchedEnd: Long) {
val finalClientInfoEvent = ClientSessionInformationEvent(
challenge = ClientChallenge(
FaceMovementAndLightClientChallenge(
val resolvedChallengeType = challengeType
if (resolvedChallengeType == null) {
onErrorReceived.accept(
PredictionsException(
"Failed to send an initial face detected event",
AmplifyException.TODO_RECOVERY_SUGGESTION
)
)
} else {
val finalClientInfoEvent = ClientSessionInformationEvent(
challenge = buildClientChallenge(
challengeType = resolvedChallengeType,
challengeId = challengeId,
videoEndTimestamp = videoEndTimestamp,
initialFace = InitialFace(
Expand All @@ -394,16 +446,16 @@ internal class LivenessWebSocket(
faceDetectedInTargetPositionStartTimestamp = adjustedDate(faceMatchedStart),
faceDetectedInTargetPositionEndTimestamp = adjustedDate(faceMatchedEnd),
boundingBox = BoundingBox(
left = targetFaceRect.left / sessionInformation.videoWidth,
top = targetFaceRect.top / sessionInformation.videoHeight,
height = targetFaceRect.height() / sessionInformation.videoHeight,
width = targetFaceRect.width() / sessionInformation.videoWidth
left = targetFaceRect.left / clientSessionInformation.videoWidth,
top = targetFaceRect.top / clientSessionInformation.videoHeight,
height = targetFaceRect.height() / clientSessionInformation.videoHeight,
width = targetFaceRect.width() / clientSessionInformation.videoWidth
)
)
)
)
)
sendClientInfoEvent(finalClientInfoEvent)
sendClientInfoEvent(finalClientInfoEvent)
}
}

fun sendColorDisplayedEvent(
Expand Down Expand Up @@ -523,8 +575,46 @@ internal class LivenessWebSocket(

private fun isTimeDiffSafe(diffInMillis: Long) = kotlin.math.abs(diffInMillis) < FOUR_MINUTES

private fun buildClientChallenge(
challengeType: FaceLivenessChallengeType,
challengeId: String,
videoStartTimestamp: Long? = null,
videoEndTimestamp: Long? = null,
initialFace: InitialFace? = null,
targetFace: TargetFace? = null,
colorDisplayed: ColorDisplayed? = null
): ClientChallenge = when (challengeType) {
FaceLivenessChallengeType.FaceMovementAndLightChallenge -> {
ClientChallenge(
faceMovementAndLightChallenge = FaceMovementAndLightClientChallenge(
challengeId = challengeId,
videoStartTimestamp = videoStartTimestamp,
videoEndTimestamp = videoEndTimestamp,
initialFace = initialFace,
targetFace = targetFace,
colorDisplayed = colorDisplayed
),
faceMovementChallenge = null
)
}
FaceLivenessChallengeType.FaceMovementChallenge -> {
ClientChallenge(
faceMovementAndLightChallenge = null,
faceMovementChallenge = FaceMovementClientChallenge(
challengeId = challengeId,
videoStartTimestamp = videoStartTimestamp,
videoEndTimestamp = videoEndTimestamp,
initialFace = initialFace,
targetFace = targetFace
)
)
}
}

companion object {
private const val NORMAL_SOCKET_CLOSURE_STATUS_CODE = 1000
// This is the same as the client-provided 'runtime error' status code
private const val UNSUPPORTED_CHALLENGE_CLOSURE_STATUS_CODE = 4005
private const val FOUR_MINUTES = 1000 * 60 * 4
@VisibleForTesting val datePattern = "EEE, d MMM yyyy HH:mm:ss z"
private val LOG = Amplify.Logging.logger(CategoryType.PREDICTIONS, "amplify:aws-predictions")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* 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.predictions.aws.models.liveness

import com.amplifyframework.predictions.models.FaceLivenessChallengeType
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
internal data class ChallengeEvent(
@SerialName("Type") val challengeType: FaceLivenessChallengeType,
@SerialName("Version") val version: String
)
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,7 @@ import kotlinx.serialization.Serializable

@Serializable
internal data class ClientChallenge(
@SerialName("FaceMovementAndLightChallenge") val faceMovementAndLightChallenge: FaceMovementAndLightClientChallenge
@SerialName("FaceMovementAndLightChallenge") val faceMovementAndLightChallenge:
FaceMovementAndLightClientChallenge? = null,
@SerialName("FaceMovementChallenge") val faceMovementChallenge: FaceMovementClientChallenge? = null
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* 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.predictions.aws.models.liveness

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
internal data class FaceMovementClientChallenge(
@SerialName("ChallengeId") val challengeId: String,
@SerialName("VideoStartTimestamp") val videoStartTimestamp: Long? = null,
@SerialName("VideoEndTimestamp") val videoEndTimestamp: Long? = null,
@SerialName("InitialFace") val initialFace: InitialFace? = null,
@SerialName("TargetFace") val targetFace: TargetFace? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* 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.predictions.aws.models.liveness

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
internal data class FaceMovementServerChallenge(
@SerialName("OvalParameters") val ovalParameters: OvalParameters,
@SerialName("ChallengeConfig") val challengeConfig: ChallengeConfig,
)
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ internal sealed class LivenessResponseStream {
internal data class Event(
@SerialName("ServerSessionInformationEvent") val serverSessionInformationEvent:
ServerSessionInformationEvent? = null,
@SerialName("DisconnectionEvent") val disconnectionEvent: DisconnectionEvent? = null
@SerialName("DisconnectionEvent") val disconnectionEvent: DisconnectionEvent? = null,
@SerialName("ChallengeEvent") val challengeEvent: ChallengeEvent? = null
) : LivenessResponseStream()

@Serializable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,7 @@ import kotlinx.serialization.Serializable

@Serializable
internal data class ServerChallenge(
@SerialName("FaceMovementAndLightChallenge") val faceMovementAndLightChallenge: FaceMovementAndLightServerChallenge
@SerialName("FaceMovementAndLightChallenge") val faceMovementAndLightChallenge:
FaceMovementAndLightServerChallenge? = null,
@SerialName("FaceMovementChallenge") val faceMovementChallenge: FaceMovementServerChallenge? = null
)
Loading

0 comments on commit f37e5b0

Please sign in to comment.