Skip to content

Commit 38a306d

Browse files
author
Ken Ackerson
committed
fixing timing race condition
1 parent 9485ac2 commit 38a306d

4 files changed

Lines changed: 144 additions & 72 deletions

File tree

identity/src/main/java/com/stripe/android/identity/ui/LiveCaptureLaunchedEffect.kt

Lines changed: 116 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ import androidx.compose.runtime.LaunchedEffect
66
import androidx.lifecycle.LifecycleOwner
77
import androidx.navigation.NavController
88
import com.stripe.android.identity.analytics.IdentityAnalyticsRequestFactory
9+
import com.stripe.android.identity.camera.IdentityAggregator
910
import com.stripe.android.identity.camera.IdentityCameraManager
1011
import com.stripe.android.identity.ml.FaceDetectorOutput
1112
import com.stripe.android.identity.ml.IDDetectorOutput
1213
import com.stripe.android.identity.navigation.CouldNotCaptureDestination
1314
import com.stripe.android.identity.navigation.navigateTo
1415
import com.stripe.android.identity.networking.models.VerificationPage
16+
import com.stripe.android.identity.states.FaceDetectorTransitioner
1517
import com.stripe.android.identity.states.IdentityScanState.Companion.isBack
1618
import com.stripe.android.identity.states.IdentityScanState.Companion.isFront
1719
import com.stripe.android.identity.viewmodel.IdentityScanViewModel
@@ -33,60 +35,26 @@ internal fun LiveCaptureLaunchedEffect(
3335
cameraManager: IdentityCameraManager? = null
3436
) {
3537
LaunchedEffect(scannerState) {
36-
if (scannerState is IdentityScanViewModel.State.Scanned) {
37-
val scanResult = scannerState.result
38-
// Capture selfie virtual camera flag while camera is still bound
39-
if (scanResult.result is FaceDetectorOutput) {
40-
identityViewModel.setSelfieIsVirtualCamera(cameraManager?.isVirtualCamera())
38+
when (scannerState) {
39+
is IdentityScanViewModel.State.Scanned -> {
40+
handleScannedState(
41+
scannerState = scannerState,
42+
identityScanViewModel = identityScanViewModel,
43+
identityViewModel = identityViewModel,
44+
lifecycleOwner = lifecycleOwner,
45+
verificationPage = verificationPage,
46+
navController = navController,
47+
cameraManager = cameraManager
48+
)
4149
}
42-
identityScanViewModel.stopScan(lifecycleOwner)
43-
44-
// Set camera metadata right before uploading
45-
cameraManager?.getCameraLensModel()?.let { identityViewModel.setCameraLensModel(it) }
46-
cameraManager?.getExposureIso()?.let { identityViewModel.setCameraExposureIso(it) }
47-
cameraManager?.getFocalLength()?.let { identityViewModel.setCameraFocalLength(it) }
48-
cameraManager?.getExposureDuration()?.let { identityViewModel.setCameraExposureDuration(it) }
49-
cameraManager?.isVirtualCamera()?.let { identityViewModel.setIsVirtualCamera(it) }
50-
51-
identityViewModel.uploadScanResult(
52-
scanResult,
53-
verificationPage
54-
)
55-
when (val detectorOutput = scanResult.result) {
56-
is FaceDetectorOutput -> {
57-
identityViewModel.updateAnalyticsState { oldState ->
58-
oldState.copy(selfieModelScore = detectorOutput.resultScore)
59-
}
60-
}
61-
is IDDetectorOutput -> {
62-
if (scanResult.identityState.type.isFront()) {
63-
identityViewModel.updateAnalyticsState { oldState ->
64-
oldState.copy(
65-
docFrontModelScore = detectorOutput.resultScore,
66-
docFrontBlurScore = detectorOutput.blurScore
67-
)
68-
}
69-
} else if (scanResult.identityState.type.isBack()) {
70-
identityViewModel.updateAnalyticsState { oldState ->
71-
oldState.copy(
72-
docBackModelScore = detectorOutput.resultScore,
73-
docBackBlurScore = detectorOutput.blurScore
74-
)
75-
}
76-
}
77-
}
50+
is IdentityScanViewModel.State.Timeout -> {
51+
handleTimeoutState(
52+
scannerState = scannerState,
53+
identityViewModel = identityViewModel,
54+
navController = navController
55+
)
7856
}
79-
} else if (scannerState is IdentityScanViewModel.State.Timeout) {
80-
identityViewModel.screenTracker.screenTransitionStart(
81-
identityViewModel.analyticsLastScreenName ?: if (scannerState.fromSelfie) {
82-
IdentityAnalyticsRequestFactory.SCREEN_NAME_SELFIE
83-
} else {
84-
IdentityAnalyticsRequestFactory.SCREEN_NAME_LIVE_CAPTURE
85-
}
86-
)
87-
navController.navigateTo(
88-
CouldNotCaptureDestination(fromSelfie = scannerState.fromSelfie)
89-
)
57+
else -> Unit
9058
}
9159
}
9260

@@ -96,3 +64,99 @@ internal fun LiveCaptureLaunchedEffect(
9664
}
9765
}
9866
}
67+
68+
private suspend fun handleScannedState(
69+
scannerState: IdentityScanViewModel.State.Scanned,
70+
identityScanViewModel: IdentityScanViewModel,
71+
identityViewModel: IdentityViewModel,
72+
lifecycleOwner: LifecycleOwner,
73+
verificationPage: VerificationPage,
74+
navController: NavController,
75+
cameraManager: IdentityCameraManager?
76+
) {
77+
val scanResult = scannerState.result
78+
val detectorOutput = scanResult.result
79+
val isSelfieResult = detectorOutput is FaceDetectorOutput
80+
81+
if (isSelfieResult) {
82+
identityViewModel.setSelfieIsVirtualCamera(cameraManager?.isVirtualCamera())
83+
}
84+
identityScanViewModel.stopScan(lifecycleOwner)
85+
identityViewModel.setCameraMetadata(cameraManager)
86+
87+
if (isSelfieResult) {
88+
identityViewModel.clearSelfieUploadedState()
89+
}
90+
identityViewModel.uploadScanResult(scanResult, verificationPage)
91+
92+
when (detectorOutput) {
93+
is FaceDetectorOutput -> {
94+
identityViewModel.handleFaceDetectorOutput(detectorOutput, scanResult, navController)
95+
}
96+
is IDDetectorOutput -> {
97+
identityViewModel.handleIdDetectorOutput(detectorOutput, scanResult)
98+
}
99+
}
100+
}
101+
102+
private fun IdentityViewModel.setCameraMetadata(cameraManager: IdentityCameraManager?) {
103+
cameraManager?.getCameraLensModel()?.let { setCameraLensModel(it) }
104+
cameraManager?.getExposureIso()?.let { setCameraExposureIso(it) }
105+
cameraManager?.getFocalLength()?.let { setCameraFocalLength(it) }
106+
cameraManager?.getExposureDuration()?.let { setCameraExposureDuration(it) }
107+
cameraManager?.isVirtualCamera()?.let { setIsVirtualCamera(it) }
108+
}
109+
110+
private suspend fun IdentityViewModel.handleFaceDetectorOutput(
111+
detectorOutput: FaceDetectorOutput,
112+
scanResult: IdentityAggregator.FinalResult,
113+
navController: NavController
114+
) {
115+
updateAnalyticsState { oldState ->
116+
oldState.copy(selfieModelScore = detectorOutput.resultScore)
117+
}
118+
(scanResult.identityState.transitioner as? FaceDetectorTransitioner)?.let {
119+
collectDataForSelfieScreen(
120+
navController = navController,
121+
faceDetectorTransitioner = it
122+
)
123+
}
124+
}
125+
126+
private fun IdentityViewModel.handleIdDetectorOutput(
127+
detectorOutput: IDDetectorOutput,
128+
scanResult: IdentityAggregator.FinalResult
129+
) {
130+
if (scanResult.identityState.type.isFront()) {
131+
updateAnalyticsState { oldState ->
132+
oldState.copy(
133+
docFrontModelScore = detectorOutput.resultScore,
134+
docFrontBlurScore = detectorOutput.blurScore
135+
)
136+
}
137+
} else if (scanResult.identityState.type.isBack()) {
138+
updateAnalyticsState { oldState ->
139+
oldState.copy(
140+
docBackModelScore = detectorOutput.resultScore,
141+
docBackBlurScore = detectorOutput.blurScore
142+
)
143+
}
144+
}
145+
}
146+
147+
private fun handleTimeoutState(
148+
scannerState: IdentityScanViewModel.State.Timeout,
149+
identityViewModel: IdentityViewModel,
150+
navController: NavController
151+
) {
152+
identityViewModel.screenTracker.screenTransitionStart(
153+
identityViewModel.analyticsLastScreenName ?: if (scannerState.fromSelfie) {
154+
IdentityAnalyticsRequestFactory.SCREEN_NAME_SELFIE
155+
} else {
156+
IdentityAnalyticsRequestFactory.SCREEN_NAME_LIVE_CAPTURE
157+
}
158+
)
159+
navController.navigateTo(
160+
CouldNotCaptureDestination(fromSelfie = scannerState.fromSelfie)
161+
)
162+
}

identity/src/main/java/com/stripe/android/identity/ui/SelfieScreen.kt

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,8 @@ internal fun SelfieScanScreen(
173173
identityViewModel = identityViewModel,
174174
lifecycleOwner = lifecycleOwner,
175175
verificationPage = pageAndModelFiles.page,
176-
navController = navController
176+
navController = navController,
177+
cameraManager = cameraManager
177178
)
178179

179180
when (selfieScannerState) {
@@ -196,7 +197,6 @@ internal fun SelfieScanScreen(
196197
fallbackUrl = pageAndModelFiles.page.fallbackUrl,
197198
identityViewModel = identityViewModel,
198199
identityScanViewModel = selfieScanViewModel,
199-
navController = navController,
200200
lifecycleOwner = lifecycleOwner,
201201
cameraManager = cameraManager,
202202
fallbackUrlLauncher = fallbackUrlLauncher
@@ -213,18 +213,20 @@ private fun SelfieCaptureScreen(
213213
fallbackUrl: String,
214214
identityViewModel: IdentityViewModel,
215215
identityScanViewModel: IdentityScanViewModel,
216-
navController: NavController,
217216
lifecycleOwner: LifecycleOwner,
218217
cameraManager: SelfieCameraManager,
219218
fallbackUrlLauncher: FallbackUrlLauncher
220219
) {
221-
LaunchedEffect(Unit) {
222-
startScanning(
223-
IdentityScanState.ScanType.SELFIE,
224-
identityViewModel = identityViewModel,
225-
identityScanViewModel = identityScanViewModel,
226-
lifecycleOwner = lifecycleOwner
227-
)
220+
if (selfieScannerState is IdentityScanViewModel.State.Scanning) {
221+
LaunchedEffect(Unit) {
222+
identityViewModel.clearSelfieUploadedState()
223+
startScanning(
224+
IdentityScanState.ScanType.SELFIE,
225+
identityViewModel = identityViewModel,
226+
identityScanViewModel = identityScanViewModel,
227+
lifecycleOwner = lifecycleOwner
228+
)
229+
}
228230
}
229231

230232
var flashed by remember {
@@ -253,15 +255,6 @@ private fun SelfieCaptureScreen(
253255
(selfieScannerState as? IdentityScanViewModel.State.Scanned)
254256
?.result?.identityState?.transitioner as? FaceDetectorTransitioner
255257

256-
LaunchedEffect(faceDetectorTransitioner) {
257-
faceDetectorTransitioner?.let {
258-
identityViewModel.collectDataForSelfieScreen(
259-
navController = navController,
260-
faceDetectorTransitioner = it
261-
)
262-
}
263-
}
264-
265258
Column(
266259
modifier = Modifier
267260
.fillMaxSize()

identity/src/test/java/com/stripe/android/identity/ui/LiveCaptureLaunchedEffectTest.kt

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@ import com.stripe.android.identity.navigation.CouldNotCaptureDestination
1919
import com.stripe.android.identity.networking.Resource
2020
import com.stripe.android.identity.networking.models.VerificationPage
2121
import com.stripe.android.identity.networking.models.VerificationPageStaticContentDocumentCapturePage
22+
import com.stripe.android.identity.states.FaceDetectorTransitioner
2223
import com.stripe.android.identity.states.IdentityScanState
2324
import com.stripe.android.identity.viewmodel.IdentityScanViewModel
2425
import com.stripe.android.identity.viewmodel.IdentityViewModel
2526
import kotlinx.coroutines.ExperimentalCoroutinesApi
27+
import kotlinx.coroutines.runBlocking
2628
import kotlinx.coroutines.test.UnconfinedTestDispatcher
2729
import org.junit.Rule
2830
import org.junit.Test
@@ -32,6 +34,7 @@ import org.mockito.kotlin.argWhere
3234
import org.mockito.kotlin.doReturn
3335
import org.mockito.kotlin.eq
3436
import org.mockito.kotlin.mock
37+
import org.mockito.kotlin.same
3538
import org.mockito.kotlin.verify
3639
import org.robolectric.RobolectricTestRunner
3740
import org.robolectric.annotation.Config
@@ -57,12 +60,14 @@ class LiveCaptureLaunchedEffectTest {
5760
on { pageAndModelFiles } doReturn pageAndModel
5861
on { workContext } doReturn UnconfinedTestDispatcher()
5962
on { screenTracker } doReturn mockScreenTracker
63+
on { selfieTrainingConsent } doReturn false
6064
}
6165
private val mockIdentityScanViewModel = mock<IdentityScanViewModel>()
6266
private val mockNavController = mock<NavController>()
6367

6468
@Test
6569
fun verifyFaceDetectorFinishedResult() {
70+
val faceDetectorTransitioner = mock<FaceDetectorTransitioner>()
6671
val faceScannedState = IdentityScanViewModel.State.Scanned(
6772
IdentityAggregator.FinalResult(
6873
frame = mock(),
@@ -72,19 +77,27 @@ class LiveCaptureLaunchedEffectTest {
7277
),
7378
identityState = IdentityScanState.Finished(
7479
type = IdentityScanState.ScanType.SELFIE,
75-
transitioner = mock()
80+
transitioner = faceDetectorTransitioner
7681
)
7782
)
7883
)
7984

8085
testLiveCaptureLaunchedEffect(
8186
scannerState = faceScannedState
8287
) {
88+
verify(mockIdentityViewModel).clearSelfieUploadedState()
8389
verify(mockIdentityViewModel).updateAnalyticsState(
8490
argWhere { block ->
8591
block(AnalyticsState()).selfieModelScore == FACE_SCORE
8692
}
8793
)
94+
runBlocking {
95+
verify(mockIdentityViewModel).collectDataForSelfieScreen(
96+
same(mockNavController),
97+
same(faceDetectorTransitioner),
98+
eq(false)
99+
)
100+
}
88101
}
89102
}
90103

identity/src/test/java/com/stripe/android/identity/ui/SelfieScreenTest.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import org.mockito.kotlin.any
3939
import org.mockito.kotlin.doReturn
4040
import org.mockito.kotlin.eq
4141
import org.mockito.kotlin.mock
42+
import org.mockito.kotlin.never
4243
import org.mockito.kotlin.same
4344
import org.mockito.kotlin.verify
4445
import org.robolectric.RobolectricTestRunner
@@ -204,6 +205,7 @@ class SelfieScreenTest {
204205
messageId = R.string.stripe_selfie_capture_complete
205206
) {
206207
verify(mockSelfieScanViewModel).stopScan(any())
208+
verify(mockSelfieScanViewModel, never()).startScan(any(), any())
207209
onNodeWithTag(SELFIE_SCAN_TITLE_TAG)
208210
.assertTextEquals(context.getString(R.string.stripe_selfie_captures))
209211

0 commit comments

Comments
 (0)