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.PromptContentItemBulletedText
32 import android.hardware.biometrics.PromptContentView
33 import android.hardware.biometrics.PromptContentViewWithMoreOptionsButton
34 import android.hardware.biometrics.PromptInfo
35 import android.hardware.biometrics.PromptVerticalListContentView
36 import android.hardware.face.FaceSensorPropertiesInternal
37 import android.hardware.fingerprint.FingerprintSensorProperties
38 import android.hardware.fingerprint.FingerprintSensorPropertiesInternal
39 import android.os.UserHandle
40 import android.platform.test.annotations.DisableFlags
41 import android.platform.test.annotations.EnableFlags
42 import android.view.HapticFeedbackConstants
43 import android.view.MotionEvent
44 import android.view.Surface
45 import androidx.test.filters.SmallTest
46 import com.android.app.activityTaskManager
47 import com.android.keyguard.AuthInteractionProperties
48 import com.android.systemui.Flags
49 import com.android.systemui.SysuiTestCase
50 import com.android.systemui.biometrics.AuthController
51 import com.android.systemui.biometrics.Utils.toBitmap
52 import com.android.systemui.biometrics.authController
53 import com.android.systemui.biometrics.data.repository.biometricStatusRepository
54 import com.android.systemui.biometrics.data.repository.fakeFingerprintPropertyRepository
55 import com.android.systemui.biometrics.domain.interactor.PromptSelectorInteractor
56 import com.android.systemui.biometrics.domain.interactor.promptSelectorInteractor
57 import com.android.systemui.biometrics.domain.interactor.udfpsOverlayInteractor
58 import com.android.systemui.biometrics.extractAuthenticatorTypes
59 import com.android.systemui.biometrics.faceSensorPropertiesInternal
60 import com.android.systemui.biometrics.fingerprintSensorPropertiesInternal
61 import com.android.systemui.biometrics.shared.model.AuthenticationReason
62 import com.android.systemui.biometrics.shared.model.BiometricModalities
63 import com.android.systemui.biometrics.shared.model.BiometricModality
64 import com.android.systemui.biometrics.shared.model.DisplayRotation
65 import com.android.systemui.biometrics.shared.model.UdfpsOverlayParams
66 import com.android.systemui.biometrics.shared.model.toSensorStrength
67 import com.android.systemui.biometrics.shared.model.toSensorType
68 import com.android.systemui.biometrics.udfpsUtils
69 import com.android.systemui.concurrency.fakeExecutor
70 import com.android.systemui.coroutines.collectLastValue
71 import com.android.systemui.coroutines.collectValues
72 import com.android.systemui.display.data.repository.displayStateRepository
73 import com.android.systemui.keyguard.shared.model.AcquiredFingerprintAuthenticationStatus
74 import com.android.systemui.kosmos.Kosmos
75 import com.android.systemui.kosmos.testScope
76 import com.android.systemui.res.R
77 import com.android.systemui.util.mockito.withArgCaptor
78 import com.google.android.msdl.data.model.MSDLToken
79 import com.google.common.truth.Truth.assertThat
80 import kotlinx.coroutines.ExperimentalCoroutinesApi
81 import kotlinx.coroutines.flow.first
82 import kotlinx.coroutines.launch
83 import kotlinx.coroutines.test.TestScope
84 import kotlinx.coroutines.test.runCurrent
85 import kotlinx.coroutines.test.runTest
86 import org.junit.Before
87 import org.junit.Rule
88 import org.junit.Test
89 import org.junit.runner.RunWith
90 import org.mockito.ArgumentMatchers.anyInt
91 import org.mockito.ArgumentMatchers.eq
92 import org.mockito.Mock
93 import org.mockito.Mockito
94 import org.mockito.junit.MockitoJUnit
95 import org.mockito.kotlin.any
96 import org.mockito.kotlin.whenever
97 import platform.test.runner.parameterized.ParameterizedAndroidJunit4
98 import platform.test.runner.parameterized.Parameters
99
100 private const val USER_ID = 4
101 private const val WORK_USER_ID = 100
102 private const val REQUEST_ID = 4L
103 private const val CHALLENGE = 2L
104 private const val DELAY = 1000L
105 private const val OP_PACKAGE_NAME_WITH_ACTIVITY_LOGO = "should.use.activiy.logo"
106 private const val OP_PACKAGE_NAME_WITH_APP_LOGO = "biometric.testapp"
107 private const val OP_PACKAGE_NAME_NO_LOGO_INFO = "biometric.testapp.nologoinfo"
108 private const val OP_PACKAGE_NAME_CAN_NOT_BE_FOUND = "can.not.be.found"
109
110 @OptIn(ExperimentalCoroutinesApi::class)
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 /** Mock [UdfpsOverlayParams] for a test. */
162 private fun mockUdfpsOverlayParams(isLandscape: Boolean = false): UdfpsOverlayParams =
163 UdfpsOverlayParams(
164 sensorBounds = Rect(400, 1600, 600, 1800),
165 overlayBounds = Rect(0, 1200, 1000, 2400),
166 naturalDisplayWidth = 1000,
167 naturalDisplayHeight = 3000,
168 scaleFactor = 1f,
169 rotation = if (isLandscape) Surface.ROTATION_90 else Surface.ROTATION_0,
170 )
171
172 private lateinit var promptContentView: PromptContentView
173 private lateinit var promptContentViewWithMoreOptionsButton:
174 PromptContentViewWithMoreOptionsButton
175
176 private val kosmos = Kosmos()
177
178 @Before
179 fun setup() {
180 setupLogo()
181 overrideResource(R.dimen.biometric_dialog_fingerprint_icon_width, mockFingerprintIconWidth)
182 overrideResource(
183 R.dimen.biometric_dialog_fingerprint_icon_height,
184 mockFingerprintIconHeight,
185 )
186 overrideResource(R.dimen.biometric_dialog_face_icon_size, mockFaceIconSize)
187
188 kosmos.applicationContext = context
189
190 if (testCase.fingerprint?.isAnyUdfpsType == true) {
191 kosmos.authController = authController
192 }
193
194 testCase.fingerprint?.let {
195 kosmos.fakeFingerprintPropertyRepository.setProperties(
196 it.sensorId,
197 it.sensorStrength.toSensorStrength(),
198 it.sensorType.toSensorType(),
199 it.allLocations.associateBy { sensorLocationInternal ->
200 sensorLocationInternal.displayId
201 },
202 )
203 }
204
205 kosmos.displayStateRepository.setCurrentRotation(DisplayRotation.ROTATION_0)
206 testCase.fingerprint?.isAnySidefpsType.let {
207 kosmos.displayStateRepository.setIsInRearDisplayMode(testCase.isInRearDisplayMode)
208 }
209
210 promptContentView =
211 PromptVerticalListContentView.Builder()
212 .addListItem(PromptContentItemBulletedText("content item 1"))
213 .addListItem(PromptContentItemBulletedText("content item 2"), 1)
214 .build()
215
216 promptContentViewWithMoreOptionsButton =
217 PromptContentViewWithMoreOptionsButton.Builder()
218 .setDescription("test")
219 .setMoreOptionsButtonListener(kosmos.fakeExecutor) { _, _ -> }
220 .build()
221 }
222
223 private fun setupLogo() {
224 // Set up app customized logo
225 overrideResource(logoResFromApp, logoDrawableFromAppRes)
226
227 // Set up when activity info should be used
228 overrideResource(
229 R.array.config_useActivityLogoForBiometricPrompt,
230 arrayOf(OP_PACKAGE_NAME_WITH_ACTIVITY_LOGO),
231 )
232 whenever(kosmos.packageManager.getActivityInfo(any(), anyInt())).thenReturn(activityInfo)
233 whenever(kosmos.iconProvider.getIcon(activityInfo))
234 .thenReturn(defaultLogoIconFromActivityInfo)
235 whenever(activityInfo.loadLabel(kosmos.packageManager))
236 .thenReturn(defaultLogoDescriptionFromActivityInfo)
237
238 // Set up when application info should be used for default logo
239 whenever(
240 kosmos.packageManager.getApplicationInfo(
241 eq(OP_PACKAGE_NAME_WITH_APP_LOGO),
242 anyInt(),
243 )
244 )
245 .thenReturn(applicationInfoWithIconAndDescription)
246 whenever(
247 kosmos.packageManager.getApplicationInfo(
248 eq(OP_PACKAGE_NAME_WITH_ACTIVITY_LOGO),
249 anyInt(),
250 )
251 )
252 .thenReturn(applicationInfoWithIconAndDescription)
253 whenever(kosmos.packageManager.getApplicationIcon(applicationInfoWithIconAndDescription))
254 .thenReturn(defaultLogoIconFromAppInfo)
255 whenever(kosmos.packageManager.getApplicationLabel(applicationInfoWithIconAndDescription))
256 .thenReturn(defaultLogoDescriptionFromAppInfo)
257
258 // Set up when package name cannot but found
259 whenever(
260 kosmos.packageManager.getApplicationInfo(
261 eq(OP_PACKAGE_NAME_CAN_NOT_BE_FOUND),
262 anyInt(),
263 )
264 )
265 .thenThrow(NameNotFoundException())
266
267 // Set up when no default logo from application info
268 whenever(
269 kosmos.packageManager.getApplicationInfo(eq(OP_PACKAGE_NAME_NO_LOGO_INFO), anyInt())
270 )
271 .thenReturn(applicationInfoNoIconOrDescription)
272 whenever(kosmos.packageManager.getApplicationIcon(applicationInfoNoIconOrDescription))
273 .thenReturn(null)
274 whenever(kosmos.packageManager.getApplicationLabel(applicationInfoNoIconOrDescription))
275 .thenReturn("")
276
277 // Set up work badge
278 whenever(kosmos.packageManager.getUserBadgedIcon(any(), eq(UserHandle.of(USER_ID)))).then {
279 it.getArgument(0)
280 }
281 whenever(kosmos.packageManager.getUserBadgedLabel(any(), eq(UserHandle.of(USER_ID)))).then {
282 it.getArgument(0)
283 }
284 whenever(kosmos.packageManager.getUserBadgedIcon(any(), eq(UserHandle.of(WORK_USER_ID))))
285 .then { defaultLogoIconWithBadge }
286 whenever(kosmos.packageManager.getUserBadgedLabel(any(), eq(UserHandle.of(WORK_USER_ID))))
287 .then { defaultLogoDescriptionWithBadge }
288 context.setMockPackageManager(kosmos.packageManager)
289 }
290
291 @Test
292 fun start_idle_and_show_authenticating() =
293 runGenericTest(doNotStart = true) {
294 var expectedPromptSize =
295 if (testCase.shouldStartAsImplicitFlow) PromptSize.SMALL else PromptSize.MEDIUM
296 val authenticating by collectLastValue(kosmos.promptViewModel.isAuthenticating)
297 val authenticated by collectLastValue(kosmos.promptViewModel.isAuthenticated)
298 val modalities by collectLastValue(kosmos.promptViewModel.modalities)
299 val iconAsset by collectLastValue(kosmos.promptViewModel.iconViewModel.iconAsset)
300 val shouldAnimateIconView by
301 collectLastValue(kosmos.promptViewModel.iconViewModel.shouldAnimateIconView)
302 val iconContentDescriptionId by
303 collectLastValue(kosmos.promptViewModel.iconViewModel.contentDescriptionId)
304 val message by collectLastValue(kosmos.promptViewModel.message)
305 val size by collectLastValue(kosmos.promptViewModel.size)
306
307 assertThat(authenticating).isFalse()
308 assertThat(authenticated?.isNotAuthenticated).isTrue()
309 with(modalities ?: throw Exception("missing modalities")) {
310 assertThat(hasFace).isEqualTo(testCase.face != null)
311 assertThat(hasFingerprint).isEqualTo(testCase.fingerprint != null)
312 }
313
314 assertThat(message).isEqualTo(PromptMessage.Empty)
315 assertThat(size).isEqualTo(expectedPromptSize)
316
317 val forceExplicitFlow =
318 testCase.isCoex && testCase.confirmationRequested ||
319 testCase.authenticatedByFingerprint
320
321 if ((testCase.isCoex && !forceExplicitFlow) || testCase.isFaceOnly) {
322 // Face-only or implicit co-ex auth
323 assertThat(iconAsset).isEqualTo(R.raw.face_dialog_idle_static)
324 assertThat(shouldAnimateIconView).isEqualTo(false)
325 }
326
327 if (forceExplicitFlow) {
328 expectedPromptSize = PromptSize.MEDIUM
329 kosmos.promptViewModel.ensureFingerprintHasStarted(isDelayed = true)
330 }
331
332 val startMessage = "here we go"
333 kosmos.promptViewModel.showAuthenticating(startMessage, isRetry = false)
334 verifyIconSize(forceExplicitFlow)
335
336 // Icon asset assertions
337 if ((testCase.isCoex && !forceExplicitFlow) || testCase.isFaceOnly) {
338 // Face-only or implicit co-ex auth
339 assertThat(iconAsset).isEqualTo(R.raw.face_dialog_authenticating)
340 assertThat(iconContentDescriptionId)
341 .isEqualTo(R.string.biometric_dialog_face_icon_description_authenticating)
342 assertThat(shouldAnimateIconView).isEqualTo(true)
343 } else if ((testCase.isCoex && forceExplicitFlow) || testCase.isFingerprintOnly) {
344 // Fingerprint-only or explicit co-ex auth
345 if (testCase.sensorType == FingerprintSensorProperties.TYPE_POWER_BUTTON) {
346 assertThat(iconAsset).isEqualTo(getSfpsAsset_fingerprintAuthenticating())
347 assertThat(iconContentDescriptionId)
348 .isEqualTo(R.string.security_settings_sfps_enroll_find_sensor_message)
349 assertThat(shouldAnimateIconView).isEqualTo(true)
350 } else {
351 assertThat(iconAsset)
352 .isEqualTo(R.raw.fingerprint_dialogue_fingerprint_to_error_lottie)
353 assertThat(iconContentDescriptionId)
354 .isEqualTo(R.string.fingerprint_dialog_touch_sensor)
355 assertThat(shouldAnimateIconView).isEqualTo(false)
356 }
357 }
358
359 assertThat(message).isEqualTo(PromptMessage.Help(startMessage))
360 assertThat(authenticating).isTrue()
361 assertThat(authenticated?.isNotAuthenticated).isTrue()
362 assertThat(size).isEqualTo(expectedPromptSize)
363 assertButtonsVisible(negative = expectedPromptSize != PromptSize.SMALL)
364 }
365
366 @Test
367 fun start_authenticating_show_and_clear_error() = runGenericTest {
368 val iconAsset by collectLastValue(kosmos.promptViewModel.iconViewModel.iconAsset)
369 val iconContentDescriptionId by
370 collectLastValue(kosmos.promptViewModel.iconViewModel.contentDescriptionId)
371 val shouldAnimateIconView by
372 collectLastValue(kosmos.promptViewModel.iconViewModel.shouldAnimateIconView)
373 val message by collectLastValue(kosmos.promptViewModel.message)
374
375 var forceExplicitFlow =
376 testCase.isCoex && testCase.confirmationRequested || testCase.authenticatedByFingerprint
377 if (forceExplicitFlow) {
378 kosmos.promptViewModel.ensureFingerprintHasStarted(isDelayed = true)
379 }
380 verifyIconSize(forceExplicitFlow)
381
382 val errorJob = launch {
383 kosmos.promptViewModel.showTemporaryError(
384 "so sad",
385 messageAfterError = "",
386 authenticateAfterError = testCase.isFingerprintOnly || testCase.isCoex,
387 )
388 forceExplicitFlow = true
389 // Usually done by binder
390 kosmos.promptViewModel.iconViewModel.setPreviousIconWasError(true)
391 }
392
393 assertThat(message?.isError).isEqualTo(true)
394 assertThat(message?.message).isEqualTo("so sad")
395
396 // Icon asset assertions
397 if (testCase.isFaceOnly) {
398 // Face-only auth
399 assertThat(iconAsset).isEqualTo(R.raw.face_dialog_dark_to_error)
400 assertThat(iconContentDescriptionId).isEqualTo(R.string.keyguard_face_failed)
401 assertThat(shouldAnimateIconView).isEqualTo(true)
402
403 // Clear error, go to idle
404 errorJob.join()
405
406 assertThat(iconAsset).isEqualTo(R.raw.face_dialog_error_to_idle)
407 assertThat(iconContentDescriptionId)
408 .isEqualTo(R.string.biometric_dialog_face_icon_description_idle)
409 assertThat(shouldAnimateIconView).isEqualTo(true)
410 } else if ((testCase.isCoex && forceExplicitFlow) || testCase.isFingerprintOnly) {
411 // Fingerprint-only or explicit co-ex auth
412 if (testCase.sensorType == FingerprintSensorProperties.TYPE_POWER_BUTTON) {
413 assertThat(iconAsset).isEqualTo(getSfpsAsset_fingerprintToError())
414 assertThat(iconContentDescriptionId).isEqualTo(R.string.biometric_dialog_try_again)
415 assertThat(shouldAnimateIconView).isEqualTo(true)
416 } else {
417 assertThat(iconAsset)
418 .isEqualTo(R.raw.fingerprint_dialogue_fingerprint_to_error_lottie)
419 assertThat(iconContentDescriptionId).isEqualTo(R.string.biometric_dialog_try_again)
420 assertThat(shouldAnimateIconView).isEqualTo(true)
421 }
422
423 // Clear error, restart authenticating
424 errorJob.join()
425
426 if (testCase.sensorType == FingerprintSensorProperties.TYPE_POWER_BUTTON) {
427 assertThat(iconAsset).isEqualTo(getSfpsAsset_errorToFingerprint())
428 assertThat(iconContentDescriptionId)
429 .isEqualTo(R.string.security_settings_sfps_enroll_find_sensor_message)
430 assertThat(shouldAnimateIconView).isEqualTo(true)
431 } else {
432 assertThat(iconAsset)
433 .isEqualTo(R.raw.fingerprint_dialogue_error_to_fingerprint_lottie)
434 assertThat(iconContentDescriptionId)
435 .isEqualTo(R.string.fingerprint_dialog_touch_sensor)
436 assertThat(shouldAnimateIconView).isEqualTo(true)
437 }
438 }
439 }
440
441 @Test
442 fun shows_error_to_unlock_or_success() {
443 // Face-only auth does not use error -> unlock or error -> success assets
444 if (testCase.isFingerprintOnly || testCase.isCoex) {
445 runGenericTest {
446 // Distinct asset for error -> success only applicable for fingerprint-only /
447 // explicit co-ex auth
448 val iconAsset by collectLastValue(kosmos.promptViewModel.iconViewModel.iconAsset)
449 val iconContentDescriptionId by
450 collectLastValue(kosmos.promptViewModel.iconViewModel.contentDescriptionId)
451 val shouldAnimateIconView by
452 collectLastValue(kosmos.promptViewModel.iconViewModel.shouldAnimateIconView)
453
454 var forceExplicitFlow =
455 testCase.isCoex && testCase.confirmationRequested ||
456 testCase.authenticatedByFingerprint
457 if (forceExplicitFlow) {
458 kosmos.promptViewModel.ensureFingerprintHasStarted(isDelayed = true)
459 }
460 verifyIconSize(forceExplicitFlow)
461
462 kosmos.promptViewModel.ensureFingerprintHasStarted(isDelayed = true)
463 kosmos.promptViewModel.iconViewModel.setPreviousIconWasError(true)
464
465 kosmos.promptViewModel.showAuthenticated(
466 modality = testCase.authenticatedModality,
467 dismissAfterDelay = DELAY,
468 )
469
470 // SFPS test cases
471 if (testCase.sensorType == FingerprintSensorProperties.TYPE_POWER_BUTTON) {
472 // Covers (1) fingerprint-only (2) co-ex, authenticated by fingerprint
473 if (testCase.authenticatedByFingerprint) {
474 assertThat(iconAsset).isEqualTo(R.raw.biometricprompt_sfps_error_to_success)
475 assertThat(iconContentDescriptionId)
476 .isEqualTo(R.string.security_settings_sfps_enroll_find_sensor_message)
477 assertThat(shouldAnimateIconView).isEqualTo(true)
478 } else { // Covers co-ex, authenticated by face
479 assertThat(iconAsset).isEqualTo(R.raw.biometricprompt_sfps_error_to_unlock)
480 assertThat(iconContentDescriptionId)
481 .isEqualTo(R.string.fingerprint_dialog_authenticated_confirmation)
482 assertThat(shouldAnimateIconView).isEqualTo(true)
483
484 // Confirm authentication
485 kosmos.promptViewModel.confirmAuthenticated()
486
487 assertThat(iconAsset)
488 .isEqualTo(R.raw.biometricprompt_sfps_unlock_to_success)
489 assertThat(iconContentDescriptionId)
490 .isEqualTo(R.string.fingerprint_dialog_touch_sensor)
491 assertThat(shouldAnimateIconView).isEqualTo(true)
492 }
493 } else { // Non-SFPS (UDFPS / rear-FPS) test cases
494 // Covers (1) fingerprint-only (2) co-ex, authenticated by fingerprint
495 if (testCase.authenticatedByFingerprint) {
496 assertThat(iconAsset)
497 .isEqualTo(R.raw.fingerprint_dialogue_error_to_success_lottie)
498 assertThat(iconContentDescriptionId)
499 .isEqualTo(R.string.fingerprint_dialog_touch_sensor)
500 assertThat(shouldAnimateIconView).isEqualTo(true)
501 } else { // co-ex, authenticated by face
502 assertThat(iconAsset)
503 .isEqualTo(R.raw.fingerprint_dialogue_error_to_unlock_lottie)
504 assertThat(iconContentDescriptionId)
505 .isEqualTo(R.string.biometric_dialog_confirm)
506 assertThat(shouldAnimateIconView).isEqualTo(true)
507
508 // Confirm authentication
509 kosmos.promptViewModel.confirmAuthenticated()
510
511 assertThat(iconAsset)
512 .isEqualTo(
513 R.raw.fingerprint_dialogue_unlocked_to_checkmark_success_lottie
514 )
515 assertThat(iconContentDescriptionId)
516 .isEqualTo(R.string.fingerprint_dialog_touch_sensor)
517 assertThat(shouldAnimateIconView).isEqualTo(true)
518 }
519 }
520 }
521 }
522 }
523
524 @Test
525 fun shows_authenticated_no_errors_no_confirmation_required() {
526 if (!testCase.confirmationRequested) {
527 runGenericTest {
528 val iconAsset by collectLastValue(kosmos.promptViewModel.iconViewModel.iconAsset)
529 val iconContentDescriptionId by
530 collectLastValue(kosmos.promptViewModel.iconViewModel.contentDescriptionId)
531 val shouldAnimateIconView by
532 collectLastValue(kosmos.promptViewModel.iconViewModel.shouldAnimateIconView)
533 val message by collectLastValue(kosmos.promptViewModel.message)
534 verifyIconSize()
535
536 kosmos.promptViewModel.showAuthenticated(
537 modality = testCase.authenticatedModality,
538 dismissAfterDelay = DELAY,
539 "TEST",
540 )
541
542 if (testCase.isFingerprintOnly) {
543 // Fingerprint icon asset assertions
544 if (testCase.sensorType == FingerprintSensorProperties.TYPE_POWER_BUTTON) {
545 assertThat(iconAsset).isEqualTo(getSfpsAsset_fingerprintToSuccess())
546 assertThat(iconContentDescriptionId)
547 .isEqualTo(R.string.security_settings_sfps_enroll_find_sensor_message)
548 assertThat(shouldAnimateIconView).isEqualTo(true)
549 } else {
550 assertThat(iconAsset)
551 .isEqualTo(R.raw.fingerprint_dialogue_fingerprint_to_success_lottie)
552 assertThat(iconContentDescriptionId)
553 .isEqualTo(R.string.fingerprint_dialog_touch_sensor)
554 assertThat(shouldAnimateIconView).isEqualTo(true)
555 }
556 } else if (testCase.isFaceOnly || testCase.isCoex) {
557 // Face icon asset assertions
558 // If co-ex, use implicit flow (explicit flow always requires confirmation)
559 assertThat(iconAsset).isEqualTo(R.raw.face_dialog_dark_to_checkmark)
560 assertThat(iconContentDescriptionId)
561 .isEqualTo(R.string.biometric_dialog_face_icon_description_authenticated)
562 assertThat(shouldAnimateIconView).isEqualTo(true)
563 assertThat(message).isEqualTo(PromptMessage.Empty)
564 }
565 }
566 }
567 }
568
569 @Test
570 fun shows_pending_confirmation() {
571 if (testCase.authenticatedByFace && testCase.confirmationRequested) {
572 runGenericTest {
573 val iconAsset by collectLastValue(kosmos.promptViewModel.iconViewModel.iconAsset)
574 val iconContentDescriptionId by
575 collectLastValue(kosmos.promptViewModel.iconViewModel.contentDescriptionId)
576 val shouldAnimateIconView by
577 collectLastValue(kosmos.promptViewModel.iconViewModel.shouldAnimateIconView)
578
579 val forceExplicitFlow = testCase.isCoex && testCase.confirmationRequested
580 verifyIconSize(forceExplicitFlow = forceExplicitFlow)
581
582 kosmos.promptViewModel.showAuthenticated(
583 modality = testCase.authenticatedModality,
584 dismissAfterDelay = DELAY,
585 )
586
587 if (testCase.isFaceOnly) {
588 assertThat(iconAsset).isEqualTo(R.raw.face_dialog_wink_from_dark)
589 assertThat(iconContentDescriptionId)
590 .isEqualTo(R.string.biometric_dialog_face_icon_description_authenticated)
591 assertThat(shouldAnimateIconView).isEqualTo(true)
592 } else if (testCase.isCoex) { // explicit flow, confirmation requested
593 if (testCase.sensorType == FingerprintSensorProperties.TYPE_POWER_BUTTON) {
594 assertThat(iconAsset).isEqualTo(getSfpsAsset_fingerprintToUnlock())
595 assertThat(iconContentDescriptionId)
596 .isEqualTo(R.string.fingerprint_dialog_authenticated_confirmation)
597 assertThat(shouldAnimateIconView).isEqualTo(true)
598 } else {
599 assertThat(iconAsset)
600 .isEqualTo(R.raw.fingerprint_dialogue_fingerprint_to_unlock_lottie)
601 assertThat(iconContentDescriptionId)
602 .isEqualTo(R.string.biometric_dialog_confirm)
603 assertThat(shouldAnimateIconView).isEqualTo(true)
604 }
605 }
606 }
607 }
608 }
609
610 @Test
611 fun shows_authenticated_explicitly_confirmed() {
612 if (testCase.authenticatedByFace && testCase.confirmationRequested) {
613 runGenericTest {
614 val iconAsset by collectLastValue(kosmos.promptViewModel.iconViewModel.iconAsset)
615 val iconContentDescriptionId by
616 collectLastValue(kosmos.promptViewModel.iconViewModel.contentDescriptionId)
617 val shouldAnimateIconView by
618 collectLastValue(kosmos.promptViewModel.iconViewModel.shouldAnimateIconView)
619 val forceExplicitFlow = testCase.isCoex && testCase.confirmationRequested
620 verifyIconSize(forceExplicitFlow = forceExplicitFlow)
621
622 kosmos.promptViewModel.showAuthenticated(
623 modality = testCase.authenticatedModality,
624 dismissAfterDelay = DELAY,
625 )
626
627 kosmos.promptViewModel.confirmAuthenticated()
628
629 if (testCase.isFaceOnly) {
630 assertThat(iconAsset).isEqualTo(R.raw.face_dialog_dark_to_checkmark)
631 assertThat(iconContentDescriptionId)
632 .isEqualTo(R.string.biometric_dialog_face_icon_description_confirmed)
633 assertThat(shouldAnimateIconView).isEqualTo(true)
634 }
635
636 // explicit flow because confirmation requested
637 if (testCase.isCoex) {
638 if (testCase.sensorType == FingerprintSensorProperties.TYPE_POWER_BUTTON) {
639 assertThat(iconAsset)
640 .isEqualTo(R.raw.biometricprompt_sfps_unlock_to_success)
641 assertThat(shouldAnimateIconView).isEqualTo(true)
642 } else {
643 assertThat(iconAsset)
644 .isEqualTo(
645 R.raw.fingerprint_dialogue_unlocked_to_checkmark_success_lottie
646 )
647 assertThat(iconContentDescriptionId)
648 .isEqualTo(R.string.fingerprint_dialog_touch_sensor)
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