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

Commit 20faf252 authored by Beverly's avatar Beverly
Browse files

Use a sliding window to analyze the last x-ms of face help messages

To determine which faceAcquiredInfo help message to show. A "boost"
is given to messages that are currently showing to the user.
This is to prevent flickering face help messages.

Also moves to faceAcquiredInfoIgnoreList logic from the
repository to the interactor.

Fixes: 244277921
Flag: EXEMPT bugfix
Test: atest DeviceEntryFaceAuthStatusInteractorTest FaceHelpMessageDebouncerTest
Change-Id: I5e9c2de1ac21fc69ccb86976bf53c16cf6351b7b
parent 294cf4a6
Loading
Loading
Loading
Loading
+15 −1
Original line number Diff line number Diff line
@@ -29,6 +29,7 @@ import com.android.systemui.authentication.domain.interactor.authenticationInter
import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
import com.android.systemui.authentication.shared.model.AuthenticationMethodModel.Pattern
import com.android.systemui.authentication.shared.model.AuthenticationMethodModel.Pin
import com.android.systemui.biometrics.FaceHelpMessageDebouncer
import com.android.systemui.biometrics.data.repository.FaceSensorInfo
import com.android.systemui.biometrics.data.repository.fakeFacePropertyRepository
import com.android.systemui.biometrics.data.repository.fakeFingerprintPropertyRepository
@@ -75,11 +76,16 @@ class BouncerMessageViewModelTest : SysuiTestCase() {
    private val authenticationInteractor by lazy { kosmos.authenticationInteractor }
    private val bouncerInteractor by lazy { kosmos.bouncerInteractor }
    private lateinit var underTest: BouncerMessageViewModel
    private val ignoreHelpMessageId = 1

    @Before
    fun setUp() {
        kosmos.fakeUserRepository.setUserInfos(listOf(PRIMARY_USER))
        kosmos.fakeComposeBouncerFlags.composeBouncerEnabled = true
        overrideResource(
            R.array.config_face_acquire_device_entry_ignorelist,
            intArrayOf(ignoreHelpMessageId)
        )
        underTest = kosmos.bouncerMessageViewModel
        overrideResource(R.string.kg_trust_agent_disabled, "Trust agent is unavailable")
        kosmos.fakeSystemPropertiesHelper.set(
@@ -379,7 +385,15 @@ class BouncerMessageViewModelTest : SysuiTestCase() {
            runCurrent()

            kosmos.fakeDeviceEntryFaceAuthRepository.setAuthenticationStatus(
                HelpFaceAuthenticationStatus(1, "some helpful message")
                HelpFaceAuthenticationStatus(0, "some helpful message", 0)
            )
            runCurrent()
            kosmos.fakeDeviceEntryFaceAuthRepository.setAuthenticationStatus(
                HelpFaceAuthenticationStatus(
                    0,
                    "some helpful message",
                    FaceHelpMessageDebouncer.DEFAULT_WINDOW_MS
                )
            )
            runCurrent()
            assertThat(bouncerMessage?.text).isEqualTo("Enter PIN")
+0 −25
Original line number Diff line number Diff line
@@ -52,7 +52,6 @@ import com.android.systemui.deviceentry.shared.FaceAuthUiEvent.FACE_AUTH_TRIGGER
import com.android.systemui.deviceentry.shared.model.ErrorFaceAuthenticationStatus
import com.android.systemui.deviceentry.shared.model.FaceAuthenticationStatus
import com.android.systemui.deviceentry.shared.model.FaceDetectionStatus
import com.android.systemui.deviceentry.shared.model.HelpFaceAuthenticationStatus
import com.android.systemui.deviceentry.shared.model.SuccessFaceAuthenticationStatus
import com.android.systemui.display.data.repository.displayRepository
import com.android.systemui.dump.DumpManager
@@ -79,7 +78,6 @@ import com.android.systemui.log.table.TableLogBuffer
import com.android.systemui.power.domain.interactor.PowerInteractor.Companion.setAsleepForTest
import com.android.systemui.power.domain.interactor.PowerInteractor.Companion.setAwakeForTest
import com.android.systemui.power.domain.interactor.powerInteractor
import com.android.systemui.res.R
import com.android.systemui.statusbar.commandQueue
import com.android.systemui.statusbar.phone.KeyguardBypassController
import com.android.systemui.testKosmos
@@ -477,29 +475,6 @@ class DeviceEntryFaceAuthRepositoryTest : SysuiTestCase() {
            assertThat((emittedValues.first() as ErrorFaceAuthenticationStatus).msgId).isEqualTo(-1)
        }

    @Test
    fun faceHelpMessagesAreIgnoredBasedOnConfig() =
        testScope.runTest {
            overrideResource(
                R.array.config_face_acquire_device_entry_ignorelist,
                intArrayOf(10, 11)
            )
            underTest = createDeviceEntryFaceAuthRepositoryImpl()
            initCollectors()
            allPreconditionsToRunFaceAuthAreTrue()

            underTest.requestAuthenticate(FACE_AUTH_TRIGGERED_SWIPE_UP_ON_BOUNCER)
            faceAuthenticateIsCalled()

            authenticationCallback.value.onAuthenticationHelp(9, "help msg")
            authenticationCallback.value.onAuthenticationHelp(10, "Ignored help msg")
            authenticationCallback.value.onAuthenticationHelp(11, "Ignored help msg")

            val response = authStatus() as HelpFaceAuthenticationStatus
            assertThat(response.msg).isEqualTo("help msg")
            assertThat(response.msgId).isEqualTo(response.msgId)
        }

    @Test
    fun dumpDoesNotErrorOutWhenFaceManagerOrBypassControllerIsNull() =
        testScope.runTest {
+125 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.biometrics

import android.util.Log
import com.android.systemui.deviceentry.shared.model.HelpFaceAuthenticationStatus

/**
 * Debounces face help messages with parameters:
 * - window: Window of time (in milliseconds) to analyze face acquired messages)
 * - startWindow: Window of time on start required before showing the first help message
 * - shownFaceMessageFrequencyBoost: Frequency boost given to messages that are currently shown to
 *   the user
 */
class FaceHelpMessageDebouncer(
    private val window: Long = DEFAULT_WINDOW_MS,
    private val startWindow: Long = window,
    private val shownFaceMessageFrequencyBoost: Int = 4,
) {
    private val TAG = "FaceHelpMessageDebouncer"
    private var startTime = 0L
    private var helpFaceAuthStatuses: MutableList<HelpFaceAuthenticationStatus> = mutableListOf()
    private var lastMessageIdShown: Int? = null

    /** Remove messages that are outside of the time [window]. */
    private fun removeOldMessages(currTimestamp: Long) {
        var numToRemove = 0
        // This works under the assumption that timestamps are ordered from first to last
        // in chronological order
        for (index in helpFaceAuthStatuses.indices) {
            if ((helpFaceAuthStatuses[index].createdAt + window) >= currTimestamp) {
                break // all timestamps from here and on are within the window
            }
            numToRemove += 1
        }

        // Remove all outside time window
        repeat(numToRemove) { helpFaceAuthStatuses.removeFirst() }

        if (numToRemove > 0) {
            Log.v(TAG, "removedFirst=$numToRemove")
        }
    }

    private fun getMostFrequentHelpMessage(): HelpFaceAuthenticationStatus? {
        // freqMap: msgId => frequency
        val freqMap = helpFaceAuthStatuses.groupingBy { it.msgId }.eachCount().toMutableMap()

        // Give shownFaceMessageFrequencyBoost to lastMessageIdShown
        if (lastMessageIdShown != null) {
            freqMap.computeIfPresent(lastMessageIdShown!!) { _, value ->
                value + shownFaceMessageFrequencyBoost
            }
        }
        // Go through all msgId keys & find the highest frequency msgId
        val msgIdWithHighestFrequency =
            freqMap.entries
                .maxWithOrNull { (msgId1, freq1), (msgId2, freq2) ->
                    // ties are broken by more recent message
                    if (freq1 == freq2) {
                        helpFaceAuthStatuses
                            .findLast { it.msgId == msgId1 }!!
                            .createdAt
                            .compareTo(
                                helpFaceAuthStatuses.findLast { it.msgId == msgId2 }!!.createdAt
                            )
                    } else {
                        freq1.compareTo(freq2)
                    }
                }
                ?.key
        return helpFaceAuthStatuses.findLast { it.msgId == msgIdWithHighestFrequency }
    }

    fun addMessage(helpFaceAuthStatus: HelpFaceAuthenticationStatus) {
        helpFaceAuthStatuses.add(helpFaceAuthStatus)
        Log.v(TAG, "added message=$helpFaceAuthStatus")
    }

    fun getMessageToShow(atTimestamp: Long): HelpFaceAuthenticationStatus? {
        if (helpFaceAuthStatuses.isEmpty() || (atTimestamp - startTime) < startWindow) {
            // there's not enough time that has passed to determine whether to show anything yet
            Log.v(TAG, "No message; haven't made initial threshold window OR no messages")
            return null
        }
        removeOldMessages(atTimestamp)
        val messageToShow = getMostFrequentHelpMessage()
        if (lastMessageIdShown != messageToShow?.msgId) {
            Log.v(
                TAG,
                "showMessage previousLastMessageId=$lastMessageIdShown" +
                    "\n\tmessageToShow=$messageToShow " +
                    "\n\thelpFaceAuthStatusesSize=${helpFaceAuthStatuses.size}" +
                    "\n\thelpFaceAuthStatuses=$helpFaceAuthStatuses"
            )
            lastMessageIdShown = messageToShow?.msgId
        }
        return messageToShow
    }

    fun startNewFaceAuthSession(faceAuthStartedTime: Long) {
        Log.d(TAG, "startNewFaceAuthSession at startTime=$startTime")
        startTime = faceAuthStartedTime
        helpFaceAuthStatuses.clear()
        lastMessageIdShown = null
    }

    companion object {
        const val DEFAULT_WINDOW_MS = 200L
    }
}
+2 −17
Original line number Diff line number Diff line
@@ -57,16 +57,13 @@ import com.android.systemui.log.FaceAuthenticationLogger
import com.android.systemui.log.SessionTracker
import com.android.systemui.log.table.TableLogBuffer
import com.android.systemui.power.domain.interactor.PowerInteractor
import com.android.systemui.res.R
import com.android.systemui.scene.shared.model.Scenes
import com.android.systemui.statusbar.phone.KeyguardBypassController
import com.android.systemui.user.data.model.SelectionStatus
import com.android.systemui.user.data.repository.UserRepository
import com.google.errorprone.annotations.CompileTimeConstant
import java.io.PrintWriter
import java.util.Arrays
import java.util.concurrent.Executor
import java.util.stream.Collectors
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
@@ -170,7 +167,6 @@ constructor(
) : DeviceEntryFaceAuthRepository, Dumpable {
    private var authCancellationSignal: CancellationSignal? = null
    private var detectCancellationSignal: CancellationSignal? = null
    private var faceAcquiredInfoIgnoreList: Set<Int>
    private var retryCount = 0

    private var pendingAuthenticateRequest = MutableStateFlow<AuthenticationRequest?>(null)
@@ -240,14 +236,6 @@ constructor(
            faceManager?.addLockoutResetCallback(faceLockoutResetCallback)
            faceAuthLogger.addLockoutResetCallbackDone()
        }
        faceAcquiredInfoIgnoreList =
            Arrays.stream(
                    context.resources.getIntArray(
                        R.array.config_face_acquire_device_entry_ignorelist
                    )
                )
                .boxed()
                .collect(Collectors.toSet())
        dumpManager.registerCriticalDumpable("DeviceEntryFaceAuthRepositoryImpl", this)

        canRunFaceAuth =
@@ -482,10 +470,8 @@ constructor(
            }

            override fun onAuthenticationHelp(code: Int, helpStr: CharSequence?) {
                if (faceAcquiredInfoIgnoreList.contains(code)) {
                    return
                }
                _authenticationStatus.value = HelpFaceAuthenticationStatus(code, helpStr.toString())
                _authenticationStatus.value =
                    HelpFaceAuthenticationStatus(code, helpStr?.toString())
            }

            override fun onAuthenticationSucceeded(result: FaceManager.AuthenticationResult) {
@@ -728,7 +714,6 @@ constructor(
        pw.println("  _pendingAuthenticateRequest: ${pendingAuthenticateRequest.value}")
        pw.println("  authCancellationSignal: $authCancellationSignal")
        pw.println("  detectCancellationSignal: $detectCancellationSignal")
        pw.println("  faceAcquiredInfoIgnoreList: $faceAcquiredInfoIgnoreList")
        pw.println("  _authenticationStatus: ${_authenticationStatus.value}")
        pw.println("  _detectionStatus: ${_detectionStatus.value}")
        pw.println("  currentUserId: $currentUserId")
+95 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.deviceentry.domain.interactor

import android.content.res.Resources
import android.hardware.biometrics.BiometricFaceConstants
import com.android.systemui.biometrics.FaceHelpMessageDebouncer
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.deviceentry.data.repository.DeviceEntryFaceAuthRepository
import com.android.systemui.deviceentry.shared.model.AcquiredFaceAuthenticationStatus
import com.android.systemui.deviceentry.shared.model.FaceAuthenticationStatus
import com.android.systemui.deviceentry.shared.model.HelpFaceAuthenticationStatus
import com.android.systemui.res.R
import java.util.Arrays
import java.util.stream.Collectors
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transform

/**
 * Process face authentication statuses.
 * - Ignores face help messages based on R.array.config_face_acquire_device_entry_ignorelist.
 * - Uses FaceHelpMessageDebouncer to debounce flickery help messages.
 */
@SysUISingleton
class DeviceEntryFaceAuthStatusInteractor
@Inject
constructor(
    repository: DeviceEntryFaceAuthRepository,
    @Main private val resources: Resources,
    @Application private val applicationScope: CoroutineScope,
) {
    private val faceHelpMessageDebouncer = FaceHelpMessageDebouncer()
    private var faceAcquiredInfoIgnoreList: Set<Int> =
        Arrays.stream(resources.getIntArray(R.array.config_face_acquire_device_entry_ignorelist))
            .boxed()
            .collect(Collectors.toSet())

    val authenticationStatus: StateFlow<FaceAuthenticationStatus?> =
        repository.authenticationStatus
            .transform { authenticationStatus ->
                if (authenticationStatus is AcquiredFaceAuthenticationStatus) {
                    if (
                        authenticationStatus.acquiredInfo ==
                            BiometricFaceConstants.FACE_ACQUIRED_START
                    ) {
                        faceHelpMessageDebouncer.startNewFaceAuthSession(
                            authenticationStatus.createdAt
                        )
                    }
                }

                if (authenticationStatus is HelpFaceAuthenticationStatus) {
                    if (!faceAcquiredInfoIgnoreList.contains(authenticationStatus.msgId)) {
                        faceHelpMessageDebouncer.addMessage(authenticationStatus)
                    }

                    val messageToShow =
                        faceHelpMessageDebouncer.getMessageToShow(
                            atTimestamp = authenticationStatus.createdAt,
                        )
                    if (messageToShow != null) {
                        emit(messageToShow)
                    }

                    return@transform
                }

                emit(authenticationStatus)
            }
            .stateIn(
                scope = applicationScope,
                started = SharingStarted.WhileSubscribed(),
                initialValue = null,
            )
}
Loading