summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/SystemUI/src/com/android/systemui/keyguard/data/repository/KeyguardFaceAuthManager.kt342
-rw-r--r--packages/SystemUI/src/com/android/systemui/keyguard/shared/model/FaceAuthenticationModels.kt53
-rw-r--r--packages/SystemUI/src/com/android/systemui/log/FaceAuthenticationLogger.kt181
-rw-r--r--packages/SystemUI/src/com/android/systemui/log/dagger/FaceAuthLog.kt6
-rw-r--r--packages/SystemUI/src/com/android/systemui/log/dagger/LogModule.java11
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/keyguard/data/repository/KeyguardFaceAuthManagerTest.kt428
6 files changed, 1021 insertions, 0 deletions
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 000000000000..2069891a23e0
--- /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<AuthenticationStatus>
+
+ /** Provide the current status of face detection. */
+ val detectionStatus: Flow<DetectionStatus>
+
+ /** Current state of whether face authentication is locked out or not. */
+ val isLockedOut: Flow<Boolean>
+
+ /** Current state of whether face authentication is running. */
+ val isAuthRunning: Flow<Boolean>
+
+ /** 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<Int>
+
+ 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<AuthenticationStatus?> =
+ MutableStateFlow(null)
+ override val authenticationStatus: Flow<AuthenticationStatus>
+ get() = _authenticationStatus.filterNotNull()
+
+ private val _detectionStatus = MutableStateFlow<DetectionStatus?>(null)
+ override val detectionStatus: Flow<DetectionStatus>
+ get() = _detectionStatus.filterNotNull()
+
+ private val _isLockedOut = MutableStateFlow(false)
+ override val isLockedOut: Flow<Boolean> = _isLockedOut
+
+ override val isDetectionSupported =
+ faceManager?.sensorPropertiesInternal?.firstOrNull()?.supportsFaceDetection ?: false
+
+ private val _isAuthRunning = MutableStateFlow(false)
+ override val isAuthRunning: Flow<Boolean>
+ 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<out String>) {
+ 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 000000000000..b1c5f8fa270b
--- /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 000000000000..f7349a2a7ae6
--- /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 <logLevel>
+ *
+ * ```
+ */
+@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<FaceSensorPropertiesInternal>?
+ ) {
+ 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 000000000000..b97e3a788396
--- /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 5341cd53b2a5..817de7976352 100644
--- a/packages/SystemUI/src/com/android/systemui/log/dagger/LogModule.java
+++ b/packages/SystemUI/src/com/android/systemui/log/dagger/LogModule.java
@@ -350,6 +350,17 @@ public class LogModule {
}
/**
+ * 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.
*/
@Provides
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 000000000000..7c604f760681
--- /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<FaceManager.AuthenticationCallback>
+ @Captor
+ private lateinit var detectionCallback: ArgumentCaptor<FaceManager.FaceDetectionCallback>
+ @Captor private lateinit var cancellationSignal: ArgumentCaptor<CancellationSignal>
+ @Captor
+ private lateinit var faceLockoutResetCallback: ArgumentCaptor<FaceManager.LockoutResetCallback>
+ private lateinit var testDispatcher: TestDispatcher
+
+ private lateinit var testScope: TestScope
+ private lateinit var fakeUserRepository: FakeUserRepository
+ private lateinit var authStatus: FlowValue<AuthenticationStatus?>
+ private lateinit var detectStatus: FlowValue<DetectionStatus?>
+ private lateinit var authRunning: FlowValue<Boolean?>
+ private lateinit var lockedOut: FlowValue<Boolean?>
+
+ @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)
+ }
+}