1 /*
<lambda>null2 * 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 ) {
toStringnull824 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
expectConfirmationnull835 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). */
initializePromptnull863 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