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.app.ActivityManager.RunningTaskInfo
20 import android.content.ComponentName
21 import android.content.applicationContext
22 import android.content.packageManager
23 import android.content.pm.ActivityInfo
24 import android.content.pm.ApplicationInfo
25 import android.content.pm.PackageManager.NameNotFoundException
26 import android.graphics.Bitmap
27 import android.graphics.Point
28 import android.graphics.Rect
29 import android.graphics.drawable.BitmapDrawable
30 import android.hardware.biometrics.BiometricFingerprintConstants
31 import android.hardware.biometrics.BiometricPrompt
32 import android.hardware.biometrics.PromptContentItemBulletedText
33 import android.hardware.biometrics.PromptContentView
34 import android.hardware.biometrics.PromptContentViewWithMoreOptionsButton
35 import android.hardware.biometrics.PromptInfo
36 import android.hardware.biometrics.PromptVerticalListContentView
37 import android.hardware.face.FaceSensorPropertiesInternal
38 import android.hardware.fingerprint.FingerprintSensorProperties
39 import android.hardware.fingerprint.FingerprintSensorPropertiesInternal
40 import android.os.UserHandle
41 import android.platform.test.annotations.DisableFlags
42 import android.platform.test.annotations.EnableFlags
43 import android.view.HapticFeedbackConstants
44 import android.view.MotionEvent
45 import android.view.Surface
46 import android.view.accessibility.accessibilityManager
47 import androidx.test.filters.SmallTest
48 import com.android.app.activityTaskManager
49 import com.android.keyguard.AuthInteractionProperties
50 import com.android.systemui.Flags
51 import com.android.systemui.SysuiTestCase
52 import com.android.systemui.biometrics.AuthController
53 import com.android.systemui.biometrics.Utils.toBitmap
54 import com.android.systemui.biometrics.authController
55 import com.android.systemui.biometrics.data.repository.biometricStatusRepository
56 import com.android.systemui.biometrics.data.repository.fakeFingerprintPropertyRepository
57 import com.android.systemui.biometrics.domain.interactor.PromptSelectorInteractor
58 import com.android.systemui.biometrics.domain.interactor.promptSelectorInteractor
59 import com.android.systemui.biometrics.domain.interactor.udfpsOverlayInteractor
60 import com.android.systemui.biometrics.extractAuthenticatorTypes
61 import com.android.systemui.biometrics.faceSensorPropertiesInternal
62 import com.android.systemui.biometrics.fingerprintSensorPropertiesInternal
63 import com.android.systemui.biometrics.shared.model.AuthenticationReason
64 import com.android.systemui.biometrics.shared.model.BiometricModalities
65 import com.android.systemui.biometrics.shared.model.BiometricModality
66 import com.android.systemui.biometrics.shared.model.DisplayRotation
67 import com.android.systemui.biometrics.shared.model.UdfpsOverlayParams
68 import com.android.systemui.biometrics.shared.model.toSensorStrength
69 import com.android.systemui.biometrics.shared.model.toSensorType
70 import com.android.systemui.biometrics.udfpsUtils
71 import com.android.systemui.concurrency.fakeExecutor
72 import com.android.systemui.coroutines.collectLastValue
73 import com.android.systemui.coroutines.collectValues
74 import com.android.systemui.display.data.repository.displayStateRepository
75 import com.android.systemui.keyguard.shared.model.AcquiredFingerprintAuthenticationStatus
76 import com.android.systemui.kosmos.testScope
77 import com.android.systemui.res.R
78 import com.android.systemui.testKosmos
79 import com.android.systemui.util.mockito.withArgCaptor
80 import com.google.android.msdl.data.model.MSDLToken
81 import com.google.common.truth.Truth.assertThat
82 import kotlinx.coroutines.flow.first
83 import kotlinx.coroutines.launch
84 import kotlinx.coroutines.test.TestScope
85 import kotlinx.coroutines.test.runCurrent
86 import kotlinx.coroutines.test.runTest
87 import org.junit.Before
88 import org.junit.Rule
89 import org.junit.Test
90 import org.junit.runner.RunWith
91 import org.mockito.ArgumentMatchers.anyInt
92 import org.mockito.ArgumentMatchers.eq
93 import org.mockito.Mock
94 import org.mockito.Mockito
95 import org.mockito.junit.MockitoJUnit
96 import org.mockito.kotlin.any
97 import org.mockito.kotlin.whenever
98 import platform.test.runner.parameterized.ParameterizedAndroidJunit4
99 import platform.test.runner.parameterized.Parameters
100
101 private const val USER_ID = 4
102 private const val WORK_USER_ID = 100
103 private const val REQUEST_ID = 4L
104 private const val CHALLENGE = 2L
105 private const val DELAY = 1000L
106 private const val OP_PACKAGE_NAME_WITH_ACTIVITY_LOGO = "should.use.activiy.logo"
107 private const val OP_PACKAGE_NAME_WITH_APP_LOGO = "biometric.testapp"
108 private const val OP_PACKAGE_NAME_NO_LOGO_INFO = "biometric.testapp.nologoinfo"
109 private const val OP_PACKAGE_NAME_CAN_NOT_BE_FOUND = "can.not.be.found"
110
111 @SmallTest
112 @RunWith(ParameterizedAndroidJunit4::class)
113 internal class PromptViewModelTest(private val testCase: TestCase) : SysuiTestCase() {
114
115 @JvmField @Rule var mockitoRule = MockitoJUnit.rule()
116
117 @Mock private lateinit var authController: AuthController
118 @Mock private lateinit var applicationInfoWithIconAndDescription: ApplicationInfo
119 @Mock private lateinit var applicationInfoNoIconOrDescription: ApplicationInfo
120 @Mock private lateinit var activityInfo: ActivityInfo
121 @Mock private lateinit var runningTaskInfo: RunningTaskInfo
122
123 private val defaultLogoIconFromAppInfo = context.getDrawable(R.drawable.ic_android)
124 private val defaultLogoIconFromActivityInfo = context.getDrawable(R.drawable.ic_add)
125 private val defaultLogoIconWithBadge = context.getDrawable(R.drawable.ic_alarm)
126 private val logoResFromApp = R.drawable.ic_cake
127 private val logoDrawableFromAppRes = context.getDrawable(logoResFromApp)
128 private val logoBitmapFromApp = Bitmap.createBitmap(400, 400, Bitmap.Config.RGB_565)
129 private val defaultLogoDescriptionFromAppInfo = "Test Android App"
130 private val defaultLogoDescriptionFromActivityInfo = "Test Coke App"
131 private val defaultLogoDescriptionWithBadge = "Work app"
132 private val logoDescriptionFromApp = "Test Cake App"
133
134 private val authInteractionProperties = AuthInteractionProperties()
135
136 /** Prompt panel size padding */
137 private val smallHorizontalGuidelinePadding =
138 context.resources.getDimensionPixelSize(
139 R.dimen.biometric_prompt_land_small_horizontal_guideline_padding
140 )
141 private val udfpsHorizontalGuidelinePadding =
142 context.resources.getDimensionPixelSize(
143 R.dimen.biometric_prompt_two_pane_udfps_horizontal_guideline_padding
144 )
145 private val udfpsHorizontalShorterGuidelinePadding =
146 context.resources.getDimensionPixelSize(
147 R.dimen.biometric_prompt_two_pane_udfps_shorter_horizontal_guideline_padding
148 )
149 private val mediumTopGuidelinePadding =
150 context.resources.getDimensionPixelSize(
151 R.dimen.biometric_prompt_one_pane_medium_top_guideline_padding
152 )
153 private val mediumHorizontalGuidelinePadding =
154 context.resources.getDimensionPixelSize(
155 R.dimen.biometric_prompt_two_pane_medium_horizontal_guideline_padding
156 )
157 private val mockFaceIconSize = 200
158 private val mockFingerprintIconWidth = 300
159 private val mockFingerprintIconHeight = 300
160
161 private val faceIconAuthingDescription =
162 R.string.biometric_dialog_face_icon_description_authenticating
163 private val faceIconAuthedDescription =
164 R.string.biometric_dialog_face_icon_description_authenticated
165 private val faceIconConfirmedDescription =
166 R.string.biometric_dialog_face_icon_description_confirmed
167 private val faceIconIdleDescription = R.string.biometric_dialog_face_icon_description_idle
168 private val sfpsFindSensorDescription =
169 R.string.security_settings_sfps_enroll_find_sensor_message
170 private val udfpsIconDescription = R.string.accessibility_fingerprint_label
171 private val faceFailedDescription = R.string.keyguard_face_failed
172 private val bpTryAgainDescription = R.string.biometric_dialog_try_again
173 private val bpConfirmDescription = R.string.biometric_dialog_confirm
174 private val fingerprintIconAuthenticatedDescription =
175 R.string.fingerprint_dialog_authenticated_confirmation
176
177 /** Mock [UdfpsOverlayParams] for a test. */
178 private fun mockUdfpsOverlayParams(isLandscape: Boolean = false): UdfpsOverlayParams =
179 UdfpsOverlayParams(
180 sensorBounds = Rect(400, 1600, 600, 1800),
181 overlayBounds = Rect(0, 1200, 1000, 2400),
182 naturalDisplayWidth = 1000,
183 naturalDisplayHeight = 3000,
184 scaleFactor = 1f,
185 rotation = if (isLandscape) Surface.ROTATION_90 else Surface.ROTATION_0,
186 )
187
188 private lateinit var promptContentView: PromptContentView
189 private lateinit var promptContentViewWithMoreOptionsButton:
190 PromptContentViewWithMoreOptionsButton
191
192 private val kosmos = testKosmos()
193
194 @Before
195 fun setup() {
196 setupLogo()
197 overrideResource(R.dimen.biometric_dialog_fingerprint_icon_width, mockFingerprintIconWidth)
198 overrideResource(
199 R.dimen.biometric_dialog_fingerprint_icon_height,
200 mockFingerprintIconHeight,
201 )
202 overrideResource(R.dimen.biometric_dialog_face_icon_size, mockFaceIconSize)
203
204 kosmos.applicationContext = context
205 whenever(kosmos.accessibilityManager.getRecommendedTimeoutMillis(anyInt(), anyInt()))
206 .thenReturn(BiometricPrompt.HIDE_DIALOG_DELAY)
207
208 if (testCase.fingerprint?.isAnyUdfpsType == true) {
209 kosmos.authController = authController
210 }
211
212 testCase.fingerprint?.let {
213 kosmos.fakeFingerprintPropertyRepository.setProperties(
214 it.sensorId,
215 it.sensorStrength.toSensorStrength(),
216 it.sensorType.toSensorType(),
217 it.allLocations.associateBy { sensorLocationInternal ->
218 sensorLocationInternal.displayId
219 },
220 )
221 }
222
223 kosmos.displayStateRepository.setCurrentRotation(DisplayRotation.ROTATION_0)
224 testCase.fingerprint?.isAnySidefpsType.let {
225 kosmos.displayStateRepository.setIsInRearDisplayMode(testCase.isInRearDisplayMode)
226 }
227
228 promptContentView =
229 PromptVerticalListContentView.Builder()
230 .addListItem(PromptContentItemBulletedText("content item 1"))
231 .addListItem(PromptContentItemBulletedText("content item 2"), 1)
232 .build()
233
234 promptContentViewWithMoreOptionsButton =
235 PromptContentViewWithMoreOptionsButton.Builder()
236 .setDescription("test")
237 .setMoreOptionsButtonListener(kosmos.fakeExecutor) { _, _ -> }
238 .build()
239 }
240
241 private fun setupLogo() {
242 // Set up app customized logo
243 overrideResource(logoResFromApp, logoDrawableFromAppRes)
244
245 // Set up when activity info should be used
246 overrideResource(
247 R.array.config_useActivityLogoForBiometricPrompt,
248 arrayOf(OP_PACKAGE_NAME_WITH_ACTIVITY_LOGO),
249 )
250 whenever(kosmos.packageManager.getActivityInfo(any(), anyInt())).thenReturn(activityInfo)
251 whenever(kosmos.iconProvider.getIcon(activityInfo))
252 .thenReturn(defaultLogoIconFromActivityInfo)
253 whenever(activityInfo.loadLabel(kosmos.packageManager))
254 .thenReturn(defaultLogoDescriptionFromActivityInfo)
255
256 // Set up when application info should be used for default logo
257 whenever(
258 kosmos.packageManager.getApplicationInfo(
259 eq(OP_PACKAGE_NAME_WITH_APP_LOGO),
260 anyInt(),
261 )
262 )
263 .thenReturn(applicationInfoWithIconAndDescription)
264 whenever(
265 kosmos.packageManager.getApplicationInfo(
266 eq(OP_PACKAGE_NAME_WITH_ACTIVITY_LOGO),
267 anyInt(),
268 )
269 )
270 .thenReturn(applicationInfoWithIconAndDescription)
271 whenever(kosmos.packageManager.getApplicationIcon(applicationInfoWithIconAndDescription))
272 .thenReturn(defaultLogoIconFromAppInfo)
273 whenever(kosmos.packageManager.getApplicationLabel(applicationInfoWithIconAndDescription))
274 .thenReturn(defaultLogoDescriptionFromAppInfo)
275
276 // Set up when package name cannot but found
277 whenever(
278 kosmos.packageManager.getApplicationInfo(
279 eq(OP_PACKAGE_NAME_CAN_NOT_BE_FOUND),
280 anyInt(),
281 )
282 )
283 .thenThrow(NameNotFoundException())
284
285 // Set up when no default logo from application info
286 whenever(
287 kosmos.packageManager.getApplicationInfo(eq(OP_PACKAGE_NAME_NO_LOGO_INFO), anyInt())
288 )
289 .thenReturn(applicationInfoNoIconOrDescription)
290 whenever(kosmos.packageManager.getApplicationIcon(applicationInfoNoIconOrDescription))
291 .thenReturn(null)
292 whenever(kosmos.packageManager.getApplicationLabel(applicationInfoNoIconOrDescription))
293 .thenReturn("")
294
295 // Set up work badge
296 whenever(kosmos.packageManager.getUserBadgedIcon(any(), eq(UserHandle.of(USER_ID)))).then {
297 it.getArgument(0)
298 }
299 whenever(kosmos.packageManager.getUserBadgedLabel(any(), eq(UserHandle.of(USER_ID)))).then {
300 it.getArgument(0)
301 }
302 whenever(kosmos.packageManager.getUserBadgedIcon(any(), eq(UserHandle.of(WORK_USER_ID))))
303 .then { defaultLogoIconWithBadge }
304 whenever(kosmos.packageManager.getUserBadgedLabel(any(), eq(UserHandle.of(WORK_USER_ID))))
305 .then { defaultLogoDescriptionWithBadge }
306 context.setMockPackageManager(kosmos.packageManager)
307 }
308
309 @Test
310 fun start_idle_and_show_authenticating() =
311 runGenericTest(doNotStart = true) {
312 var expectedPromptSize =
313 if (testCase.shouldStartAsImplicitFlow) PromptSize.SMALL else PromptSize.MEDIUM
314 val authenticating by collectLastValue(kosmos.promptViewModel.isAuthenticating)
315 val authenticated by collectLastValue(kosmos.promptViewModel.isAuthenticated)
316 val modalities by collectLastValue(kosmos.promptViewModel.modalities)
317 val iconAsset by collectLastValue(kosmos.promptViewModel.iconViewModel.iconAsset)
318 val shouldAnimateIconView by
319 collectLastValue(kosmos.promptViewModel.iconViewModel.shouldAnimateIconView)
320 val iconContentDescriptionId by
321 collectLastValue(kosmos.promptViewModel.iconViewModel.contentDescriptionId)
322 val message by collectLastValue(kosmos.promptViewModel.message)
323 val size by collectLastValue(kosmos.promptViewModel.size)
324
325 assertThat(authenticating).isFalse()
326 assertThat(authenticated?.isNotAuthenticated).isTrue()
327 with(modalities ?: throw Exception("missing modalities")) {
328 assertThat(hasFace).isEqualTo(testCase.face != null)
329 assertThat(hasFingerprint).isEqualTo(testCase.fingerprint != null)
330 }
331
332 assertThat(message).isEqualTo(PromptMessage.Empty)
333 assertThat(size).isEqualTo(expectedPromptSize)
334
335 val forceExplicitFlow =
336 testCase.isCoex && testCase.confirmationRequested ||
337 testCase.authenticatedByFingerprint
338
339 if ((testCase.isCoex && !forceExplicitFlow) || testCase.isFaceOnly) {
340 // Face-only or implicit co-ex auth
341 assertThat(iconAsset).isEqualTo(R.raw.face_dialog_idle_static)
342 assertThat(shouldAnimateIconView).isEqualTo(false)
343 }
344
345 if (forceExplicitFlow) {
346 expectedPromptSize = PromptSize.MEDIUM
347 kosmos.promptViewModel.ensureFingerprintHasStarted(isDelayed = true)
348 }
349
350 val startMessage = "here we go"
351 kosmos.promptViewModel.showAuthenticating(startMessage, isRetry = false)
352 verifyIconSize(forceExplicitFlow)
353
354 // Icon asset assertions
355 if ((testCase.isCoex && !forceExplicitFlow) || testCase.isFaceOnly) {
356 // Face-only or implicit co-ex auth
357 assertThat(iconAsset).isEqualTo(R.raw.face_dialog_authenticating)
358 assertThat(iconContentDescriptionId).isEqualTo(faceIconAuthingDescription)
359 assertThat(shouldAnimateIconView).isEqualTo(true)
360 } else if ((testCase.isCoex && forceExplicitFlow) || testCase.isFingerprintOnly) {
361 // Fingerprint-only or explicit co-ex auth
362 if (testCase.sensorType == FingerprintSensorProperties.TYPE_POWER_BUTTON) {
363 assertThat(iconAsset).isEqualTo(getSfpsAsset_fingerprintAuthenticating())
364 assertThat(iconContentDescriptionId).isEqualTo(sfpsFindSensorDescription)
365 assertThat(shouldAnimateIconView).isEqualTo(true)
366 } else {
367 assertThat(iconAsset)
368 .isEqualTo(R.raw.fingerprint_dialogue_fingerprint_to_error_lottie)
369 assertThat(iconContentDescriptionId).isEqualTo(udfpsIconDescription)
370 assertThat(shouldAnimateIconView).isEqualTo(false)
371 }
372 }
373
374 assertThat(message).isEqualTo(PromptMessage.Help(startMessage))
375 assertThat(authenticating).isTrue()
376 assertThat(authenticated?.isNotAuthenticated).isTrue()
377 assertThat(size).isEqualTo(expectedPromptSize)
378 assertButtonsVisible(negative = expectedPromptSize != PromptSize.SMALL)
379 }
380
381 @Test
382 fun start_authenticating_show_and_clear_error() = runGenericTest {
383 val iconAsset by collectLastValue(kosmos.promptViewModel.iconViewModel.iconAsset)
384 val iconContentDescriptionId by
385 collectLastValue(kosmos.promptViewModel.iconViewModel.contentDescriptionId)
386 val shouldAnimateIconView by
387 collectLastValue(kosmos.promptViewModel.iconViewModel.shouldAnimateIconView)
388 val message by collectLastValue(kosmos.promptViewModel.message)
389
390 var forceExplicitFlow =
391 testCase.isCoex && testCase.confirmationRequested || testCase.authenticatedByFingerprint
392 if (forceExplicitFlow) {
393 kosmos.promptViewModel.ensureFingerprintHasStarted(isDelayed = true)
394 }
395 verifyIconSize(forceExplicitFlow)
396
397 val errorJob = launch {
398 kosmos.promptViewModel.showTemporaryError(
399 "so sad",
400 messageAfterError = "",
401 authenticateAfterError = testCase.isFingerprintOnly || testCase.isCoex,
402 )
403 forceExplicitFlow = true
404 // Usually done by binder
405 kosmos.promptViewModel.iconViewModel.setPreviousIconWasError(true)
406 }
407
408 assertThat(message?.isError).isEqualTo(true)
409 assertThat(message?.message).isEqualTo("so sad")
410
411 // Icon asset assertions
412 if (testCase.isFaceOnly) {
413 // Face-only auth
414 assertThat(iconAsset).isEqualTo(R.raw.face_dialog_dark_to_error)
415 assertThat(iconContentDescriptionId).isEqualTo(faceFailedDescription)
416 assertThat(shouldAnimateIconView).isEqualTo(true)
417
418 // Clear error, go to idle
419 errorJob.join()
420
421 assertThat(iconAsset).isEqualTo(R.raw.face_dialog_error_to_idle)
422 assertThat(iconContentDescriptionId).isEqualTo(faceIconIdleDescription)
423 assertThat(shouldAnimateIconView).isEqualTo(true)
424 } else if ((testCase.isCoex && forceExplicitFlow) || testCase.isFingerprintOnly) {
425 // Fingerprint-only or explicit co-ex auth
426 if (testCase.sensorType == FingerprintSensorProperties.TYPE_POWER_BUTTON) {
427 assertThat(iconAsset).isEqualTo(getSfpsAsset_fingerprintToError())
428 assertThat(iconContentDescriptionId).isEqualTo(bpTryAgainDescription)
429 assertThat(shouldAnimateIconView).isEqualTo(true)
430 } else {
431 assertThat(iconAsset)
432 .isEqualTo(R.raw.fingerprint_dialogue_fingerprint_to_error_lottie)
433 assertThat(iconContentDescriptionId).isEqualTo(bpTryAgainDescription)
434 assertThat(shouldAnimateIconView).isEqualTo(true)
435 }
436
437 // Clear error, restart authenticating
438 errorJob.join()
439
440 if (testCase.sensorType == FingerprintSensorProperties.TYPE_POWER_BUTTON) {
441 assertThat(iconAsset).isEqualTo(getSfpsAsset_errorToFingerprint())
442 assertThat(iconContentDescriptionId).isEqualTo(sfpsFindSensorDescription)
443 assertThat(shouldAnimateIconView).isEqualTo(true)
444 } else {
445 assertThat(iconAsset)
446 .isEqualTo(R.raw.fingerprint_dialogue_error_to_fingerprint_lottie)
447 assertThat(iconContentDescriptionId).isEqualTo(udfpsIconDescription)
448 assertThat(shouldAnimateIconView).isEqualTo(true)
449 }
450 }
451 }
452
453 @Test
454 fun shows_error_to_unlock_or_success() {
455 // Face-only auth does not use error -> unlock or error -> success assets
456 if (testCase.isFingerprintOnly || testCase.isCoex) {
457 runGenericTest {
458 // Distinct asset for error -> success only applicable for fingerprint-only /
459 // explicit co-ex auth
460 val iconAsset by collectLastValue(kosmos.promptViewModel.iconViewModel.iconAsset)
461 val iconContentDescriptionId by
462 collectLastValue(kosmos.promptViewModel.iconViewModel.contentDescriptionId)
463 val shouldAnimateIconView by
464 collectLastValue(kosmos.promptViewModel.iconViewModel.shouldAnimateIconView)
465
466 var forceExplicitFlow =
467 testCase.isCoex && testCase.confirmationRequested ||
468 testCase.authenticatedByFingerprint
469 if (forceExplicitFlow) {
470 kosmos.promptViewModel.ensureFingerprintHasStarted(isDelayed = true)
471 }
472 verifyIconSize(forceExplicitFlow)
473
474 kosmos.promptViewModel.ensureFingerprintHasStarted(isDelayed = true)
475 kosmos.promptViewModel.iconViewModel.setPreviousIconWasError(true)
476
477 kosmos.promptViewModel.showAuthenticated(
478 modality = testCase.authenticatedModality,
479 dismissAfterDelay = DELAY,
480 )
481
482 // SFPS test cases
483 if (testCase.sensorType == FingerprintSensorProperties.TYPE_POWER_BUTTON) {
484 // Covers (1) fingerprint-only (2) co-ex, authenticated by fingerprint
485 if (testCase.authenticatedByFingerprint) {
486 assertThat(iconAsset).isEqualTo(R.raw.biometricprompt_sfps_error_to_success)
487 assertThat(iconContentDescriptionId).isEqualTo(sfpsFindSensorDescription)
488 assertThat(shouldAnimateIconView).isEqualTo(true)
489 } else { // Covers co-ex, authenticated by face
490 assertThat(iconAsset).isEqualTo(R.raw.biometricprompt_sfps_error_to_unlock)
491 assertThat(iconContentDescriptionId)
492 .isEqualTo(fingerprintIconAuthenticatedDescription)
493 assertThat(shouldAnimateIconView).isEqualTo(true)
494
495 // Confirm authentication
496 kosmos.promptViewModel.confirmAuthenticated()
497
498 assertThat(iconAsset)
499 .isEqualTo(R.raw.biometricprompt_sfps_unlock_to_success)
500 assertThat(iconContentDescriptionId).isEqualTo(udfpsIconDescription)
501 assertThat(shouldAnimateIconView).isEqualTo(true)
502 }
503 } else { // Non-SFPS (UDFPS / rear-FPS) test cases
504 // Covers (1) fingerprint-only (2) co-ex, authenticated by fingerprint
505 if (testCase.authenticatedByFingerprint) {
506 assertThat(iconAsset)
507 .isEqualTo(R.raw.fingerprint_dialogue_error_to_success_lottie)
508 assertThat(iconContentDescriptionId).isEqualTo(udfpsIconDescription)
509 assertThat(shouldAnimateIconView).isEqualTo(true)
510 } else { // co-ex, authenticated by face
511 assertThat(iconAsset)
512 .isEqualTo(R.raw.fingerprint_dialogue_error_to_unlock_lottie)
513 assertThat(iconContentDescriptionId).isEqualTo(bpConfirmDescription)
514 assertThat(shouldAnimateIconView).isEqualTo(true)
515
516 // Confirm authentication
517 kosmos.promptViewModel.confirmAuthenticated()
518
519 assertThat(iconAsset)
520 .isEqualTo(
521 R.raw.fingerprint_dialogue_unlocked_to_checkmark_success_lottie
522 )
523 assertThat(iconContentDescriptionId).isEqualTo(udfpsIconDescription)
524 assertThat(shouldAnimateIconView).isEqualTo(true)
525 }
526 }
527 }
528 }
529 }
530
531 @Test
532 fun shows_authenticated_no_errors_no_confirmation_required() {
533 if (!testCase.confirmationRequested) {
534 runGenericTest {
535 val iconAsset by collectLastValue(kosmos.promptViewModel.iconViewModel.iconAsset)
536 val iconContentDescriptionId by
537 collectLastValue(kosmos.promptViewModel.iconViewModel.contentDescriptionId)
538 val shouldAnimateIconView by
539 collectLastValue(kosmos.promptViewModel.iconViewModel.shouldAnimateIconView)
540 val message by collectLastValue(kosmos.promptViewModel.message)
541 verifyIconSize()
542
543 kosmos.promptViewModel.showAuthenticated(
544 modality = testCase.authenticatedModality,
545 dismissAfterDelay = DELAY,
546 "TEST",
547 )
548
549 if (testCase.isFingerprintOnly) {
550 // Fingerprint icon asset assertions
551 if (testCase.sensorType == FingerprintSensorProperties.TYPE_POWER_BUTTON) {
552 assertThat(iconAsset).isEqualTo(getSfpsAsset_fingerprintToSuccess())
553 assertThat(iconContentDescriptionId).isEqualTo(sfpsFindSensorDescription)
554 assertThat(shouldAnimateIconView).isEqualTo(true)
555 } else {
556 assertThat(iconAsset)
557 .isEqualTo(R.raw.fingerprint_dialogue_fingerprint_to_success_lottie)
558 assertThat(iconContentDescriptionId).isEqualTo(udfpsIconDescription)
559 assertThat(shouldAnimateIconView).isEqualTo(true)
560 }
561 } else if (testCase.isFaceOnly || testCase.isCoex) {
562 // Face icon asset assertions
563 // If co-ex, use implicit flow (explicit flow always requires confirmation)
564 assertThat(iconAsset).isEqualTo(R.raw.face_dialog_dark_to_checkmark)
565 assertThat(iconContentDescriptionId).isEqualTo(faceIconAuthedDescription)
566 assertThat(shouldAnimateIconView).isEqualTo(true)
567 assertThat(message).isEqualTo(PromptMessage.Empty)
568 }
569 }
570 }
571 }
572
573 @Test
574 fun shows_pending_confirmation() {
575 if (testCase.authenticatedByFace && testCase.confirmationRequested) {
576 runGenericTest {
577 val iconAsset by collectLastValue(kosmos.promptViewModel.iconViewModel.iconAsset)
578 val iconContentDescriptionId by
579 collectLastValue(kosmos.promptViewModel.iconViewModel.contentDescriptionId)
580 val shouldAnimateIconView by
581 collectLastValue(kosmos.promptViewModel.iconViewModel.shouldAnimateIconView)
582
583 val forceExplicitFlow = testCase.isCoex && testCase.confirmationRequested
584 verifyIconSize(forceExplicitFlow = forceExplicitFlow)
585
586 kosmos.promptViewModel.showAuthenticated(
587 modality = testCase.authenticatedModality,
588 dismissAfterDelay = DELAY,
589 )
590
591 if (testCase.isFaceOnly) {
592 assertThat(iconAsset).isEqualTo(R.raw.face_dialog_wink_from_dark)
593 assertThat(iconContentDescriptionId).isEqualTo(faceIconAuthedDescription)
594 assertThat(shouldAnimateIconView).isEqualTo(true)
595 } else if (testCase.isCoex) { // explicit flow, confirmation requested
596 if (testCase.sensorType == FingerprintSensorProperties.TYPE_POWER_BUTTON) {
597 assertThat(iconAsset).isEqualTo(getSfpsAsset_fingerprintToUnlock())
598 assertThat(iconContentDescriptionId)
599 .isEqualTo(fingerprintIconAuthenticatedDescription)
600 assertThat(shouldAnimateIconView).isEqualTo(true)
601 } else {
602 assertThat(iconAsset)
603 .isEqualTo(R.raw.fingerprint_dialogue_fingerprint_to_unlock_lottie)
604 assertThat(iconContentDescriptionId).isEqualTo(bpConfirmDescription)
605 assertThat(shouldAnimateIconView).isEqualTo(true)
606 }
607 }
608 }
609 }
610 }
611
612 @Test
613 fun shows_authenticated_explicitly_confirmed() {
614 if (testCase.authenticatedByFace && testCase.confirmationRequested) {
615 runGenericTest {
616 val iconAsset by collectLastValue(kosmos.promptViewModel.iconViewModel.iconAsset)
617 val iconContentDescriptionId by
618 collectLastValue(kosmos.promptViewModel.iconViewModel.contentDescriptionId)
619 val shouldAnimateIconView by
620 collectLastValue(kosmos.promptViewModel.iconViewModel.shouldAnimateIconView)
621 val forceExplicitFlow = testCase.isCoex && testCase.confirmationRequested
622 verifyIconSize(forceExplicitFlow = forceExplicitFlow)
623
624 kosmos.promptViewModel.showAuthenticated(
625 modality = testCase.authenticatedModality,
626 dismissAfterDelay = DELAY,
627 )
628
629 kosmos.promptViewModel.confirmAuthenticated()
630
631 if (testCase.isFaceOnly) {
632 assertThat(iconAsset).isEqualTo(R.raw.face_dialog_dark_to_checkmark)
633 assertThat(iconContentDescriptionId).isEqualTo(faceIconConfirmedDescription)
634 assertThat(shouldAnimateIconView).isEqualTo(true)
635 }
636
637 // explicit flow because confirmation requested
638 if (testCase.isCoex) {
639 if (testCase.sensorType == FingerprintSensorProperties.TYPE_POWER_BUTTON) {
640 assertThat(iconAsset)
641 .isEqualTo(R.raw.biometricprompt_sfps_unlock_to_success)
642 assertThat(shouldAnimateIconView).isEqualTo(true)
643 } else {
644 assertThat(iconAsset)
645 .isEqualTo(
646 R.raw.fingerprint_dialogue_unlocked_to_checkmark_success_lottie
647 )
648 assertThat(iconContentDescriptionId).isEqualTo(udfpsIconDescription)
649 assertThat(shouldAnimateIconView).isEqualTo(true)
650 }
651 }
652 }
653 }
654 }
655
656 private fun getSfpsAsset_fingerprintAuthenticating(): Int =
657 if (testCase.isInRearDisplayMode) {
658 R.raw.biometricprompt_sfps_rear_display_fingerprint_authenticating
659 } else {
660 R.raw.biometricprompt_sfps_fingerprint_authenticating
661 }
662
663 private fun getSfpsAsset_fingerprintToError(): Int =
664 if (testCase.isInRearDisplayMode) {
665 R.raw.biometricprompt_sfps_rear_display_fingerprint_to_error
666 } else {
667 R.raw.biometricprompt_sfps_fingerprint_to_error
668 }
669
670 private fun getSfpsAsset_fingerprintToUnlock(): Int =
671 if (testCase.isInRearDisplayMode) {
672 R.raw.biometricprompt_sfps_rear_display_fingerprint_to_unlock
673 } else {
674 R.raw.biometricprompt_sfps_fingerprint_to_unlock
675 }
676
677 private fun getSfpsAsset_errorToFingerprint(): Int =
678 if (testCase.isInRearDisplayMode) {
679 R.raw.biometricprompt_sfps_rear_display_error_to_fingerprint
680 } else {
681 R.raw.biometricprompt_sfps_error_to_fingerprint
682 }
683
684 private fun getSfpsAsset_fingerprintToSuccess(): Int =
685 if (testCase.isInRearDisplayMode) {
686 R.raw.biometricprompt_sfps_rear_display_fingerprint_to_success
687 } else {
688 R.raw.biometricprompt_sfps_fingerprint_to_success
689 }
690
691 @Test
692 fun shows_authenticated_with_no_errors() = runGenericTest {
693 // this case can't happen until fingerprint is started
694 // trigger it now since no error has occurred in this test
695 val forceError = testCase.isCoex && testCase.authenticatedByFingerprint
696
697 if (forceError) {
698 assertThat(kosmos.promptViewModel.fingerprintStartMode.first())
699 .isEqualTo(FingerprintStartMode.Pending)
700 kosmos.promptViewModel.ensureFingerprintHasStarted(isDelayed = true)
701 }
702
703 showAuthenticated(
704 testCase.authenticatedModality,
705 testCase.expectConfirmation(atLeastOneFailure = forceError),
706 )
707 }
708
709 // Verifies expected icon sizes for all modalities
710 private fun TestScope.verifyIconSize(forceExplicitFlow: Boolean = false) {
711 val iconSize by collectLastValue(kosmos.promptViewModel.iconSize)
712 if ((testCase.isCoex && !forceExplicitFlow) || testCase.isFaceOnly) {
713 // Face-only or implicit co-ex auth
714 assertThat(iconSize).isEqualTo(Pair(mockFaceIconSize, mockFaceIconSize))
715 } else if ((testCase.isCoex && forceExplicitFlow) || testCase.isFingerprintOnly) {
716 // Fingerprint-only or explicit co-ex auth
717 if (testCase.fingerprint?.isAnyUdfpsType == true) {
718 val udfpsOverlayParams by
719 collectLastValue(kosmos.promptViewModel.udfpsOverlayParams)
720 val expectedUdfpsOverlayParams = mockUdfpsOverlayParams()
721 assertThat(udfpsOverlayParams).isEqualTo(expectedUdfpsOverlayParams)
722
723 assertThat(iconSize)
724 .isEqualTo(
725 Pair(
726 expectedUdfpsOverlayParams.sensorBounds.width(),
727 expectedUdfpsOverlayParams.sensorBounds.height(),
728 )
729 )
730 } else {
731 assertThat(iconSize)
732 .isEqualTo(Pair(mockFingerprintIconWidth, mockFingerprintIconHeight))
733 }
734 }
735 }
736
737 @Test
738 @DisableFlags(Flags.FLAG_MSDL_FEEDBACK)
739 fun set_haptic_on_confirm_when_confirmation_required_otherwise_on_authenticated() =
740 runGenericTest {
741 val expectConfirmation = testCase.expectConfirmation(atLeastOneFailure = false)
742
743 kosmos.promptViewModel.showAuthenticated(testCase.authenticatedModality, 1_000L)
744
745 val hapticsPreConfirm by collectLastValue(kosmos.promptViewModel.hapticsToPlay)
746 if (expectConfirmation) {
747 assertThat(hapticsPreConfirm).isEqualTo(PromptViewModel.HapticsToPlay.None)
748 } else {
749 val confirmHaptics =
750 hapticsPreConfirm as PromptViewModel.HapticsToPlay.HapticConstant
751 assertThat(confirmHaptics.constant)
752 .isEqualTo(HapticFeedbackConstants.BIOMETRIC_CONFIRM)
753 assertThat(confirmHaptics.flag).isNull()
754 }
755
756 if (expectConfirmation) {
757 kosmos.promptViewModel.confirmAuthenticated()
758 }
759
760 val hapticsPostConfirm by collectLastValue(kosmos.promptViewModel.hapticsToPlay)
761 val confirmedHaptics =
762 hapticsPostConfirm as PromptViewModel.HapticsToPlay.HapticConstant
763 assertThat(confirmedHaptics.constant)
764 .isEqualTo(HapticFeedbackConstants.BIOMETRIC_CONFIRM)
765 assertThat(confirmedHaptics.flag).isNull()
766 }
767
768 @Test
769 @EnableFlags(Flags.FLAG_MSDL_FEEDBACK)
770 fun set_msdl_haptic_on_confirm_when_confirmation_required_otherwise_on_authenticated() =
771 runGenericTest {
772 val expectConfirmation = testCase.expectConfirmation(atLeastOneFailure = false)
773
774 kosmos.promptViewModel.showAuthenticated(testCase.authenticatedModality, 1_000L)
775
776 val hapticsPreConfirm by collectLastValue(kosmos.promptViewModel.hapticsToPlay)
777
778 if (expectConfirmation) {
779 assertThat(hapticsPreConfirm).isEqualTo(PromptViewModel.HapticsToPlay.None)
780 } else {
781 val confirmHaptics = hapticsPreConfirm as PromptViewModel.HapticsToPlay.MSDL
782 assertThat(confirmHaptics.token).isEqualTo(MSDLToken.UNLOCK)
783 assertThat(confirmHaptics.properties).isEqualTo(authInteractionProperties)
784 }
785
786 if (expectConfirmation) {
787 kosmos.promptViewModel.confirmAuthenticated()
788 }
789
790 val hapticsPostConfirm by collectLastValue(kosmos.promptViewModel.hapticsToPlay)
791 val confirmedHaptics = hapticsPostConfirm as PromptViewModel.HapticsToPlay.MSDL
792 assertThat(confirmedHaptics.token).isEqualTo(MSDLToken.UNLOCK)
793 assertThat(confirmedHaptics.properties).isEqualTo(authInteractionProperties)
794 }
795
796 @Test
797 @DisableFlags(Flags.FLAG_MSDL_FEEDBACK)
798 fun playSuccessHaptic_SetsConfirmConstant() = runGenericTest {
799 val expectConfirmation = testCase.expectConfirmation(atLeastOneFailure = false)
800 kosmos.promptViewModel.showAuthenticated(testCase.authenticatedModality, 1_000L)
801
802 if (expectConfirmation) {
803 kosmos.promptViewModel.confirmAuthenticated()
804 }
805
806 val haptics by collectLastValue(kosmos.promptViewModel.hapticsToPlay)
807 val currentHaptics = haptics as PromptViewModel.HapticsToPlay.HapticConstant
808 assertThat(currentHaptics.constant).isEqualTo(HapticFeedbackConstants.BIOMETRIC_CONFIRM)
809 assertThat(currentHaptics.flag).isNull()
810 }
811
812 @Test
813 @EnableFlags(Flags.FLAG_MSDL_FEEDBACK)
814 fun playSuccessHaptic_SetsUnlockMSDLFeedback() = runGenericTest {
815 val expectConfirmation = testCase.expectConfirmation(atLeastOneFailure = false)
816 kosmos.promptViewModel.showAuthenticated(testCase.authenticatedModality, 1_000L)
817
818 if (expectConfirmation) {
819 kosmos.promptViewModel.confirmAuthenticated()
820 }
821
822 val haptics by collectLastValue(kosmos.promptViewModel.hapticsToPlay)
823 val currentHaptics = haptics as PromptViewModel.HapticsToPlay.MSDL
824 assertThat(currentHaptics.token).isEqualTo(MSDLToken.UNLOCK)
825 assertThat(currentHaptics.properties).isEqualTo(authInteractionProperties)
826 }
827
828 @Test
829 @DisableFlags(Flags.FLAG_MSDL_FEEDBACK)
830 fun playErrorHaptic_SetsRejectConstant() = runGenericTest {
831 kosmos.promptViewModel.showTemporaryError("test", "messageAfterError", false)
832
833 val haptics by collectLastValue(kosmos.promptViewModel.hapticsToPlay)
834 val currentHaptics = haptics as PromptViewModel.HapticsToPlay.HapticConstant
835 assertThat(currentHaptics.constant).isEqualTo(HapticFeedbackConstants.BIOMETRIC_REJECT)
836 assertThat(currentHaptics.flag).isNull()
837 }
838
839 @Test
840 @EnableFlags(Flags.FLAG_MSDL_FEEDBACK)
841 fun playErrorHaptic_SetsFailureMSDLFeedback() = runGenericTest {
842 kosmos.promptViewModel.showTemporaryError("test", "messageAfterError", false)
843
844 val haptics by collectLastValue(kosmos.promptViewModel.hapticsToPlay)
845 val currentHaptics = haptics as PromptViewModel.HapticsToPlay.MSDL
846 assertThat(currentHaptics.token).isEqualTo(MSDLToken.FAILURE)
847 assertThat(currentHaptics.properties).isEqualTo(authInteractionProperties)
848 }
849
850 // biometricprompt_sfps_fingerprint_authenticating reused across rotations
851 // Other SFPS assets change across rotations, testing authenticated asset
852 @Test
853 fun sfpsAuthenticatedIconUpdates_onRotation() {
854 if (testCase.sensorType == FingerprintSensorProperties.TYPE_POWER_BUTTON) {
855 runGenericTest {
856 val currentIcon by collectLastValue(kosmos.promptViewModel.iconViewModel.iconAsset)
857
858 kosmos.promptViewModel.showAuthenticated(
859 modality = testCase.authenticatedModality,
860 dismissAfterDelay = DELAY,
861 )
862
863 kosmos.displayStateRepository.setCurrentRotation(DisplayRotation.ROTATION_0)
864 val iconRotation0 = currentIcon
865
866 kosmos.displayStateRepository.setCurrentRotation(DisplayRotation.ROTATION_90)
867 val iconRotation90 = currentIcon
868
869 kosmos.displayStateRepository.setCurrentRotation(DisplayRotation.ROTATION_180)
870 val iconRotation180 = currentIcon
871
872 kosmos.displayStateRepository.setCurrentRotation(DisplayRotation.ROTATION_270)
873 val iconRotation270 = currentIcon
874
875 assertThat(iconRotation0).isNotEqualTo(iconRotation90)
876 assertThat(iconRotation0).isNotEqualTo(iconRotation180)
877 assertThat(iconRotation0).isNotEqualTo(iconRotation270)
878 assertThat(iconRotation90).isNotEqualTo(iconRotation180)
879 assertThat(iconRotation90).isNotEqualTo(iconRotation270)
880 assertThat(iconRotation180).isNotEqualTo(iconRotation270)
881 }
882 }
883 }
884
885 @Test
886 fun sfpsIconUpdates_onRearDisplayMode() {
887 if (testCase.sensorType == FingerprintSensorProperties.TYPE_POWER_BUTTON) {
888 runGenericTest {
889 val currentIcon by collectLastValue(kosmos.promptViewModel.iconViewModel.iconAsset)
890
891 kosmos.displayStateRepository.setIsInRearDisplayMode(false)
892 val iconNotRearDisplayMode = currentIcon
893
894 kosmos.displayStateRepository.setIsInRearDisplayMode(true)
895 val iconRearDisplayMode = currentIcon
896
897 assertThat(iconNotRearDisplayMode).isNotEqualTo(iconRearDisplayMode)
898 }
899 }
900 }
901
902 private suspend fun TestScope.showAuthenticated(
903 authenticatedModality: BiometricModality,
904 expectConfirmation: Boolean,
905 ) {
906 val authenticating by collectLastValue(kosmos.promptViewModel.isAuthenticating)
907 val authenticated by collectLastValue(kosmos.promptViewModel.isAuthenticated)
908 val fpStartMode by collectLastValue(kosmos.promptViewModel.fingerprintStartMode)
909 val size by collectLastValue(kosmos.promptViewModel.size)
910
911 val authWithSmallPrompt =
912 testCase.shouldStartAsImplicitFlow &&
913 (fpStartMode == FingerprintStartMode.Pending || testCase.isFaceOnly)
914 assertThat(authenticating).isTrue()
915 assertThat(authenticated?.isNotAuthenticated).isTrue()
916 assertThat(size).isEqualTo(if (authWithSmallPrompt) PromptSize.SMALL else PromptSize.MEDIUM)
917 assertButtonsVisible(negative = !authWithSmallPrompt)
918
919 kosmos.promptViewModel.showAuthenticated(authenticatedModality, DELAY)
920
921 assertThat(authenticated?.isAuthenticated).isTrue()
922 assertThat(authenticated?.delay).isEqualTo(DELAY)
923 assertThat(authenticated?.needsUserConfirmation).isEqualTo(expectConfirmation)
924 assertThat(size)
925 .isEqualTo(
926 if (authenticatedModality == BiometricModality.Fingerprint || expectConfirmation) {
927 PromptSize.MEDIUM
928 } else {
929 PromptSize.SMALL
930 }
931 )
932
933 assertButtonsVisible(cancel = expectConfirmation, confirm = expectConfirmation)
934 }
935
936 @Test
937 fun shows_temporary_errors() = runGenericTest {
938 val checkAtEnd = suspend { assertButtonsVisible(negative = true) }
939
940 showTemporaryErrors(restart = false) { checkAtEnd() }
941 showTemporaryErrors(restart = false, helpAfterError = "foo") { checkAtEnd() }
942 showTemporaryErrors(restart = true) { checkAtEnd() }
943 }
944
945 @Test
946 @DisableFlags(Flags.FLAG_MSDL_FEEDBACK)
947 fun set_haptic_on_errors() = runGenericTest {
948 kosmos.promptViewModel.showTemporaryError(
949 "so sad",
950 messageAfterError = "",
951 authenticateAfterError = false,
952 hapticFeedback = true,
953 )
954
955 val hapticsToPlay by collectLastValue(kosmos.promptViewModel.hapticsToPlay)
956 val haptics = hapticsToPlay as PromptViewModel.HapticsToPlay.HapticConstant
957 assertThat(haptics.constant).isEqualTo(HapticFeedbackConstants.BIOMETRIC_REJECT)
958 assertThat(haptics.flag).isNull()
959 }
960
961 @Test
962 @EnableFlags(Flags.FLAG_MSDL_FEEDBACK)
963 fun set_msdl_haptic_on_errors() = runGenericTest {
964 kosmos.promptViewModel.showTemporaryError(
965 "so sad",
966 messageAfterError = "",
967 authenticateAfterError = false,
968 hapticFeedback = true,
969 )
970
971 val hapticsToPlay by collectLastValue(kosmos.promptViewModel.hapticsToPlay)
972 val haptics = hapticsToPlay as PromptViewModel.HapticsToPlay.MSDL
973 assertThat(haptics.token).isEqualTo(MSDLToken.FAILURE)
974 assertThat(haptics.properties).isEqualTo(authInteractionProperties)
975 }
976
977 @Test
978 @DisableFlags(Flags.FLAG_MSDL_FEEDBACK)
979 fun plays_haptic_on_errors_unless_skipped() = runGenericTest {
980 kosmos.promptViewModel.showTemporaryError(
981 "still sad",
982 messageAfterError = "",
983 authenticateAfterError = false,
984 hapticFeedback = false,
985 )
986
987 val hapticsToPlay by collectLastValue(kosmos.promptViewModel.hapticsToPlay)
988 assertThat(hapticsToPlay).isEqualTo(PromptViewModel.HapticsToPlay.None)
989 }
990
991 @Test
992 @EnableFlags(Flags.FLAG_MSDL_FEEDBACK)
993 fun plays_msdl_haptic_on_errors_unless_skipped() = runGenericTest {
994 kosmos.promptViewModel.showTemporaryError(
995 "still sad",
996 messageAfterError = "",
997 authenticateAfterError = false,
998 hapticFeedback = false,
999 )
1000
1001 val hapticsToPlay by collectLastValue(kosmos.promptViewModel.hapticsToPlay)
1002 assertThat(hapticsToPlay).isEqualTo(PromptViewModel.HapticsToPlay.None)
1003 }
1004
1005 @Test
1006 @DisableFlags(Flags.FLAG_MSDL_FEEDBACK)
1007 fun plays_haptic_on_error_after_auth_when_confirmation_needed() = runGenericTest {
1008 val expectConfirmation = testCase.expectConfirmation(atLeastOneFailure = false)
1009 kosmos.promptViewModel.showAuthenticated(testCase.authenticatedModality, 0)
1010
1011 kosmos.promptViewModel.showTemporaryError(
1012 "still sad",
1013 messageAfterError = "",
1014 authenticateAfterError = false,
1015 hapticFeedback = true,
1016 )
1017
1018 val hapticsToPlay by collectLastValue(kosmos.promptViewModel.hapticsToPlay)
1019 val haptics = hapticsToPlay as PromptViewModel.HapticsToPlay.HapticConstant
1020 if (expectConfirmation) {
1021 assertThat(haptics.constant).isEqualTo(HapticFeedbackConstants.BIOMETRIC_REJECT)
1022 assertThat(haptics.flag).isNull()
1023 } else {
1024 assertThat(haptics.constant).isEqualTo(HapticFeedbackConstants.BIOMETRIC_CONFIRM)
1025 }
1026 }
1027
1028 @Test
1029 @EnableFlags(Flags.FLAG_MSDL_FEEDBACK)
1030 fun plays_msdl_haptic_on_error_after_auth_when_confirmation_needed() = runGenericTest {
1031 val expectConfirmation = testCase.expectConfirmation(atLeastOneFailure = false)
1032 kosmos.promptViewModel.showAuthenticated(testCase.authenticatedModality, 0)
1033
1034 kosmos.promptViewModel.showTemporaryError(
1035 "still sad",
1036 messageAfterError = "",
1037 authenticateAfterError = false,
1038 hapticFeedback = true,
1039 )
1040
1041 val hapticsToPlay by collectLastValue(kosmos.promptViewModel.hapticsToPlay)
1042 val haptics = hapticsToPlay as PromptViewModel.HapticsToPlay.MSDL
1043 if (expectConfirmation) {
1044 assertThat(haptics.token).isEqualTo(MSDLToken.FAILURE)
1045 } else {
1046 assertThat(haptics.token).isEqualTo(MSDLToken.UNLOCK)
1047 }
1048 assertThat(haptics.properties).isEqualTo(authInteractionProperties)
1049 }
1050
1051 private suspend fun TestScope.showTemporaryErrors(
1052 restart: Boolean,
1053 helpAfterError: String = "",
1054 block: suspend TestScope.() -> Unit = {},
1055 ) {
1056 val errorMessage = "oh no!"
1057 val authenticating by collectLastValue(kosmos.promptViewModel.isAuthenticating)
1058 val authenticated by collectLastValue(kosmos.promptViewModel.isAuthenticated)
1059 val message by collectLastValue(kosmos.promptViewModel.message)
1060 val messageVisible by collectLastValue(kosmos.promptViewModel.isIndicatorMessageVisible)
1061 val size by collectLastValue(kosmos.promptViewModel.size)
1062 val canTryAgainNow by collectLastValue(kosmos.promptViewModel.canTryAgainNow)
1063
1064 val errorJob = launch {
1065 kosmos.promptViewModel.showTemporaryError(
1066 errorMessage,
1067 authenticateAfterError = restart,
1068 messageAfterError = helpAfterError,
1069 )
1070 }
1071
1072 assertThat(size).isEqualTo(PromptSize.MEDIUM)
1073 assertThat(message).isEqualTo(PromptMessage.Error(errorMessage))
1074 assertThat(messageVisible).isTrue()
1075
1076 // temporary error should disappear after a delay
1077 errorJob.join()
1078 if (helpAfterError.isNotBlank()) {
1079 assertThat(message).isEqualTo(PromptMessage.Help(helpAfterError))
1080 assertThat(messageVisible).isTrue()
1081 } else {
1082 assertThat(message).isEqualTo(PromptMessage.Empty)
1083 assertThat(messageVisible).isFalse()
1084 }
1085
1086 assertThat(authenticating).isEqualTo(restart)
1087 assertThat(authenticated?.isNotAuthenticated).isTrue()
1088 assertThat(canTryAgainNow).isFalse()
1089
1090 block()
1091 }
1092
1093 @Test
1094 fun no_errors_or_temporary_help_after_authenticated() = runGenericTest {
1095 val authenticating by collectLastValue(kosmos.promptViewModel.isAuthenticating)
1096 val authenticated by collectLastValue(kosmos.promptViewModel.isAuthenticated)
1097 val message by collectLastValue(kosmos.promptViewModel.message)
1098 val messageIsShowing by collectLastValue(kosmos.promptViewModel.isIndicatorMessageVisible)
1099 val canTryAgain by collectLastValue(kosmos.promptViewModel.canTryAgainNow)
1100
1101 kosmos.promptViewModel.showAuthenticated(testCase.authenticatedModality, 0)
1102
1103 val verifyNoError = {
1104 assertThat(authenticating).isFalse()
1105 assertThat(authenticated?.isAuthenticated).isTrue()
1106 assertThat(message).isEqualTo(PromptMessage.Empty)
1107 assertThat(canTryAgain).isFalse()
1108 }
1109
1110 val errorJob = launch {
1111 kosmos.promptViewModel.showTemporaryError(
1112 "error",
1113 messageAfterError = "",
1114 authenticateAfterError = false,
1115 )
1116 }
1117 verifyNoError()
1118 errorJob.join()
1119 verifyNoError()
1120
1121 val helpJob = launch { kosmos.promptViewModel.showTemporaryHelp("hi") }
1122 verifyNoError()
1123 helpJob.join()
1124 verifyNoError()
1125
1126 // persistent help is allowed
1127 val stickyHelpMessage = "blah"
1128 kosmos.promptViewModel.showHelp(stickyHelpMessage)
1129 assertThat(authenticating).isFalse()
1130 assertThat(authenticated?.isAuthenticated).isTrue()
1131 assertThat(message).isEqualTo(PromptMessage.Help(stickyHelpMessage))
1132 assertThat(messageIsShowing).isTrue()
1133 }
1134
1135 @Test
1136 fun suppress_temporary_error() = runGenericTest {
1137 val messages by collectValues(kosmos.promptViewModel.message)
1138
1139 for (error in listOf("never", "see", "me")) {
1140 launch {
1141 kosmos.promptViewModel.showTemporaryError(
1142 error,
1143 messageAfterError = "or me",
1144 authenticateAfterError = false,
1145 suppressIf = { _, _ -> true },
1146 )
1147 }
1148 }
1149
1150 testScheduler.advanceUntilIdle()
1151 assertThat(messages).containsExactly(PromptMessage.Empty)
1152 }
1153
1154 @Test
1155 fun suppress_temporary_error_when_already_showing_when_requested() =
1156 suppress_temporary_error_when_already_showing(suppress = true)
1157
1158 @Test
1159 fun do_not_suppress_temporary_error_when_already_showing_when_not_requested() =
1160 suppress_temporary_error_when_already_showing(suppress = false)
1161
1162 private fun suppress_temporary_error_when_already_showing(suppress: Boolean) = runGenericTest {
1163 val errors = listOf("woot", "oh yeah", "nope")
1164 val afterSuffix = "(after)"
1165 val expectedErrorMessage = if (suppress) errors.first() else errors.last()
1166 val messages by collectValues(kosmos.promptViewModel.message)
1167
1168 for (error in errors) {
1169 launch {
1170 kosmos.promptViewModel.showTemporaryError(
1171 error,
1172 messageAfterError = "$error $afterSuffix",
1173 authenticateAfterError = false,
1174 suppressIf = { currentMessage, _ -> suppress && currentMessage.isError },
1175 )
1176 }
1177 }
1178
1179 testScheduler.runCurrent()
1180 assertThat(messages)
1181 .containsExactly(PromptMessage.Empty, PromptMessage.Error(expectedErrorMessage))
1182 .inOrder()
1183
1184 testScheduler.advanceUntilIdle()
1185 assertThat(messages)
1186 .containsExactly(
1187 PromptMessage.Empty,
1188 PromptMessage.Error(expectedErrorMessage),
1189 PromptMessage.Help("$expectedErrorMessage $afterSuffix"),
1190 )
1191 .inOrder()
1192 }
1193
1194 @Test
1195 fun authenticated_at_most_once_same_modality() = runGenericTest {
1196 val authenticating by collectLastValue(kosmos.promptViewModel.isAuthenticating)
1197 val authenticated by collectLastValue(kosmos.promptViewModel.isAuthenticated)
1198
1199 kosmos.promptViewModel.showAuthenticated(testCase.authenticatedModality, 0)
1200
1201 assertThat(authenticating).isFalse()
1202 assertThat(authenticated?.isAuthenticated).isTrue()
1203
1204 kosmos.promptViewModel.showAuthenticated(testCase.authenticatedModality, 0)
1205
1206 assertThat(authenticating).isFalse()
1207 assertThat(authenticated?.isAuthenticated).isTrue()
1208 }
1209
1210 @Test
1211 fun authenticating_cannot_restart_after_authenticated() = runGenericTest {
1212 val authenticating by collectLastValue(kosmos.promptViewModel.isAuthenticating)
1213 val authenticated by collectLastValue(kosmos.promptViewModel.isAuthenticated)
1214
1215 kosmos.promptViewModel.showAuthenticated(testCase.authenticatedModality, 0)
1216
1217 assertThat(authenticating).isFalse()
1218 assertThat(authenticated?.isAuthenticated).isTrue()
1219
1220 kosmos.promptViewModel.showAuthenticating("again!")
1221
1222 assertThat(authenticating).isFalse()
1223 assertThat(authenticated?.isAuthenticated).isTrue()
1224 }
1225
1226 @Test
1227 fun confirm_authentication() = runGenericTest {
1228 val expectConfirmation = testCase.expectConfirmation(atLeastOneFailure = false)
1229
1230 kosmos.promptViewModel.showAuthenticated(testCase.authenticatedModality, 0)
1231
1232 val authenticating by collectLastValue(kosmos.promptViewModel.isAuthenticating)
1233 val authenticated by collectLastValue(kosmos.promptViewModel.isAuthenticated)
1234 val message by collectLastValue(kosmos.promptViewModel.message)
1235 val size by collectLastValue(kosmos.promptViewModel.size)
1236 val canTryAgain by collectLastValue(kosmos.promptViewModel.canTryAgainNow)
1237
1238 assertThat(authenticated?.needsUserConfirmation).isEqualTo(expectConfirmation)
1239 if (expectConfirmation) {
1240 assertThat(size).isEqualTo(PromptSize.MEDIUM)
1241 assertButtonsVisible(cancel = true, confirm = true)
1242
1243 kosmos.promptViewModel.confirmAuthenticated()
1244 assertThat(message).isEqualTo(PromptMessage.Empty)
1245 assertButtonsVisible()
1246 }
1247
1248 assertThat(authenticating).isFalse()
1249 assertThat(authenticated?.isAuthenticated).isTrue()
1250 assertThat(canTryAgain).isFalse()
1251 }
1252
1253 @Test
1254 fun second_authentication_acts_as_confirmation() = runGenericTest {
1255 val expectConfirmation = testCase.expectConfirmation(atLeastOneFailure = false)
1256
1257 kosmos.promptViewModel.showAuthenticated(testCase.authenticatedModality, 0)
1258
1259 val authenticating by collectLastValue(kosmos.promptViewModel.isAuthenticating)
1260 val authenticated by collectLastValue(kosmos.promptViewModel.isAuthenticated)
1261 val message by collectLastValue(kosmos.promptViewModel.message)
1262 val size by collectLastValue(kosmos.promptViewModel.size)
1263 val canTryAgain by collectLastValue(kosmos.promptViewModel.canTryAgainNow)
1264
1265 assertThat(authenticated?.needsUserConfirmation).isEqualTo(expectConfirmation)
1266 if (expectConfirmation) {
1267 assertThat(size).isEqualTo(PromptSize.MEDIUM)
1268 assertButtonsVisible(cancel = true, confirm = true)
1269
1270 if (testCase.modalities.hasSfps) {
1271 kosmos.promptViewModel.showAuthenticated(BiometricModality.Fingerprint, 0)
1272 assertThat(message).isEqualTo(PromptMessage.Empty)
1273 assertButtonsVisible()
1274 }
1275 }
1276
1277 assertThat(authenticating).isFalse()
1278 assertThat(authenticated?.isAuthenticated).isTrue()
1279 assertThat(canTryAgain).isFalse()
1280 }
1281
1282 @Test
1283 fun auto_confirm_authentication_when_finger_down() = runGenericTest {
1284 val expectConfirmation = testCase.expectConfirmation(atLeastOneFailure = false)
1285
1286 if (testCase.isCoex) {
1287 kosmos.promptViewModel.onOverlayTouch(obtainMotionEvent(MotionEvent.ACTION_DOWN))
1288 }
1289 kosmos.promptViewModel.showAuthenticated(testCase.authenticatedModality, 0)
1290
1291 val authenticating by collectLastValue(kosmos.promptViewModel.isAuthenticating)
1292 val authenticated by collectLastValue(kosmos.promptViewModel.isAuthenticated)
1293 val message by collectLastValue(kosmos.promptViewModel.message)
1294 val size by collectLastValue(kosmos.promptViewModel.size)
1295 val canTryAgain by collectLastValue(kosmos.promptViewModel.canTryAgainNow)
1296
1297 assertThat(authenticating).isFalse()
1298 assertThat(canTryAgain).isFalse()
1299 assertThat(authenticated?.isAuthenticated).isTrue()
1300
1301 if (expectConfirmation) {
1302 if (testCase.isFaceOnly) {
1303 assertThat(size).isEqualTo(PromptSize.MEDIUM)
1304 assertButtonsVisible(cancel = true, confirm = true)
1305
1306 kosmos.promptViewModel.confirmAuthenticated()
1307 } else if (testCase.isCoex) {
1308 assertThat(authenticated?.isAuthenticatedAndConfirmed).isTrue()
1309 }
1310 assertThat(message).isEqualTo(PromptMessage.Empty)
1311 assertButtonsVisible()
1312 }
1313 }
1314
1315 @Test
1316 fun cannot_auto_confirm_authentication_when_finger_up() = runGenericTest {
1317 val expectConfirmation = testCase.expectConfirmation(atLeastOneFailure = false)
1318
1319 if (testCase.isCoex) {
1320 kosmos.promptViewModel.onOverlayTouch(obtainMotionEvent(MotionEvent.ACTION_DOWN))
1321 kosmos.promptViewModel.onOverlayTouch(obtainMotionEvent(MotionEvent.ACTION_UP))
1322 }
1323 kosmos.promptViewModel.showAuthenticated(testCase.authenticatedModality, 0)
1324
1325 val authenticating by collectLastValue(kosmos.promptViewModel.isAuthenticating)
1326 val authenticated by collectLastValue(kosmos.promptViewModel.isAuthenticated)
1327 val message by collectLastValue(kosmos.promptViewModel.message)
1328 val size by collectLastValue(kosmos.promptViewModel.size)
1329 val canTryAgain by collectLastValue(kosmos.promptViewModel.canTryAgainNow)
1330
1331 assertThat(authenticated?.needsUserConfirmation).isEqualTo(expectConfirmation)
1332 if (expectConfirmation) {
1333 assertThat(size).isEqualTo(PromptSize.MEDIUM)
1334 assertButtonsVisible(cancel = true, confirm = true)
1335
1336 kosmos.promptViewModel.confirmAuthenticated()
1337 assertThat(message).isEqualTo(PromptMessage.Empty)
1338 assertButtonsVisible()
1339 }
1340
1341 assertThat(authenticating).isFalse()
1342 assertThat(authenticated?.isAuthenticated).isTrue()
1343 assertThat(canTryAgain).isFalse()
1344 }
1345
1346 @Test
1347 fun cannot_confirm_unless_authenticated() = runGenericTest {
1348 val authenticating by collectLastValue(kosmos.promptViewModel.isAuthenticating)
1349 val authenticated by collectLastValue(kosmos.promptViewModel.isAuthenticated)
1350
1351 kosmos.promptViewModel.confirmAuthenticated()
1352 assertThat(authenticating).isTrue()
1353 assertThat(authenticated?.isNotAuthenticated).isTrue()
1354
1355 kosmos.promptViewModel.showAuthenticated(testCase.authenticatedModality, 0)
1356
1357 // reconfirm should be a no-op
1358 kosmos.promptViewModel.confirmAuthenticated()
1359 kosmos.promptViewModel.confirmAuthenticated()
1360
1361 assertThat(authenticating).isFalse()
1362 assertThat(authenticated?.isNotAuthenticated).isFalse()
1363 }
1364
1365 @Test
1366 fun shows_help_before_authenticated() = runGenericTest {
1367 val helpMessage = "please help yourself to some cookies"
1368 val message by collectLastValue(kosmos.promptViewModel.message)
1369 val messageVisible by collectLastValue(kosmos.promptViewModel.isIndicatorMessageVisible)
1370 val size by collectLastValue(kosmos.promptViewModel.size)
1371
1372 kosmos.promptViewModel.showHelp(helpMessage)
1373
1374 assertThat(size).isEqualTo(PromptSize.MEDIUM)
1375 assertThat(message).isEqualTo(PromptMessage.Help(helpMessage))
1376 assertThat(messageVisible).isTrue()
1377
1378 assertThat(kosmos.promptViewModel.isAuthenticating.first()).isFalse()
1379 assertThat(kosmos.promptViewModel.isAuthenticated.first().isNotAuthenticated).isTrue()
1380 }
1381
1382 @Test
1383 fun shows_help_after_authenticated() = runGenericTest {
1384 val expectConfirmation = testCase.expectConfirmation(atLeastOneFailure = false)
1385 val helpMessage = "more cookies please"
1386 val authenticating by collectLastValue(kosmos.promptViewModel.isAuthenticating)
1387 val authenticated by collectLastValue(kosmos.promptViewModel.isAuthenticated)
1388 val message by collectLastValue(kosmos.promptViewModel.message)
1389 val messageVisible by collectLastValue(kosmos.promptViewModel.isIndicatorMessageVisible)
1390 val size by collectLastValue(kosmos.promptViewModel.size)
1391 val confirmationRequired by collectLastValue(kosmos.promptViewModel.isConfirmationRequired)
1392
1393 if (testCase.isCoex && testCase.authenticatedByFingerprint) {
1394 kosmos.promptViewModel.ensureFingerprintHasStarted(isDelayed = true)
1395 }
1396 kosmos.promptViewModel.showAuthenticated(testCase.authenticatedModality, 0)
1397 kosmos.promptViewModel.showHelp(helpMessage)
1398
1399 assertThat(size).isEqualTo(PromptSize.MEDIUM)
1400
1401 assertThat(message).isEqualTo(PromptMessage.Help(helpMessage))
1402 assertThat(messageVisible).isTrue()
1403 assertThat(authenticating).isFalse()
1404 assertThat(authenticated?.isAuthenticated).isTrue()
1405 assertThat(authenticated?.needsUserConfirmation).isEqualTo(expectConfirmation)
1406 assertButtonsVisible(cancel = expectConfirmation, confirm = expectConfirmation)
1407 }
1408
1409 @Test
1410 fun retries_after_failure() = runGenericTest {
1411 val errorMessage = "bad"
1412 val helpMessage = "again?"
1413 val expectTryAgainButton = testCase.isFaceOnly
1414 val authenticating by collectLastValue(kosmos.promptViewModel.isAuthenticating)
1415 val authenticated by collectLastValue(kosmos.promptViewModel.isAuthenticated)
1416 val message by collectLastValue(kosmos.promptViewModel.message)
1417 val messageVisible by collectLastValue(kosmos.promptViewModel.isIndicatorMessageVisible)
1418 val canTryAgain by collectLastValue(kosmos.promptViewModel.canTryAgainNow)
1419
1420 kosmos.promptViewModel.showAuthenticating("go")
1421 val errorJob = launch {
1422 kosmos.promptViewModel.showTemporaryError(
1423 errorMessage,
1424 messageAfterError = helpMessage,
1425 authenticateAfterError = false,
1426 failedModality = testCase.authenticatedModality,
1427 )
1428 }
1429
1430 assertThat(authenticating).isFalse()
1431 assertThat(authenticated?.isAuthenticated).isFalse()
1432 assertThat(message).isEqualTo(PromptMessage.Error(errorMessage))
1433 assertThat(messageVisible).isTrue()
1434 assertThat(canTryAgain).isEqualTo(testCase.authenticatedByFace)
1435 assertButtonsVisible(negative = true, tryAgain = expectTryAgainButton)
1436
1437 errorJob.join()
1438
1439 assertThat(authenticating).isFalse()
1440 assertThat(authenticated?.isAuthenticated).isFalse()
1441 assertThat(message).isEqualTo(PromptMessage.Help(helpMessage))
1442 assertThat(messageVisible).isTrue()
1443 assertThat(canTryAgain).isEqualTo(testCase.authenticatedByFace)
1444 assertButtonsVisible(negative = true, tryAgain = expectTryAgainButton)
1445
1446 val helpMessage2 = "foo"
1447 kosmos.promptViewModel.showAuthenticating(helpMessage2, isRetry = true)
1448 assertThat(authenticating).isTrue()
1449 assertThat(authenticated?.isAuthenticated).isFalse()
1450 assertThat(message).isEqualTo(PromptMessage.Help(helpMessage2))
1451 assertThat(messageVisible).isTrue()
1452 assertButtonsVisible(negative = true)
1453 }
1454
1455 @Test
1456 fun switch_to_credential_fallback() = runGenericTest {
1457 val size by collectLastValue(kosmos.promptViewModel.size)
1458
1459 // TODO(b/251476085): remove Spaghetti, migrate logic, and update this test
1460 kosmos.promptViewModel.onSwitchToCredential()
1461
1462 assertThat(size).isEqualTo(PromptSize.LARGE)
1463 }
1464
1465 @Test
1466 fun hint_for_talkback_guidance() = runGenericTest {
1467 val hint by collectLastValue(kosmos.promptViewModel.accessibilityHint)
1468
1469 // Touches should fall outside of sensor area
1470 whenever(kosmos.udfpsUtils.getTouchInNativeCoordinates(any(), any(), any()))
1471 .thenReturn(Point(0, 0))
1472 whenever(kosmos.udfpsUtils.onTouchOutsideOfSensorArea(any(), any(), any(), any(), any()))
1473 .thenReturn("Direction")
1474
1475 kosmos.promptViewModel.onAnnounceAccessibilityHint(
1476 obtainMotionEvent(MotionEvent.ACTION_HOVER_ENTER),
1477 true,
1478 )
1479
1480 if (testCase.modalities.hasUdfps) {
1481 assertThat(hint?.isNotBlank()).isTrue()
1482 } else {
1483 assertThat(hint.isNullOrBlank()).isTrue()
1484 }
1485 }
1486
1487 @Test
1488 fun no_hint_for_talkback_guidance_after_auth() = runGenericTest {
1489 val hint by collectLastValue(kosmos.promptViewModel.accessibilityHint)
1490
1491 kosmos.promptViewModel.showAuthenticated(testCase.authenticatedModality, 0)
1492 kosmos.promptViewModel.confirmAuthenticated()
1493
1494 // Touches should fall outside of sensor area
1495 whenever(kosmos.udfpsUtils.getTouchInNativeCoordinates(any(), any(), any()))
1496 .thenReturn(Point(0, 0))
1497 whenever(kosmos.udfpsUtils.onTouchOutsideOfSensorArea(any(), any(), any(), any(), any()))
1498 .thenReturn("Direction")
1499
1500 kosmos.promptViewModel.onAnnounceAccessibilityHint(
1501 obtainMotionEvent(MotionEvent.ACTION_HOVER_ENTER),
1502 true,
1503 )
1504
1505 assertThat(hint.isNullOrBlank()).isTrue()
1506 }
1507
1508 @Test
1509 fun descriptionOverriddenByVerticalListContentView() =
1510 runGenericTest(description = "test description", contentView = promptContentView) {
1511 val contentView by collectLastValue(kosmos.promptViewModel.contentView)
1512 val description by collectLastValue(kosmos.promptViewModel.description)
1513
1514 assertThat(description).isEqualTo("")
1515 assertThat(contentView).isEqualTo(promptContentView)
1516 }
1517
1518 @Test
1519 fun descriptionOverriddenByContentViewWithMoreOptionsButton() =
1520 runGenericTest(
1521 description = "test description",
1522 contentView = promptContentViewWithMoreOptionsButton,
1523 ) {
1524 val contentView by collectLastValue(kosmos.promptViewModel.contentView)
1525 val description by collectLastValue(kosmos.promptViewModel.description)
1526
1527 assertThat(description).isEqualTo("")
1528 assertThat(contentView).isEqualTo(promptContentViewWithMoreOptionsButton)
1529 }
1530
1531 @Test
1532 fun descriptionWithoutContentView() =
1533 runGenericTest(description = "test description") {
1534 val contentView by collectLastValue(kosmos.promptViewModel.contentView)
1535 val description by collectLastValue(kosmos.promptViewModel.description)
1536
1537 assertThat(description).isEqualTo("test description")
1538 assertThat(contentView).isNull()
1539 }
1540
1541 @Test
1542 fun logo_nullIfPkgNameNotFound() =
1543 runGenericTest(packageName = OP_PACKAGE_NAME_CAN_NOT_BE_FOUND) {
1544 val logoInfo by collectLastValue(kosmos.promptViewModel.logoInfo)
1545 assertThat(logoInfo).isNotNull()
1546 assertThat(logoInfo!!.first).isNull()
1547 assertThat(logoInfo!!.second).isEqualTo("")
1548 }
1549
1550 @Test
1551 fun logo_defaultIsNull() =
1552 runGenericTest(packageName = OP_PACKAGE_NAME_NO_LOGO_INFO) {
1553 val logoInfo by collectLastValue(kosmos.promptViewModel.logoInfo)
1554 assertThat(logoInfo).isNotNull()
1555 assertThat(logoInfo!!.first).isNull()
1556 assertThat(logoInfo!!.second).isEqualTo("")
1557 }
1558
1559 @Test
1560 fun logo_defaultFromActivityInfo() =
1561 runGenericTest(packageName = OP_PACKAGE_NAME_WITH_ACTIVITY_LOGO) {
1562 val logoInfo by collectLastValue(kosmos.promptViewModel.logoInfo)
1563
1564 assertThat(logoInfo).isNotNull()
1565 // 1. PM.getApplicationInfo(OP_PACKAGE_NAME_WITH_ACTIVITY_LOGO) is set to return
1566 // applicationInfoWithIconAndDescription with "defaultLogoIconFromAppInfo",
1567 // 2. iconProvider.getIcon(activityInfo) is set to return
1568 // "defaultLogoIconFromActivityInfo"
1569 // For the apps with OP_PACKAGE_NAME_WITH_ACTIVITY_LOGO, 2 should be called instead of 1
1570 assertThat(logoInfo!!.first).isEqualTo(defaultLogoIconFromActivityInfo)
1571 // 1. PM.getApplicationInfo(OP_PACKAGE_NAME_WITH_ACTIVITY_LOGO) is set to return
1572 // applicationInfoWithIconAndDescription with "defaultLogoDescriptionFromAppInfo",
1573 // 2. activityInfo.loadLabel() is set to return defaultLogoDescriptionFromActivityInfo
1574 // For the apps with OP_PACKAGE_NAME_WITH_ACTIVITY_LOGO, 2 should be called instead of 1
1575 assertThat(logoInfo!!.second).isEqualTo(defaultLogoDescriptionFromActivityInfo)
1576 }
1577
1578 @Test
1579 fun logo_defaultFromApplicationInfo() = runGenericTest {
1580 val logoInfo by collectLastValue(kosmos.promptViewModel.logoInfo)
1581 assertThat(logoInfo).isNotNull()
1582 assertThat(logoInfo!!.first).isEqualTo(defaultLogoIconFromAppInfo)
1583 assertThat(logoInfo!!.second).isEqualTo(defaultLogoDescriptionFromAppInfo)
1584 }
1585
1586 @Test
1587 fun logo_defaultWithWorkBadge() =
1588 runGenericTest(userId = WORK_USER_ID) {
1589 val logoInfo by collectLastValue(kosmos.promptViewModel.logoInfo)
1590 assertThat(logoInfo).isNotNull()
1591 assertThat(logoInfo!!.first).isEqualTo(defaultLogoIconWithBadge)
1592 // Logo label does not use badge info.
1593 assertThat(logoInfo!!.second).isEqualTo(defaultLogoDescriptionFromAppInfo)
1594 }
1595
1596 @Test
1597 fun logoRes_setByApp() =
1598 runGenericTest(logoRes = logoResFromApp) {
1599 val expectedBitmap = context.getDrawable(logoResFromApp).toBitmap()
1600 val logoInfo by collectLastValue(kosmos.promptViewModel.logoInfo)
1601 assertThat(logoInfo).isNotNull()
1602 assertThat((logoInfo!!.first as BitmapDrawable).bitmap.sameAs(expectedBitmap)).isTrue()
1603 }
1604
1605 @Test
1606 fun logoBitmap_setByApp() =
1607 runGenericTest(logoBitmap = logoBitmapFromApp) {
1608 val logoInfo by collectLastValue(kosmos.promptViewModel.logoInfo)
1609 assertThat((logoInfo!!.first as BitmapDrawable).bitmap).isEqualTo(logoBitmapFromApp)
1610 }
1611
1612 @Test
1613 fun logoDescription_setByApp() =
1614 runGenericTest(logoDescription = logoDescriptionFromApp) {
1615 val logoInfo by collectLastValue(kosmos.promptViewModel.logoInfo)
1616 assertThat(logoInfo!!.second).isEqualTo(logoDescriptionFromApp)
1617 }
1618
1619 @Test
1620 fun position_bottom_rotation0() = runGenericTest {
1621 kosmos.displayStateRepository.setCurrentRotation(DisplayRotation.ROTATION_0)
1622 val position by collectLastValue(kosmos.promptViewModel.position)
1623 assertThat(position).isEqualTo(PromptPosition.Bottom)
1624 } // TODO(b/335278136): Add test for no sensor landscape
1625
1626 @Test
1627 fun position_bottom_forceLarge() = runGenericTest {
1628 kosmos.displayStateRepository.setCurrentRotation(DisplayRotation.ROTATION_270)
1629 kosmos.promptViewModel.onSwitchToCredential()
1630 val position by collectLastValue(kosmos.promptViewModel.position)
1631 assertThat(position).isEqualTo(PromptPosition.Bottom)
1632 }
1633
1634 @Test
1635 fun position_bottom_largeScreen() = runGenericTest {
1636 kosmos.displayStateRepository.setCurrentRotation(DisplayRotation.ROTATION_270)
1637 kosmos.displayStateRepository.setIsLargeScreen(true)
1638 val position by collectLastValue(kosmos.promptViewModel.position)
1639 assertThat(position).isEqualTo(PromptPosition.Bottom)
1640 }
1641
1642 @Test
1643 fun position_right_rotation90() = runGenericTest {
1644 kosmos.displayStateRepository.setCurrentRotation(DisplayRotation.ROTATION_90)
1645 val position by collectLastValue(kosmos.promptViewModel.position)
1646 assertThat(position).isEqualTo(PromptPosition.Right)
1647 }
1648
1649 @Test
1650 fun position_left_rotation270() = runGenericTest {
1651 kosmos.displayStateRepository.setCurrentRotation(DisplayRotation.ROTATION_270)
1652 val position by collectLastValue(kosmos.promptViewModel.position)
1653 assertThat(position).isEqualTo(PromptPosition.Left)
1654 }
1655
1656 @Test
1657 fun position_top_rotation180() = runGenericTest {
1658 kosmos.displayStateRepository.setCurrentRotation(DisplayRotation.ROTATION_180)
1659 val position by collectLastValue(kosmos.promptViewModel.position)
1660 if (testCase.modalities.hasUdfps) {
1661 assertThat(position).isEqualTo(PromptPosition.Top)
1662 } else {
1663 assertThat(position).isEqualTo(PromptPosition.Bottom)
1664 }
1665 }
1666
1667 @Test
1668 fun guideline_bottom() = runGenericTest {
1669 kosmos.displayStateRepository.setCurrentRotation(DisplayRotation.ROTATION_0)
1670 val guidelineBounds by collectLastValue(kosmos.promptViewModel.guidelineBounds)
1671 assertThat(guidelineBounds).isEqualTo(Rect(0, mediumTopGuidelinePadding, 0, 0))
1672 } // TODO(b/335278136): Add test for no sensor landscape
1673
1674 @Test
1675 fun guideline_right() = runGenericTest {
1676 kosmos.displayStateRepository.setCurrentRotation(DisplayRotation.ROTATION_90)
1677
1678 val isSmall = testCase.shouldStartAsImplicitFlow
1679 val guidelineBounds by collectLastValue(kosmos.promptViewModel.guidelineBounds)
1680
1681 if (isSmall) {
1682 assertThat(guidelineBounds).isEqualTo(Rect(-smallHorizontalGuidelinePadding, 0, 0, 0))
1683 } else if (testCase.modalities.hasUdfps) {
1684 assertThat(guidelineBounds).isEqualTo(Rect(udfpsHorizontalGuidelinePadding, 0, 0, 0))
1685 } else {
1686 assertThat(guidelineBounds).isEqualTo(Rect(-mediumHorizontalGuidelinePadding, 0, 0, 0))
1687 }
1688 }
1689
1690 @Test
1691 fun guideline_right_onlyShortTitle() =
1692 runGenericTest(subtitle = "") {
1693 kosmos.displayStateRepository.setCurrentRotation(DisplayRotation.ROTATION_90)
1694
1695 val isSmall = testCase.shouldStartAsImplicitFlow
1696 val guidelineBounds by collectLastValue(kosmos.promptViewModel.guidelineBounds)
1697
1698 if (!isSmall && testCase.modalities.hasUdfps) {
1699 assertThat(guidelineBounds)
1700 .isEqualTo(Rect(-udfpsHorizontalShorterGuidelinePadding, 0, 0, 0))
1701 }
1702 }
1703
1704 @Test
1705 fun guideline_left() = runGenericTest {
1706 kosmos.displayStateRepository.setCurrentRotation(DisplayRotation.ROTATION_270)
1707
1708 val isSmall = testCase.shouldStartAsImplicitFlow
1709 val guidelineBounds by collectLastValue(kosmos.promptViewModel.guidelineBounds)
1710
1711 if (isSmall) {
1712 assertThat(guidelineBounds).isEqualTo(Rect(0, 0, -smallHorizontalGuidelinePadding, 0))
1713 } else if (testCase.modalities.hasUdfps) {
1714 assertThat(guidelineBounds).isEqualTo(Rect(0, 0, udfpsHorizontalGuidelinePadding, 0))
1715 } else {
1716 assertThat(guidelineBounds).isEqualTo(Rect(0, 0, -mediumHorizontalGuidelinePadding, 0))
1717 }
1718 }
1719
1720 @Test
1721 fun guideline_left_onlyShortTitle() =
1722 runGenericTest(subtitle = "") {
1723 kosmos.displayStateRepository.setCurrentRotation(DisplayRotation.ROTATION_270)
1724
1725 val isSmall = testCase.shouldStartAsImplicitFlow
1726 val guidelineBounds by collectLastValue(kosmos.promptViewModel.guidelineBounds)
1727
1728 if (!isSmall && testCase.modalities.hasUdfps) {
1729 assertThat(guidelineBounds)
1730 .isEqualTo(Rect(0, 0, -udfpsHorizontalShorterGuidelinePadding, 0))
1731 }
1732 }
1733
1734 @Test
1735 fun guideline_top() = runGenericTest {
1736 kosmos.displayStateRepository.setCurrentRotation(DisplayRotation.ROTATION_180)
1737 val guidelineBounds by collectLastValue(kosmos.promptViewModel.guidelineBounds)
1738 if (testCase.modalities.hasUdfps) {
1739 assertThat(guidelineBounds).isEqualTo(Rect(0, 0, 0, 0))
1740 }
1741 }
1742
1743 @Test
1744 fun iconViewLoaded() = runGenericTest {
1745 val isIconViewLoaded by collectLastValue(kosmos.promptViewModel.isIconViewLoaded)
1746 // TODO(b/328677869): Add test for noIcon logic.
1747 assertThat(isIconViewLoaded).isFalse()
1748
1749 kosmos.promptViewModel.setIsIconViewLoaded(true)
1750
1751 assertThat(isIconViewLoaded).isTrue()
1752 }
1753
1754 /** Asserts that the selected buttons are visible now. */
1755 private suspend fun TestScope.assertButtonsVisible(
1756 tryAgain: Boolean = false,
1757 confirm: Boolean = false,
1758 cancel: Boolean = false,
1759 negative: Boolean = false,
1760 credential: Boolean = false,
1761 ) {
1762 runCurrent()
1763 assertThat(kosmos.promptViewModel.isTryAgainButtonVisible.first()).isEqualTo(tryAgain)
1764 assertThat(kosmos.promptViewModel.isConfirmButtonVisible.first()).isEqualTo(confirm)
1765 assertThat(kosmos.promptViewModel.isCancelButtonVisible.first()).isEqualTo(cancel)
1766 assertThat(kosmos.promptViewModel.isNegativeButtonVisible.first()).isEqualTo(negative)
1767 assertThat(kosmos.promptViewModel.isCredentialButtonVisible.first()).isEqualTo(credential)
1768 }
1769
1770 private fun runGenericTest(
1771 doNotStart: Boolean = false,
1772 allowCredentialFallback: Boolean = false,
1773 subtitle: String? = "s",
1774 description: String? = null,
1775 contentView: PromptContentView? = null,
1776 logoRes: Int = 0,
1777 logoBitmap: Bitmap? = null,
1778 logoDescription: String? = null,
1779 packageName: String = OP_PACKAGE_NAME_WITH_APP_LOGO,
1780 userId: Int = USER_ID,
1781 block: suspend TestScope.() -> Unit,
1782 ) {
1783 val topActivity = ComponentName(packageName, "test app")
1784 runningTaskInfo.topActivity = topActivity
1785 whenever(kosmos.activityTaskManager.getTasks(1)).thenReturn(listOf(runningTaskInfo))
1786 kosmos.promptSelectorInteractor.resetPrompt(REQUEST_ID)
1787
1788 kosmos.promptSelectorInteractor.initializePrompt(
1789 requireConfirmation = testCase.confirmationRequested,
1790 allowCredentialFallback = allowCredentialFallback,
1791 fingerprint = testCase.fingerprint,
1792 face = testCase.face,
1793 subtitleFromApp = subtitle,
1794 descriptionFromApp = description,
1795 contentViewFromApp = contentView,
1796 logoResFromApp = logoRes,
1797 logoBitmapFromApp = if (logoRes != 0) logoDrawableFromAppRes.toBitmap() else logoBitmap,
1798 logoDescriptionFromApp = logoDescription,
1799 packageName = packageName,
1800 userId = userId,
1801 )
1802
1803 kosmos.biometricStatusRepository.setFingerprintAcquiredStatus(
1804 AcquiredFingerprintAuthenticationStatus(
1805 AuthenticationReason.BiometricPromptAuthentication,
1806 BiometricFingerprintConstants.FINGERPRINT_ACQUIRED_UNKNOWN,
1807 )
1808 )
1809
1810 // put the view model in the initial authenticating state, unless explicitly skipped
1811 val startMode =
1812 when {
1813 doNotStart -> null
1814 testCase.isCoex -> FingerprintStartMode.Delayed
1815 else -> FingerprintStartMode.Normal
1816 }
1817 when (startMode) {
1818 FingerprintStartMode.Normal -> {
1819 kosmos.promptViewModel.ensureFingerprintHasStarted(isDelayed = false)
1820 kosmos.promptViewModel.showAuthenticating()
1821 }
1822 FingerprintStartMode.Delayed -> {
1823 kosmos.promptViewModel.showAuthenticating()
1824 }
1825 else -> {
1826 /* skip */
1827 }
1828 }
1829
1830 if (testCase.fingerprint?.isAnyUdfpsType == true) {
1831 kosmos.testScope.collectLastValue(kosmos.udfpsOverlayInteractor.udfpsOverlayParams)
1832 kosmos.testScope.runCurrent()
1833 overrideUdfpsOverlayParams()
1834 }
1835
1836 kosmos.testScope.runTest { block() }
1837 }
1838
1839 private fun overrideUdfpsOverlayParams(isLandscape: Boolean = false) {
1840 val authControllerCallback = authController.captureCallback()
1841 authControllerCallback.onUdfpsLocationChanged(
1842 mockUdfpsOverlayParams(isLandscape = isLandscape)
1843 )
1844 }
1845
1846 /** Obtain a MotionEvent with the specified MotionEvent action constant */
1847 private fun obtainMotionEvent(action: Int): MotionEvent =
1848 MotionEvent.obtain(0, 0, action, 0f, 0f, 0)
1849
1850 companion object {
1851 @JvmStatic
1852 @Parameters(name = "{0}")
1853 fun data(): Collection<TestCase> = singleModalityTestCases + coexTestCases
1854
1855 private val singleModalityTestCases =
1856 listOf(
1857 TestCase(
1858 face = faceSensorPropertiesInternal(strong = true).first(),
1859 authenticatedModality = BiometricModality.Face,
1860 ),
1861 TestCase(
1862 fingerprint =
1863 fingerprintSensorPropertiesInternal(
1864 sensorType = FingerprintSensorProperties.TYPE_REAR
1865 )
1866 .first(),
1867 authenticatedModality = BiometricModality.Fingerprint,
1868 ),
1869 TestCase(
1870 fingerprint =
1871 fingerprintSensorPropertiesInternal(
1872 strong = true,
1873 sensorType = FingerprintSensorProperties.TYPE_POWER_BUTTON,
1874 )
1875 .first(),
1876 authenticatedModality = BiometricModality.Fingerprint,
1877 isInRearDisplayMode = false,
1878 ),
1879 TestCase(
1880 fingerprint =
1881 fingerprintSensorPropertiesInternal(
1882 strong = true,
1883 sensorType = FingerprintSensorProperties.TYPE_POWER_BUTTON,
1884 )
1885 .first(),
1886 authenticatedModality = BiometricModality.Fingerprint,
1887 isInRearDisplayMode = true,
1888 ),
1889 TestCase(
1890 fingerprint =
1891 fingerprintSensorPropertiesInternal(
1892 strong = true,
1893 sensorType = FingerprintSensorProperties.TYPE_UDFPS_OPTICAL,
1894 )
1895 .first(),
1896 authenticatedModality = BiometricModality.Fingerprint,
1897 ),
1898 TestCase(
1899 face = faceSensorPropertiesInternal(strong = true).first(),
1900 authenticatedModality = BiometricModality.Face,
1901 confirmationRequested = true,
1902 ),
1903 TestCase(
1904 fingerprint = fingerprintSensorPropertiesInternal(strong = true).first(),
1905 authenticatedModality = BiometricModality.Fingerprint,
1906 confirmationRequested = true,
1907 ),
1908 TestCase(
1909 fingerprint =
1910 fingerprintSensorPropertiesInternal(
1911 strong = true,
1912 sensorType = FingerprintSensorProperties.TYPE_POWER_BUTTON,
1913 )
1914 .first(),
1915 authenticatedModality = BiometricModality.Fingerprint,
1916 confirmationRequested = true,
1917 ),
1918 )
1919
1920 private val coexTestCases =
1921 listOf(
1922 TestCase(
1923 face = faceSensorPropertiesInternal(strong = true).first(),
1924 fingerprint = fingerprintSensorPropertiesInternal(strong = true).first(),
1925 authenticatedModality = BiometricModality.Face,
1926 ),
1927 TestCase(
1928 face = faceSensorPropertiesInternal(strong = true).first(),
1929 fingerprint = fingerprintSensorPropertiesInternal(strong = true).first(),
1930 authenticatedModality = BiometricModality.Face,
1931 confirmationRequested = true,
1932 ),
1933 TestCase(
1934 face = faceSensorPropertiesInternal(strong = true).first(),
1935 fingerprint =
1936 fingerprintSensorPropertiesInternal(
1937 strong = true,
1938 sensorType = FingerprintSensorProperties.TYPE_POWER_BUTTON,
1939 )
1940 .first(),
1941 authenticatedModality = BiometricModality.Fingerprint,
1942 confirmationRequested = true,
1943 ),
1944 TestCase(
1945 face = faceSensorPropertiesInternal(strong = true).first(),
1946 fingerprint =
1947 fingerprintSensorPropertiesInternal(
1948 strong = true,
1949 sensorType = FingerprintSensorProperties.TYPE_UDFPS_OPTICAL,
1950 )
1951 .first(),
1952 authenticatedModality = BiometricModality.Fingerprint,
1953 ),
1954 )
1955 }
1956 }
1957
1958 internal data class TestCase(
1959 val fingerprint: FingerprintSensorPropertiesInternal? = null,
1960 val face: FaceSensorPropertiesInternal? = null,
1961 val isInRearDisplayMode: Boolean = false,
1962 val authenticatedModality: BiometricModality,
1963 val confirmationRequested: Boolean = false,
1964 ) {
toStringnull1965 override fun toString(): String {
1966 val modality =
1967 when {
1968 fingerprint != null && face != null -> "coex"
1969 fingerprint != null && fingerprint.isAnySidefpsType -> "fingerprint only, sideFps"
1970 fingerprint != null && fingerprint.isAnyUdfpsType -> "fingerprint only, udfps"
1971 fingerprint != null &&
1972 fingerprint.sensorType == FingerprintSensorProperties.TYPE_REAR ->
1973 "fingerprint only, rearFps"
1974 face != null -> "face only"
1975 else -> "?"
1976 }
1977 return "[$modality, isInRearDisplayMode: $isInRearDisplayMode, by: " +
1978 "$authenticatedModality, confirm: $confirmationRequested]"
1979 }
1980
expectConfirmationnull1981 fun expectConfirmation(atLeastOneFailure: Boolean): Boolean =
1982 when {
1983 isCoex && authenticatedModality == BiometricModality.Face ->
1984 atLeastOneFailure || confirmationRequested
1985 isFaceOnly -> confirmationRequested
1986 else -> false
1987 }
1988
1989 val modalities: BiometricModalities
1990 get() = BiometricModalities(fingerprint, face)
1991
1992 val authenticatedByFingerprint: Boolean
1993 get() = authenticatedModality == BiometricModality.Fingerprint
1994
1995 val authenticatedByFace: Boolean
1996 get() = authenticatedModality == BiometricModality.Face
1997
1998 val isFaceOnly: Boolean
1999 get() = face != null && fingerprint == null
2000
2001 val isFingerprintOnly: Boolean
2002 get() = face == null && fingerprint != null
2003
2004 val isCoex: Boolean
2005 get() = face != null && fingerprint != null
2006
2007 @FingerprintSensorProperties.SensorType val sensorType: Int? = fingerprint?.sensorType
2008
2009 val shouldStartAsImplicitFlow: Boolean
2010 get() = (isFaceOnly || isCoex) && !confirmationRequested
2011 }
2012
2013 /** Initialize the test by selecting the give [fingerprint] or [face] configuration(s). */
initializePromptnull2014 private fun PromptSelectorInteractor.initializePrompt(
2015 fingerprint: FingerprintSensorPropertiesInternal? = null,
2016 face: FaceSensorPropertiesInternal? = null,
2017 requireConfirmation: Boolean = false,
2018 allowCredentialFallback: Boolean = false,
2019 subtitleFromApp: String? = "s",
2020 descriptionFromApp: String? = null,
2021 contentViewFromApp: PromptContentView? = null,
2022 logoResFromApp: Int = 0,
2023 logoBitmapFromApp: Bitmap? = null,
2024 logoDescriptionFromApp: String? = null,
2025 packageName: String = OP_PACKAGE_NAME_WITH_APP_LOGO,
2026 userId: Int = USER_ID,
2027 ) {
2028 val info =
2029 PromptInfo().apply {
2030 logoDescription = logoDescriptionFromApp
2031 title = "t"
2032 subtitle = subtitleFromApp
2033 description = descriptionFromApp
2034 contentView = contentViewFromApp
2035 authenticators = listOf(face, fingerprint).extractAuthenticatorTypes()
2036 isDeviceCredentialAllowed = allowCredentialFallback
2037 isConfirmationRequested = requireConfirmation
2038 }
2039 if (logoBitmapFromApp != null) {
2040 info.setLogo(logoResFromApp, logoBitmapFromApp)
2041 }
2042
2043 setPrompt(
2044 info,
2045 userId,
2046 REQUEST_ID,
2047 BiometricModalities(fingerprintProperties = fingerprint, faceProperties = face),
2048 CHALLENGE,
2049 packageName,
2050 onSwitchToCredential = false,
2051 isLandscape = false,
2052 )
2053 }
2054
AuthControllernull2055 private fun AuthController.captureCallback() =
2056 withArgCaptor<AuthController.Callback> {
2057 Mockito.verify(this@captureCallback).addCallback(capture())
2058 }
2059