diff options
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) + } +} |