Skip to content

Commit 3c6b81b

Browse files
author
Thomas Leing
committed
Initial commit for liveness integration tests
1 parent 7a77c1d commit 3c6b81b

File tree

5 files changed

+375
-1
lines changed

5 files changed

+375
-1
lines changed

gradle/libs.versions.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,19 @@ tensorflow-support = "org.tensorflow:tensorflow-lite-support:0.3.0"
4242

4343
# Testing libraries
4444
test-androidx-junit = "androidx.test.ext:junit:1.1.4"
45+
test-androidx-monitor = "androidx.test:monitor:1.5.0"
46+
test-androidx-rules = "androidx.test:rules:1.5.0"
4547
test-compose-junit = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" }
4648
test-compose-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose" }
4749
test-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
4850
test-espresso = "androidx.test.espresso:espresso-core:3.5.1"
4951
test-junit = "junit:junit:4.13.2"
5052
test-kotest-assertions = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" }
5153
test-mockk = "io.mockk:mockk:1.13.4"
54+
test-mockk-android = "io.mockk:mockk-android:1.13.4"
5255
test-robolectric = "org.robolectric:robolectric:4.9.2"
5356
test-turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
57+
debug-ui-test-manifest = "androidx.compose.ui:ui-test-manifest:1.5.0-beta01"
5458

5559
# Dependencies for convention plugins
5660
plugin-android-gradle = { module = "com.android.tools.build:gradle", version.ref = "agp" }

liveness/build.gradle.kts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ android {
3333
androidResources {
3434
noCompress += "tflite"
3535
}
36+
37+
packagingOptions {
38+
resources.excludes.add("META-INF/LICENSE.md")
39+
resources.excludes.add("META-INF/LICENSE-notice.md")
40+
}
3641
}
3742

