Skip to content
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

feat(predictions): Add support for a no light freshness challenge #2841

Merged
merged 9 commits into from
Jul 2, 2024
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) {
vincetran marked this conversation as resolved.
Show resolved Hide resolved

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) {
[email protected]?.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) {
vincetran marked this conversation as resolved.
Show resolved Hide resolved
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(
vincetran marked this conversation as resolved.
Show resolved Hide resolved
@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
Loading