Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 6d1104f0 authored by Grace Cheng's avatar Grace Cheng
Browse files

Implement biometric auth messages and haptics

Implement secure lock device biometric auth messages and haptics

Flag: android.security.secure_lock_device
Fixes: 398990889
Bug: 401645997
Test: atest SecureLockDeviceBiometricAuthContentViewModelTest
Change-Id: Ia44afe02eec072ace78bb436fbb08b39026130f0
parent d4cd244e
Loading
Loading
Loading
Loading
+188 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.securelockdevice.ui.viewmodel

import android.platform.test.annotations.EnableFlags
import android.security.Flags.FLAG_SECURE_LOCK_DEVICE
import android.testing.TestableLooper.RunWithLooper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.biometrics.shared.model.BiometricModality
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.coroutines.collectValues
import com.android.systemui.haptics.msdl.fakeMSDLPlayer
import com.android.systemui.kosmos.testScope
import com.android.systemui.kosmos.useUnconfinedTestDispatcher
import com.android.systemui.lifecycle.activateIn
import com.android.systemui.securelockdevice.data.repository.fakeSecureLockDeviceRepository
import com.android.systemui.securelockdevice.domain.interactor.SecureLockDeviceInteractor
import com.android.systemui.securelockdevice.domain.interactor.secureLockDeviceInteractor
import com.android.systemui.testKosmos
import com.google.android.msdl.data.model.MSDLToken
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@SmallTest
@RunWith(AndroidJUnit4::class)
@RunWithLooper(setAsMainLooper = true)
@EnableFlags(FLAG_SECURE_LOCK_DEVICE)
class SecureLockDeviceBiometricAuthContentViewModelTest : SysuiTestCase() {

    private val kosmos = testKosmos().useUnconfinedTestDispatcher()
    private val testScope = kosmos.testScope
    private lateinit var secureLockDeviceInteractor: SecureLockDeviceInteractor
    private lateinit var underTest: SecureLockDeviceBiometricAuthContentViewModel

    @Before
    fun setUp() {
        kosmos.fakeSecureLockDeviceRepository.onSecureLockDeviceEnabled()
        kosmos.fakeSecureLockDeviceRepository.onSuccessfulPrimaryAuth()
        secureLockDeviceInteractor = kosmos.secureLockDeviceInteractor

        underTest = kosmos.secureLockDeviceBiometricAuthContentViewModel
        underTest.activateIn(testScope)
    }

    @Test
    fun updatesStateAndPlaysHaptics_onFaceFailureOrError() =
        testScope.runTest {
            val isAuthenticating by collectValues(underTest.isAuthenticating)
            val isAuthenticated by collectLastValue(underTest.isAuthenticated)
            val showingError by collectValues(underTest.showingError)

            // On face error shown
            underTest.showTemporaryError(
                authenticateAfterError = false,
                failedModality = BiometricModality.Face,
            )

            // Verify internal state updated to show error
            assertThat(isAuthenticating[1]).isFalse()
            assertThat(showingError[1]).isTrue()
            assertThat(isAuthenticated?.isAuthenticated).isFalse()
            assertThat(kosmos.fakeMSDLPlayer.latestTokenPlayed).isEqualTo(MSDLToken.FAILURE)

            runCurrent()

            // Verify internal state updated to clear error
            assertThat(showingError[2]).isFalse()
        }

    @Test
    fun updatesStateAndSkipsHaptics_onFaceHelp() =
        testScope.runTest {
            val isAuthenticating by collectLastValue(underTest.isAuthenticating)
            val isAuthenticated by collectLastValue(underTest.isAuthenticated)
            val showingError by collectLastValue(underTest.showingError)

            // On face help shown
            underTest.showHelp()
            runCurrent()

            // Verify internal state updated to show help
            assertThat(isAuthenticating).isFalse()
            assertThat(showingError).isFalse()
            assertThat(isAuthenticated?.isAuthenticated).isFalse()
            assertThat(kosmos.fakeMSDLPlayer.latestTokenPlayed).isNull()
        }

    @Test
    fun updatesState_onFaceSuccess() =
        testScope.runTest {
            val isAuthenticating by collectLastValue(underTest.isAuthenticating)
            val isAuthenticated by collectLastValue(underTest.isAuthenticated)
            val showingError by collectLastValue(underTest.showingError)

            // On face success shown
            underTest.showAuthenticated(modality = BiometricModality.Face)
            runCurrent()

            // Verify internal state updated to show success
            assertThat(isAuthenticating).isFalse()
            assertThat(showingError).isFalse()
            assertThat(isAuthenticated?.isAuthenticated).isTrue()
            assertThat(isAuthenticated?.isAuthenticatedAndExplicitlyConfirmed).isFalse()
            assertThat(kosmos.fakeMSDLPlayer.latestTokenPlayed).isNull()
        }

