From 5bb605d3c76490df0f474bcfa75663013c1d8cc1 Mon Sep 17 00:00:00 2001 From: Chandru S Date: Tue, 20 Dec 2022 19:18:11 +0530 Subject: [PATCH] Create KeyguardFaceAuthManager to provide API for running face auth & face detection from Keyguard Scenarios to handle while running face auth: 1. If a previous face auth request is being cancelled and new request arrives, we should run face auth after the cancellation error is received 2. Ignore requests to run face auth if it is already running 3. After triggering face auth, if face cancellation error is never received from faceManager for 3s, assume cancellation was successful. Bug: 262838215 Test: atest KeyguardFaceAuthManagerTest Change-Id: I495432d05e828c8e9d28c6b5ea12858c531f8e22 --- .../repository/KeyguardFaceAuthManager.kt | 342 ++++++++++++++ .../shared/model/FaceAuthenticationModels.kt | 53 +++ .../systemui/log/FaceAuthenticationLogger.kt | 181 ++++++++ .../systemui/log/dagger/FaceAuthLog.kt | 6 + .../systemui/log/dagger/LogModule.java | 11 + .../repository/KeyguardFaceAuthManagerTest.kt | 428 ++++++++++++++++++ 6 files changed, 1021 insertions(+) create mode 100644 packages/SystemUI/src/com/android/systemui/keyguard/data/repository/KeyguardFaceAuthManager.kt create mode 100644 packages/SystemUI/src/com/android/systemui/keyguard/shared/model/FaceAuthenticationModels.kt create mode 100644 packages/SystemUI/src/com/android/systemui/log/FaceAuthenticationLogger.kt create mode 100644 packages/SystemUI/src/com/android/systemui/log/dagger/FaceAuthLog.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/keyguard/data/repository/KeyguardFaceAuthManagerTest.kt diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/data/repository/KeyguardFaceAuthManager.kt b/packages/SystemUI/src/com/android/systemui/keyguard/data/repository/KeyguardFaceAuthManager.kt new file mode 100644 index 0000000000000..2069891a23e02 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/keyguard/data/repository/KeyguardFaceAuthManager.kt @@ -0,0 +1,342 @@ +/* + * Copyright (C) 2023 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.keyguard.data.repository + +import android.app.StatusBarManager +import android.content.Context +import android.hardware.face.FaceManager +import android.os.CancellationSignal +import com.android.internal.logging.InstanceId +import com.android.internal.logging.UiEventLogger +import com.android.keyguard.FaceAuthUiEvent +import com.android.systemui.Dumpable +import com.android.systemui.R +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.dagger.qualifiers.Main +import com.android.systemui.dump.DumpManager +import com.android.systemui.keyguard.shared.model.AcquiredAuthenticationStatus +import com.android.systemui.keyguard.shared.model.AuthenticationStatus +import com.android.systemui.keyguard.shared.model.DetectionStatus +import com.android.systemui.keyguard.shared.model.ErrorAuthenticationStatus +import com.android.systemui.keyguard.shared.model.FailedAuthenticationStatus +import com.android.systemui.keyguard.shared.model.HelpAuthenticationStatus +import com.android.systemui.keyguard.shared.model.SuccessAuthenticationStatus +import com.android.systemui.log.FaceAuthenticationLogger +import com.android.systemui.log.SessionTracker +import com.android.systemui.statusbar.phone.KeyguardBypassController +import com.android.systemui.user.data.repository.UserRepository +import java.io.PrintWriter +import java.util.Arrays +import java.util.stream.Collectors +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * API to run face authentication and detection for device entry / on keyguard (as opposed to the + * biometric prompt). + */ +interface KeyguardFaceAuthManager { + /** + * Trigger face authentication. + * + * [uiEvent] provided should be logged whenever face authentication runs. Invocation should be + * ignored if face authentication is already running. Results should be propagated through + * [authenticationStatus] + */ + suspend fun authenticate(uiEvent: FaceAuthUiEvent) + + /** + * Trigger face detection. + * + * Invocation should be ignored if face authentication is currently running. + */ + suspend fun detect() + + /** Stop currently running face authentication or detection. */ + fun cancel() + + /** Provide the current status of face authentication. */ + val authenticationStatus: Flow + + /** Provide the current status of face detection. */ + val detectionStatus: Flow + + /** Current state of whether face authentication is locked out or not. */ + val isLockedOut: Flow + + /** Current state of whether face authentication is running. */ + val isAuthRunning: Flow + + /** Is face detection supported. */ + val isDetectionSupported: Boolean +} + +@SysUISingleton +class KeyguardFaceAuthManagerImpl +@Inject +constructor( + context: Context, + private val faceManager: FaceManager? = null, + private val userRepository: UserRepository, + private val keyguardBypassController: KeyguardBypassController? = null, + @Application private val applicationScope: CoroutineScope, + @Main private val mainDispatcher: CoroutineDispatcher, + private val sessionTracker: SessionTracker, + private val uiEventsLogger: UiEventLogger, + private val faceAuthLogger: FaceAuthenticationLogger, + dumpManager: DumpManager, +) : KeyguardFaceAuthManager, Dumpable { + private var cancellationSignal: CancellationSignal? = null + private val lockscreenBypassEnabled: Boolean + get() = keyguardBypassController?.bypassEnabled ?: false + private var faceAcquiredInfoIgnoreList: Set + + private val faceLockoutResetCallback = + object : FaceManager.LockoutResetCallback() { + override fun onLockoutReset(sensorId: Int) { + _isLockedOut.value = false + } + } + + init { + faceManager?.addLockoutResetCallback(faceLockoutResetCallback) + faceAcquiredInfoIgnoreList = + Arrays.stream( + context.resources.getIntArray( + R.array.config_face_acquire_device_entry_ignorelist + ) + ) + .boxed() + .collect(Collectors.toSet()) + dumpManager.registerCriticalDumpable("KeyguardFaceAuthManagerImpl", this) + } + + private val faceAuthCallback = + object : FaceManager.AuthenticationCallback() { + override fun onAuthenticationFailed() { + _authenticationStatus.value = FailedAuthenticationStatus + faceAuthLogger.authenticationFailed() + onFaceAuthRequestCompleted() + } + + override fun onAuthenticationAcquired(acquireInfo: Int) { + _authenticationStatus.value = AcquiredAuthenticationStatus(acquireInfo) + faceAuthLogger.authenticationAcquired(acquireInfo) + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence?) { + val errorStatus = ErrorAuthenticationStatus(errorCode, errString.toString()) + if (errorStatus.isLockoutError()) { + _isLockedOut.value = true + } + _authenticationStatus.value = errorStatus + if (errorStatus.isCancellationError()) { + cancelNotReceivedHandlerJob?.cancel() + applicationScope.launch { + faceAuthLogger.launchingQueuedFaceAuthRequest( + faceAuthRequestedWhileCancellation + ) + faceAuthRequestedWhileCancellation?.let { authenticate(it) } + faceAuthRequestedWhileCancellation = null + } + } + faceAuthLogger.authenticationError( + errorCode, + errString, + errorStatus.isLockoutError(), + errorStatus.isCancellationError() + ) + onFaceAuthRequestCompleted() + } + + override fun onAuthenticationHelp(code: Int, helpStr: CharSequence?) { + if (faceAcquiredInfoIgnoreList.contains(code)) { + return + } + _authenticationStatus.value = HelpAuthenticationStatus(code, helpStr.toString()) + } + + override fun onAuthenticationSucceeded(result: FaceManager.AuthenticationResult) { + _authenticationStatus.value = SuccessAuthenticationStatus(result) + faceAuthLogger.faceAuthSuccess(result) + onFaceAuthRequestCompleted() + } + } + + private fun onFaceAuthRequestCompleted() { + cancellationInProgress = false + _isAuthRunning.value = false + cancellationSignal = null + } + + private val detectionCallback = + FaceManager.FaceDetectionCallback { sensorId, userId, isStrong -> + faceAuthLogger.faceDetected() + _detectionStatus.value = DetectionStatus(sensorId, userId, isStrong) + } + + private var cancellationInProgress = false + private var faceAuthRequestedWhileCancellation: FaceAuthUiEvent? = null + + override suspend fun authenticate(uiEvent: FaceAuthUiEvent) { + if (_isAuthRunning.value) { + faceAuthLogger.ignoredFaceAuthTrigger(uiEvent) + return + } + + if (cancellationInProgress) { + faceAuthLogger.queuingRequestWhileCancelling( + faceAuthRequestedWhileCancellation, + uiEvent + ) + faceAuthRequestedWhileCancellation = uiEvent + return + } else { + faceAuthRequestedWhileCancellation = null + } + + withContext(mainDispatcher) { + // We always want to invoke face auth in the main thread. + cancellationSignal = CancellationSignal() + _isAuthRunning.value = true + uiEventsLogger.logWithInstanceIdAndPosition( + uiEvent, + 0, + null, + keyguardSessionId, + uiEvent.extraInfo + ) + faceAuthLogger.authenticating(uiEvent) + faceManager?.authenticate( + null, + cancellationSignal, + faceAuthCallback, + null, + currentUserId, + lockscreenBypassEnabled + ) + } + } + + override suspend fun detect() { + if (!isDetectionSupported) { + faceAuthLogger.detectionNotSupported(faceManager, faceManager?.sensorPropertiesInternal) + return + } + if (_isAuthRunning.value) { + faceAuthLogger.skippingBecauseAlreadyRunning("detection") + return + } + + cancellationSignal = CancellationSignal() + withContext(mainDispatcher) { + // We always want to invoke face detect in the main thread. + faceAuthLogger.faceDetectionStarted() + faceManager?.detectFace(cancellationSignal, detectionCallback, currentUserId) + } + } + + private val currentUserId: Int + get() = userRepository.getSelectedUserInfo().id + + override fun cancel() { + if (cancellationSignal == null) return + + cancellationSignal?.cancel() + cancelNotReceivedHandlerJob = + applicationScope.launch { + delay(DEFAULT_CANCEL_SIGNAL_TIMEOUT) + faceAuthLogger.cancelSignalNotReceived( + _isAuthRunning.value, + _isLockedOut.value, + cancellationInProgress, + faceAuthRequestedWhileCancellation + ) + onFaceAuthRequestCompleted() + } + cancellationInProgress = true + _isAuthRunning.value = false + } + + private var cancelNotReceivedHandlerJob: Job? = null + + private val _authenticationStatus: MutableStateFlow = + MutableStateFlow(null) + override val authenticationStatus: Flow + get() = _authenticationStatus.filterNotNull() + + private val _detectionStatus = MutableStateFlow(null) + override val detectionStatus: Flow + get() = _detectionStatus.filterNotNull() + + private val _isLockedOut = MutableStateFlow(false) + override val isLockedOut: Flow = _isLockedOut + + override val isDetectionSupported = + faceManager?.sensorPropertiesInternal?.firstOrNull()?.supportsFaceDetection ?: false + + private val _isAuthRunning = MutableStateFlow(false) + override val isAuthRunning: Flow + get() = _isAuthRunning + + private val keyguardSessionId: InstanceId? + get() = sessionTracker.getSessionId(StatusBarManager.SESSION_KEYGUARD) + + companion object { + const val TAG = "KeyguardFaceAuthManager" + + /** + * If no cancel signal has been received after this amount of time, assume that it is + * cancelled. + */ + const val DEFAULT_CANCEL_SIGNAL_TIMEOUT = 3000L + } + + override fun dump(pw: PrintWriter, args: Array) { + pw.println("KeyguardFaceAuthManagerImpl state:") + pw.println(" cancellationInProgress: $cancellationInProgress") + pw.println(" _isLockedOut.value: ${_isLockedOut.value}") + pw.println(" _isAuthRunning.value: ${_isAuthRunning.value}") + pw.println(" isDetectionSupported: $isDetectionSupported") + pw.println(" FaceManager state:") + pw.println(" faceManager: $faceManager") + pw.println(" sensorPropertiesInternal: ${faceManager?.sensorPropertiesInternal}") + pw.println( + " supportsFaceDetection: " + + "${faceManager?.sensorPropertiesInternal?.firstOrNull()?.supportsFaceDetection}" + ) + pw.println( + " faceAuthRequestedWhileCancellation: ${faceAuthRequestedWhileCancellation?.reason}" + ) + pw.println(" cancellationSignal: $cancellationSignal") + pw.println(" faceAcquiredInfoIgnoreList: $faceAcquiredInfoIgnoreList") + pw.println(" _authenticationStatus: ${_authenticationStatus.value}") + pw.println(" _detectionStatus: ${_detectionStatus.value}") + pw.println(" currentUserId: $currentUserId") + pw.println(" keyguardSessionId: $keyguardSessionId") + pw.println(" lockscreenBypassEnabled: $lockscreenBypassEnabled") + } +} diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/shared/model/FaceAuthenticationModels.kt b/packages/SystemUI/src/com/android/systemui/keyguard/shared/model/FaceAuthenticationModels.kt new file mode 100644 index 0000000000000..b1c5f8fa270bf --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/keyguard/shared/model/FaceAuthenticationModels.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2023 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.keyguard.shared.model + +import android.hardware.face.FaceManager + +/** Authentication status provided by [com.android.keyguard.faceauth.KeyguardFaceAuthManager] */ +sealed class AuthenticationStatus + +/** Success authentication status. */ +data class SuccessAuthenticationStatus(val successResult: FaceManager.AuthenticationResult) : + AuthenticationStatus() + +/** Face authentication help message. */ +data class HelpAuthenticationStatus(val msgId: Int, val msg: String?) : AuthenticationStatus() + +/** Face acquired message. */ +data class AcquiredAuthenticationStatus(val acquiredInfo: Int) : AuthenticationStatus() + +/** Face authentication failed message. */ +object FailedAuthenticationStatus : AuthenticationStatus() + +/** Face authentication error message */ +data class ErrorAuthenticationStatus(val msgId: Int, val msg: String?) : AuthenticationStatus() { + /** + * Method that checks if [msgId] is a lockout error. A lockout error means that face + * authentication is locked out. + */ + fun isLockoutError() = msgId == FaceManager.FACE_ERROR_LOCKOUT_PERMANENT + + /** + * Method that checks if [msgId] is a cancellation error. This means that face authentication + * was cancelled before it completed. + */ + fun isCancellationError() = msgId == FaceManager.FACE_ERROR_CANCELED +} + +/** Face detection success message. */ +data class DetectionStatus(val sensorId: Int, val userId: Int, val isStrongBiometric: Boolean) diff --git a/packages/SystemUI/src/com/android/systemui/log/FaceAuthenticationLogger.kt b/packages/SystemUI/src/com/android/systemui/log/FaceAuthenticationLogger.kt new file mode 100644 index 0000000000000..f7349a2a7ae68 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/log/FaceAuthenticationLogger.kt @@ -0,0 +1,181 @@ +package com.android.systemui.log + +import android.hardware.face.FaceManager +import android.hardware.face.FaceSensorPropertiesInternal +import com.android.keyguard.FaceAuthUiEvent +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.log.dagger.FaceAuthLog +import com.android.systemui.plugins.log.LogBuffer +import com.android.systemui.plugins.log.LogLevel.DEBUG +import com.google.errorprone.annotations.CompileTimeConstant +import javax.inject.Inject + +private const val TAG = "KeyguardFaceAuthManagerLog" + +/** + * Helper class for logging for [com.android.keyguard.faceauth.KeyguardFaceAuthManager] + * + * To enable logcat echoing for an entire buffer: + * + * ``` + * adb shell settings put global systemui/buffer/KeyguardFaceAuthManagerLog + * + * ``` + */ +@SysUISingleton +class FaceAuthenticationLogger +@Inject +constructor( + @FaceAuthLog private val logBuffer: LogBuffer, +) { + fun ignoredFaceAuthTrigger(uiEvent: FaceAuthUiEvent) { + logBuffer.log( + TAG, + DEBUG, + { str1 = uiEvent.reason }, + { + "Ignoring trigger because face auth is currently running. " + + "Trigger reason: $str1" + } + ) + } + + fun queuingRequestWhileCancelling( + alreadyQueuedRequest: FaceAuthUiEvent?, + newRequest: FaceAuthUiEvent + ) { + logBuffer.log( + TAG, + DEBUG, + { + str1 = alreadyQueuedRequest?.reason + str2 = newRequest.reason + }, + { + "Face auth requested while previous request is being cancelled, " + + "already queued request: $str1 queueing the new request: $str2" + } + ) + } + + fun authenticating(uiEvent: FaceAuthUiEvent) { + logBuffer.log(TAG, DEBUG, { str1 = uiEvent.reason }, { "Running authenticate for $str1" }) + } + + fun detectionNotSupported( + faceManager: FaceManager?, + sensorPropertiesInternal: MutableList? + ) { + logBuffer.log( + TAG, + DEBUG, + { + bool1 = faceManager == null + bool2 = sensorPropertiesInternal.isNullOrEmpty() + bool2 = sensorPropertiesInternal?.firstOrNull()?.supportsFaceDetection ?: false + }, + { + "skipping detection request because it is not supported, " + + "faceManager isNull: $bool1, " + + "sensorPropertiesInternal isNullOrEmpty: $bool2, " + + "supportsFaceDetection: $bool3" + } + ) + } + + fun skippingBecauseAlreadyRunning(@CompileTimeConstant operation: String) { + logBuffer.log(TAG, DEBUG, "isAuthRunning is true, skipping $operation") + } + + fun faceDetectionStarted() { + logBuffer.log(TAG, DEBUG, "Face detection started.") + } + + fun faceDetected() { + logBuffer.log(TAG, DEBUG, "Face detected") + } + + fun cancelSignalNotReceived( + isAuthRunning: Boolean, + isLockedOut: Boolean, + cancellationInProgress: Boolean, + faceAuthRequestedWhileCancellation: FaceAuthUiEvent? + ) { + logBuffer.log( + TAG, + DEBUG, + { + bool1 = isAuthRunning + bool2 = isLockedOut + bool3 = cancellationInProgress + str1 = "${faceAuthRequestedWhileCancellation?.reason}" + }, + { + "Cancel signal was not received, running timeout handler to reset state. " + + "State before reset: " + + "isAuthRunning: $bool1, " + + "isLockedOut: $bool2, " + + "cancellationInProgress: $bool3, " + + "faceAuthRequestedWhileCancellation: $str1" + } + ) + } + + fun authenticationFailed() { + logBuffer.log(TAG, DEBUG, "Face authentication failed") + } + + fun authenticationAcquired(acquireInfo: Int) { + logBuffer.log( + TAG, + DEBUG, + { int1 = acquireInfo }, + { "Face acquired during face authentication: acquireInfo: $int1 " } + ) + } + + fun authenticationError( + errorCode: Int, + errString: CharSequence?, + lockoutError: Boolean, + cancellationError: Boolean + ) { + logBuffer.log( + TAG, + DEBUG, + { + int1 = errorCode + str1 = "$errString" + bool1 = lockoutError + bool2 = cancellationError + }, + { + "Received authentication error: errorCode: $int1, " + + "errString: $str1, " + + "isLockoutError: $bool1, " + + "isCancellationError: $bool2" + } + ) + } + + fun launchingQueuedFaceAuthRequest(faceAuthRequestedWhileCancellation: FaceAuthUiEvent?) { + logBuffer.log( + TAG, + DEBUG, + { str1 = "${faceAuthRequestedWhileCancellation?.reason}" }, + { "Received cancellation error and starting queued face auth request: $str1" } + ) + } + + fun faceAuthSuccess(result: FaceManager.AuthenticationResult) { + logBuffer.log( + TAG, + DEBUG, + { + int1 = result.userId + bool1 = result.isStrongBiometric + }, + { "Face authenticated successfully: userId: $int1, isStrongBiometric: $bool1" } + ) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/log/dagger/FaceAuthLog.kt b/packages/SystemUI/src/com/android/systemui/log/dagger/FaceAuthLog.kt new file mode 100644 index 0000000000000..b97e3a7883969 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/log/dagger/FaceAuthLog.kt @@ -0,0 +1,6 @@ +package com.android.systemui.log.dagger + +import javax.inject.Qualifier + +/** A [com.android.systemui.log.LogBuffer] for Face authentication triggered by SysUI. */ +@Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) annotation class FaceAuthLog() diff --git a/packages/SystemUI/src/com/android/systemui/log/dagger/LogModule.java b/packages/SystemUI/src/com/android/systemui/log/dagger/LogModule.java index 41774800c5277..3ea2940f1c307 100644 --- a/packages/SystemUI/src/com/android/systemui/log/dagger/LogModule.java +++ b/packages/SystemUI/src/com/android/systemui/log/dagger/LogModule.java @@ -359,6 +359,17 @@ public class LogModule { return factory.create("ScreenDecorationsLog", 200); } + /** + * Provides a {@link LogBuffer} for use by + * {@link com.android.keyguard.faceauth.KeyguardFaceAuthManagerImpl}. + */ + @Provides + @SysUISingleton + @FaceAuthLog + public static LogBuffer provideFaceAuthLog(LogBufferFactory factory) { + return factory.create("KeyguardFaceAuthManagerLog", 300); + } + /** * Provides a {@link LogBuffer} for bluetooth-related logs. */ diff --git a/packages/SystemUI/tests/src/com/android/systemui/keyguard/data/repository/KeyguardFaceAuthManagerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/keyguard/data/repository/KeyguardFaceAuthManagerTest.kt new file mode 100644 index 0000000000000..7c604f7606810 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/keyguard/data/repository/KeyguardFaceAuthManagerTest.kt @@ -0,0 +1,428 @@ +/* + * Copyright (C) 2023 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.keyguard.data.repository + +import android.app.StatusBarManager.SESSION_KEYGUARD +import android.content.pm.UserInfo +import android.hardware.biometrics.BiometricFaceConstants.FACE_ERROR_CANCELED +import android.hardware.biometrics.BiometricFaceConstants.FACE_ERROR_LOCKOUT_PERMANENT +import android.hardware.biometrics.ComponentInfoInternal +import android.hardware.face.FaceManager +import android.hardware.face.FaceSensorProperties +import android.hardware.face.FaceSensorPropertiesInternal +import android.os.CancellationSignal +import androidx.test.filters.SmallTest +import com.android.internal.logging.InstanceId.fakeInstanceId +import com.android.internal.logging.UiEventLogger +import com.android.keyguard.FaceAuthUiEvent +import com.android.keyguard.FaceAuthUiEvent.FACE_AUTH_TRIGGERED_ALTERNATE_BIOMETRIC_BOUNCER_SHOWN +import com.android.keyguard.FaceAuthUiEvent.FACE_AUTH_TRIGGERED_SWIPE_UP_ON_BOUNCER +import com.android.systemui.R +import com.android.systemui.SysuiTestCase +import com.android.systemui.coroutines.FlowValue +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.dump.DumpManager +import com.android.systemui.dump.logcatLogBuffer +import com.android.systemui.keyguard.shared.model.AuthenticationStatus +import com.android.systemui.keyguard.shared.model.DetectionStatus +import com.android.systemui.keyguard.shared.model.ErrorAuthenticationStatus +import com.android.systemui.keyguard.shared.model.HelpAuthenticationStatus +import com.android.systemui.keyguard.shared.model.SuccessAuthenticationStatus +import com.android.systemui.log.FaceAuthenticationLogger +import com.android.systemui.log.SessionTracker +import com.android.systemui.statusbar.phone.KeyguardBypassController +import com.android.systemui.user.data.repository.FakeUserRepository +import com.android.systemui.util.mockito.whenever +import com.google.common.truth.Truth.assertThat +import java.io.PrintWriter +import java.io.StringWriter +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.eq +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.clearInvocations +import org.mockito.Mockito.isNull +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoMoreInteractions +import org.mockito.MockitoAnnotations + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(JUnit4::class) +class KeyguardFaceAuthManagerTest : SysuiTestCase() { + private lateinit var underTest: KeyguardFaceAuthManagerImpl + + @Mock private lateinit var faceManager: FaceManager + @Mock private lateinit var bypassController: KeyguardBypassController + @Mock private lateinit var sessionTracker: SessionTracker + @Mock private lateinit var uiEventLogger: UiEventLogger + @Mock private lateinit var dumpManager: DumpManager + + @Captor + private lateinit var authenticationCallback: ArgumentCaptor + @Captor + private lateinit var detectionCallback: ArgumentCaptor + @Captor private lateinit var cancellationSignal: ArgumentCaptor + @Captor + private lateinit var faceLockoutResetCallback: ArgumentCaptor + private lateinit var testDispatcher: TestDispatcher + + private lateinit var testScope: TestScope + private lateinit var fakeUserRepository: FakeUserRepository + private lateinit var authStatus: FlowValue + private lateinit var detectStatus: FlowValue + private lateinit var authRunning: FlowValue + private lateinit var lockedOut: FlowValue + + @Before + fun setup() { + MockitoAnnotations.initMocks(this) + fakeUserRepository = FakeUserRepository() + fakeUserRepository.setUserInfos(listOf(currentUser)) + testDispatcher = StandardTestDispatcher() + testScope = TestScope(testDispatcher) + whenever(sessionTracker.getSessionId(SESSION_KEYGUARD)).thenReturn(keyguardSessionId) + whenever(bypassController.bypassEnabled).thenReturn(true) + underTest = createFaceAuthManagerImpl(faceManager) + } + + private fun createFaceAuthManagerImpl( + fmOverride: FaceManager? = faceManager, + bypassControllerOverride: KeyguardBypassController? = bypassController + ) = + KeyguardFaceAuthManagerImpl( + mContext, + fmOverride, + fakeUserRepository, + bypassControllerOverride, + testScope.backgroundScope, + testDispatcher, + sessionTracker, + uiEventLogger, + FaceAuthenticationLogger(logcatLogBuffer("KeyguardFaceAuthManagerLog")), + dumpManager, + ) + + @Test + fun faceAuthRunsAndProvidesAuthStatusUpdates() = + testScope.runTest { + testSetup(this) + + FACE_AUTH_TRIGGERED_SWIPE_UP_ON_BOUNCER.extraInfo = 10 + underTest.authenticate(FACE_AUTH_TRIGGERED_SWIPE_UP_ON_BOUNCER) + faceAuthenticateIsCalled() + uiEventIsLogged(FACE_AUTH_TRIGGERED_SWIPE_UP_ON_BOUNCER) + + assertThat(authRunning()).isTrue() + + val successResult = successResult() + authenticationCallback.value.onAuthenticationSucceeded(successResult) + + assertThat(authStatus()).isEqualTo(SuccessAuthenticationStatus(successResult)) + + assertThat(authRunning()).isFalse() + } + + private fun uiEventIsLogged(faceAuthUiEvent: FaceAuthUiEvent) { + verify(uiEventLogger) + .logWithInstanceIdAndPosition( + faceAuthUiEvent, + 0, + null, + keyguardSessionId, + faceAuthUiEvent.extraInfo + ) + } + + @Test + fun faceAuthDoesNotRunWhileItIsAlreadyRunning() = + testScope.runTest { + testSetup(this) + + underTest.authenticate(FACE_AUTH_TRIGGERED_SWIPE_UP_ON_BOUNCER) + faceAuthenticateIsCalled() + clearInvocations(faceManager) + clearInvocations(uiEventLogger) + + underTest.authenticate(FACE_AUTH_TRIGGERED_SWIPE_UP_ON_BOUNCER) + verifyNoMoreInteractions(faceManager) + verifyNoMoreInteractions(uiEventLogger) + } + + @Test + fun faceLockoutStatusIsPropagated() = + testScope.runTest { + testSetup(this) + verify(faceManager).addLockoutResetCallback(faceLockoutResetCallback.capture()) + + underTest.authenticate(FACE_AUTH_TRIGGERED_SWIPE_UP_ON_BOUNCER) + faceAuthenticateIsCalled() + + authenticationCallback.value.onAuthenticationError( + FACE_ERROR_LOCKOUT_PERMANENT, + "face locked out" + ) + + assertThat(lockedOut()).isTrue() + + faceLockoutResetCallback.value.onLockoutReset(0) + assertThat(lockedOut()).isFalse() + } + + @Test + fun faceDetectionSupportIsTheCorrectValue() = + testScope.runTest { + assertThat(createFaceAuthManagerImpl(fmOverride = null).isDetectionSupported).isFalse() + + whenever(faceManager.sensorPropertiesInternal).thenReturn(null) + assertThat(createFaceAuthManagerImpl().isDetectionSupported).isFalse() + + whenever(faceManager.sensorPropertiesInternal).thenReturn(listOf()) + assertThat(createFaceAuthManagerImpl().isDetectionSupported).isFalse() + + whenever(faceManager.sensorPropertiesInternal) + .thenReturn(listOf(createFaceSensorProperties(supportsFaceDetection = false))) + assertThat(createFaceAuthManagerImpl().isDetectionSupported).isFalse() + + whenever(faceManager.sensorPropertiesInternal) + .thenReturn( + listOf( + createFaceSensorProperties(supportsFaceDetection = false), + createFaceSensorProperties(supportsFaceDetection = true) + ) + ) + assertThat(createFaceAuthManagerImpl().isDetectionSupported).isFalse() + + whenever(faceManager.sensorPropertiesInternal) + .thenReturn( + listOf( + createFaceSensorProperties(supportsFaceDetection = true), + createFaceSensorProperties(supportsFaceDetection = false) + ) + ) + assertThat(createFaceAuthManagerImpl().isDetectionSupported).isTrue() + } + + @Test + fun cancelStopsFaceAuthentication() = + testScope.runTest { + testSetup(this) + + underTest.authenticate(FACE_AUTH_TRIGGERED_SWIPE_UP_ON_BOUNCER) + faceAuthenticateIsCalled() + + var wasAuthCancelled = false + cancellationSignal.value.setOnCancelListener { wasAuthCancelled = true } + + underTest.cancel() + assertThat(wasAuthCancelled).isTrue() + assertThat(authRunning()).isFalse() + } + + @Test + fun cancelInvokedWithoutFaceAuthRunningIsANoop() = testScope.runTest { underTest.cancel() } + + @Test + fun faceDetectionRunsAndPropagatesDetectionStatus() = + testScope.runTest { + whenever(faceManager.sensorPropertiesInternal) + .thenReturn(listOf(createFaceSensorProperties(supportsFaceDetection = true))) + underTest = createFaceAuthManagerImpl() + testSetup(this) + + underTest.detect() + faceDetectIsCalled() + + detectionCallback.value.onFaceDetected(1, 1, true) + + assertThat(detectStatus()).isEqualTo(DetectionStatus(1, 1, true)) + } + + @Test + fun faceDetectDoesNotRunIfDetectionIsNotSupported() = + testScope.runTest { + whenever(faceManager.sensorPropertiesInternal) + .thenReturn(listOf(createFaceSensorProperties(supportsFaceDetection = false))) + underTest = createFaceAuthManagerImpl() + testSetup(this) + clearInvocations(faceManager) + + underTest.detect() + + verify(faceManager, never()).detectFace(any(), any(), anyInt()) + } + + @Test + fun faceAuthShouldWaitAndRunIfTriggeredWhileCancelling() = + testScope.runTest { + testSetup(this) + + underTest.authenticate(FACE_AUTH_TRIGGERED_SWIPE_UP_ON_BOUNCER) + faceAuthenticateIsCalled() + + // Enter cancelling state + underTest.cancel() + clearInvocations(faceManager) + + // Auth is while cancelling. + underTest.authenticate(FACE_AUTH_TRIGGERED_ALTERNATE_BIOMETRIC_BOUNCER_SHOWN) + // Auth is not started + verifyNoMoreInteractions(faceManager) + + // Auth is done cancelling. + authenticationCallback.value.onAuthenticationError( + FACE_ERROR_CANCELED, + "First auth attempt cancellation completed" + ) + assertThat(authStatus()) + .isEqualTo( + ErrorAuthenticationStatus( + FACE_ERROR_CANCELED, + "First auth attempt cancellation completed" + ) + ) + + faceAuthenticateIsCalled() + uiEventIsLogged(FACE_AUTH_TRIGGERED_ALTERNATE_BIOMETRIC_BOUNCER_SHOWN) + } + + @Test + fun faceAuthAutoCancelsAfterDefaultCancellationTimeout() = + testScope.runTest { + testSetup(this) + + underTest.authenticate(FACE_AUTH_TRIGGERED_SWIPE_UP_ON_BOUNCER) + faceAuthenticateIsCalled() + + clearInvocations(faceManager) + underTest.cancel() + advanceTimeBy(KeyguardFaceAuthManagerImpl.DEFAULT_CANCEL_SIGNAL_TIMEOUT + 1) + + underTest.authenticate(FACE_AUTH_TRIGGERED_SWIPE_UP_ON_BOUNCER) + faceAuthenticateIsCalled() + } + + @Test + fun faceHelpMessagesAreIgnoredBasedOnConfig() = + testScope.runTest { + overrideResource( + R.array.config_face_acquire_device_entry_ignorelist, + intArrayOf(10, 11) + ) + underTest = createFaceAuthManagerImpl() + testSetup(this) + + underTest.authenticate(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") + + assertThat(authStatus()).isEqualTo(HelpAuthenticationStatus(9, "help msg")) + } + + @Test + fun dumpDoesNotErrorOutWhenFaceManagerOrBypassControllerIsNull() = + testScope.runTest { + fakeUserRepository.setSelectedUserInfo(currentUser) + underTest.dump(PrintWriter(StringWriter()), emptyArray()) + + underTest = + createFaceAuthManagerImpl(fmOverride = null, bypassControllerOverride = null) + fakeUserRepository.setSelectedUserInfo(currentUser) + + underTest.dump(PrintWriter(StringWriter()), emptyArray()) + } + + private suspend fun testSetup(testScope: TestScope) { + with(testScope) { + authStatus = collectLastValue(underTest.authenticationStatus) + detectStatus = collectLastValue(underTest.detectionStatus) + authRunning = collectLastValue(underTest.isAuthRunning) + lockedOut = collectLastValue(underTest.isLockedOut) + fakeUserRepository.setSelectedUserInfo(currentUser) + } + } + + private fun successResult() = FaceManager.AuthenticationResult(null, null, currentUserId, false) + + private fun faceDetectIsCalled() { + verify(faceManager) + .detectFace( + cancellationSignal.capture(), + detectionCallback.capture(), + eq(currentUserId) + ) + } + + private fun faceAuthenticateIsCalled() { + verify(faceManager) + .authenticate( + isNull(), + cancellationSignal.capture(), + authenticationCallback.capture(), + isNull(), + eq(currentUserId), + eq(true) + ) + } + + private fun createFaceSensorProperties( + supportsFaceDetection: Boolean + ): FaceSensorPropertiesInternal { + val componentInfo = + listOf( + ComponentInfoInternal( + "faceSensor" /* componentId */, + "vendor/model/revision" /* hardwareVersion */, + "1.01" /* firmwareVersion */, + "00000001" /* serialNumber */, + "" /* softwareVersion */ + ) + ) + return FaceSensorPropertiesInternal( + 0 /* id */, + FaceSensorProperties.STRENGTH_STRONG, + 1 /* maxTemplatesAllowed */, + componentInfo, + FaceSensorProperties.TYPE_UNKNOWN, + supportsFaceDetection /* supportsFaceDetection */, + true /* supportsSelfIllumination */, + false /* resetLockoutRequiresChallenge */ + ) + } + + companion object { + const val currentUserId = 1 + val keyguardSessionId = fakeInstanceId(10)!! + val currentUser = UserInfo(currentUserId, "test user", 0) + } +}