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