    @Test
    fun updatesStateAndPlaysHaptics_onFingerprintFailureOrError() =
        testScope.runTest {
            val isAuthenticating by collectValues(underTest.isAuthenticating)
            val isAuthenticated by collectLastValue(underTest.isAuthenticated)
            val showingError by collectValues(underTest.showingError)

            // On fingerprint error shown
            underTest.showTemporaryError(
                authenticateAfterError = true,
                failedModality = BiometricModality.Fingerprint,
            )

            // Verify internal state updated to show error
            assertThat(isAuthenticating[1]).isFalse()
            assertThat(showingError[1]).isTrue()
            assertThat(isAuthenticated?.isAuthenticated).isFalse()
            assertThat(kosmos.fakeMSDLPlayer.latestTokenPlayed).isEqualTo(MSDLToken.FAILURE)

            // Verify internal state updated to clear error, restart authentication
            assertThat(isAuthenticating[2]).isTrue()
            assertThat(showingError[2]).isFalse()
            assertThat(isAuthenticated?.isAuthenticated).isFalse()
        }

    @Test
    fun updatesStateAndSkipsHaptics_onFingerprintHelp() =
        testScope.runTest {
            val isAuthenticating by collectLastValue(underTest.isAuthenticating)
            val isAuthenticated by collectLastValue(underTest.isAuthenticated)
            val showingError by collectLastValue(underTest.showingError)

            // On face error shown
            underTest.showHelp()
            runCurrent()

            // Verify internal state updated to show help
            assertThat(isAuthenticating).isFalse()
            assertThat(showingError).isFalse()
            assertThat(isAuthenticated?.isAuthenticated).isFalse()
            assertThat(kosmos.fakeMSDLPlayer.latestTokenPlayed).isNull()
        }

    @Test
    fun updatesStateAndPlaysHaptics_onFingerprintSuccess() =
        testScope.runTest {
            val isAuthenticating by collectLastValue(underTest.isAuthenticating)
            val isAuthenticated by collectLastValue(underTest.isAuthenticated)
            val showingError by collectLastValue(underTest.showingError)

            // On fingerprint success shown
            underTest.showAuthenticated(modality = BiometricModality.Fingerprint)
            runCurrent()

            // Verify internal state updated to show success
            assertThat(isAuthenticating).isFalse()
            assertThat(showingError).isFalse()
            assertThat(isAuthenticated?.isAuthenticated).isTrue()
            assertThat(isAuthenticated?.isAuthenticatedAndConfirmed).isTrue()
            assertThat(kosmos.fakeMSDLPlayer.latestTokenPlayed).isEqualTo(MSDLToken.UNLOCK)
        }
}
+6 −6
Original line number Diff line number Diff line
@@ -95,7 +95,7 @@ constructor(
        return isCancellationError() || isUnableToProcessError()
    }

    private val fingerprintErrorMessage: Flow<FingerprintMessage> =
    val fingerprintErrorMessage: Flow<FingerprintMessage> =
        fingerprintAuthInteractor.fingerprintError
            .filterNot { it.shouldSuppressError() }
            .sample(biometricSettingsInteractor.fingerprintAuthCurrentlyAllowed, ::Pair)
