Skip to content

Commit d87e9d9

Browse files
tylerjroachThomas Leing
andauthored
feat(liveness): Update Liveness UI (#100)
Co-authored-by: Thomas Leing <[email protected]>
1 parent 5f01305 commit d87e9d9

File tree

15 files changed

+531
-544
lines changed

15 files changed

+531
-544
lines changed

authenticator-screenshots/build.gradle.kts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,16 @@ plugins {
2121

2222
android {
2323
namespace = "com.amplifyframework.ui.authenticator.screenshots"
24+
25+
compileOptions {
26+
isCoreLibraryDesugaringEnabled = true
27+
}
2428
}
2529

2630
dependencies {
2731
implementation(libs.bundles.compose)
2832
implementation(libs.test.mockk)
2933
implementation(projects.authenticator)
34+
35+
coreLibraryDesugaring(libs.android.desugar)
3036
}

build-logic/plugins/src/main/kotlin/AndroidLibraryConventionPlugin.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,8 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
101101
lint {
102102
warningsAsErrors = true
103103
abortOnError = true
104-
enable += listOf("UnusedResources", "NewerVersionAvailable")
105-
disable += listOf("GradleDependency")
104+
enable += listOf("UnusedResources")
105+
disable += listOf("GradleDependency", "NewerVersionAvailable", "AndroidGradlePluginVersion")
106106
}
107107

108108
compileOptions {

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,15 +69,17 @@ internal class LivenessCoordinator(
6969
private val sessionId: String,
7070
private val region: String,
7171
private val credentialsProvider: AWSCredentialsProvider<AWSCredentials>?,
72+
disableStartView: Boolean,
7273
private val onChallengeComplete: OnChallengeComplete,
73-
val onChallengeFailed: Consumer<FaceLivenessDetectionException>,
74+
val onChallengeFailed: Consumer<FaceLivenessDetectionException>
7475
) {
7576

7677
private val analysisExecutor = Executors.newSingleThreadExecutor()
7778

7879
val livenessState = LivenessState(
7980
sessionId,
8081
context,
82+
disableStartView,
8183
this::processCaptureReady,
8284
this::startLivenessSession,
8385
this::processSessionError,
@@ -270,10 +272,10 @@ internal class LivenessCoordinator(
270272

271273
fun destroy(context: Context) {
272274
// Destroy all resources so a new coordinator can safely be created
273-
// livenessWebSocket.destroy()
274275
encoder.stop {
275276
encoder.destroy()
276277
}
278+
livenessState.onDestroy(true)
277279
unbindCamera(context)
278280
analysisExecutor.shutdown()
279281
}

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

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,11 @@ 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) {
23-
class Initial(instructionId: Int? = null) : LivenessCheckState(instructionId) {
22+
internal sealed class LivenessCheckState(val instructionId: Int? = null, val isActionable: Boolean = true) {
23+
class Initial(
24+
instructionId: Int? = null,
25+
isActionable: Boolean = true
26+
) : LivenessCheckState(instructionId, isActionable) {
2427
companion object {
2528
fun withMoveFaceMessage() =
2629
Initial(R.string.amplify_ui_liveness_challenge_instruction_move_face)
@@ -29,10 +32,12 @@ internal sealed class LivenessCheckState(val instructionId: Int? = null) {
2932
fun withMoveFaceFurtherAwayMessage() =
3033
Initial(R.string.amplify_ui_liveness_challenge_instruction_move_face_further)
3134
fun withConnectingMessage() =
32-
Initial(R.string.amplify_ui_liveness_challenge_connecting)
35+
Initial(R.string.amplify_ui_liveness_challenge_connecting, false)
36+
fun withStartViewMessage() =
37+
Initial(R.string.amplify_ui_liveness_get_ready_center_face_label)
3338
}
3439
}
35-
class Running(instructionId: Int? = null) : LivenessCheckState(instructionId) {
40+
class Running(instructionId: Int? = null) : LivenessCheckState(instructionId, true) {
3641
companion object {
3742
fun withMoveFaceMessage() = Running(
3843
R.string.amplify_ui_liveness_challenge_instruction_move_face_closer
@@ -44,7 +49,7 @@ internal sealed class LivenessCheckState(val instructionId: Int? = null) {
4449
Running(faceOvalPosition.instructionStringRes)
4550
}
4651
}
47-
object Error : LivenessCheckState()
52+
object Error : LivenessCheckState(isActionable = false)
4853
class Success(val faceGuideRect: RectF) :
49-
LivenessCheckState(R.string.amplify_ui_liveness_challenge_verifying)
54+
LivenessCheckState(R.string.amplify_ui_liveness_challenge_verifying, false)
5055
}

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

Lines changed: 73 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,19 @@ import com.amplifyframework.ui.liveness.model.LivenessCheckState
3636
import com.amplifyframework.ui.liveness.ui.helper.VideoViewportSize
3737
import java.util.Date
3838
import java.util.Timer
39+
import java.util.TimerTask
3940
import kotlin.concurrent.schedule
4041

4142
internal data class InitialStreamFace(val faceRect: RectF, val timestamp: Long)
4243

4344
internal data class LivenessState(
4445
val sessionId: String,
4546
val context: Context,
47+
val disableStartView: Boolean,
4648
val onCaptureReady: () -> Unit,
4749
val onFaceDistanceCheckPassed: () -> Unit,
4850
val onSessionError: (FaceLivenessDetectionException, Boolean) -> Unit,
49-
val onFinalEventsSent: () -> Unit
51+
val onFinalEventsSent: () -> Unit,
5052
) {
5153
var videoViewportSize: VideoViewportSize? by mutableStateOf(null)
5254
var livenessCheckState = mutableStateOf<LivenessCheckState>(
@@ -58,13 +60,15 @@ internal data class LivenessState(
5860
var initialFaceDistanceCheckPassed by mutableStateOf(false)
5961
var initialLocalFaceFound by mutableStateOf(false)
6062

63+
var showingStartView by mutableStateOf(!disableStartView)
64+
6165
private var initialStreamFace: InitialStreamFace? = null
6266
@VisibleForTesting
6367
var faceMatchOvalStart: Long? = null
6468
@VisibleForTesting
6569
var faceMatchOvalEnd: Long? = null
6670
private var initialFaceOvalIou = -1f
67-
private var faceOvalMatchTimerStarted = false
71+
private var faceOvalMatchTimer: TimerTask? = null
6872
private var detectedFaceMatchedOval = false
6973

7074
@VisibleForTesting
@@ -85,6 +89,12 @@ internal data class LivenessState(
8589

8690
fun onError(stopLivenessSession: Boolean) {
8791
livenessCheckState.value = LivenessCheckState.Error
92+
onDestroy(stopLivenessSession)
93+
}
94+
95+
// Cleans up state when challenge is completed or cancelled
96+
fun onDestroy(stopLivenessSession: Boolean) {
97+
faceOvalMatchTimer?.cancel()
8898
readyForOval = false
8999
faceGuideRect = null
90100
runningFreshness = false
@@ -127,28 +137,44 @@ internal data class LivenessState(
127137
* @return true if FrameAnalyzer should continue processing the frame
128138
*/
129139
fun onFrameAvailable(): Boolean {
130-
val livenessCheckState = livenessCheckState.value
131-
if (livenessCheckState == LivenessCheckState.Error) return false
132-
if (livenessCheckState !is LivenessCheckState.Success) return true
133-
134-
if (readyToSendFinalEvents) {
135-
readyToSendFinalEvents = false
136-
137-
livenessSessionInfo!!.sendChallengeResponseEvent(
138-
FaceTargetChallengeResponse(
139-
colorChallenge!!.challengeId,
140-
livenessCheckState.faceGuideRect,
141-
Date(faceMatchOvalStart!!),
142-
Date(faceMatchOvalEnd!!)
143-
)
144-
)
140+
if (showingStartView) return false
141+
142+
return when (val livenessCheckState = livenessCheckState.value) {
143+
is LivenessCheckState.Error -> false
144+
is LivenessCheckState.Initial, is LivenessCheckState.Running -> {
145+
/**
146+
* Start freshness check if the face has matched oval (we know this if faceMatchOvalStart is not null)
147+
* We trigger this in onFrameAvailable instead of onFrameFaceUpdate in the event the user moved the face
148+
* away from the camera. We want to run this check on every frame if the challenge is in process.
149+
*/
150+
if (!runningFreshness && colorChallenge?.challengeType ==
151+
ColorChallengeType.SEQUENTIAL &&
152+
faceMatchOvalStart?.let { (Date().time - it) > 1000 } == true
153+
) {
154+
runningFreshness = true
155+
}
156+
true
157+
}
158+
is LivenessCheckState.Success -> {
159+
if (readyToSendFinalEvents) {
160+
readyToSendFinalEvents = false
161+
162+
livenessSessionInfo!!.sendChallengeResponseEvent(
163+
FaceTargetChallengeResponse(
164+
colorChallenge!!.challengeId,
165+
livenessCheckState.faceGuideRect,
166+
Date(faceMatchOvalStart!!),
167+
Date(faceMatchOvalEnd!!)
168+
)
169+
)
145170

146-
// Send empty video event to signal we're done sending video
147-
livenessSessionInfo!!.sendVideoEvent(VideoEvent(ByteArray(0), Date()))
148-
onFinalEventsSent()
171+
// Send empty video event to signal we're done sending video
172+
livenessSessionInfo!!.sendVideoEvent(VideoEvent(ByteArray(0), Date()))
173+
onFinalEventsSent()
174+
}
175+
false
176+
}
149177
}
150-
151-
return false
152178
}
153179

154180
fun onFrameFaceCountUpdate(faceCount: Int) {
@@ -178,12 +204,19 @@ internal data class LivenessState(
178204
}
179205
}
180206

207+
/**
208+
* returns true if face update inspect, false if thrown away
209+
*/
181210
fun onFrameFaceUpdate(
182211
faceRect: RectF,
183212
leftEye: FaceDetector.Landmark,
184213
rightEye: FaceDetector.Landmark,
185214
mouth: FaceDetector.Landmark
186-
) {
215+
): Boolean {
216+
if (showingStartView) {
217+
return false
218+
}
219+
187220
if (!initialFaceDistanceCheckPassed) {
188221
val faceDistance = FaceDetector.calculateFaceDistance(
189222
leftEye, rightEye, mouth,
@@ -261,29 +294,25 @@ internal data class LivenessState(
261294

262295
// Start timer and then timeout if the detected face doesn't match
263296
// the oval after a period of time
264-
if (!detectedFaceMatchedOval && !faceOvalMatchTimerStarted) {
265-
faceOvalMatchTimerStarted = true
266-
Timer().schedule(faceTargetChallenge!!.faceTargetMatching.ovalFitTimeout.toLong()) {
267-
if (!detectedFaceMatchedOval && faceGuideRect != null) {
268-
readyForOval = false
269-
val timeoutError =
270-
FaceLivenessDetectionException(
271-
"Face did not match oval within time limit."
272-
)
273-
onSessionError(timeoutError, true)
297+
if (!detectedFaceMatchedOval && faceOvalMatchTimer == null) {
298+
faceOvalMatchTimer =
299+
Timer().schedule(faceTargetChallenge!!.faceTargetMatching.ovalFitTimeout.toLong()) {
300+
if (!detectedFaceMatchedOval && faceGuideRect != null) {
301+
readyForOval = false
302+
val timeoutError =
303+
FaceLivenessDetectionException(
304+
"Face did not match oval within time limit."
305+
)
306+
onSessionError(timeoutError, true)
307+
}
308+
cancel()
274309
}
275-
faceOvalMatchTimerStarted = false
276-
cancel()
277-
}
278-
}
279-
280-
// Start freshness check if it's not already started and face is in oval
281-
if (!runningFreshness && colorChallenge?.challengeType ==
282-
ColorChallengeType.SEQUENTIAL &&
283-
faceOvalPosition == FaceDetector.FaceOvalPosition.MATCHED
284-
) {
285-
runningFreshness = true
286310
}
287311
}
312+
return true
313+
}
314+
315+
fun onStartViewComplete() {
316+
showingStartView = false
288317
}
289318
}

liveness/src/main/java/com/amplifyframework/ui/liveness/ui/FaceGuide.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ import com.amplifyframework.ui.liveness.ui.helper.VideoViewportSize
4040
internal fun FaceGuide(
4141
modifier: Modifier,
4242
faceGuideRect: RectF?,
43-
videoViewportSize: VideoViewportSize
43+
videoViewportSize: VideoViewportSize,
44+
backgroundColor: Color = Color.White
4445
) {
4546

4647
val scaledBoundingRect = faceGuideRect?.let {
@@ -50,7 +51,7 @@ internal fun FaceGuide(
5051
Canvas(modifier.graphicsLayer(alpha = 0.99f)) {
5152

5253
drawRect(
53-
color = Color.White,
54+
color = backgroundColor,
5455
size = size
5556
)
5657

0 commit comments

Comments
 (0)