1 /*
2  * Copyright (C) 2023 The Android Open Source Project
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  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.biometrics.ui.viewmodel
18 
19 import android.hardware.biometrics.PromptInfo
20 import android.hardware.face.FaceSensorPropertiesInternal
21 import android.hardware.fingerprint.FingerprintSensorPropertiesInternal
22 import android.view.HapticFeedbackConstants
23 import android.view.MotionEvent
24 import androidx.test.filters.SmallTest
25 import com.android.internal.widget.LockPatternUtils
26 import com.android.systemui.SysuiTestCase
27 import com.android.systemui.biometrics.AuthBiometricView
28 import com.android.systemui.biometrics.data.repository.FakeDisplayStateRepository
29 import com.android.systemui.biometrics.data.repository.FakeFingerprintPropertyRepository
30 import com.android.systemui.biometrics.data.repository.FakePromptRepository
31 import com.android.systemui.biometrics.domain.interactor.DisplayStateInteractorImpl
32 import com.android.systemui.biometrics.domain.interactor.PromptSelectorInteractor
33 import com.android.systemui.biometrics.domain.interactor.PromptSelectorInteractorImpl
34 import com.android.systemui.biometrics.domain.model.BiometricModalities
35 import com.android.systemui.biometrics.extractAuthenticatorTypes
36 import com.android.systemui.biometrics.faceSensorPropertiesInternal
37 import com.android.systemui.biometrics.fingerprintSensorPropertiesInternal
38 import com.android.systemui.biometrics.shared.model.BiometricModality
39 import com.android.systemui.coroutines.collectLastValue
40 import com.android.systemui.coroutines.collectValues
41 import com.android.systemui.flags.FakeFeatureFlags
42 import com.android.systemui.flags.Flags.ONE_WAY_HAPTICS_API_MIGRATION
43 import com.android.systemui.statusbar.VibratorHelper
44 import com.android.systemui.util.concurrency.FakeExecutor
45 import com.android.systemui.util.mockito.any
46 import com.android.systemui.util.time.FakeSystemClock
47 import com.google.common.truth.Truth.assertThat
48 import kotlinx.coroutines.ExperimentalCoroutinesApi
49 import kotlinx.coroutines.flow.first
50 import kotlinx.coroutines.launch
51 import kotlinx.coroutines.test.TestScope
52 import kotlinx.coroutines.test.runCurrent
53 import kotlinx.coroutines.test.runTest
54 import org.junit.Before
55 import org.junit.Rule
56 import org.junit.Test
57 import org.junit.runner.RunWith
58 import org.junit.runners.Parameterized
59 import org.mockito.Mock
60 import org.mockito.Mockito.never
61 import org.mockito.Mockito.times
62 import org.mockito.Mockito.verify
63 import org.mockito.junit.MockitoJUnit
64 
65 private const val USER_ID = 4
66 private const val CHALLENGE = 2L
67 
68 @OptIn(ExperimentalCoroutinesApi::class)
69 @SmallTest
70 @RunWith(Parameterized::class)
71 internal class PromptViewModelTest(private val testCase: TestCase) : SysuiTestCase() {
72 
73     @JvmField @Rule var mockitoRule = MockitoJUnit.rule()
74 
75     @Mock private lateinit var lockPatternUtils: LockPatternUtils
76     @Mock private lateinit var vibrator: VibratorHelper
77 
78     private val fakeExecutor = FakeExecutor(FakeSystemClock())
79     private val testScope = TestScope()
80     private val fingerprintRepository = FakeFingerprintPropertyRepository()
81     private val promptRepository = FakePromptRepository()
82     private val displayStateRepository = FakeDisplayStateRepository()
83 
84     private val displayStateInteractor =
85         DisplayStateInteractorImpl(
86             testScope.backgroundScope,
87             mContext,
88             fakeExecutor,
89             displayStateRepository
90         )
91 
92     private lateinit var selector: PromptSelectorInteractor
93     private lateinit var viewModel: PromptViewModel
94     private val featureFlags = FakeFeatureFlags()
95 
96     @Before
97     fun setup() {
98         selector =
99             PromptSelectorInteractorImpl(fingerprintRepository, promptRepository, lockPatternUtils)
100         selector.resetPrompt()
101 
102         viewModel =
103             PromptViewModel(displayStateInteractor, selector, vibrator, mContext, featureFlags)
104         featureFlags.set(ONE_WAY_HAPTICS_API_MIGRATION, false)
105     }
106 
107     @Test
108     fun start_idle_and_show_authenticating() =
109         runGenericTest(doNotStart = true) {
110             val expectedSize =
111                 if (testCase.shouldStartAsImplicitFlow) PromptSize.SMALL else PromptSize.MEDIUM
112             val authenticating by collectLastValue(viewModel.isAuthenticating)
113             val authenticated by collectLastValue(viewModel.isAuthenticated)
114             val modalities by collectLastValue(viewModel.modalities)
115             val message by collectLastValue(viewModel.message)
116             val size by collectLastValue(viewModel.size)
117             val legacyState by collectLastValue(viewModel.legacyState)
118 
119             assertThat(authenticating).isFalse()
120             assertThat(authenticated?.isNotAuthenticated).isTrue()
121             with(modalities ?: throw Exception("missing modalities")) {
122                 assertThat(hasFace).isEqualTo(testCase.face != null)
123                 assertThat(hasFingerprint).isEqualTo(testCase.fingerprint != null)
124             }
125             assertThat(message).isEqualTo(PromptMessage.Empty)
126             assertThat(size).isEqualTo(expectedSize)
127             assertThat(legacyState).isEqualTo(AuthBiometricView.STATE_IDLE)
128 
129             val startMessage = "here we go"
130             viewModel.showAuthenticating(startMessage, isRetry = false)
131 
132             assertThat(message).isEqualTo(PromptMessage.Help(startMessage))
133             assertThat(authenticating).isTrue()
134             assertThat(authenticated?.isNotAuthenticated).isTrue()
135             assertThat(size).isEqualTo(expectedSize)
136             assertButtonsVisible(negative = expectedSize != PromptSize.SMALL)
137             assertThat(legacyState).isEqualTo(AuthBiometricView.STATE_AUTHENTICATING)
138         }
139 
140     @Test
141     fun shows_authenticated_with_no_errors() = runGenericTest {
142         // this case can't happen until fingerprint is started
143         // trigger it now since no error has occurred in this test
144         val forceError = testCase.isCoex && testCase.authenticatedByFingerprint
145 
146         if (forceError) {
147             assertThat(viewModel.fingerprintStartMode.first())
148                 .isEqualTo(FingerprintStartMode.Pending)
149             viewModel.ensureFingerprintHasStarted(isDelayed = true)
150         }
151 
152         showAuthenticated(
153             testCase.authenticatedModality,
154             testCase.expectConfirmation(atLeastOneFailure = forceError),
155         )
156     }
157 
158     @Test
159     fun play_haptic_on_confirm_when_confirmation_required_otherwise_on_authenticated() =
160         runGenericTest {
161             val expectConfirmation = testCase.expectConfirmation(atLeastOneFailure = false)
162 
163             viewModel.showAuthenticated(testCase.authenticatedModality, 1_000L)
164 
165             verify(vibrator, if (expectConfirmation) never() else times(1))
166                 .vibrateAuthSuccess(any())
167 
168             if (expectConfirmation) {
169                 viewModel.confirmAuthenticated()
170             }
171 
172             verify(vibrator).vibrateAuthSuccess(any())
173             verify(vibrator, never()).vibrateAuthError(any())
174         }
175 
176     @Test
177     fun playSuccessHaptic_onwayHapticsEnabled_SetsConfirmConstant() = runGenericTest {
178         featureFlags.set(ONE_WAY_HAPTICS_API_MIGRATION, true)
179         val expectConfirmation = testCase.expectConfirmation(atLeastOneFailure = false)
180         viewModel.showAuthenticated(testCase.authenticatedModality, 1_000L)
181 
182         if (expectConfirmation) {
183             viewModel.confirmAuthenticated()
184         }
185 
186         val currentConstant by collectLastValue(viewModel.hapticsToPlay)
187         assertThat(currentConstant).isEqualTo(HapticFeedbackConstants.CONFIRM)
188     }
189 
190     @Test
191     fun playErrorHaptic_onwayHapticsEnabled_SetsRejectConstant() = runGenericTest {
192         featureFlags.set(ONE_WAY_HAPTICS_API_MIGRATION, true)
193         viewModel.showTemporaryError("test", "messageAfterError", false)
194 
195         val currentConstant by collectLastValue(viewModel.hapticsToPlay)
196         assertThat(currentConstant).isEqualTo(HapticFeedbackConstants.REJECT)
197     }
198 
199     private suspend fun TestScope.showAuthenticated(
200         authenticatedModality: BiometricModality,
201         expectConfirmation: Boolean,
202     ) {
203         val authenticating by collectLastValue(viewModel.isAuthenticating)
204         val authenticated by collectLastValue(viewModel.isAuthenticated)
205         val fpStartMode by collectLastValue(viewModel.fingerprintStartMode)
206         val size by collectLastValue(viewModel.size)
207         val legacyState by collectLastValue(viewModel.legacyState)
208 
209         val authWithSmallPrompt =
210             testCase.shouldStartAsImplicitFlow &&
211                 (fpStartMode == FingerprintStartMode.Pending || testCase.isFaceOnly)
212         assertThat(authenticating).isTrue()
213         assertThat(authenticated?.isNotAuthenticated).isTrue()
214         assertThat(size).isEqualTo(if (authWithSmallPrompt) PromptSize.SMALL else PromptSize.MEDIUM)
215         assertThat(legacyState).isEqualTo(AuthBiometricView.STATE_AUTHENTICATING)
216         assertButtonsVisible(negative = !authWithSmallPrompt)
217 
218         val delay = 1000L
219         viewModel.showAuthenticated(authenticatedModality, delay)
220 
221         assertThat(authenticated?.isAuthenticated).isTrue()
222         assertThat(authenticated?.delay).isEqualTo(delay)
223         assertThat(authenticated?.needsUserConfirmation).isEqualTo(expectConfirmation)
224         assertThat(size)
225             .isEqualTo(
226                 if (authenticatedModality == BiometricModality.Fingerprint || expectConfirmation) {
227                     PromptSize.MEDIUM
228                 } else {
229                     PromptSize.SMALL
230                 }
231             )
232         assertThat(legacyState)
233             .isEqualTo(
234                 if (expectConfirmation) {
235                     AuthBiometricView.STATE_PENDING_CONFIRMATION
236                 } else {
237                     AuthBiometricView.STATE_AUTHENTICATED
238                 }
239             )
240         assertButtonsVisible(
241             cancel = expectConfirmation,
242             confirm = expectConfirmation,
243         )
244     }
245 
246     @Test
247     fun shows_temporary_errors() = runGenericTest {
248         val checkAtEnd = suspend { assertButtonsVisible(negative = true) }
249 
250         showTemporaryErrors(restart = false) { checkAtEnd() }
251         showTemporaryErrors(restart = false, helpAfterError = "foo") { checkAtEnd() }
252         showTemporaryErrors(restart = true) { checkAtEnd() }
253     }
254 
255     @Test
256     fun plays_haptic_on_errors() = runGenericTest {
257         viewModel.showTemporaryError(
258             "so sad",
259             messageAfterError = "",
260             authenticateAfterError = false,
261             hapticFeedback = true,
262         )
263 
264         verify(vibrator).vibrateAuthError(any())
265         verify(vibrator, never()).vibrateAuthSuccess(any())
266     }
267 
268     @Test
269     fun plays_haptic_on_errors_unless_skipped() = runGenericTest {
270         viewModel.showTemporaryError(
271             "still sad",
272             messageAfterError = "",
273             authenticateAfterError = false,
274             hapticFeedback = false,
275         )
276 
277         verify(vibrator, never()).vibrateAuthError(any())
278         verify(vibrator, never()).vibrateAuthSuccess(any())
279     }
280 
281     private suspend fun TestScope.showTemporaryErrors(
282         restart: Boolean,
283         helpAfterError: String = "",
284         block: suspend TestScope.() -> Unit = {},
285     ) {
286         val errorMessage = "oh no!"
287         val authenticating by collectLastValue(viewModel.isAuthenticating)
288         val authenticated by collectLastValue(viewModel.isAuthenticated)
289         val message by collectLastValue(viewModel.message)
290         val messageVisible by collectLastValue(viewModel.isIndicatorMessageVisible)
291         val size by collectLastValue(viewModel.size)
292         val legacyState by collectLastValue(viewModel.legacyState)
293         val canTryAgainNow by collectLastValue(viewModel.canTryAgainNow)
294 
295         val errorJob = launch {
296             viewModel.showTemporaryError(
297                 errorMessage,
298                 authenticateAfterError = restart,
299                 messageAfterError = helpAfterError,
300             )
301         }
302 
303         assertThat(size).isEqualTo(PromptSize.MEDIUM)
304         assertThat(message).isEqualTo(PromptMessage.Error(errorMessage))
305         assertThat(messageVisible).isTrue()
306         assertThat(legacyState).isEqualTo(AuthBiometricView.STATE_ERROR)
307 
308         // temporary error should disappear after a delay
309         errorJob.join()
310         if (helpAfterError.isNotBlank()) {
311             assertThat(message).isEqualTo(PromptMessage.Help(helpAfterError))
312             assertThat(messageVisible).isTrue()
313         } else {
314             assertThat(message).isEqualTo(PromptMessage.Empty)
315             assertThat(messageVisible).isFalse()
316         }
317         val clearIconError = !restart
318         assertThat(legacyState)
319             .isEqualTo(
320                 if (restart) {
321                     AuthBiometricView.STATE_AUTHENTICATING
322                 } else if (clearIconError) {
323                     AuthBiometricView.STATE_IDLE
324                 } else {
325                     AuthBiometricView.STATE_HELP
326                 }
327             )
328 
329         assertThat(authenticating).isEqualTo(restart)
330         assertThat(authenticated?.isNotAuthenticated).isTrue()
331         assertThat(canTryAgainNow).isFalse()
332 
333         block()
334     }
335 
336     @Test
337     fun no_errors_or_temporary_help_after_authenticated() = runGenericTest {
338         val authenticating by collectLastValue(viewModel.isAuthenticating)
339         val authenticated by collectLastValue(viewModel.isAuthenticated)
340         val message by collectLastValue(viewModel.message)
341         val messageIsShowing by collectLastValue(viewModel.isIndicatorMessageVisible)
342         val canTryAgain by collectLastValue(viewModel.canTryAgainNow)
343 
344         viewModel.showAuthenticated(testCase.authenticatedModality, 0)
345 
346         val verifyNoError = {
347             assertThat(authenticating).isFalse()
348             assertThat(authenticated?.isAuthenticated).isTrue()
349             assertThat(message).isEqualTo(PromptMessage.Empty)
350             assertThat(canTryAgain).isFalse()
351         }
352 
353         val errorJob = launch {
354             viewModel.showTemporaryError(
355                 "error",
356                 messageAfterError = "",
357                 authenticateAfterError = false,
358             )
359         }
360         verifyNoError()
361         errorJob.join()
362         verifyNoError()
363 
364         val helpJob = launch { viewModel.showTemporaryHelp("hi") }
365         verifyNoError()
366         helpJob.join()
367         verifyNoError()
368 
369         // persistent help is allowed
370         val stickyHelpMessage = "blah"
371         viewModel.showHelp(stickyHelpMessage)
372         assertThat(authenticating).isFalse()
373         assertThat(authenticated?.isAuthenticated).isTrue()
374         assertThat(message).isEqualTo(PromptMessage.Help(stickyHelpMessage))
375         assertThat(messageIsShowing).isTrue()
376     }
377 
378     @Test
379     fun suppress_temporary_error() = runGenericTest {
380         val messages by collectValues(viewModel.message)
381 
382         for (error in listOf("never", "see", "me")) {
383             launch {
384                 viewModel.showTemporaryError(
385                     error,
386                     messageAfterError = "or me",
387                     authenticateAfterError = false,
388                     suppressIf = { _, _ -> true },
389                 )
390             }
391         }
392 
393         testScheduler.advanceUntilIdle()
394         assertThat(messages).containsExactly(PromptMessage.Empty)
395     }
396 
397     @Test
398     fun suppress_temporary_error_when_already_showing_when_requested() =
399         suppress_temporary_error_when_already_showing(suppress = true)
400 
401     @Test
402     fun do_not_suppress_temporary_error_when_already_showing_when_not_requested() =
403         suppress_temporary_error_when_already_showing(suppress = false)
404 
405     private fun suppress_temporary_error_when_already_showing(suppress: Boolean) = runGenericTest {
406         val errors = listOf("woot", "oh yeah", "nope")
407         val afterSuffix = "(after)"
408         val expectedErrorMessage = if (suppress) errors.first() else errors.last()
409         val messages by collectValues(viewModel.message)
410 
411         for (error in errors) {
412             launch {
413                 viewModel.showTemporaryError(
414                     error,
415                     messageAfterError = "$error $afterSuffix",
416                     authenticateAfterError = false,
417                     suppressIf = { currentMessage, _ -> suppress && currentMessage.isError },
418                 )
419             }
420         }
421 
422         testScheduler.runCurrent()
423         assertThat(messages)
424             .containsExactly(
425                 PromptMessage.Empty,
426                 PromptMessage.Error(expectedErrorMessage),
427             )
428             .inOrder()
429 
430         testScheduler.advanceUntilIdle()
431         assertThat(messages)
432             .containsExactly(
433                 PromptMessage.Empty,
434                 PromptMessage.Error(expectedErrorMessage),
435                 PromptMessage.Help("$expectedErrorMessage $afterSuffix"),
436             )
437             .inOrder()
438     }
439 
440     @Test
441     fun authenticated_at_most_once() = runGenericTest {
442         val authenticating by collectLastValue(viewModel.isAuthenticating)
443         val authenticated by collectLastValue(viewModel.isAuthenticated)
444 
445         viewModel.showAuthenticated(testCase.authenticatedModality, 0)
446 
447         assertThat(authenticating).isFalse()
448         assertThat(authenticated?.isAuthenticated).isTrue()
449 
450         viewModel.showAuthenticated(testCase.authenticatedModality, 0)
451 
452         assertThat(authenticating).isFalse()
453         assertThat(authenticated?.isAuthenticated).isTrue()
454     }
455 
456     @Test
457     fun authenticating_cannot_restart_after_authenticated() = runGenericTest {
458         val authenticating by collectLastValue(viewModel.isAuthenticating)
459         val authenticated by collectLastValue(viewModel.isAuthenticated)
460 
461         viewModel.showAuthenticated(testCase.authenticatedModality, 0)
462 
463         assertThat(authenticating).isFalse()
464         assertThat(authenticated?.isAuthenticated).isTrue()
465 
466         viewModel.showAuthenticating("again!")
467 
468         assertThat(authenticating).isFalse()
469         assertThat(authenticated?.isAuthenticated).isTrue()
470     }
471 
472     @Test
473     fun confirm_authentication() = runGenericTest {
474         val expectConfirmation = testCase.expectConfirmation(atLeastOneFailure = false)
475 
476         viewModel.showAuthenticated(testCase.authenticatedModality, 0)
477 
478         val authenticating by collectLastValue(viewModel.isAuthenticating)
479         val authenticated by collectLastValue(viewModel.isAuthenticated)
480         val message by collectLastValue(viewModel.message)
481         val size by collectLastValue(viewModel.size)
482         val legacyState by collectLastValue(viewModel.legacyState)
483         val canTryAgain by collectLastValue(viewModel.canTryAgainNow)
484 
485         assertThat(authenticated?.needsUserConfirmation).isEqualTo(expectConfirmation)
486         if (expectConfirmation) {
487             assertThat(size).isEqualTo(PromptSize.MEDIUM)
488             assertButtonsVisible(
489                 cancel = true,
490                 confirm = true,
491             )
492 
493             viewModel.confirmAuthenticated()
494             assertThat(message).isEqualTo(PromptMessage.Empty)
495             assertButtonsVisible()
496         }
497 
498         assertThat(authenticating).isFalse()
499         assertThat(authenticated?.isAuthenticated).isTrue()
500         assertThat(legacyState).isEqualTo(AuthBiometricView.STATE_AUTHENTICATED)
501         assertThat(canTryAgain).isFalse()
502     }
503 
504     @Test
505     fun auto_confirm_authentication_when_finger_down() = runGenericTest {
506         val expectConfirmation = testCase.expectConfirmation(atLeastOneFailure = false)
507 
508         // No icon button when face only, can't confirm before auth
509         if (!testCase.isFaceOnly) {
510             viewModel.onOverlayTouch(obtainMotionEvent(MotionEvent.ACTION_DOWN))
511         }
512         viewModel.showAuthenticated(testCase.authenticatedModality, 0)
513 
514         val authenticating by collectLastValue(viewModel.isAuthenticating)
515         val authenticated by collectLastValue(viewModel.isAuthenticated)
516         val message by collectLastValue(viewModel.message)
517         val size by collectLastValue(viewModel.size)
518         val legacyState by collectLastValue(viewModel.legacyState)
519         val canTryAgain by collectLastValue(viewModel.canTryAgainNow)
520 
521         assertThat(authenticating).isFalse()
522         assertThat(canTryAgain).isFalse()
523         assertThat(authenticated?.isAuthenticated).isTrue()
524 
525         if (testCase.isFaceOnly && expectConfirmation) {
526             assertThat(legacyState).isEqualTo(AuthBiometricView.STATE_PENDING_CONFIRMATION)
527 
528             assertThat(size).isEqualTo(PromptSize.MEDIUM)
529             assertButtonsVisible(
530                 cancel = true,
531                 confirm = true,
532             )
533 
534             viewModel.confirmAuthenticated()
535             assertThat(message).isEqualTo(PromptMessage.Empty)
536             assertButtonsVisible()
537         } else {
538             assertThat(legacyState).isEqualTo(AuthBiometricView.STATE_AUTHENTICATED)
539         }
540     }
541 
542     @Test
543     fun cannot_auto_confirm_authentication_when_finger_up() = runGenericTest {
544         val expectConfirmation = testCase.expectConfirmation(atLeastOneFailure = false)
545 
546         // No icon button when face only, can't confirm before auth
547         if (!testCase.isFaceOnly) {
548             viewModel.onOverlayTouch(obtainMotionEvent(MotionEvent.ACTION_DOWN))
549             viewModel.onOverlayTouch(obtainMotionEvent(MotionEvent.ACTION_UP))
550         }
551         viewModel.showAuthenticated(testCase.authenticatedModality, 0)
552 
553         val authenticating by collectLastValue(viewModel.isAuthenticating)
554         val authenticated by collectLastValue(viewModel.isAuthenticated)
555         val message by collectLastValue(viewModel.message)
556         val size by collectLastValue(viewModel.size)
557         val legacyState by collectLastValue(viewModel.legacyState)
558         val canTryAgain by collectLastValue(viewModel.canTryAgainNow)
559 
560         assertThat(authenticated?.needsUserConfirmation).isEqualTo(expectConfirmation)
561         if (expectConfirmation) {
562             assertThat(size).isEqualTo(PromptSize.MEDIUM)
563             assertButtonsVisible(
564                 cancel = true,
565                 confirm = true,
566             )
567 
568             viewModel.confirmAuthenticated()
569             assertThat(message).isEqualTo(PromptMessage.Empty)
570             assertButtonsVisible()
571         }
572 
573         assertThat(authenticating).isFalse()
574         assertThat(authenticated?.isAuthenticated).isTrue()
575         assertThat(legacyState).isEqualTo(AuthBiometricView.STATE_AUTHENTICATED)
576         assertThat(canTryAgain).isFalse()
577     }
578 
579     @Test
580     fun cannot_confirm_unless_authenticated() = runGenericTest {
581         val authenticating by collectLastValue(viewModel.isAuthenticating)
582         val authenticated by collectLastValue(viewModel.isAuthenticated)
583 
584         viewModel.confirmAuthenticated()
585         assertThat(authenticating).isTrue()
586         assertThat(authenticated?.isNotAuthenticated).isTrue()
587 
588         viewModel.showAuthenticated(testCase.authenticatedModality, 0)
589 
590         // reconfirm should be a no-op
591         viewModel.confirmAuthenticated()
592         viewModel.confirmAuthenticated()
593 
594         assertThat(authenticating).isFalse()
595         assertThat(authenticated?.isNotAuthenticated).isFalse()
596     }
597 
598     @Test
599     fun shows_help_before_authenticated() = runGenericTest {
600         val helpMessage = "please help yourself to some cookies"
601         val message by collectLastValue(viewModel.message)
602         val messageVisible by collectLastValue(viewModel.isIndicatorMessageVisible)
603         val size by collectLastValue(viewModel.size)
604         val legacyState by collectLastValue(viewModel.legacyState)
605 
606         viewModel.showHelp(helpMessage)
607 
608         assertThat(size).isEqualTo(PromptSize.MEDIUM)
609         assertThat(legacyState).isEqualTo(AuthBiometricView.STATE_HELP)
610         assertThat(message).isEqualTo(PromptMessage.Help(helpMessage))
611         assertThat(messageVisible).isTrue()
612 
613         assertThat(viewModel.isAuthenticating.first()).isFalse()
614         assertThat(viewModel.isAuthenticated.first().isNotAuthenticated).isTrue()
615     }
616 
617     @Test
618     fun shows_help_after_authenticated() = runGenericTest {
619         val expectConfirmation = testCase.expectConfirmation(atLeastOneFailure = false)
620         val helpMessage = "more cookies please"
621         val authenticating by collectLastValue(viewModel.isAuthenticating)
622         val authenticated by collectLastValue(viewModel.isAuthenticated)
623         val message by collectLastValue(viewModel.message)
624         val messageVisible by collectLastValue(viewModel.isIndicatorMessageVisible)
625         val size by collectLastValue(viewModel.size)
626         val legacyState by collectLastValue(viewModel.legacyState)
627         val confirmationRequired by collectLastValue(viewModel.isConfirmationRequired)
628 
629         if (testCase.isCoex && testCase.authenticatedByFingerprint) {
630             viewModel.ensureFingerprintHasStarted(isDelayed = true)
631         }
632         viewModel.showAuthenticated(testCase.authenticatedModality, 0)
633         viewModel.showHelp(helpMessage)
634 
635         assertThat(size).isEqualTo(PromptSize.MEDIUM)
636         if (confirmationRequired == true) {
637             assertThat(legacyState).isEqualTo(AuthBiometricView.STATE_PENDING_CONFIRMATION)
638         } else {
639             assertThat(legacyState).isEqualTo(AuthBiometricView.STATE_AUTHENTICATED)
640         }
641         assertThat(message).isEqualTo(PromptMessage.Help(helpMessage))
642         assertThat(messageVisible).isTrue()
643         assertThat(authenticating).isFalse()
644         assertThat(authenticated?.isAuthenticated).isTrue()
645         assertThat(authenticated?.needsUserConfirmation).isEqualTo(expectConfirmation)
646         assertButtonsVisible(
647             cancel = expectConfirmation,
648             confirm = expectConfirmation,
649         )
650     }
651 
652     @Test
653     fun retries_after_failure() = runGenericTest {
654         val errorMessage = "bad"
655         val helpMessage = "again?"
656         val expectTryAgainButton = testCase.isFaceOnly
657         val authenticating by collectLastValue(viewModel.isAuthenticating)
658         val authenticated by collectLastValue(viewModel.isAuthenticated)
659         val message by collectLastValue(viewModel.message)
660         val messageVisible by collectLastValue(viewModel.isIndicatorMessageVisible)
661         val canTryAgain by collectLastValue(viewModel.canTryAgainNow)
662 
663         viewModel.showAuthenticating("go")
664         val errorJob = launch {
665             viewModel.showTemporaryError(
666                 errorMessage,
667                 messageAfterError = helpMessage,
668                 authenticateAfterError = false,
669                 failedModality = testCase.authenticatedModality
670             )
671         }
672 
673         assertThat(authenticating).isFalse()
674         assertThat(authenticated?.isAuthenticated).isFalse()
675         assertThat(message).isEqualTo(PromptMessage.Error(errorMessage))
676         assertThat(messageVisible).isTrue()
677         assertThat(canTryAgain).isEqualTo(testCase.authenticatedByFace)
678         assertButtonsVisible(negative = true, tryAgain = expectTryAgainButton)
679 
680         errorJob.join()
681 
682         assertThat(authenticating).isFalse()
683         assertThat(authenticated?.isAuthenticated).isFalse()
684         assertThat(message).isEqualTo(PromptMessage.Help(helpMessage))
685         assertThat(messageVisible).isTrue()
686         assertThat(canTryAgain).isEqualTo(testCase.authenticatedByFace)
687         assertButtonsVisible(negative = true, tryAgain = expectTryAgainButton)
688 
689         val helpMessage2 = "foo"
690         viewModel.showAuthenticating(helpMessage2, isRetry = true)
691         assertThat(authenticating).isTrue()
692         assertThat(authenticated?.isAuthenticated).isFalse()
693         assertThat(message).isEqualTo(PromptMessage.Help(helpMessage2))
694         assertThat(messageVisible).isTrue()
695         assertButtonsVisible(negative = true)
696     }
697 
698     @Test
699     fun switch_to_credential_fallback() = runGenericTest {
700         val size by collectLastValue(viewModel.size)
701 
702         // TODO(b/251476085): remove Spaghetti, migrate logic, and update this test
703         viewModel.onSwitchToCredential()
704 
705         assertThat(size).isEqualTo(PromptSize.LARGE)
706     }
707 
708     /** Asserts that the selected buttons are visible now. */
709     private suspend fun TestScope.assertButtonsVisible(
710         tryAgain: Boolean = false,
711         confirm: Boolean = false,
712         cancel: Boolean = false,
713         negative: Boolean = false,
714         credential: Boolean = false,
715     ) {
716         runCurrent()
717         assertThat(viewModel.isTryAgainButtonVisible.first()).isEqualTo(tryAgain)
718         assertThat(viewModel.isConfirmButtonVisible.first()).isEqualTo(confirm)
719         assertThat(viewModel.isCancelButtonVisible.first()).isEqualTo(cancel)
720         assertThat(viewModel.isNegativeButtonVisible.first()).isEqualTo(negative)
721         assertThat(viewModel.isCredentialButtonVisible.first()).isEqualTo(credential)
722     }
723 
724     private fun runGenericTest(
725         doNotStart: Boolean = false,
726         allowCredentialFallback: Boolean = false,
727         block: suspend TestScope.() -> Unit
728     ) {
729         selector.initializePrompt(
730             requireConfirmation = testCase.confirmationRequested,
731             allowCredentialFallback = allowCredentialFallback,
732             fingerprint = testCase.fingerprint,
733             face = testCase.face,
734         )
735 
736         // put the view model in the initial authenticating state, unless explicitly skipped
737         val startMode =
738             when {
739                 doNotStart -> null
740                 testCase.isCoex -> FingerprintStartMode.Delayed
741                 else -> FingerprintStartMode.Normal
742             }
743         when (startMode) {
744             FingerprintStartMode.Normal -> {
745                 viewModel.ensureFingerprintHasStarted(isDelayed = false)
746                 viewModel.showAuthenticating()
747             }
748             FingerprintStartMode.Delayed -> {
749                 viewModel.showAuthenticating()
750             }
751             else -> {
752                 /* skip */
753             }
754         }
755 
756         testScope.runTest { block() }
757     }
758 
759     /** Obtain a MotionEvent with the specified MotionEvent action constant */
760     private fun obtainMotionEvent(action: Int): MotionEvent =
761         MotionEvent.obtain(0, 0, action, 0f, 0f, 0)
762 
763     companion object {
764         @JvmStatic
765         @Parameterized.Parameters(name = "{0}")
766         fun data(): Collection<TestCase> = singleModalityTestCases + coexTestCases
767 
768         private val singleModalityTestCases =
769             listOf(
770                 TestCase(
771                     face = faceSensorPropertiesInternal(strong = true).first(),
772                     authenticatedModality = BiometricModality.Face,
773                 ),
774                 TestCase(
775                     fingerprint = fingerprintSensorPropertiesInternal(strong = true).first(),
776                     authenticatedModality = BiometricModality.Fingerprint,
777                 ),
778                 TestCase(
779                     face = faceSensorPropertiesInternal(strong = true).first(),
780                     authenticatedModality = BiometricModality.Face,
781                     confirmationRequested = true,
782                 ),
783                 TestCase(
784                     fingerprint = fingerprintSensorPropertiesInternal(strong = true).first(),
785                     authenticatedModality = BiometricModality.Fingerprint,
786                     confirmationRequested = true,
787                 ),
788             )
789 
790         private val coexTestCases =
791             listOf(
792                 TestCase(
793                     face = faceSensorPropertiesInternal(strong = true).first(),
794                     fingerprint = fingerprintSensorPropertiesInternal(strong = true).first(),
795                     authenticatedModality = BiometricModality.Face,
796                 ),
797                 TestCase(
798                     face = faceSensorPropertiesInternal(strong = true).first(),
799                     fingerprint = fingerprintSensorPropertiesInternal(strong = true).first(),
800                     authenticatedModality = BiometricModality.Fingerprint,
801                 ),
802                 TestCase(
803                     face = faceSensorPropertiesInternal(strong = true).first(),
804                     fingerprint = fingerprintSensorPropertiesInternal(strong = true).first(),
805                     authenticatedModality = BiometricModality.Face,
806                     confirmationRequested = true,
807                 ),
808                 TestCase(
809                     face = faceSensorPropertiesInternal(strong = true).first(),
810                     fingerprint = fingerprintSensorPropertiesInternal(strong = true).first(),
811                     authenticatedModality = BiometricModality.Fingerprint,
812                     confirmationRequested = true,
813                 ),
814             )
815     }
816 }
817 
818 internal data class TestCase(
819     val fingerprint: FingerprintSensorPropertiesInternal? = null,
820     val face: FaceSensorPropertiesInternal? = null,
821     val authenticatedModality: BiometricModality,
822     val confirmationRequested: Boolean = false,
823 ) {
824     override fun toString(): String {
825         val modality =
826             when {
827                 fingerprint != null && face != null -> "coex"
828                 fingerprint != null -> "fingerprint only"
829                 face != null -> "face only"
830                 else -> "?"
831             }
832         return "[$modality, by: $authenticatedModality, confirm: $confirmationRequested]"
833     }
834 
835     fun expectConfirmation(atLeastOneFailure: Boolean): Boolean =
836         when {
837             isCoex && authenticatedModality == BiometricModality.Face ->
838                 atLeastOneFailure || confirmationRequested
839             isFaceOnly -> confirmationRequested
840             else -> false
841         }
842 
843     val authenticatedByFingerprint: Boolean
844         get() = authenticatedModality == BiometricModality.Fingerprint
845 
846     val authenticatedByFace: Boolean
847         get() = authenticatedModality == BiometricModality.Face
848 
849     val isFaceOnly: Boolean
850         get() = face != null && fingerprint == null
851 
852     val isFingerprintOnly: Boolean
853         get() = face == null && fingerprint != null
854 
855     val isCoex: Boolean
856         get() = face != null && fingerprint != null
857 
858     val shouldStartAsImplicitFlow: Boolean
859         get() = (isFaceOnly || isCoex) && !confirmationRequested
860 }
861 
862 /** Initialize the test by selecting the give [fingerprint] or [face] configuration(s). */
863 private fun PromptSelectorInteractor.initializePrompt(
864     fingerprint: FingerprintSensorPropertiesInternal? = null,
865     face: FaceSensorPropertiesInternal? = null,
866     requireConfirmation: Boolean = false,
867     allowCredentialFallback: Boolean = false,
868 ) {
869     val info =
870         PromptInfo().apply {
871             title = "t"
872             subtitle = "s"
873             authenticators = listOf(face, fingerprint).extractAuthenticatorTypes()
874             isDeviceCredentialAllowed = allowCredentialFallback
875             isConfirmationRequested = requireConfirmation
876         }
877     useBiometricsForAuthentication(
878         info,
879         USER_ID,
880         CHALLENGE,
881         BiometricModalities(fingerprintProperties = fingerprint, faceProperties = face),
882     )
883 }
884