Skip to content

Commit fad42d2

Browse files
authored
feat(liveness): Add support for a no light liveness challenge (#248)
1 parent f23f847 commit fad42d2

File tree

11 files changed

+530
-136
lines changed

11 files changed

+530
-136
lines changed

gradle/libs.versions.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[versions]
22
accompanist = "0.28.0"
33
agp = "8.7.2"
4-
amplify = "2.27.2"
4+
amplify = "2.29.0"
55
appcompat = "1.6.1"
66
androidx-core = "1.9.0"
77
androidx-junit = "1.1.4"

liveness/api/liveness.api

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,72 @@ public final class com/amplifyframework/ui/liveness/model/FaceLivenessDetectionE
4444
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
4545
}
4646

47+
public final class com/amplifyframework/ui/liveness/model/FaceLivenessDetectionException$UnsupportedChallengeTypeException : com/amplifyframework/ui/liveness/model/FaceLivenessDetectionException {
48+
public static final field $stable I
49+
public fun <init> ()V
50+
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;)V
51+
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
52+
}
53+
4754
public final class com/amplifyframework/ui/liveness/model/FaceLivenessDetectionException$UserCancelledException : com/amplifyframework/ui/liveness/model/FaceLivenessDetectionException {
4855
public static final field $stable I
4956
public fun <init> ()V
5057
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;)V
5158
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
5259
}
5360