3843
dependencies {
@@ -52,4 +57,11 @@ dependencies {
5257
implementation(libs.tensorflow.support)
5358

5459
testImplementation(projects.testing)
60+
androidTestImplementation(dependency.amplify.auth)
61+
androidTestImplementation(dependency.test.compose.junit)
62+
androidTestImplementation(dependency.test.androidx.monitor)
63+
androidTestImplementation(dependency.test.androidx.rules)
64+
androidTestImplementation(dependency.test.junit)
65+
androidTestImplementation(dependency.test.mockk.android)
66+
debugImplementation(dependency.debug.ui.test.manifest)
5567
}
Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
package com.amplifyframework.ui.liveness
2+
3+
import android.Manifest
4+
import android.content.Context
5+
import android.graphics.RectF
6+
import androidx.compose.ui.test.assertIsDisplayed
7+
import androidx.compose.ui.test.junit4.createComposeRule
8+
import androidx.compose.ui.test.onAllNodesWithText
9+
import androidx.compose.ui.test.onNodeWithText
10+
import androidx.compose.ui.test.performClick
11+
import androidx.test.platform.app.InstrumentationRegistry
12+
import androidx.test.rule.GrantPermissionRule
13+
import com.amplifyframework.annotations.InternalAmplifyApi
14+
import com.amplifyframework.auth.AuthSession
15+
import com.amplifyframework.auth.cognito.AWSCognitoAuthPlugin
16+
import com.amplifyframework.core.Action
17+
import com.amplifyframework.core.Amplify
18+
import com.amplifyframework.core.Consumer
19+
import com.amplifyframework.predictions.aws.AWSPredictionsPlugin
20+
import com.amplifyframework.predictions.aws.models.FaceTargetChallenge
21+
import com.amplifyframework.predictions.models.FaceLivenessSession
22+
import com.amplifyframework.predictions.models.FaceLivenessSessionInformation
23+
import com.amplifyframework.predictions.options.FaceLivenessSessionOptions
24+
import com.amplifyframework.ui.liveness.camera.FrameAnalyzer
25+
import com.amplifyframework.ui.liveness.ml.FaceDetector
26+
import com.amplifyframework.ui.liveness.state.LivenessState
27+
import com.amplifyframework.ui.liveness.ui.FaceLivenessDetector
28+
import io.mockk.CapturingSlot
29+
import io.mockk.InvokeMatcher
30+
import io.mockk.OfTypeMatcher
31+
import io.mockk.Runs
32+
import io.mockk.every
33+
import io.mockk.just
34+
import io.mockk.mockkConstructor
35+
import io.mockk.mockkObject
36+
import io.mockk.mockkStatic
37+
import io.mockk.slot
38+
import io.mockk.unmockkConstructor
39+
import org.junit.Assert.assertTrue
40+
import org.junit.Before
41+
import org.junit.BeforeClass
42+
import org.junit.Rule
43+
import org.junit.Test
44+
45+
// mock calls to Rekognition service, just make sure the flow functions as normal
46+
// steps:
47+
// 1. start the flow
48+
// 2. click the button to start the liveness session
49+
// 3. verify that the flow was started and shows correct face distance UI
50+
// 4. trigger fake response that the face is at the right distance
51+
// 5. verify that the flow displays colored rectangles
52+
// 6. verify that the component is sending the video feed through the fake websocket
53+
// 7. send fake correct/incorrect response
54+
class LivenessFlowInstrumentationTest {
55+
private lateinit var livenessSessionInformation: CapturingSlot<FaceLivenessSessionInformation>
56+
private lateinit var livenessSessionOptions: CapturingSlot<FaceLivenessSessionOptions>
57+
private lateinit var onSessionStarted: CapturingSlot<Consumer<FaceLivenessSession>>
58+
private lateinit var onLivenessComplete: CapturingSlot<Action>
59+
private lateinit var tooCloseString: String
60+
private lateinit var beginCheckString: String
61+
private lateinit var noFaceString: String
62+
private lateinit var multipleFaceString: String
63+
private lateinit var connectingString: String
64+
private lateinit var countdownString: String
65+
66+
@get:Rule
67+
val composeTestRule = createComposeRule()
68+
69+
@get:Rule
70+
var mRuntimePermissionRule: GrantPermissionRule = GrantPermissionRule.grant(Manifest.permission.CAMERA)
71+
72+
@Before
73+
fun setup() {
74+
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
75+
76+
livenessSessionInformation = slot()
77+
livenessSessionOptions = slot()
78+
onSessionStarted = slot()
79+
onLivenessComplete = slot()
80+
mockkStatic(AWSPredictionsPlugin::class)
81+
every {
82+
AWSPredictionsPlugin.startFaceLivenessSession(
83+
any(), // sessionId
84+
capture(livenessSessionInformation), // sessionInformation
85+
capture(livenessSessionOptions), // options
86+
capture(onSessionStarted), // onSessionStarted
87+
capture(onLivenessComplete), // onComplete
88+
any(), // onError
89+
)
90+
} just Runs
91+
92+
// string resources
93+
beginCheckString = context.getString(R.string.amplify_ui_liveness_get_ready_begin_check)
94+
tooCloseString = context.getString(R.string.amplify_ui_liveness_challenge_instruction_move_face_further)
95+
noFaceString = context.getString(R.string.amplify_ui_liveness_challenge_instruction_move_face)
96+
multipleFaceString = context.getString(
97+
R.string.amplify_ui_liveness_challenge_instruction_multiple_faces_detected,
98+
)
99+
connectingString = context.getString(R.string.amplify_ui_liveness_challenge_connecting)
100+
countdownString = context.getString(
101+
R.string.amplify_ui_liveness_challenge_instruction_hold_face_during_countdown,
102+
)
103+
}
104+
105+
@Test
106+
fun testLivenessDefaultCameraGivesNoFaceError() {
107+
val sessionId = "sessionId"
108+
composeTestRule.setContent {
109+
FaceLivenessDetector(sessionId = sessionId, region = "us-east-1", onComplete = {
110+
}, onError = { assertTrue(false) })
111+
}
112+
113+
composeTestRule.onNodeWithText(beginCheckString).assertExists()
114+
composeTestRule.onNodeWithText(beginCheckString).performClick()
115+
composeTestRule.waitUntil(5000) {
116+
composeTestRule.onAllNodesWithText(noFaceString)
117+
.fetchSemanticsNodes().size == 1
118+
}
119+
// make sure compose flow reaches this point
120+
composeTestRule.onNodeWithText(noFaceString).assertIsDisplayed()
121+
}
122+
123+
@Test
124+
fun testLivenessFlowTooClose() {
125+
mockkConstructor(FrameAnalyzer::class)
126+
var livenessState: LivenessState? = null
127+
every {
128+
constructedWith<FrameAnalyzer>(
129+
OfTypeMatcher<Context>(Context::class),
130+
InvokeMatcher<LivenessState> {
131+
livenessState = it
132+
},
133+
).analyze(any())
134+
} answers {
135+
assert(livenessState != null)
136+
137+
livenessState?.onFrameFaceCountUpdate(1)
138+
139+
// Features too far apart, this face must be too close to the camera
140+
livenessState?.onFrameFaceUpdate(
141+
RectF(0f, 0f, 400f, 400f),
142+
FaceDetector.Landmark(120f, 120f),
143+
FaceDetector.Landmark(280f, 120f),
144+
FaceDetector.Landmark(200f, 320f),
145+
)
146+
}
147+
148+
val sessionId = "sessionId"
149+
composeTestRule.setContent {
150+
FaceLivenessDetector(sessionId = sessionId, region = "us-east-1", onComplete = {
151+
}, onError = { assertTrue(false) })
152+
}
153+
154+
composeTestRule.onNodeWithText(beginCheckString).assertExists()
155+
composeTestRule.onNodeWithText(beginCheckString).performClick()
156+
composeTestRule.waitUntil(5000) {
157+
composeTestRule.onAllNodesWithText(tooCloseString)
158+
.fetchSemanticsNodes().size == 1
159+
}
160+
161+
// make sure compose flow reaches this point
162+
composeTestRule.onNodeWithText(tooCloseString).assertIsDisplayed()
163+
164+
unmockkConstructor(FrameAnalyzer::class)
165+
}
166+
167+
@Test
168+
fun testLivenessFlowTooManyFaces() {
169+
mockkConstructor(FrameAnalyzer::class)
170+
var livenessState: LivenessState? = null
171+
every {
172+
constructedWith<FrameAnalyzer>(
173+
OfTypeMatcher<Context>(Context::class),
174+
InvokeMatcher<LivenessState> {
175+
livenessState = it
176+
},
177+
).analyze(any())
178+
} answers {
179+
assert(livenessState != null)
180+
181+
livenessState?.onFrameFaceCountUpdate(2)
182+
}
183+
184+
val sessionId = "sessionId"
185+
composeTestRule.setContent {
186+
FaceLivenessDetector(sessionId = sessionId, region = "us-east-1", onComplete = {
187+
}, onError = { assertTrue(false) })
188+
}
189+
190+
composeTestRule.onNodeWithText(beginCheckString).assertExists()
191+
composeTestRule.onNodeWithText(beginCheckString).performClick()
192+
composeTestRule.waitUntil(5000) {
193+
composeTestRule.onAllNodesWithText(multipleFaceString)
194+
.fetchSemanticsNodes().size == 1
195+
}
196+
197+
// make sure compose flow reaches this point
198+
composeTestRule.onNodeWithText(multipleFaceString).assertIsDisplayed()
199+
200+
unmockkConstructor(FrameAnalyzer::class)
201+
}
202+
203+
@Test
204+
fun testLivenessFlowNoChallenges() {
205+
mockkConstructor(FrameAnalyzer::class)
206+
var livenessState: LivenessState? = null
207+
every {
208+
constructedWith<FrameAnalyzer>(
209+
OfTypeMatcher<Context>(Context::class),
210+
InvokeMatcher<LivenessState> {
211+
livenessState = it
212+
},
213+
).analyze(any())
214+
} answers {
215+
assert(livenessState != null)
216+
217+
livenessState?.onFrameFaceCountUpdate(1)
218+
219+
// Features should be sized correctly here
220+
livenessState?.onFrameFaceUpdate(
221+
RectF(0f, 0f, 200f, 200f),
222+
FaceDetector.Landmark(60f, 60f),
223+
FaceDetector.Landmark(140f, 60f),
224+
FaceDetector.Landmark(100f, 160f),
225+
)
226+
}
227+
228+
val sessionId = "sessionId"
229+
var completesSuccessfully = false
230+
composeTestRule.setContent {
231+
FaceLivenessDetector(sessionId = sessionId, region = "us-east-1", onComplete = {
232+
completesSuccessfully = true
233+
}, onError = { assertTrue(false) })
234+
}
235+
236+
composeTestRule.onNodeWithText(beginCheckString).assertExists()
237+
composeTestRule.onNodeWithText(beginCheckString).performClick()
238+
composeTestRule.waitUntil(5000) {
239+
composeTestRule.onAllNodesWithText(connectingString)
240+
.fetchSemanticsNodes().size == 1
241+
}
242+
243+
onSessionStarted.captured.accept(FaceLivenessSession(emptyList(), {}, {}, {}))
244+
245+
composeTestRule.waitForIdle()
246+
247+
onLivenessComplete.captured.call()
248+
assertTrue(completesSuccessfully)
249+
250+
unmockkConstructor(FrameAnalyzer::class)
251+
}
252+
253+
@Test
254+
fun testLivenessFlowWithChallenges() {
255+
mockkConstructor(FrameAnalyzer::class)
256+
var livenessState: LivenessState? = null
257+
every {
258+
constructedWith<FrameAnalyzer>(
259+
OfTypeMatcher<Context>(Context::class),
260+
InvokeMatcher<LivenessState> {
261+
livenessState = it
262+
},
263+
).analyze(any())
264+
} answers {
265+
assert(livenessState != null)
266+
267+
livenessState?.onFrameFaceCountUpdate(1)
268+
269+
// Features should be sized correctly here
270+
livenessState?.onFrameFaceUpdate(
271+
RectF(0f, 0f, 200f, 200f),
272+
FaceDetector.Landmark(60f, 60f),
273+
FaceDetector.Landmark(140f, 60f),
274+
FaceDetector.Landmark(100f, 160f),
275+
)
276+
}
277+
278+
val sessionId = "sessionId"
279+
var completesSuccessfully = false
280+
composeTestRule.setContent {
281+
FaceLivenessDetector(sessionId = sessionId, region = "us-east-1", onComplete = {
282+
completesSuccessfully = true
283+
}, onError = { assertTrue(false) })
284+
}
285+
286+
composeTestRule.onNodeWithText(beginCheckString).assertExists()
287+
composeTestRule.onNodeWithText(beginCheckString).performClick()
288+
composeTestRule.waitUntil(5000) {
289+
composeTestRule.onAllNodesWithText(connectingString)
290+
.fetchSemanticsNodes().size == 1
291+
}
292+
293+
@OptIn(InternalAmplifyApi::class)
294+
val faceTargetChallenge = FaceTargetChallenge()
295+
onSessionStarted.captured.accept(FaceLivenessSession(listOf(FaceTargetChallenge, ColorChallenge)))
296+
297+
composeTestRule.waitForIdle()
298+
299+
onLivenessComplete.captured.call()
300+
assertTrue(completesSuccessfully)
301+
302+
unmockkConstructor(FrameAnalyzer::class)
303+
}
304+
305+
// TODO: this gets to the camera page! next up:
306+
// 1. figure out how to trigger the next step
307+
// 2. test on virtual device, might be fine...
308+
309+
companion object {
310+
@BeforeClass
311+
@JvmStatic
312+
fun setupAmplify() {
313+
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
314+
315+
// mock the Amplify Auth category
316+
val authPlugin = AWSCognitoAuthPlugin()
317+
mockkObject(authPlugin)
318+
every { authPlugin.fetchAuthSession(any(), any()) } answers {
319+
firstArg<(AuthSession) -> Unit>().invoke(AuthSession(true))
320+
}
321+
Amplify.addPlugin(authPlugin)
322+
Amplify.configure(context)
323+
}
324+
}
325+
}

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package com.amplifyframework.ui.liveness.camera
1818
import android.content.Context
1919
import android.graphics.Bitmap
2020
import android.util.Size
21+
import androidx.annotation.VisibleForTesting
2122
import androidx.camera.core.ImageAnalysis
2223
import androidx.camera.core.ImageProxy
2324
import com.amplifyframework.ui.liveness.ml.FaceDetector
@@ -32,7 +33,8 @@ import org.tensorflow.lite.support.image.ops.Rot90Op
3233

3334
internal class FrameAnalyzer(
3435
context: Context,
35-
private val livenessState: LivenessState
36+
@get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
37+
val livenessState: LivenessState
3638
) : ImageAnalysis.Analyzer {
3739

3840
private val tfLite = FaceDetector.loadModel(context)

0 commit comments

Comments
 (0)