Skip to content

Commit f37e5b0

Browse files
authored
feat(predictions): Add support for a no light freshness challenge (#2841)
1 parent 0b0c15b commit f37e5b0

File tree

14 files changed

+839
-93
lines changed

14 files changed

+839
-93
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
package com.amplifyframework.predictions.aws.exceptions
16+
17+
import com.amplifyframework.annotations.InternalAmplifyApi
18+
import com.amplifyframework.predictions.PredictionsException
19+
20+
@InternalAmplifyApi
21+
class FaceLivenessUnsupportedChallengeTypeException internal constructor(
22+
message: String = "Received an unsupported ChallengeType from the backend.",
23+
cause: Throwable? = null,
24+
recoverySuggestion: String = "Verify that the Challenges configured in your backend are supported by the " +
25+
"frontend code (e.g. Amplify UI)"
26+
) : PredictionsException(message, cause, recoverySuggestion)

aws-predictions/src/main/java/com/amplifyframework/predictions/aws/http/LivenessWebSocket.kt

Lines changed: 115 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,22 @@ import com.amplifyframework.predictions.PredictionsException
3030
import com.amplifyframework.predictions.aws.BuildConfig
3131
import com.amplifyframework.predictions.aws.exceptions.AccessDeniedException
3232
import com.amplifyframework.predictions.aws.exceptions.FaceLivenessSessionNotFoundException
33+
import com.amplifyframework.predictions.aws.exceptions.FaceLivenessUnsupportedChallengeTypeException
3334
import com.amplifyframework.predictions.aws.models.liveness.BoundingBox
3435
import com.amplifyframework.predictions.aws.models.liveness.ClientChallenge
3536
import com.amplifyframework.predictions.aws.models.liveness.ClientSessionInformationEvent
3637
import com.amplifyframework.predictions.aws.models.liveness.ColorDisplayed
3738
import com.amplifyframework.predictions.aws.models.liveness.FaceMovementAndLightClientChallenge
39+
import com.amplifyframework.predictions.aws.models.liveness.FaceMovementClientChallenge
3840
import com.amplifyframework.predictions.aws.models.liveness.FreshnessColor
3941
import com.amplifyframework.predictions.aws.models.liveness.InitialFace
4042
import com.amplifyframework.predictions.aws.models.liveness.InvalidSignatureException
4143
import com.amplifyframework.predictions.aws.models.liveness.LivenessResponseStream
4244
import com.amplifyframework.predictions.aws.models.liveness.SessionInformation
4345
import com.amplifyframework.predictions.aws.models.liveness.TargetFace
4446
import com.amplifyframework.predictions.aws.models.liveness.VideoEvent
47+
import com.amplifyframework.predictions.models.Challenge
48+
import com.amplifyframework.predictions.models.FaceLivenessChallengeType
4549
import com.amplifyframework.predictions.models.FaceLivenessSessionInformation
4650
import com.amplifyframework.util.UserAgent
4751
import java.net.URI
@@ -73,12 +77,16 @@ internal class LivenessWebSocket(
7377
val credentialsProvider: CredentialsProvider,
7478
val endpoint: String,
7579
val region: String,
76-
val sessionInformation: FaceLivenessSessionInformation,
80+
val clientSessionInformation: FaceLivenessSessionInformation,
7781
val livenessVersion: String?,
78-
val onSessionInformationReceived: Consumer<SessionInformation>,
82+
val onSessionResponseReceived: Consumer<SessionResponse>,
7983
val onErrorReceived: Consumer<PredictionsException>,
8084
val onComplete: Action
8185
) {
86+
internal data class SessionResponse(
87+
val faceLivenessSession: SessionInformation,
88+
val livenessChallengeType: FaceLivenessChallengeType
89+
)
8290

8391
private val signer = AWSV4Signer()
8492
private var credentials: Credentials? = null
@@ -94,6 +102,7 @@ internal class LivenessWebSocket(
94102
@VisibleForTesting
95103
internal var webSocket: WebSocket? = null
96104
internal val challengeId = UUID.randomUUID().toString()
105+
var challengeType: FaceLivenessChallengeType? = null
97106
private var initialDetectedFace: BoundingBox? = null
98107
private var faceDetectedStart = 0L
99108
private var videoStartTimestamp = 0L
@@ -145,10 +154,34 @@ internal class LivenessWebSocket(
145154
try {
146155
when (val response = LivenessEventStream.decode(bytes, json)) {
147156
is LivenessResponseStream.Event -> {
148-
if (response.serverSessionInformationEvent != null) {
149-
onSessionInformationReceived.accept(
150-
response.serverSessionInformationEvent.sessionInformation
151-
)
157+
if (response.challengeEvent != null) {
158+
challengeType = response.challengeEvent.challengeType
159+
} else if (response.serverSessionInformationEvent != null) {
160+
161+
val clientRequestedOldLightChallenge = clientSessionInformation.challengeVersions
162+
.any { it == Challenge.FaceMovementAndLightChallenge("1.0.0") }
163+
164+
if (challengeType == null && clientRequestedOldLightChallenge) {
165+
// For the 1.0.0 version of FaceMovementAndLight challenge, backend doesn't send a
166+
// ChallengeEvent so we need to manually check and set it if that specific challenge
167+
// was requested.
168+
challengeType = FaceLivenessChallengeType.FaceMovementAndLightChallenge
169+
}
170+
171+
// If challengeType hasn't been initialized by this point it's because server sent an
172+
// unsupported challenge type so return an error to the client and close the web socket.
173+
val resolvedChallengeType = challengeType
174+
if (resolvedChallengeType == null) {
175+
webSocketError = FaceLivenessUnsupportedChallengeTypeException()
176+
destroy(UNSUPPORTED_CHALLENGE_CLOSURE_STATUS_CODE)
177+
} else {
178+
onSessionResponseReceived.accept(
179+
SessionResponse(
180+
response.serverSessionInformationEvent.sessionInformation,
181+
resolvedChallengeType
182+
)
183+
)
184+
}
152185
} else if (response.disconnectionEvent != null) {
153186
this@LivenessWebSocket.webSocket?.close(
154187
NORMAL_SOCKET_CLOSURE_STATUS_CODE,
@@ -358,16 +391,26 @@ internal class LivenessWebSocket(
358391
// Send initial ClientSessionInformationEvent
359392
videoStartTimestamp = adjustedDate(videoStartTime)
360393
initialDetectedFace = BoundingBox(
361-
left = initialFaceRect.left / sessionInformation.videoWidth,
362-
top = initialFaceRect.top / sessionInformation.videoHeight,
363-
height = initialFaceRect.height() / sessionInformation.videoHeight,
364-
width = initialFaceRect.width() / sessionInformation.videoWidth
394+
left = initialFaceRect.left / clientSessionInformation.videoWidth,
395+
top = initialFaceRect.top / clientSessionInformation.videoHeight,
396+
height = initialFaceRect.height() / clientSessionInformation.videoHeight,
397+
width = initialFaceRect.width() / clientSessionInformation.videoWidth
365398
)
366399
faceDetectedStart = adjustedDate(videoStartTime)
367-
val clientInfoEvent =
368-
ClientSessionInformationEvent(
369-
challenge = ClientChallenge(
370-
faceMovementAndLightChallenge = FaceMovementAndLightClientChallenge(
400+
401+
val resolvedChallengeType = challengeType
402+
if (resolvedChallengeType == null) {
403+
onErrorReceived.accept(
404+
PredictionsException(
405+
"Failed to send an initial face detected event",
406+
AmplifyException.TODO_RECOVERY_SUGGESTION
407+
)
408+
)
409+
} else {
410+
val clientInfoEvent =
411+
ClientSessionInformationEvent(
412+
challenge = buildClientChallenge(
413+
challengeType = resolvedChallengeType,
371414
challengeId = challengeId,
372415
initialFace = InitialFace(
373416
boundingBox = initialDetectedFace!!,
@@ -376,14 +419,23 @@ internal class LivenessWebSocket(
376419
videoStartTimestamp = videoStartTimestamp
377420
)
378421
)
379-
)
380-
sendClientInfoEvent(clientInfoEvent)
422+
sendClientInfoEvent(clientInfoEvent)
423+
}
381424
}
382425

383426
fun sendFinalEvent(targetFaceRect: RectF, faceMatchedStart: Long, faceMatchedEnd: Long) {
384-
val finalClientInfoEvent = ClientSessionInformationEvent(
385-
challenge = ClientChallenge(
386-
FaceMovementAndLightClientChallenge(
427+
val resolvedChallengeType = challengeType
428+
if (resolvedChallengeType == null) {
429+
onErrorReceived.accept(
430+
PredictionsException(
431+
"Failed to send an initial face detected event",
432+
AmplifyException.TODO_RECOVERY_SUGGESTION
433+
)
434+
)
435+
} else {
436+
val finalClientInfoEvent = ClientSessionInformationEvent(
437+
challenge = buildClientChallenge(
438+
challengeType = resolvedChallengeType,
387439
challengeId = challengeId,
388440
videoEndTimestamp = videoEndTimestamp,
389441
initialFace = InitialFace(
@@ -394,16 +446,16 @@ internal class LivenessWebSocket(
394446
faceDetectedInTargetPositionStartTimestamp = adjustedDate(faceMatchedStart),
395447
faceDetectedInTargetPositionEndTimestamp = adjustedDate(faceMatchedEnd),
396448
boundingBox = BoundingBox(
397-
left = targetFaceRect.left / sessionInformation.videoWidth,
398-
top = targetFaceRect.top / sessionInformation.videoHeight,
399-
height = targetFaceRect.height() / sessionInformation.videoHeight,
400-
width = targetFaceRect.width() / sessionInformation.videoWidth
449+
left = targetFaceRect.left / clientSessionInformation.videoWidth,
450+
top = targetFaceRect.top / clientSessionInformation.videoHeight,
451+
height = targetFaceRect.height() / clientSessionInformation.videoHeight,
452+
width = targetFaceRect.width() / clientSessionInformation.videoWidth
401453
)
402454
)
403455
)
404456
)
405-
)
406-
sendClientInfoEvent(finalClientInfoEvent)
457+
sendClientInfoEvent(finalClientInfoEvent)
458+
}
407459
}
408460

409461
fun sendColorDisplayedEvent(
@@ -523,8 +575,46 @@ internal class LivenessWebSocket(
523575

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

578+
private fun buildClientChallenge(
579+
challengeType: FaceLivenessChallengeType,
580+
challengeId: String,
581+
videoStartTimestamp: Long? = null,
582+
videoEndTimestamp: Long? = null,
583+
initialFace: InitialFace? = null,
584+
targetFace: TargetFace? = null,
585+
colorDisplayed: ColorDisplayed? = null
586+
): ClientChallenge = when (challengeType) {
587+
FaceLivenessChallengeType.FaceMovementAndLightChallenge -> {
588+
ClientChallenge(
589+
faceMovementAndLightChallenge = FaceMovementAndLightClientChallenge(
590+
challengeId = challengeId,
591+
videoStartTimestamp = videoStartTimestamp,
592+
videoEndTimestamp = videoEndTimestamp,
593+
initialFace = initialFace,
594+
targetFace = targetFace,
595+
colorDisplayed = colorDisplayed
596+
),
597+
faceMovementChallenge = null
598+
)
599+
}
600+
FaceLivenessChallengeType.FaceMovementChallenge -> {
601+
ClientChallenge(
602+
faceMovementAndLightChallenge = null,
603+
faceMovementChallenge = FaceMovementClientChallenge(
604+
challengeId = challengeId,
605+
videoStartTimestamp = videoStartTimestamp,
606+
videoEndTimestamp = videoEndTimestamp,
607+
initialFace = initialFace,
608+
targetFace = targetFace
609+
)
610+
)
611+
}
612+
}
613+
526614
companion object {
527615
private const val NORMAL_SOCKET_CLOSURE_STATUS_CODE = 1000
616+
// This is the same as the client-provided 'runtime error' status code
617+
private const val UNSUPPORTED_CHALLENGE_CLOSURE_STATUS_CODE = 4005
528618
private const val FOUR_MINUTES = 1000 * 60 * 4
529619
@VisibleForTesting val datePattern = "EEE, d MMM yyyy HH:mm:ss z"
530620
private val LOG = Amplify.Logging.logger(CategoryType.PREDICTIONS, "amplify:aws-predictions")
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
* Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
package com.amplifyframework.predictions.aws.models.liveness
16+
17+
import com.amplifyframework.predictions.models.FaceLivenessChallengeType
18+
import kotlinx.serialization.SerialName
19+
import kotlinx.serialization.Serializable
20+
21+
@Serializable
22+
internal data class ChallengeEvent(
23+
@SerialName("Type") val challengeType: FaceLivenessChallengeType,
24+
@SerialName("Version") val version: String
25+
)

aws-predictions/src/main/java/com/amplifyframework/predictions/aws/models/liveness/ClientChallenge.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,7 @@ import kotlinx.serialization.Serializable
1919

2020
@Serializable
2121
internal data class ClientChallenge(
22-
@SerialName("FaceMovementAndLightChallenge") val faceMovementAndLightChallenge: FaceMovementAndLightClientChallenge
22+
@SerialName("FaceMovementAndLightChallenge") val faceMovementAndLightChallenge:
23+
FaceMovementAndLightClientChallenge? = null,
24+
@SerialName("FaceMovementChallenge") val faceMovementChallenge: FaceMovementClientChallenge? = null
2325
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
package com.amplifyframework.predictions.aws.models.liveness
16+
17+
import kotlinx.serialization.SerialName
18+
import kotlinx.serialization.Serializable
19+
20+
@Serializable
21+
internal data class FaceMovementClientChallenge(
22+
@SerialName("ChallengeId") val challengeId: String,
23+
@SerialName("VideoStartTimestamp") val videoStartTimestamp: Long? = null,
24+
@SerialName("VideoEndTimestamp") val videoEndTimestamp: Long? = null,
25+
@SerialName("InitialFace") val initialFace: InitialFace? = null,
26+
@SerialName("TargetFace") val targetFace: TargetFace? = null,
27+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
package com.amplifyframework.predictions.aws.models.liveness
16+
17+
import kotlinx.serialization.SerialName
18+
import kotlinx.serialization.Serializable
19+
20+
@Serializable
21+
internal data class FaceMovementServerChallenge(
22+
@SerialName("OvalParameters") val ovalParameters: OvalParameters,
23+
@SerialName("ChallengeConfig") val challengeConfig: ChallengeConfig,
24+
)

aws-predictions/src/main/java/com/amplifyframework/predictions/aws/models/liveness/LivenessResponseStream.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ internal sealed class LivenessResponseStream {
2323
internal data class Event(
2424
@SerialName("ServerSessionInformationEvent") val serverSessionInformationEvent:
2525
ServerSessionInformationEvent? = null,
26-
@SerialName("DisconnectionEvent") val disconnectionEvent: DisconnectionEvent? = null
26+
@SerialName("DisconnectionEvent") val disconnectionEvent: DisconnectionEvent? = null,
27+
@SerialName("ChallengeEvent") val challengeEvent: ChallengeEvent? = null
2728
) : LivenessResponseStream()
2829

2930
@Serializable

aws-predictions/src/main/java/com/amplifyframework/predictions/aws/models/liveness/ServerChallenge.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,7 @@ import kotlinx.serialization.Serializable
1919

2020
@Serializable
2121
internal data class ServerChallenge(
22-
@SerialName("FaceMovementAndLightChallenge") val faceMovementAndLightChallenge: FaceMovementAndLightServerChallenge
22+
@SerialName("FaceMovementAndLightChallenge") val faceMovementAndLightChallenge:
23+
FaceMovementAndLightServerChallenge? = null,
24+
@SerialName("FaceMovementChallenge") val faceMovementChallenge: FaceMovementServerChallenge? = null
2325
)

0 commit comments

Comments
 (0)