61+
public final class com/amplifyframework/ui/liveness/state/AttemptCounter {
62+
public static final field $stable I
63+
public static final field ATTEMPT_COUNT_RESET_INTERVAL_MS J
64+
public static final field Companion Lcom/amplifyframework/ui/liveness/state/AttemptCounter$Companion;
65+
public fun <init> ()V
66+
public final fun countAttempt ()V
67+
public final fun getCount ()I
68+
}
69+
70+
public final class com/amplifyframework/ui/liveness/state/AttemptCounter$Companion {
71+
public final fun getAttemptCount ()I
72+
public final fun getLatestAttemptTimeStamp ()J
73+
public final fun setAttemptCount (I)V
74+
public final fun setLatestAttemptTimeStamp (J)V
75+
}
76+
77+
public abstract class com/amplifyframework/ui/liveness/ui/Camera {
78+
public static final field $stable I
79+
}
80+
81+
public final class com/amplifyframework/ui/liveness/ui/Camera$Back : com/amplifyframework/ui/liveness/ui/Camera {
82+
public static final field $stable I
83+
public static final field INSTANCE Lcom/amplifyframework/ui/liveness/ui/Camera$Back;
84+
public fun equals (Ljava/lang/Object;)Z
85+
public fun hashCode ()I
86+
public fun toString ()Ljava/lang/String;
87+
}
88+
89+
public final class com/amplifyframework/ui/liveness/ui/Camera$Front : com/amplifyframework/ui/liveness/ui/Camera {
90+
public static final field $stable I
91+
public static final field INSTANCE Lcom/amplifyframework/ui/liveness/ui/Camera$Front;
92+
public fun equals (Ljava/lang/Object;)Z
93+
public fun hashCode ()I
94+
public fun toString ()Ljava/lang/String;
95+
}
96+
97+
public final class com/amplifyframework/ui/liveness/ui/ChallengeOptions {
98+
public static final field $stable I
99+
public fun <init> ()V
100+
public fun <init> (Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovementAndLight;Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovement;)V
101+
public synthetic fun <init> (Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovementAndLight;Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovement;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
102+
public final fun component1 ()Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovementAndLight;
103+
public final fun component2 ()Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovement;
104+
public final fun copy (Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovementAndLight;Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovement;)Lcom/amplifyframework/ui/liveness/ui/ChallengeOptions;
105+
public static synthetic fun copy$default (Lcom/amplifyframework/ui/liveness/ui/ChallengeOptions;Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovementAndLight;Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovement;ILjava/lang/Object;)Lcom/amplifyframework/ui/liveness/ui/ChallengeOptions;
106+
public fun equals (Ljava/lang/Object;)Z
107+
public final fun getFaceMovement ()Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovement;
108+
public final fun getFaceMovementAndLight ()Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovementAndLight;
109+
public fun hashCode ()I
110+
public fun toString ()Ljava/lang/String;
111+
}
112+
54113
public final class com/amplifyframework/ui/liveness/ui/ComposableSingletons$CancelChallengeButtonKt {
55114
public static final field INSTANCE Lcom/amplifyframework/ui/liveness/ui/ComposableSingletons$CancelChallengeButtonKt;
56115
public static field lambda-1 Lkotlin/jvm/functions/Function2;
@@ -110,6 +169,36 @@ public final class com/amplifyframework/ui/liveness/ui/ComposableSingletons$Reco
110169

111170
public final class com/amplifyframework/ui/liveness/ui/FaceLivenessDetectorKt {
112171
public static final fun FaceLivenessDetector (Ljava/lang/String;Ljava/lang/String;Lcom/amplifyframework/auth/AWSCredentialsProvider;ZLcom/amplifyframework/core/Action;Lcom/amplifyframework/core/Consumer;Landroidx/compose/runtime/Composer;II)V
172+
public static final fun FaceLivenessDetector (Ljava/lang/String;Ljava/lang/String;Lcom/amplifyframework/auth/AWSCredentialsProvider;ZLcom/amplifyframework/core/Action;Lcom/amplifyframework/core/Consumer;Lcom/amplifyframework/ui/liveness/ui/ChallengeOptions;Landroidx/compose/runtime/Composer;II)V
173+
}
174+
175+
public abstract class com/amplifyframework/ui/liveness/ui/LivenessChallenge {
176+
public static final field $stable I
177+
public synthetic fun <init> (Lcom/amplifyframework/ui/liveness/ui/Camera;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
178+
public synthetic fun <init> (Lcom/amplifyframework/ui/liveness/ui/Camera;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
179+
public fun getCamera ()Lcom/amplifyframework/ui/liveness/ui/Camera;
180+
}
181+
182+
public final class com/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovement : com/amplifyframework/ui/liveness/ui/LivenessChallenge {
183+
public static final field $stable I
184+
public fun <init> ()V
185+
public fun <init> (Lcom/amplifyframework/ui/liveness/ui/Camera;)V
186+
public synthetic fun <init> (Lcom/amplifyframework/ui/liveness/ui/Camera;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
187+
public final fun component1 ()Lcom/amplifyframework/ui/liveness/ui/Camera;
188+
public final fun copy (Lcom/amplifyframework/ui/liveness/ui/Camera;)Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovement;
189+
public static synthetic fun copy$default (Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovement;Lcom/amplifyframework/ui/liveness/ui/Camera;ILjava/lang/Object;)Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovement;
190+
public fun equals (Ljava/lang/Object;)Z
191+
public fun getCamera ()Lcom/amplifyframework/ui/liveness/ui/Camera;
192+
public fun hashCode ()I
193+
public fun toString ()Ljava/lang/String;
194+
}
195+
196+
public final class com/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovementAndLight : com/amplifyframework/ui/liveness/ui/LivenessChallenge {
197+
public static final field $stable I
198+
public static final field INSTANCE Lcom/amplifyframework/ui/liveness/ui/LivenessChallenge$FaceMovementAndLight;
199+
public fun equals (Ljava/lang/Object;)Z
200+
public fun hashCode ()I
201+
public fun toString ()Ljava/lang/String;
113202
}
114203

115204
public final class com/amplifyframework/ui/liveness/ui/LivenessColorScheme {

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

Lines changed: 63 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,20 @@ 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
50+
import com.amplifyframework.ui.liveness.ui.Camera
51+
import com.amplifyframework.ui.liveness.ui.ChallengeOptions
4752
import com.amplifyframework.ui.liveness.util.WebSocketCloseCode
4853
import java.util.Date
4954
import java.util.concurrent.Executors
@@ -65,25 +70,26 @@ internal typealias OnFreshnessColorDisplayed = (
6570
@SuppressLint("UnsafeOptInUsageError")
6671
internal class LivenessCoordinator(
6772
val context: Context,
68-
lifecycleOwner: LifecycleOwner,
73+
private val lifecycleOwner: LifecycleOwner,
6974
private val sessionId: String,
7075
private val region: String,
7176
private val credentialsProvider: AWSCredentialsProvider<AWSCredentials>?,
72-
disableStartView: Boolean,
77+
private val disableStartView: Boolean,
78+
private val challengeOptions: ChallengeOptions,
7379
private val onChallengeComplete: OnChallengeComplete,
7480
val onChallengeFailed: Consumer<FaceLivenessDetectionException>
7581
) {
7682

83+
private val attemptCounter = AttemptCounter()
7784
private val analysisExecutor = Executors.newSingleThreadExecutor()
7885

7986
val livenessState = LivenessState(
80-
sessionId,
81-
context,
82-
disableStartView,
83-
this::processCaptureReady,
84-
this::startLivenessSession,
85-
this::processSessionError,
86-
this::processFinalEventsSent
87+
sessionId = sessionId,
88+
context = context,
89+
disableStartView = disableStartView,
90+
onCaptureReady = this::processCaptureReady,
91+
onSessionError = this::processSessionError,
92+
onFinalEventsSent = this::processFinalEventsSent
8793
)
8894

8995
private val preview = Preview.Builder().apply {
@@ -138,6 +144,15 @@ internal class LivenessCoordinator(
138144
private var disconnectEventReceived = false
139145

140146
init {
147+
startLivenessSession()
148+
if (challengeOptions.hasOneCameraConfigured()) {
149+
launchCamera(challengeOptions.faceMovementAndLight.camera)
150+
} else {
151+
livenessState.loadingCameraPreview = true
152+
}
153+
}
154+
155+
private fun launchCamera(camera: Camera) {
141156
MainScope().launch {
142157
delay(5_000)
143158
if (!previewTextureView.hasReceivedUpdate) {
@@ -152,17 +167,24 @@ internal class LivenessCoordinator(
152167
getCameraProvider(context).apply {
153168
if (lifecycleOwner.lifecycle.currentState != Lifecycle.State.DESTROYED) {
154169
unbindAll()
155-
if (this.hasCamera(CameraSelector.DEFAULT_FRONT_CAMERA)) {
170+
171+
val (chosenCamera, orientation) = when (camera) {
172+
Camera.Front -> Pair(CameraSelector.DEFAULT_FRONT_CAMERA, "front")
173+
Camera.Back -> Pair(CameraSelector.DEFAULT_BACK_CAMERA, "back")
174+
}
175+
176+
if (this.hasCamera(chosenCamera)) {
156177
bindToLifecycle(
157178
lifecycleOwner,
158-
CameraSelector.DEFAULT_FRONT_CAMERA,
179+
chosenCamera,
159180
preview,
160181
analysis
161182
)
162183
} else {
184+
livenessState.loadingCameraPreview = false
163185
val faceLivenessException = FaceLivenessDetectionException(
164-
"A front facing camera is required but no front facing camera detected.",
165-
"Enable a front facing camera."
186+
"A $orientation facing camera is required but no $orientation facing camera detected.",
187+
"Enable a $orientation facing camera."
166188
)
167189
processSessionError(faceLivenessException, true)
168190
}
@@ -172,13 +194,19 @@ internal class LivenessCoordinator(
172194
}
173195

174196
private fun startLivenessSession() {
175-
livenessState.livenessCheckState.value = LivenessCheckState.Initial.withConnectingMessage()
197+
livenessState.livenessCheckState = LivenessCheckState.Initial.withConnectingMessage()
198+
attemptCounter.countAttempt()
176199

177200
val faceLivenessSessionInformation = FaceLivenessSessionInformation(
178-
TARGET_WIDTH.toFloat(),
179-
TARGET_HEIGHT.toFloat(),
180-
"FaceMovementAndLightChallenge_1.0.0",
181-
region
201+
videoWidth = TARGET_WIDTH.toFloat(),
202+
videoHeight = TARGET_HEIGHT.toFloat(),
203+
challengeVersions = listOf(
204+
Challenge.FaceMovementAndLightChallenge("2.0.0"),
205+
Challenge.FaceMovementChallenge("1.0.0")
206+
),
207+
region = region,
208+
preCheckViewEnabled = !disableStartView,
209+
attemptCount = attemptCounter.getCount()
182210
)
183211

184212
val faceLivenessSessionOptions = AWSFaceLivenessSessionOptions.builder().apply {
@@ -190,25 +218,33 @@ internal class LivenessCoordinator(
190218
faceLivenessSessionInformation,
191219
faceLivenessSessionOptions,
192220
BuildConfig.LIVENESS_VERSION_NAME,
193-
{ livenessState.onLivenessSessionReady(it) },
221+
{
222+
livenessState.onLivenessSessionReady(it)
223+
if (!challengeOptions.hasOneCameraConfigured()) {
224+
val foundChallenge = challengeOptions.getLivenessChallenge(it.challengeType)
225+
launchCamera(foundChallenge.camera)
226+
}
227+
},
194228
{
195229
disconnectEventReceived = true
196230
onChallengeComplete()
197231
},
198232
{ error ->
199-
val faceLivenessException = when (error) {
233+
val (faceLivenessException, shouldStopLivenessSession) = when (error) {
200234
is AccessDeniedException ->
201-
FaceLivenessDetectionException.AccessDeniedException(throwable = error)
235+
FaceLivenessDetectionException.AccessDeniedException(throwable = error) to false
202236
is FaceLivenessSessionNotFoundException ->
203-
FaceLivenessDetectionException.SessionNotFoundException(throwable = error)
237+
FaceLivenessDetectionException.SessionNotFoundException(throwable = error) to false
204238
is FaceLivenessSessionTimeoutException ->
205-
FaceLivenessDetectionException.SessionTimedOutException(throwable = error)
239+
FaceLivenessDetectionException.SessionTimedOutException(throwable = error) to false
240+
is FaceLivenessUnsupportedChallengeTypeException ->
241+
FaceLivenessDetectionException.UnsupportedChallengeTypeException(throwable = error) to true
206242
else -> FaceLivenessDetectionException(
207243
error.message ?: "Unknown error.",
208244
error.recoverySuggestion, error
209-
)
245+
) to false
210246
}
211-
processSessionError(faceLivenessException, false)
247+
processSessionError(faceLivenessException, shouldStopLivenessSession)
212248
}
213249
)
214250
}
@@ -256,8 +292,8 @@ internal class LivenessCoordinator(
256292
)
257293
}
258294

259-
fun processFreshnessChallengeComplete() {
260-
livenessState.onFreshnessComplete()
295+
fun processLivenessCheckComplete() {
296+
livenessState.onLivenessChallengeComplete()
261297
stopEncoder { livenessState.onFullChallengeComplete() }
262298
}
263299

liveness/src/main/java/com/amplifyframework/ui/liveness/ml/FaceDetector.kt

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,12 @@ internal class FaceDetector(private val livenessState: LivenessState) {
4242
outputScores: Array<Array<FloatArray>>
4343
): List<Detection> {
4444
val detections = mutableListOf<Detection>()
45+
val faceTargetChallenge = livenessState.faceTargetChallenge ?: return emptyList()
4546
for (i in 0 until NUM_BOXES) {
4647
var score = outputScores[0][i][0]
4748
score = computeSigmoid(score)
48-
if (score < MIN_SCORE_THRESHOLD) {
49+
50+
if (score < faceTargetChallenge.faceTargetMatching.faceDetectionThreshold) {
4951
continue
5052
}
5153

@@ -159,6 +161,7 @@ internal class FaceDetector(private val livenessState: LivenessState) {
159161
scaledMouth,
160162
scaledLeftEar,
161163
scaledRightEar,
164+
faceTargetChallenge.faceTargetMatching.targetHeightWidthRatio
162165
)
163166
renormalizedDetections.add(
164167
Detection(
@@ -183,13 +186,14 @@ internal class FaceDetector(private val livenessState: LivenessState) {
183186
nose: Landmark,
184187
mouth: Landmark,
185188
leftEar: Landmark,
186-
rightEar: Landmark
189+
rightEar: Landmark,
190+
heightWidthRatio: Float
187191
): RectF {
188192
val pupilDistance = calculatePupilDistance(leftEye, rightEye)
189193
val faceHeight = calculateFaceHeight(leftEye, rightEye, mouth)
190194

191195
val ow = (ALPHA * pupilDistance + GAMMA * faceHeight) / 2
192-
val oh = GOLDEN_RATIO * ow
196+
val oh = heightWidthRatio * ow
193197

194198
val eyeCenterX = (leftEye.x + rightEye.x) / 2
195199
val eyeCenterY = (leftEye.y + rightEye.y) / 2
@@ -450,7 +454,6 @@ internal class FaceDetector(private val livenessState: LivenessState) {
450454

451455
companion object {
452456
private const val MIN_SUPPRESSION_THRESHOLD = 0.3f
453-
private const val MIN_SCORE_THRESHOLD = 0.7f
454457
private val strides = listOf(8, 16, 16, 16)
455458
private const val ASPECT_RATIOS_SIZE = 1
456459
private const val MIN_SCALE = 0.1484375f
@@ -459,7 +462,6 @@ internal class FaceDetector(private val livenessState: LivenessState) {
459462
private const val ANCHOR_OFFSET_Y = 0.5f
460463
private const val INPUT_SIZE_HEIGHT = 128
461464
private const val INPUT_SIZE_WIDTH = 128
462-
private const val GOLDEN_RATIO = 1.618f
463465
private const val ALPHA = 2.0f
464466
private const val GAMMA = 1.8f
465467
const val X_SCALE = 128f
@@ -479,7 +481,6 @@ internal class FaceDetector(private val livenessState: LivenessState) {
479481
* 14, 15 - right eye tragion
480482
*/
481483
const val NUM_COORDS = 16
482-
const val INITIAL_FACE_DISTANCE_THRESHOLD = 0.32f
483484

484485
fun loadModel(context: Context): Interpreter {
485486
val modelFileDescriptor =

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

Lines changed: 7 additions & 0 deletions
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.",

0 commit comments

Comments
 (0)