@@ -109,13 +109,13 @@ constructor(
                }
            }

    private val fingerprintHelpMessage: Flow<FingerprintMessage> =
    val fingerprintHelpMessage: Flow<FingerprintMessage> =
        fingerprintAuthInteractor.fingerprintHelp
            .sample(biometricSettingsInteractor.fingerprintAuthCurrentlyAllowed, ::Pair)
            .filter { (_, fingerprintAuthAllowed) -> fingerprintAuthAllowed }
            .map { (helpStatus, _) -> FingerprintMessage(helpStatus.msg) }

    private val fingerprintFailMessage: Flow<FingerprintMessage> =
    val fingerprintFailMessage: Flow<FingerprintMessage> =
        fingerprintPropertyInteractor.isUdfps.flatMapLatest { isUdfps ->
            fingerprintAuthInteractor.fingerprintFailure
                .sample(biometricSettingsInteractor.fingerprintAuthCurrentlyAllowed)
@@ -186,7 +186,7 @@ constructor(
                }
            }

    private val faceHelpMessage: Flow<FaceMessage> =
    val faceHelpMessage: Flow<FaceMessage> =
        faceHelp
            .filterNot {
                // Message deferred to potentially show at face timeout error instead
@@ -196,13 +196,13 @@ constructor(
            .filter { (helpMessage, filterCondition) -> filterCondition(helpMessage) }
            .map { (status, _) -> FaceMessage(status.msg) }

    private val faceFailureMessage: Flow<FaceMessage> =
    val faceFailureMessage: Flow<FaceMessage> =
        faceFailure
            .sample(biometricSettingsInteractor.faceAuthCurrentlyAllowed)
            .filter { faceAuthCurrentlyAllowed -> faceAuthCurrentlyAllowed }
            .map { FaceFailureMessage(resources.getString(R.string.keyguard_face_failed)) }

    private val faceErrorMessage: Flow<FaceMessage> =
    val faceErrorMessage: Flow<FaceMessage> =
        faceError
            .filterNot { it.shouldSuppressError() }
            .sample(biometricSettingsInteractor.faceAuthCurrentlyAllowed, ::Pair)
+5 −0
Original line number Diff line number Diff line
@@ -37,6 +37,7 @@ import com.android.systemui.deviceentry.data.repository.FaceWakeUpTriggersConfig
import com.android.systemui.deviceentry.shared.FaceAuthUiEvent
import com.android.systemui.deviceentry.shared.model.ErrorFaceAuthenticationStatus
import com.android.systemui.deviceentry.shared.model.FaceAuthenticationStatus
import com.android.systemui.deviceentry.shared.model.SuccessFaceAuthenticationStatus
import com.android.systemui.keyguard.data.repository.BiometricSettingsRepository
import com.android.systemui.keyguard.domain.interactor.KeyguardTransitionInteractor
import com.android.systemui.keyguard.shared.model.DevicePosture
@@ -74,6 +75,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
@@ -413,6 +415,9 @@ constructor(
            )
    override val isBypassEnabled: StateFlow<Boolean> = repository.isBypassEnabled

    val faceSuccess: Flow<SuccessFaceAuthenticationStatus> =
        authenticationStatus.filterIsInstance<SuccessFaceAuthenticationStatus>()

    private fun runFaceAuth(uiEvent: FaceAuthUiEvent, fallbackToDetect: Boolean) {
        if (repository.isLockedOut.value && !isBypassEnabled.value) {
            faceAuthenticationStatusOverride.value =
+45 −0
Original line number Diff line number Diff line
@@ -20,6 +20,7 @@ import com.android.systemui.biometrics.domain.interactor.FacePropertyInteractor
import com.android.systemui.biometrics.domain.interactor.FingerprintPropertyInteractor
import com.android.systemui.biometrics.shared.model.BiometricModalities
import com.android.systemui.biometrics.shared.model.SensorStrength
import com.android.systemui.biometrics.ui.viewmodel.PromptAuthState
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.deviceentry.domain.interactor.DeviceEntryBiometricSettingsInteractor
@@ -76,7 +77,51 @@ constructor(
    /** Whether the secure lock device biometric auth UI should be shown. */
    val shouldShowBiometricAuth: StateFlow<Boolean> = _shouldShowBiometricAuth.asStateFlow()

    /**
     * The timestamp of the last strong face authentication success, or null otherwise. This is used
     * to ensure that a stale face authentication success will not re-authenticate the user if
     * secure lock device biometric auth is interrupted (e.g. power press, back gesture, etc) after
     * authenticating a user's face but before the user confirms the authentication on the UI.
     */
    var lastProcessedFaceAuthSuccessTime: Long? = null

    /**
     * If the user has successfully authenticated a strong biometric in the secure lock device UI
     * (and explicitly confirmed if required).
     */
    private val _strongBiometricAuthenticationComplete = MutableStateFlow(false)

    private val _isFullyUnlockedAndReadyToDismiss = MutableStateFlow<Boolean>(false)
    /**
     * Whether the user completed successful two-factor authentication (primary + strong biometric)
     * in secure lock device, and the device should be considered unlocked. This is true when the
     * strong biometric does not require confirmation (e.g. fingerprint) or when the strong
     * biometric does require confirmation (e.g. face) but the user has completed confirmation on
     * the UI, and the confirmation animation has played, so the UI is ready to be dismissed.
     */
    val isFullyUnlockedAndReadyToDismiss: StateFlow<Boolean> =
        _isFullyUnlockedAndReadyToDismiss.asStateFlow()

    /**
     * Whether the user has completed two-factor authentication (primary authentication and active
     * strong biometric authentication or confirmed passive strong biometric authentication)
     */
    val isAuthenticatedButPendingDismissal: StateFlow<Boolean> =
        combine(_strongBiometricAuthenticationComplete, isFullyUnlockedAndReadyToDismiss) {
                strongBiometricAuthenticationComplete,
                isFullyUnlockedAndReadyToDismiss ->
                strongBiometricAuthenticationComplete || isFullyUnlockedAndReadyToDismiss
            }
            .stateIn(
                scope = applicationScope,
                started = SharingStarted.WhileSubscribed(),
                initialValue = false,
            )

    /** Called upon updates to strong biometric authenticated status. */
    fun onBiometricAuthenticatedStateUpdated(authState: PromptAuthState) {
        _strongBiometricAuthenticationComplete.value = authState.isAuthenticatedAndConfirmed
    }

    /**
     * Called after the user completes successful two-factor authentication (primary + strong
+130 −4
Original line number Diff line number Diff line
@@ -22,7 +22,14 @@ import androidx.annotation.VisibleForTesting
import com.android.systemui.biometrics.shared.model.BiometricModality
import com.android.systemui.biometrics.ui.viewmodel.BiometricAuthIconViewModel
import com.android.systemui.biometrics.ui.viewmodel.PromptAuthState
import com.android.systemui.bouncer.ui.helper.BouncerHapticPlayer
import com.android.systemui.deviceentry.domain.interactor.BiometricMessageInteractor
import com.android.systemui.deviceentry.domain.interactor.DeviceEntryFingerprintAuthInteractor
import com.android.systemui.deviceentry.domain.interactor.SystemUIDeviceEntryFaceAuthInteractor
import com.android.systemui.deviceentry.shared.model.FaceMessage
import com.android.systemui.deviceentry.shared.model.FingerprintMessage
import com.android.systemui.deviceentry.shared.model.SuccessFaceAuthenticationStatus
import com.android.systemui.keyguard.shared.model.SuccessFingerprintAuthenticationStatus
import com.android.systemui.lifecycle.ExclusiveActivatable
import com.android.systemui.scene.shared.flag.SceneContainerFlag
import com.android.systemui.securelockdevice.domain.interactor.SecureLockDeviceInteractor
@@ -39,6 +46,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
@@ -50,7 +58,10 @@ class SecureLockDeviceBiometricAuthContentViewModel
constructor(
    accessibilityManager: AccessibilityManager,
    biometricAuthIconViewModelFactory: BiometricAuthIconViewModel.Factory,
    biometricMessageInteractor: BiometricMessageInteractor,
    private val bouncerHapticPlayer: BouncerHapticPlayer,
    private val deviceEntryFaceAuthInteractor: SystemUIDeviceEntryFaceAuthInteractor,
    deviceEntryFingerprintAuthInteractor: DeviceEntryFingerprintAuthInteractor,
    private val secureLockDeviceInteractor: SecureLockDeviceInteractor,
) : ExclusiveActivatable() {
    private var mDisappearAnimationFinishedRunnable: Runnable? = null
@@ -112,6 +123,43 @@ constructor(
        )
    }

    /** Face help message. */
    @VisibleForTesting
    val faceHelpMessage: Flow<FaceMessage> = biometricMessageInteractor.faceHelpMessage

    /** Face error message. */
    @VisibleForTesting
    val faceErrorMessage: Flow<FaceMessage> = biometricMessageInteractor.faceErrorMessage

    /** Face failure message. */
    @VisibleForTesting
    val faceFailureMessage: Flow<FaceMessage> = biometricMessageInteractor.faceFailureMessage

    /** Fingerprint help message. */
    @VisibleForTesting
    val fingerprintHelpMessage: Flow<FingerprintMessage> =
        biometricMessageInteractor.fingerprintHelpMessage

    /** Fingerprint error message. */
    @VisibleForTesting
    val fingerprintErrorMessage: Flow<FingerprintMessage> =
        biometricMessageInteractor.fingerprintErrorMessage

    /** Fingerprint failure message. */
    @VisibleForTesting
    val fingerprintFailureMessage: Flow<FingerprintMessage> =
        biometricMessageInteractor.fingerprintFailMessage

    /** Fingerprint success status. */
    private val fingerprintSuccessStatus: Flow<SuccessFingerprintAuthenticationStatus> =
        deviceEntryFingerprintAuthInteractor.fingerprintSuccess

    /** Emits on face authentication success. */
    private val faceSuccessStatus: Flow<SuccessFaceAuthenticationStatus> =
        deviceEntryFaceAuthInteractor.faceSuccess

    private val _lastAnimatedFaceAuthSuccessTime = MutableStateFlow<Long?>(null)

    private var displayErrorJob: Job? = null

    // When a11y enabled, increase message delay to ensure messages get read
@@ -145,6 +193,10 @@ constructor(
        _showingError.value = true
        _isAuthenticated.value = PromptAuthState(false)

        if (hapticFeedback) {
            bouncerHapticPlayer.playAuthenticationFeedback(/* authenticationSucceeded= */ false)
        }

        displayErrorJob?.cancel()
        displayErrorJob = launch {
            delay(displayErrorLength)
@@ -197,6 +249,10 @@ constructor(
        val needsUserConfirmation = needsExplicitConfirmation(modality)
        _isAuthenticated.value = PromptAuthState(true, modality, needsUserConfirmation)

        if (!needsUserConfirmation) {
            bouncerHapticPlayer.playAuthenticationFeedback(/* authenticationSucceeded= */ true)
        }

        _showingError.value = false
        displayErrorJob?.cancel()
        displayErrorJob = null
@@ -220,15 +276,78 @@ constructor(

    private fun CoroutineScope.listenForFaceMessages() {
        // Listen for any events from face authentication and update the child view models
        // TODO: showTemporaryError on face auth error, failure, help
        // TODO: showAuthenticated on face auth success
        launch {
            faceErrorMessage.collectLatest {
                showTemporaryError(
                    authenticateAfterError = hasFingerprint(),
                    failedModality = BiometricModality.Face,
                )
            }
        }

        launch {
            faceFailureMessage.collectLatest {
                showTemporaryError(
                    authenticateAfterError = hasFingerprint(),
                    failedModality = BiometricModality.Face,
                )
            }
        }

        launch {
            faceHelpMessage.collectLatest {
                showTemporaryError(
                    authenticateAfterError = hasFingerprint(),
                    hapticFeedback = false,
                )
            }
        }

        // This is required to ensure that a stale face authentication success will not
        // re-authenticate the user (e.g. if secure lock device biometric auth is interrupted
        // after authenticating a user's face but before the user confirmation)
        launch {
            faceSuccessStatus.debounce(DEBOUNCE_FACE_AUTH_SUCCESS_MS).collectLatest {
                if (it.createdAt != secureLockDeviceInteractor.lastProcessedFaceAuthSuccessTime) {
                    showAuthenticated(modality = BiometricModality.Face)
                    _lastAnimatedFaceAuthSuccessTime.value = it.createdAt
                }
            }
        }
    }

    private fun CoroutineScope.listenForFingerprintMessages() {
        // Listen for any events from fingerprint authentication and update the child view
        // models
        // TODO: showTemporaryError on fingerprint auth error, failure, help
        // TODO: showAuthenticated on fingerprint auth success
        launch {
            fingerprintErrorMessage.collectLatest {
                showTemporaryError(
                    authenticateAfterError = true,
                    failedModality = BiometricModality.Fingerprint,
                )
            }
        }

        launch {
            fingerprintFailureMessage.collectLatest {
                showTemporaryError(
                    authenticateAfterError = true,
                    failedModality = BiometricModality.Fingerprint,
                )
            }
        }

        launch {
            fingerprintHelpMessage.collectLatest {
                showTemporaryError(authenticateAfterError = true, hapticFeedback = false)
            }
        }

        launch {
            fingerprintSuccessStatus.collectLatest {
                showAuthenticated(modality = BiometricModality.Fingerprint)
            }
        }
    }

    @AssistedFactory
@@ -258,6 +377,12 @@ constructor(
                        listenForFaceMessages()
                        listenForFingerprintMessages()

                        launch {
                            isAuthenticated.collectLatest {
                                secureLockDeviceInteractor.onBiometricAuthenticatedStateUpdated(it)
                            }
                        }

                        launch {
                            isReadyToDismissBiometricAuth
                                .filter { it }
@@ -308,5 +433,6 @@ constructor(

    companion object {
        const val TAG = "SecureLockDeviceBiometricAuthContentViewModel"
        const val DEBOUNCE_FACE_AUTH_SUCCESS_MS = 500L
    }
}
Loading