Skip to content

Commit 00283d4

Browse files
authored
feat(liveness): Add support for a no light freshness challenge (#158)
1 parent e3ea4f8 commit 00283d4

File tree

7 files changed

+256
-118
lines changed

7 files changed

+256
-118
lines changed

liveness/src/main/java/com/amplifyframework/ui/liveness/camera/LivenessCoordinator.kt

+33-21
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,17 @@ import com.amplifyframework.predictions.aws.AWSPredictionsPlugin
3535
import com.amplifyframework.predictions.aws.exceptions.AccessDeniedException
3636
import com.amplifyframework.predictions.aws.exceptions.FaceLivenessSessionNotFoundException
3737
import com.amplifyframework.predictions.aws.exceptions.FaceLivenessSessionTimeoutException
38+
import com.amplifyframework.predictions.aws.exceptions.FaceLivenessUnsupportedChallengeTypeException
3839
import com.amplifyframework.predictions.aws.models.ColorChallengeResponse
3940
import com.amplifyframework.predictions.aws.models.RgbColor
4041
import com.amplifyframework.predictions.aws.options.AWSFaceLivenessSessionOptions
42+
import com.amplifyframework.predictions.models.Challenge
4143
import com.amplifyframework.predictions.models.FaceLivenessSessionInformation
4244
import com.amplifyframework.predictions.models.VideoEvent
4345
import com.amplifyframework.ui.liveness.BuildConfig
4446
import com.amplifyframework.ui.liveness.model.FaceLivenessDetectionException
4547
import com.amplifyframework.ui.liveness.model.LivenessCheckState
48+
import com.amplifyframework.ui.liveness.state.AttemptCounter
4649
import com.amplifyframework.ui.liveness.state.LivenessState
4750
import com.amplifyframework.ui.liveness.util.WebSocketCloseCode
4851
import java.util.Date
@@ -68,21 +71,21 @@ internal class LivenessCoordinator(
6871
private val sessionId: String,
6972
private val region: String,
7073
private val credentialsProvider: AWSCredentialsProvider<AWSCredentials>?,
71-
disableStartView: Boolean,
74+
private val disableStartView: Boolean,
7275
private val onChallengeComplete: OnChallengeComplete,
7376
val onChallengeFailed: Consumer<FaceLivenessDetectionException>
7477
) {
7578

79+
private val attemptCounter = AttemptCounter()
7680
private val analysisExecutor = Executors.newSingleThreadExecutor()
7781

7882
val livenessState = LivenessState(
79-
sessionId,
80-
context,
81-
disableStartView,
82-
this::processCaptureReady,
83-
this::startLivenessSession,
84-
this::processSessionError,
85-
this::processFinalEventsSent
83+
sessionId = sessionId,
84+
context = context,
85+
disableStartView = disableStartView,
86+
onCaptureReady = this::processCaptureReady,
87+
onSessionError = this::processSessionError,
88+
onFinalEventsSent = this::processFinalEventsSent
8689
)
8790

8891
private val preview = Preview.Builder().apply {
@@ -137,6 +140,7 @@ internal class LivenessCoordinator(
137140
private var disconnectEventReceived = false
138141

139142
init {
143+
startLivenessSession()
140144
MainScope().launch {
141145
getCameraProvider(context).apply {
142146
if (lifecycleOwner.lifecycle.currentState != Lifecycle.State.DESTROYED) {
@@ -161,13 +165,19 @@ internal class LivenessCoordinator(
161165
}
162166

163167
private fun startLivenessSession() {
164-
livenessState.livenessCheckState.value = LivenessCheckState.Initial.withConnectingMessage()
168+
livenessState.livenessCheckState = LivenessCheckState.Initial.withConnectingMessage()
169+
attemptCounter.countAttempt()
165170

166171
val faceLivenessSessionInformation = FaceLivenessSessionInformation(
167-
TARGET_WIDTH.toFloat(),
168-
TARGET_HEIGHT.toFloat(),
169-
"FaceMovementAndLightChallenge_1.0.0",
170-
region
172+
videoWidth = TARGET_WIDTH.toFloat(),
173+
videoHeight = TARGET_HEIGHT.toFloat(),
174+
challengeVersions = listOf(
175+
Challenge.FaceMovementAndLightChallenge("2.0.0"),
176+
Challenge.FaceMovementChallenge("1.0.0")
177+
),
178+
region = region,
179+
preCheckViewEnabled = !disableStartView,
180+
attemptCount = attemptCounter.getCount()
171181
)
172182

173183
val faceLivenessSessionOptions = AWSFaceLivenessSessionOptions.builder().apply {
@@ -185,19 +195,21 @@ internal class LivenessCoordinator(
185195
onChallengeComplete()
186196
},
187197
{ error ->
188-
val faceLivenessException = when (error) {
198+
val (faceLivenessException, shouldStopLivenessSession) = when (error) {
189199
is AccessDeniedException ->
190-
FaceLivenessDetectionException.AccessDeniedException(throwable = error)
200+
FaceLivenessDetectionException.AccessDeniedException(throwable = error) to false
191201
is FaceLivenessSessionNotFoundException ->
192-
FaceLivenessDetectionException.SessionNotFoundException(throwable = error)
202+
FaceLivenessDetectionException.SessionNotFoundException(throwable = error) to false
193203
is FaceLivenessSessionTimeoutException ->
194-
FaceLivenessDetectionException.SessionTimedOutException(throwable = error)
204+
FaceLivenessDetectionException.SessionTimedOutException(throwable = error) to false
205+
is FaceLivenessUnsupportedChallengeTypeException ->
206+
FaceLivenessDetectionException.UnsupportedChallengeTypeException(throwable = error) to true
195207
else -> FaceLivenessDetectionException(
196208
error.message ?: "Unknown error.",
197209
error.recoverySuggestion, error
198-
)
210+
) to false
199211
}
200-
processSessionError(faceLivenessException, false)
212+
processSessionError(faceLivenessException, shouldStopLivenessSession)
201213
}
202214
)
203215
}
@@ -245,8 +257,8 @@ internal class LivenessCoordinator(
245257
)
246258
}
247259

248-
fun processFreshnessChallengeComplete() {
249-
livenessState.onFreshnessComplete()
260+
fun processLivenessCheckComplete() {
261+
livenessState.onLivenessChallengeComplete()
250262
stopEncoder { livenessState.onFullChallengeComplete() }
251263
}
252264

liveness/src/main/java/com/amplifyframework/ui/liveness/model/FaceLivenessDetectionException.kt

+7
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ open class FaceLivenessDetectionException(
4545
throwable: Throwable? = null
4646
) : FaceLivenessDetectionException(message, recoverySuggestion, throwable)
4747

48+
class UnsupportedChallengeTypeException(
49+
message: String = "Received an unsupported ChallengeType from the backend.",
50+
recoverySuggestion: String = "Verify that the Challenges configured in your backend are supported by " +
51+
"this library.",
52+
throwable: Throwable? = null
53+
) : FaceLivenessDetectionException(message, recoverySuggestion, throwable)
54+
4855
class UserCancelledException(
4956
message: String = "User cancelled the face liveness check.",
5057
recoverySuggestion: String = "Retry the face liveness check.",

liveness/src/main/java/com/amplifyframework/ui/liveness/model/LivenessCheckState.kt

+5-5
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ import android.graphics.RectF
1919
import com.amplifyframework.ui.liveness.R
2020
import com.amplifyframework.ui.liveness.ml.FaceDetector
2121

22-
internal sealed class LivenessCheckState(val instructionId: Int? = null, val isActionable: Boolean = true) {
23-
class Initial(
24-
instructionId: Int? = null,
25-
isActionable: Boolean = true
22+
internal sealed class LivenessCheckState(open val instructionId: Int? = null, open val isActionable: Boolean = true) {
23+
data class Initial(
24+
override val instructionId: Int? = null,
25+
override val isActionable: Boolean = true
2626
) : LivenessCheckState(instructionId, isActionable) {
2727
companion object {
2828
fun withMoveFaceMessage() =
@@ -37,7 +37,7 @@ internal sealed class LivenessCheckState(val instructionId: Int? = null, val isA
3737
Initial(R.string.amplify_ui_liveness_get_ready_center_face_label)
3838
}
3939
}
40-
class Running(instructionId: Int? = null) : LivenessCheckState(instructionId, true) {
40+
data class Running(override val instructionId: Int? = null) : LivenessCheckState(instructionId, true) {
4141
companion object {
4242
fun withMoveFaceMessage() = Running(
4343
R.string.amplify_ui_liveness_challenge_instruction_move_face_closer
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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+
16+
package com.amplifyframework.ui.liveness.state
17+
18+
class AttemptCounter {
19+
20+
fun getCount() = attemptCount
21+
22+
fun countAttempt() {
23+
val timestamp = System.currentTimeMillis()
24+
if (timestamp - latestAttemptTimeStamp > ATTEMPT_COUNT_RESET_INTERVAL_MS) {
25+
// Reset interval has lapsed so reset the attemptCount
26+
attemptCount = 0
27+
}
28+
29+
attemptCount += 1
30+
latestAttemptTimeStamp = timestamp
31+
}
32+
33+
companion object {
34+
const val ATTEMPT_COUNT_RESET_INTERVAL_MS = 300_000L
35+
var attemptCount = 0
36+
var latestAttemptTimeStamp: Long = System.currentTimeMillis()
37+
}
38+
}

liveness/src/main/java/com/amplifyframework/ui/liveness/state/LivenessState.kt

+30-33
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import androidx.compose.runtime.getValue
2222
import androidx.compose.runtime.mutableStateOf
2323
import androidx.compose.runtime.setValue
2424
import com.amplifyframework.predictions.aws.models.ColorChallenge
25-
import com.amplifyframework.predictions.aws.models.ColorChallengeType
2625
import com.amplifyframework.predictions.aws.models.FaceTargetChallenge
2726
import com.amplifyframework.predictions.aws.models.FaceTargetChallengeResponse
2827
import com.amplifyframework.predictions.aws.models.InitialFaceDetected
@@ -47,15 +46,14 @@ internal data class LivenessState(
4746
val context: Context,
4847
val disableStartView: Boolean,
4948
val onCaptureReady: () -> Unit,
50-
val onFaceDistanceCheckPassed: () -> Unit,
5149
val onSessionError: (FaceLivenessDetectionException, Boolean) -> Unit,
5250
val onFinalEventsSent: () -> Unit,
5351
) {
5452
var videoViewportSize: VideoViewportSize? by mutableStateOf(null)
55-
var livenessCheckState = mutableStateOf<LivenessCheckState>(
53+
var livenessCheckState by mutableStateOf<LivenessCheckState>(
5654
LivenessCheckState.Initial()
5755
)
58-
var runningFreshness by mutableStateOf(false)
56+
var faceMatched by mutableStateOf(false)
5957
var faceGuideRect: RectF? by mutableStateOf(null)
6058
var faceMatchPercentage: Float by mutableStateOf(0.25f)
6159
var initialFaceDistanceCheckPassed by mutableStateOf(false)
@@ -78,7 +76,7 @@ internal data class LivenessState(
7876
@VisibleForTesting
7977
var readyToSendFinalEvents = false
8078

81-
var livenessSessionInfo: FaceLivenessSession? = null
79+
var livenessSessionInfo: FaceLivenessSession? by mutableStateOf(null)
8280
var faceTargetChallenge: FaceTargetChallenge? by mutableStateOf(null)
8381
var colorChallenge: ColorChallenge? = null
8482

@@ -89,18 +87,18 @@ internal data class LivenessState(
8987
}
9088

9189
fun onError(stopLivenessSession: Boolean, webSocketCloseCode: WebSocketCloseCode) {
92-
livenessCheckState.value = LivenessCheckState.Error
90+
livenessCheckState = LivenessCheckState.Error
9391
onDestroy(stopLivenessSession, webSocketCloseCode)
9492
}
9593

9694
// Cleans up state when challenge is completed or cancelled.
9795
// We only send webSocketCloseCode if error encountered.
9896
fun onDestroy(stopLivenessSession: Boolean, webSocketCloseCode: WebSocketCloseCode? = null) {
99-
livenessCheckState.value = LivenessCheckState.Error
97+
livenessCheckState = LivenessCheckState.Error
10098
faceOvalMatchTimer?.cancel()
10199
readyForOval = false
102100
faceGuideRect = null
103-
runningFreshness = false
101+
faceMatched = false
104102
if (stopLivenessSession) {
105103
livenessSessionInfo?.stopSession(webSocketCloseCode?.code)
106104
}
@@ -112,24 +110,23 @@ internal data class LivenessState(
112110
.filterIsInstance<FaceTargetChallenge>().firstOrNull()
113111
colorChallenge = faceLivenessSession.challenges
114112
.filterIsInstance<ColorChallenge>().firstOrNull()
115-
livenessCheckState.value = LivenessCheckState.Running()
116113
readyForOval = true
117114
}
118115

119116
fun onFullChallengeComplete() {
120117
readyToSendFinalEvents = true
121118
}
122119

123-
fun onFreshnessComplete() {
120+
fun onLivenessChallengeComplete() {
124121
val faceGuideRect = this.faceGuideRect
125122
readyForOval = false
126123
this.faceGuideRect = null
127-
runningFreshness = false
124+
faceMatched = false
128125
if (faceMatchOvalEnd == null) {
129126
faceMatchOvalEnd = Date().time
130127
}
131128

132-
livenessCheckState.value = if (faceGuideRect != null) {
129+
livenessCheckState = if (faceGuideRect != null) {
133130
LivenessCheckState.Success(faceGuideRect)
134131
} else {
135132
LivenessCheckState.Error
@@ -142,19 +139,19 @@ internal data class LivenessState(
142139
fun onFrameAvailable(): Boolean {
143140
if (showingStartView) return false
144141

145-
return when (val livenessCheckState = livenessCheckState.value) {
142+
return when (val livenessCheckState = livenessCheckState) {
146143
is LivenessCheckState.Error -> false
147144
is LivenessCheckState.Initial, is LivenessCheckState.Running -> {
148145
/**
149-
* Start freshness check if the face has matched oval (we know this if faceMatchOvalStart is not null)
150-
* We trigger this in onFrameAvailable instead of onFrameFaceUpdate in the event the user moved the face
151-
* away from the camera. We want to run this check on every frame if the challenge is in process.
146+
* Start the challenge checks once the face has matched oval (we know this if faceMatchOvalStart is
147+
* not null). We trigger this in onFrameAvailable instead of onFrameFaceUpdate in the event the user
148+
* moved the face away from the camera. We want to run this check on every frame if the challenge is
149+
* in process.
152150
*/
153-
if (!runningFreshness && colorChallenge?.challengeType ==
154-
ColorChallengeType.SEQUENTIAL &&
151+
if (!faceMatched &&
155152
faceMatchOvalStart?.let { (Date().time - it) > 1000 } == true
156153
) {
157-
runningFreshness = true
154+
faceMatched = true
158155
}
159156
true
160157
}
@@ -164,7 +161,7 @@ internal data class LivenessState(
164161

165162
livenessSessionInfo!!.sendChallengeResponseEvent(
166163
FaceTargetChallengeResponse(
167-
colorChallenge!!.challengeId,
164+
livenessSessionInfo!!.challengeId,
168165
livenessCheckState.faceGuideRect,
169166
Date(faceMatchOvalStart!!),
170167
Date(faceMatchOvalEnd!!)
@@ -186,10 +183,10 @@ internal data class LivenessState(
186183
}
187184
when (faceCount) {
188185
0 -> {
189-
if (!initialLocalFaceFound || livenessCheckState.value is LivenessCheckState.Initial) {
190-
livenessCheckState.value = LivenessCheckState.Initial.withMoveFaceMessage()
191-
} else if (livenessCheckState.value is LivenessCheckState.Running) {
192-
livenessCheckState.value = LivenessCheckState.Running.withMoveFaceMessage()
186+
if (!initialLocalFaceFound || livenessCheckState is LivenessCheckState.Initial) {
187+
livenessCheckState = LivenessCheckState.Initial.withMoveFaceMessage()
188+
} else if (livenessCheckState is LivenessCheckState.Running) {
189+
livenessCheckState = LivenessCheckState.Running.withMoveFaceMessage()
193190
}
194191
}
195192
1 -> {
@@ -198,10 +195,10 @@ internal data class LivenessState(
198195
}
199196
}
200197
else -> {
201-
if (!initialLocalFaceFound || livenessCheckState.value is LivenessCheckState.Initial) {
202-
livenessCheckState.value = LivenessCheckState.Initial.withMultipleFaceMessage()
203-
} else if (livenessCheckState.value is LivenessCheckState.Running) {
204-
livenessCheckState.value = LivenessCheckState.Running.withMultipleFaceMessage()
198+
if (!initialLocalFaceFound || livenessCheckState is LivenessCheckState.Initial) {
199+
livenessCheckState = LivenessCheckState.Initial.withMultipleFaceMessage()
200+
} else if (livenessCheckState is LivenessCheckState.Running) {
201+
livenessCheckState = LivenessCheckState.Running.withMultipleFaceMessage()
205202
}
206203
}
207204
}
@@ -226,11 +223,10 @@ internal data class LivenessState(
226223
LivenessCoordinator.TARGET_WIDTH, LivenessCoordinator.TARGET_HEIGHT
227224
)
228225
if (faceDistance >= FaceDetector.INITIAL_FACE_DISTANCE_THRESHOLD) {
229-
livenessCheckState.value =
226+
livenessCheckState =
230227
LivenessCheckState.Initial.withMoveFaceFurtherAwayMessage()
231228
} else {
232229
initialFaceDistanceCheckPassed = true
233-
onFaceDistanceCheckPassed()
234230
}
235231
}
236232

@@ -240,7 +236,7 @@ internal data class LivenessState(
240236
onCaptureReady()
241237
livenessSessionInfo!!.sendChallengeResponseEvent(
242238
InitialFaceDetected(
243-
colorChallenge!!.challengeId,
239+
livenessSessionInfo!!.challengeId,
244240
face.faceRect,
245241
Date(face.timestamp)
246242
)
@@ -278,11 +274,11 @@ internal data class LivenessState(
278274
faceOvalPosition == FaceDetector.FaceOvalPosition.MATCHED
279275

280276
if (detectedFaceMatchedOval) {
281-
livenessCheckState.value = LivenessCheckState.Running.withFaceOvalPosition(
277+
livenessCheckState = LivenessCheckState.Running.withFaceOvalPosition(
282278
FaceDetector.FaceOvalPosition.MATCHED
283279
)
284280
} else {
285-
livenessCheckState.value = LivenessCheckState.Running.withFaceOvalPosition(
281+
livenessCheckState = LivenessCheckState.Running.withFaceOvalPosition(
286282
faceOvalPosition
287283
)
288284
}
@@ -314,6 +310,7 @@ internal data class LivenessState(
314310
}
315311

316312
fun onStartViewComplete() {
313+
livenessCheckState = LivenessCheckState.Running()
317314
showingStartView = false
318315
}
319316
}

0 commit comments

Comments
 (0)