diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/dagger/BiometricsModule.kt b/packages/SystemUI/src/com/android/systemui/biometrics/dagger/BiometricsModule.kt index a242d4d71b754..7c0c3b710e664 100644 --- a/packages/SystemUI/src/com/android/systemui/biometrics/dagger/BiometricsModule.kt +++ b/packages/SystemUI/src/com/android/systemui/biometrics/dagger/BiometricsModule.kt @@ -18,6 +18,8 @@ package com.android.systemui.biometrics.dagger import com.android.systemui.biometrics.data.repository.PromptRepository import com.android.systemui.biometrics.data.repository.PromptRepositoryImpl +import com.android.systemui.biometrics.domain.interactor.CredentialInteractor +import com.android.systemui.biometrics.domain.interactor.CredentialInteractorImpl import com.android.systemui.dagger.SysUISingleton import com.android.systemui.util.concurrency.ThreadFactory import dagger.Binds @@ -34,6 +36,10 @@ interface BiometricsModule { @SysUISingleton fun biometricPromptRepository(impl: PromptRepositoryImpl): PromptRepository + @Binds + @SysUISingleton + fun providesCredentialInteractor(impl: CredentialInteractorImpl): CredentialInteractor + companion object { /** Background [Executor] for HAL related operations. */ @Provides diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/domain/interactor/CredentialInteractor.kt b/packages/SystemUI/src/com/android/systemui/biometrics/domain/interactor/CredentialInteractor.kt new file mode 100644 index 0000000000000..1f1a1b5c83bd5 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/biometrics/domain/interactor/CredentialInteractor.kt @@ -0,0 +1,282 @@ +package com.android.systemui.biometrics.domain.interactor + +import android.app.admin.DevicePolicyManager +import android.app.admin.DevicePolicyResources +import android.content.Context +import android.os.UserManager +import com.android.internal.widget.LockPatternUtils +import com.android.internal.widget.LockscreenCredential +import com.android.internal.widget.VerifyCredentialResponse +import com.android.systemui.R +import com.android.systemui.biometrics.domain.model.BiometricPromptRequest +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.util.time.SystemClock +import javax.inject.Inject +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +/** + * A wrapper for [LockPatternUtils] to verify PIN, pattern, or password credentials. + * + * This class also uses the [DevicePolicyManager] to generate appropriate error messages when policy + * exceptions are raised (i.e. wipe device due to excessive failed attempts, etc.). + */ +interface CredentialInteractor { + /** If the user's pattern credential should be hidden */ + fun isStealthModeActive(userId: Int): Boolean + + /** Get the effective user id (profile owner, if one exists) */ + fun getCredentialOwnerOrSelfId(userId: Int): Int + + /** + * Verifies a credential and returns a stream of results. + * + * The final emitted value will either be a [CredentialStatus.Fail.Error] or a + * [CredentialStatus.Success.Verified]. + */ + fun verifyCredential( + request: BiometricPromptRequest.Credential, + credential: LockscreenCredential, + ): Flow +} + +/** Standard implementation of [CredentialInteractor]. */ +class CredentialInteractorImpl +@Inject +constructor( + @Application private val applicationContext: Context, + private val lockPatternUtils: LockPatternUtils, + private val userManager: UserManager, + private val devicePolicyManager: DevicePolicyManager, + private val systemClock: SystemClock, +) : CredentialInteractor { + + override fun isStealthModeActive(userId: Int): Boolean = + !lockPatternUtils.isVisiblePatternEnabled(userId) + + override fun getCredentialOwnerOrSelfId(userId: Int): Int = + userManager.getCredentialOwnerProfile(userId) + + override fun verifyCredential( + request: BiometricPromptRequest.Credential, + credential: LockscreenCredential, + ): Flow = flow { + // Request LockSettingsService to return the Gatekeeper Password in the + // VerifyCredentialResponse so that we can request a Gatekeeper HAT with the + // Gatekeeper Password and operationId. + val effectiveUserId = request.userInfo.deviceCredentialOwnerId + val response = + lockPatternUtils.verifyCredential( + credential, + effectiveUserId, + LockPatternUtils.VERIFY_FLAG_REQUEST_GK_PW_HANDLE + ) + + if (response.isMatched) { + lockPatternUtils.userPresent(effectiveUserId) + + // The response passed into this method contains the Gatekeeper + // Password. We still have to request Gatekeeper to create a + // Hardware Auth Token with the Gatekeeper Password and Challenge + // (keystore operationId in this case) + val pwHandle = response.gatekeeperPasswordHandle + val gkResponse: VerifyCredentialResponse = + lockPatternUtils.verifyGatekeeperPasswordHandle( + pwHandle, + request.operationInfo.gatekeeperChallenge, + effectiveUserId + ) + val hat = gkResponse.gatekeeperHAT + lockPatternUtils.removeGatekeeperPasswordHandle(pwHandle) + emit(CredentialStatus.Success.Verified(hat)) + } else if (response.timeout > 0) { + // if requests are being throttled, update the error message every + // second until the temporary lock has expired + val deadline: Long = + lockPatternUtils.setLockoutAttemptDeadline(effectiveUserId, response.timeout) + val interval = LockPatternUtils.FAILED_ATTEMPT_COUNTDOWN_INTERVAL_MS + var remaining = deadline - systemClock.elapsedRealtime() + while (remaining > 0) { + emit( + CredentialStatus.Fail.Throttled( + applicationContext.getString( + R.string.biometric_dialog_credential_too_many_attempts, + remaining / 1000 + ) + ) + ) + delay(interval) + remaining -= interval + } + emit(CredentialStatus.Fail.Error("")) + } else { // bad request, but not throttled + val numAttempts = lockPatternUtils.getCurrentFailedPasswordAttempts(effectiveUserId) + 1 + val maxAttempts = lockPatternUtils.getMaximumFailedPasswordsForWipe(effectiveUserId) + if (maxAttempts <= 0 || numAttempts <= 0) { + // use a generic message if there's no maximum number of attempts + emit(CredentialStatus.Fail.Error()) + } else { + val remainingAttempts = (maxAttempts - numAttempts).coerceAtLeast(0) + emit( + CredentialStatus.Fail.Error( + applicationContext.getString( + R.string.biometric_dialog_credential_attempts_before_wipe, + numAttempts, + maxAttempts + ), + remainingAttempts, + fetchFinalAttemptMessageOrNull(request, remainingAttempts) + ) + ) + } + lockPatternUtils.reportFailedPasswordAttempt(effectiveUserId) + } + } + + private fun fetchFinalAttemptMessageOrNull( + request: BiometricPromptRequest.Credential, + remainingAttempts: Int?, + ): String? = + if (remainingAttempts != null && remainingAttempts <= 1) { + applicationContext.getFinalAttemptMessageOrBlank( + request, + devicePolicyManager, + userManager.getUserTypeForWipe( + devicePolicyManager, + request.userInfo.deviceCredentialOwnerId + ), + remainingAttempts + ) + } else { + null + } +} + +private enum class UserType { + PRIMARY, + MANAGED_PROFILE, + SECONDARY, +} + +private fun UserManager.getUserTypeForWipe( + devicePolicyManager: DevicePolicyManager, + effectiveUserId: Int, +): UserType { + val userToBeWiped = + getUserInfo( + devicePolicyManager.getProfileWithMinimumFailedPasswordsForWipe(effectiveUserId) + ) + return when { + userToBeWiped == null || userToBeWiped.isPrimary -> UserType.PRIMARY + userToBeWiped.isManagedProfile -> UserType.MANAGED_PROFILE + else -> UserType.SECONDARY + } +} + +private fun Context.getFinalAttemptMessageOrBlank( + request: BiometricPromptRequest.Credential, + devicePolicyManager: DevicePolicyManager, + userType: UserType, + remaining: Int, +): String = + when { + remaining == 1 -> getLastAttemptBeforeWipeMessage(request, devicePolicyManager, userType) + remaining <= 0 -> getNowWipingMessage(devicePolicyManager, userType) + else -> "" + } + +private fun Context.getLastAttemptBeforeWipeMessage( + request: BiometricPromptRequest.Credential, + devicePolicyManager: DevicePolicyManager, + userType: UserType, +): String = + when (userType) { + UserType.PRIMARY -> getLastAttemptBeforeWipeDeviceMessage(request) + UserType.MANAGED_PROFILE -> + getLastAttemptBeforeWipeProfileMessage(request, devicePolicyManager) + UserType.SECONDARY -> getLastAttemptBeforeWipeUserMessage(request) + } + +private fun Context.getLastAttemptBeforeWipeDeviceMessage( + request: BiometricPromptRequest.Credential, +): String { + val id = + when (request) { + is BiometricPromptRequest.Credential.Pin -> + R.string.biometric_dialog_last_pin_attempt_before_wipe_device + is BiometricPromptRequest.Credential.Pattern -> + R.string.biometric_dialog_last_pattern_attempt_before_wipe_device + is BiometricPromptRequest.Credential.Password -> + R.string.biometric_dialog_last_password_attempt_before_wipe_device + } + return getString(id) +} + +private fun Context.getLastAttemptBeforeWipeProfileMessage( + request: BiometricPromptRequest.Credential, + devicePolicyManager: DevicePolicyManager, +): String { + val id = + when (request) { + is BiometricPromptRequest.Credential.Pin -> + DevicePolicyResources.Strings.SystemUi.BIOMETRIC_DIALOG_WORK_PIN_LAST_ATTEMPT + is BiometricPromptRequest.Credential.Pattern -> + DevicePolicyResources.Strings.SystemUi.BIOMETRIC_DIALOG_WORK_PATTERN_LAST_ATTEMPT + is BiometricPromptRequest.Credential.Password -> + DevicePolicyResources.Strings.SystemUi.BIOMETRIC_DIALOG_WORK_PASSWORD_LAST_ATTEMPT + } + return devicePolicyManager.resources.getString(id) { + // use fallback a string if not found + val defaultId = + when (request) { + is BiometricPromptRequest.Credential.Pin -> + R.string.biometric_dialog_last_pin_attempt_before_wipe_profile + is BiometricPromptRequest.Credential.Pattern -> + R.string.biometric_dialog_last_pattern_attempt_before_wipe_profile + is BiometricPromptRequest.Credential.Password -> + R.string.biometric_dialog_last_password_attempt_before_wipe_profile + } + getString(defaultId) + } +} + +private fun Context.getLastAttemptBeforeWipeUserMessage( + request: BiometricPromptRequest.Credential, +): String { + val resId = + when (request) { + is BiometricPromptRequest.Credential.Pin -> + R.string.biometric_dialog_last_pin_attempt_before_wipe_user + is BiometricPromptRequest.Credential.Pattern -> + R.string.biometric_dialog_last_pattern_attempt_before_wipe_user + is BiometricPromptRequest.Credential.Password -> + R.string.biometric_dialog_last_password_attempt_before_wipe_user + } + return getString(resId) +} + +private fun Context.getNowWipingMessage( + devicePolicyManager: DevicePolicyManager, + userType: UserType, +): String { + val id = + when (userType) { + UserType.MANAGED_PROFILE -> + DevicePolicyResources.Strings.SystemUi.BIOMETRIC_DIALOG_WORK_LOCK_FAILED_ATTEMPTS + else -> DevicePolicyResources.UNDEFINED + } + return devicePolicyManager.resources.getString(id) { + // use fallback a string if not found + val defaultId = + when (userType) { + UserType.PRIMARY -> + com.android.settingslib.R.string.failed_attempts_now_wiping_device + UserType.MANAGED_PROFILE -> + com.android.settingslib.R.string.failed_attempts_now_wiping_profile + UserType.SECONDARY -> + com.android.settingslib.R.string.failed_attempts_now_wiping_user + } + getString(defaultId) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/domain/interactor/CredentialStatus.kt b/packages/SystemUI/src/com/android/systemui/biometrics/domain/interactor/CredentialStatus.kt new file mode 100644 index 0000000000000..40b76121f2374 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/biometrics/domain/interactor/CredentialStatus.kt @@ -0,0 +1,23 @@ +package com.android.systemui.biometrics.domain.interactor + +/** Result of a [CredentialInteractor.verifyCredential] check. */ +sealed interface CredentialStatus { + /** A successful result. */ + sealed interface Success : CredentialStatus { + /** The credential is valid and a [hat] has been generated. */ + data class Verified(val hat: ByteArray) : Success + } + /** A failed result. */ + sealed interface Fail : CredentialStatus { + val error: String? + + /** The credential check failed with an [error]. */ + data class Error( + override val error: String? = null, + val remainingAttempts: Int? = null, + val urgentMessage: String? = null, + ) : Fail + /** The credential check failed with an [error] and is temporarily locked out. */ + data class Throttled(override val error: String) : Fail + } +} diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/domain/interactor/PromptCredentialInteractor.kt b/packages/SystemUI/src/com/android/systemui/biometrics/domain/interactor/PromptCredentialInteractor.kt new file mode 100644 index 0000000000000..6362c2f627d3c --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/biometrics/domain/interactor/PromptCredentialInteractor.kt @@ -0,0 +1,189 @@ +package com.android.systemui.biometrics.domain.interactor + +import android.hardware.biometrics.PromptInfo +import com.android.internal.widget.LockPatternView +import com.android.internal.widget.LockscreenCredential +import com.android.systemui.biometrics.Utils +import com.android.systemui.biometrics.data.model.PromptKind +import com.android.systemui.biometrics.data.repository.PromptRepository +import com.android.systemui.biometrics.domain.model.BiometricOperationInfo +import com.android.systemui.biometrics.domain.model.BiometricPromptRequest +import com.android.systemui.biometrics.domain.model.BiometricUserInfo +import com.android.systemui.dagger.qualifiers.Background +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.lastOrNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext + +/** + * Business logic for BiometricPrompt's CredentialViews, which primarily includes checking a users + * PIN, pattern, or password credential instead of a biometric. + */ +class BiometricPromptCredentialInteractor +@Inject +constructor( + @Background private val bgDispatcher: CoroutineDispatcher, + private val biometricPromptRepository: PromptRepository, + private val credentialInteractor: CredentialInteractor, +) { + /** If the prompt is currently showing. */ + val isShowing: Flow = biometricPromptRepository.isShowing + + /** Metadata about the current credential prompt, including app-supplied preferences. */ + val prompt: Flow = + combine( + biometricPromptRepository.promptInfo, + biometricPromptRepository.challenge, + biometricPromptRepository.userId, + biometricPromptRepository.kind + ) { promptInfo, challenge, userId, kind -> + if (promptInfo == null || userId == null || challenge == null) { + return@combine null + } + + when (kind) { + PromptKind.PIN -> + BiometricPromptRequest.Credential.Pin( + info = promptInfo, + userInfo = userInfo(userId), + operationInfo = operationInfo(challenge) + ) + PromptKind.PATTERN -> + BiometricPromptRequest.Credential.Pattern( + info = promptInfo, + userInfo = userInfo(userId), + operationInfo = operationInfo(challenge), + stealthMode = credentialInteractor.isStealthModeActive(userId) + ) + PromptKind.PASSWORD -> + BiometricPromptRequest.Credential.Password( + info = promptInfo, + userInfo = userInfo(userId), + operationInfo = operationInfo(challenge) + ) + else -> null + } + } + .distinctUntilChanged() + + private fun userInfo(userId: Int): BiometricUserInfo = + BiometricUserInfo( + userId = userId, + deviceCredentialOwnerId = credentialInteractor.getCredentialOwnerOrSelfId(userId) + ) + + private fun operationInfo(challenge: Long): BiometricOperationInfo = + BiometricOperationInfo(gatekeeperChallenge = challenge) + + /** Most recent error due to [verifyCredential]. */ + private val _verificationError = MutableStateFlow(null) + val verificationError: Flow = _verificationError.asStateFlow() + + /** Update the current request to use credential-based authentication instead of biometrics. */ + fun useCredentialsForAuthentication( + promptInfo: PromptInfo, + @Utils.CredentialType kind: Int, + userId: Int, + challenge: Long, + ) { + biometricPromptRepository.setPrompt( + promptInfo, + userId, + challenge, + kind.asBiometricPromptCredential() + ) + } + + /** Unset the current authentication request. */ + fun resetPrompt() { + biometricPromptRepository.unsetPrompt() + } + + /** + * Check a credential and return the attestation token (HAT) if successful. + * + * This method will not return if credential checks are being throttled until the throttling has + * expired and the user can try again. It will periodically update the [verificationError] until + * cancelled or the throttling has completed. If the request is not throttled, but unsuccessful, + * the [verificationError] will be set and an optional + * [CredentialStatus.Fail.Error.urgentMessage] message may be provided to indicate additional + * hints to the user (i.e. device will be wiped on next failure, etc.). + * + * The check happens on the background dispatcher given in the constructor. + */ + suspend fun checkCredential( + request: BiometricPromptRequest.Credential, + text: CharSequence? = null, + pattern: List? = null, + ): CredentialStatus = + withContext(bgDispatcher) { + val credential = + when (request) { + is BiometricPromptRequest.Credential.Pin -> + LockscreenCredential.createPinOrNone(text ?: "") + is BiometricPromptRequest.Credential.Password -> + LockscreenCredential.createPasswordOrNone(text ?: "") + is BiometricPromptRequest.Credential.Pattern -> + LockscreenCredential.createPattern(pattern ?: listOf()) + } + + credential.use { c -> verifyCredential(request, c) } + } + + private suspend fun verifyCredential( + request: BiometricPromptRequest.Credential, + credential: LockscreenCredential? + ): CredentialStatus { + if (credential == null || credential.isNone) { + return CredentialStatus.Fail.Error() + } + + val finalStatus = + credentialInteractor + .verifyCredential(request, credential) + .onEach { status -> + when (status) { + is CredentialStatus.Success -> _verificationError.value = null + is CredentialStatus.Fail -> _verificationError.value = status + } + } + .lastOrNull() + + return finalStatus ?: CredentialStatus.Fail.Error() + } + + /** + * Report a user-visible error. + * + * Use this instead of calling [verifyCredential] when it is not necessary because the check + * will obviously fail (i.e. too short, empty, etc.) + */ + fun setVerificationError(error: CredentialStatus.Fail.Error?) { + if (error != null) { + _verificationError.value = error + } else { + resetVerificationError() + } + } + + /** Clear the current error message, if any. */ + fun resetVerificationError() { + _verificationError.value = null + } +} + +// TODO(b/251476085): remove along with Utils.CredentialType +/** Convert a [Utils.CredentialType] to the corresponding [PromptKind]. */ +private fun @receiver:Utils.CredentialType Int.asBiometricPromptCredential(): PromptKind = + when (this) { + Utils.CREDENTIAL_PIN -> PromptKind.PIN + Utils.CREDENTIAL_PASSWORD -> PromptKind.PASSWORD + Utils.CREDENTIAL_PATTERN -> PromptKind.PATTERN + else -> PromptKind.ANY_BIOMETRIC + } diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/domain/model/BiometricOperationInfo.kt b/packages/SystemUI/src/com/android/systemui/biometrics/domain/model/BiometricOperationInfo.kt new file mode 100644 index 0000000000000..c619b12361c45 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/biometrics/domain/model/BiometricOperationInfo.kt @@ -0,0 +1,4 @@ +package com.android.systemui.biometrics.domain.model + +/** Metadata about an in-progress biometric operation. */ +data class BiometricOperationInfo(val gatekeeperChallenge: Long = -1) diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/domain/model/BiometricPromptRequest.kt b/packages/SystemUI/src/com/android/systemui/biometrics/domain/model/BiometricPromptRequest.kt new file mode 100644 index 0000000000000..5ee0381db6304 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/biometrics/domain/model/BiometricPromptRequest.kt @@ -0,0 +1,69 @@ +package com.android.systemui.biometrics.domain.model + +import android.hardware.biometrics.PromptInfo + +/** + * Preferences for BiometricPrompt, such as title & description, that are immutable while the prompt + * is showing. + * + * This roughly corresponds to a "request" by the system or an app to show BiometricPrompt and it + * contains a subset of the information in a [PromptInfo] that is relevant to SysUI. + */ +sealed class BiometricPromptRequest( + val title: String, + val subtitle: String, + val description: String, + val userInfo: BiometricUserInfo, + val operationInfo: BiometricOperationInfo, +) { + /** Prompt using one or more biometrics. */ + class Biometric( + info: PromptInfo, + userInfo: BiometricUserInfo, + operationInfo: BiometricOperationInfo, + ) : + BiometricPromptRequest( + title = info.title?.toString() ?: "", + subtitle = info.subtitle?.toString() ?: "", + description = info.description?.toString() ?: "", + userInfo = userInfo, + operationInfo = operationInfo + ) + + /** Prompt using a credential (pin, pattern, password). */ + sealed class Credential( + info: PromptInfo, + userInfo: BiometricUserInfo, + operationInfo: BiometricOperationInfo, + ) : + BiometricPromptRequest( + title = (info.deviceCredentialTitle ?: info.title)?.toString() ?: "", + subtitle = (info.deviceCredentialSubtitle ?: info.subtitle)?.toString() ?: "", + description = (info.deviceCredentialDescription ?: info.description)?.toString() ?: "", + userInfo = userInfo, + operationInfo = operationInfo, + ) { + + /** PIN prompt. */ + class Pin( + info: PromptInfo, + userInfo: BiometricUserInfo, + operationInfo: BiometricOperationInfo, + ) : Credential(info, userInfo, operationInfo) + + /** Password prompt. */ + class Password( + info: PromptInfo, + userInfo: BiometricUserInfo, + operationInfo: BiometricOperationInfo, + ) : Credential(info, userInfo, operationInfo) + + /** Pattern prompt. */ + class Pattern( + info: PromptInfo, + userInfo: BiometricUserInfo, + operationInfo: BiometricOperationInfo, + val stealthMode: Boolean, + ) : Credential(info, userInfo, operationInfo) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/biometrics/domain/model/BiometricUserInfo.kt b/packages/SystemUI/src/com/android/systemui/biometrics/domain/model/BiometricUserInfo.kt new file mode 100644 index 0000000000000..08da04d276064 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/biometrics/domain/model/BiometricUserInfo.kt @@ -0,0 +1,7 @@ +package com.android.systemui.biometrics.domain.model + +/** Metadata about the current user BiometricPrompt is being shown to. */ +data class BiometricUserInfo( + val userId: Int, + val deviceCredentialOwnerId: Int = userId, +) diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/BiometricTestExtensions.kt b/packages/SystemUI/tests/src/com/android/systemui/biometrics/BiometricTestExtensions.kt index 8820c164cba4b..1379a0eeebdd3 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/biometrics/BiometricTestExtensions.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/biometrics/BiometricTestExtensions.kt @@ -22,12 +22,11 @@ import android.hardware.biometrics.BiometricManager import android.hardware.biometrics.ComponentInfoInternal import android.hardware.biometrics.PromptInfo import android.hardware.biometrics.SensorProperties -import android.hardware.face.FaceSensorPropertiesInternal import android.hardware.face.FaceSensorProperties +import android.hardware.face.FaceSensorPropertiesInternal import android.hardware.fingerprint.FingerprintSensorProperties import android.hardware.fingerprint.FingerprintSensorPropertiesInternal import android.os.Bundle - import android.testing.ViewUtils import android.view.LayoutInflater @@ -83,26 +82,31 @@ internal fun AuthBiometricView?.destroyDialog() { internal fun fingerprintSensorPropertiesInternal( ids: List = listOf(0) ): List { - val componentInfo = listOf( + val componentInfo = + listOf( ComponentInfoInternal( - "fingerprintSensor" /* componentId */, - "vendor/model/revision" /* hardwareVersion */, "1.01" /* firmwareVersion */, - "00000001" /* serialNumber */, "" /* softwareVersion */ + "fingerprintSensor" /* componentId */, + "vendor/model/revision" /* hardwareVersion */, + "1.01" /* firmwareVersion */, + "00000001" /* serialNumber */, + "" /* softwareVersion */ ), ComponentInfoInternal( - "matchingAlgorithm" /* componentId */, - "" /* hardwareVersion */, "" /* firmwareVersion */, "" /* serialNumber */, - "vendor/version/revision" /* softwareVersion */ + "matchingAlgorithm" /* componentId */, + "" /* hardwareVersion */, + "" /* firmwareVersion */, + "" /* serialNumber */, + "vendor/version/revision" /* softwareVersion */ ) - ) + ) return ids.map { id -> FingerprintSensorPropertiesInternal( - id, - SensorProperties.STRENGTH_STRONG, - 5 /* maxEnrollmentsPerUser */, - componentInfo, - FingerprintSensorProperties.TYPE_REAR, - false /* resetLockoutRequiresHardwareAuthToken */ + id, + SensorProperties.STRENGTH_STRONG, + 5 /* maxEnrollmentsPerUser */, + componentInfo, + FingerprintSensorProperties.TYPE_REAR, + false /* resetLockoutRequiresHardwareAuthToken */ ) } } @@ -111,28 +115,53 @@ internal fun fingerprintSensorPropertiesInternal( internal fun faceSensorPropertiesInternal( ids: List = listOf(1) ): List { - val componentInfo = listOf( + val componentInfo = + listOf( ComponentInfoInternal( - "faceSensor" /* componentId */, - "vendor/model/revision" /* hardwareVersion */, "1.01" /* firmwareVersion */, - "00000001" /* serialNumber */, "" /* softwareVersion */ + "faceSensor" /* componentId */, + "vendor/model/revision" /* hardwareVersion */, + "1.01" /* firmwareVersion */, + "00000001" /* serialNumber */, + "" /* softwareVersion */ ), ComponentInfoInternal( - "matchingAlgorithm" /* componentId */, - "" /* hardwareVersion */, "" /* firmwareVersion */, "" /* serialNumber */, - "vendor/version/revision" /* softwareVersion */ + "matchingAlgorithm" /* componentId */, + "" /* hardwareVersion */, + "" /* firmwareVersion */, + "" /* serialNumber */, + "vendor/version/revision" /* softwareVersion */ ) - ) + ) return ids.map { id -> FaceSensorPropertiesInternal( - id, - SensorProperties.STRENGTH_STRONG, - 2 /* maxEnrollmentsPerUser */, - componentInfo, - FaceSensorProperties.TYPE_RGB, - true /* supportsFaceDetection */, - true /* supportsSelfIllumination */, - false /* resetLockoutRequiresHardwareAuthToken */ + id, + SensorProperties.STRENGTH_STRONG, + 2 /* maxEnrollmentsPerUser */, + componentInfo, + FaceSensorProperties.TYPE_RGB, + true /* supportsFaceDetection */, + true /* supportsSelfIllumination */, + false /* resetLockoutRequiresHardwareAuthToken */ ) } } + +internal fun promptInfo( + title: String = "title", + subtitle: String = "sub", + description: String = "desc", + credentialTitle: String? = "cred title", + credentialSubtitle: String? = "cred sub", + credentialDescription: String? = "cred desc", + negativeButton: String = "neg", +): PromptInfo { + val info = PromptInfo() + info.title = title + info.subtitle = subtitle + info.description = description + credentialTitle?.let { info.deviceCredentialTitle = it } + credentialSubtitle?.let { info.deviceCredentialSubtitle = it } + credentialDescription?.let { info.deviceCredentialDescription = it } + info.negativeButtonText = negativeButton + return info +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/interactor/CredentialInteractorImplTest.kt b/packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/interactor/CredentialInteractorImplTest.kt new file mode 100644 index 0000000000000..97d3e688ed80b --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/interactor/CredentialInteractorImplTest.kt @@ -0,0 +1,216 @@ +package com.android.systemui.biometrics.domain.interactor + +import android.app.admin.DevicePolicyManager +import android.app.admin.DevicePolicyResourcesManager +import android.content.pm.UserInfo +import android.os.UserManager +import androidx.test.filters.SmallTest +import com.android.internal.widget.LockPatternUtils +import com.android.internal.widget.LockscreenCredential +import com.android.internal.widget.VerifyCredentialResponse +import com.android.systemui.SysuiTestCase +import com.android.systemui.biometrics.domain.model.BiometricOperationInfo +import com.android.systemui.biometrics.domain.model.BiometricPromptRequest +import com.android.systemui.biometrics.domain.model.BiometricUserInfo +import com.android.systemui.biometrics.promptInfo +import com.android.systemui.util.mockito.any +import com.android.systemui.util.mockito.eq +import com.android.systemui.util.mockito.whenever +import com.android.systemui.util.time.FakeSystemClock +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnit + +private const val USER_ID = 22 +private const val OPERATION_ID = 100L +private const val MAX_ATTEMPTS = 5 + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(JUnit4::class) +class CredentialInteractorImplTest : SysuiTestCase() { + + @JvmField @Rule var mockitoRule = MockitoJUnit.rule() + + @Mock private lateinit var lockPatternUtils: LockPatternUtils + @Mock private lateinit var userManager: UserManager + @Mock private lateinit var devicePolicyManager: DevicePolicyManager + @Mock private lateinit var devicePolicyResourcesManager: DevicePolicyResourcesManager + + private val systemClock = FakeSystemClock() + + private lateinit var interactor: CredentialInteractorImpl + + @Before + fun setup() { + whenever(devicePolicyManager.resources).thenReturn(devicePolicyResourcesManager) + whenever(lockPatternUtils.getMaximumFailedPasswordsForWipe(anyInt())) + .thenReturn(MAX_ATTEMPTS) + whenever(userManager.getUserInfo(eq(USER_ID))).thenReturn(UserInfo(USER_ID, "", 0)) + whenever(devicePolicyManager.getProfileWithMinimumFailedPasswordsForWipe(eq(USER_ID))) + .thenReturn(USER_ID) + + interactor = + CredentialInteractorImpl( + mContext, + lockPatternUtils, + userManager, + devicePolicyManager, + systemClock + ) + } + + @Test + fun testStealthMode() { + for (value in listOf(true, false, false, true)) { + whenever(lockPatternUtils.isVisiblePatternEnabled(eq(USER_ID))).thenReturn(value) + + assertThat(interactor.isStealthModeActive(USER_ID)).isEqualTo(!value) + } + } + + @Test + fun testCredentialOwner() { + for (value in listOf(12, 8, 4)) { + whenever(userManager.getCredentialOwnerProfile(eq(USER_ID))).thenReturn(value) + + assertThat(interactor.getCredentialOwnerOrSelfId(USER_ID)).isEqualTo(value) + } + } + + @Test fun pinCredentialWhenGood() = pinCredential(goodCredential()) + + @Test fun pinCredentialWhenBad() = pinCredential(badCredential()) + + @Test fun pinCredentialWhenBadAndThrottled() = pinCredential(badCredential(timeout = 5_000)) + + private fun pinCredential(result: VerifyCredentialResponse) = runTest { + val usedAttempts = 1 + whenever(lockPatternUtils.getCurrentFailedPasswordAttempts(eq(USER_ID))) + .thenReturn(usedAttempts) + whenever(lockPatternUtils.verifyCredential(any(), eq(USER_ID), anyInt())).thenReturn(result) + whenever(lockPatternUtils.verifyGatekeeperPasswordHandle(anyLong(), anyLong(), eq(USER_ID))) + .thenReturn(result) + whenever(lockPatternUtils.setLockoutAttemptDeadline(anyInt(), anyInt())).thenAnswer { + systemClock.elapsedRealtime() + (it.arguments[1] as Int) + } + + // wrap in an async block so the test can advance the clock if throttling credential + // checks prevents the method from returning + val statusList = mutableListOf() + interactor + .verifyCredential(pinRequest(), LockscreenCredential.createPin("1234")) + .toList(statusList) + + val last = statusList.removeLastOrNull() + if (result.isMatched) { + assertThat(statusList).isEmpty() + val successfulResult = last as? CredentialStatus.Success.Verified + assertThat(successfulResult).isNotNull() + assertThat(successfulResult!!.hat).isEqualTo(result.gatekeeperHAT) + + verify(lockPatternUtils).userPresent(eq(USER_ID)) + verify(lockPatternUtils) + .removeGatekeeperPasswordHandle(eq(result.gatekeeperPasswordHandle)) + } else { + val failedResult = last as? CredentialStatus.Fail.Error + assertThat(failedResult).isNotNull() + assertThat(failedResult!!.remainingAttempts) + .isEqualTo(if (result.timeout > 0) null else MAX_ATTEMPTS - usedAttempts - 1) + assertThat(failedResult.urgentMessage).isNull() + + if (result.timeout > 0) { // failed and throttled + // messages are in the throttled errors, so the final Error.error is empty + assertThat(failedResult.error).isEmpty() + assertThat(statusList).isNotEmpty() + assertThat(statusList.filterIsInstance(CredentialStatus.Fail.Throttled::class.java)) + .hasSize(statusList.size) + + verify(lockPatternUtils).setLockoutAttemptDeadline(eq(USER_ID), eq(result.timeout)) + } else { // failed + assertThat(failedResult.error) + .matches(Regex("(.*)try again(.*)", RegexOption.IGNORE_CASE).toPattern()) + assertThat(statusList).isEmpty() + + verify(lockPatternUtils).reportFailedPasswordAttempt(eq(USER_ID)) + } + } + } + + @Test + fun pinCredentialWhenBadAndFinalAttempt() = runTest { + whenever(lockPatternUtils.verifyCredential(any(), eq(USER_ID), anyInt())) + .thenReturn(badCredential()) + whenever(lockPatternUtils.getCurrentFailedPasswordAttempts(eq(USER_ID))) + .thenReturn(MAX_ATTEMPTS - 2) + + val statusList = mutableListOf() + interactor + .verifyCredential(pinRequest(), LockscreenCredential.createPin("1234")) + .toList(statusList) + + val result = statusList.removeLastOrNull() as? CredentialStatus.Fail.Error + assertThat(result).isNotNull() + assertThat(result!!.remainingAttempts).isEqualTo(1) + assertThat(result.urgentMessage).isNotEmpty() + assertThat(statusList).isEmpty() + + verify(lockPatternUtils).reportFailedPasswordAttempt(eq(USER_ID)) + } + + @Test + fun pinCredentialWhenBadAndNoMoreAttempts() = runTest { + whenever(lockPatternUtils.verifyCredential(any(), eq(USER_ID), anyInt())) + .thenReturn(badCredential()) + whenever(lockPatternUtils.getCurrentFailedPasswordAttempts(eq(USER_ID))) + .thenReturn(MAX_ATTEMPTS - 1) + whenever(devicePolicyResourcesManager.getString(any(), any())).thenReturn("wipe") + + val statusList = mutableListOf() + interactor + .verifyCredential(pinRequest(), LockscreenCredential.createPin("1234")) + .toList(statusList) + + val result = statusList.removeLastOrNull() as? CredentialStatus.Fail.Error + assertThat(result).isNotNull() + assertThat(result!!.remainingAttempts).isEqualTo(0) + assertThat(result.urgentMessage).isNotEmpty() + assertThat(statusList).isEmpty() + + verify(lockPatternUtils).reportFailedPasswordAttempt(eq(USER_ID)) + } +} + +private fun pinRequest(): BiometricPromptRequest.Credential.Pin = + BiometricPromptRequest.Credential.Pin( + promptInfo(), + BiometricUserInfo(USER_ID), + BiometricOperationInfo(OPERATION_ID) + ) + +private fun goodCredential( + passwordHandle: Long = 90, + hat: ByteArray = ByteArray(69), +): VerifyCredentialResponse = + VerifyCredentialResponse.Builder() + .setGatekeeperPasswordHandle(passwordHandle) + .setGatekeeperHAT(hat) + .build() + +private fun badCredential(timeout: Int = 0): VerifyCredentialResponse = + if (timeout > 0) { + VerifyCredentialResponse.fromTimeout(timeout) + } else { + VerifyCredentialResponse.fromError() + } diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/interactor/PromptCredentialInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/interactor/PromptCredentialInteractorTest.kt new file mode 100644 index 0000000000000..dbcbf415221ef --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/interactor/PromptCredentialInteractorTest.kt @@ -0,0 +1,270 @@ +package com.android.systemui.biometrics.domain.interactor + +import android.hardware.biometrics.PromptInfo +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.biometrics.Utils +import com.android.systemui.biometrics.data.repository.FakePromptRepository +import com.android.systemui.biometrics.domain.model.BiometricOperationInfo +import com.android.systemui.biometrics.domain.model.BiometricPromptRequest +import com.android.systemui.biometrics.domain.model.BiometricUserInfo +import com.android.systemui.biometrics.promptInfo +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.junit.MockitoJUnit + +private const val USER_ID = 22 +private const val OPERATION_ID = 100L + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(JUnit4::class) +class PromptCredentialInteractorTest : SysuiTestCase() { + + @JvmField @Rule var mockitoRule = MockitoJUnit.rule() + + private val dispatcher = UnconfinedTestDispatcher() + private val biometricPromptRepository = FakePromptRepository() + private val credentialInteractor = FakeCredentialInteractor() + + private lateinit var interactor: BiometricPromptCredentialInteractor + + @Before + fun setup() { + interactor = + BiometricPromptCredentialInteractor( + dispatcher, + biometricPromptRepository, + credentialInteractor + ) + } + + @Test + fun testIsShowing() = + runTest(dispatcher) { + var showing = false + val job = launch { interactor.isShowing.collect { showing = it } } + + biometricPromptRepository.setIsShowing(false) + assertThat(showing).isFalse() + + biometricPromptRepository.setIsShowing(true) + assertThat(showing).isTrue() + + job.cancel() + } + + @Test + fun testShowError() = + runTest(dispatcher) { + var error: CredentialStatus.Fail? = null + val job = launch { interactor.verificationError.collect { error = it } } + + for (msg in listOf("once", "again")) { + interactor.setVerificationError(error(msg)) + assertThat(error).isEqualTo(error(msg)) + } + + interactor.resetVerificationError() + assertThat(error).isNull() + + job.cancel() + } + + @Test + fun nullWhenNoPromptInfo() = + runTest(dispatcher) { + var prompt: BiometricPromptRequest? = null + val job = launch { interactor.prompt.collect { prompt = it } } + + assertThat(prompt).isNull() + + job.cancel() + } + + @Test fun usePinCredentialForPrompt() = useCredentialForPrompt(Utils.CREDENTIAL_PIN) + + @Test fun usePasswordCredentialForPrompt() = useCredentialForPrompt(Utils.CREDENTIAL_PASSWORD) + + @Test fun usePatternCredentialForPrompt() = useCredentialForPrompt(Utils.CREDENTIAL_PATTERN) + + private fun useCredentialForPrompt(kind: Int) = + runTest(dispatcher) { + val isStealth = false + credentialInteractor.stealthMode = isStealth + + var prompt: BiometricPromptRequest? = null + val job = launch { interactor.prompt.collect { prompt = it } } + + val title = "what a prompt" + val subtitle = "s" + val description = "something to see" + + interactor.useCredentialsForAuthentication( + PromptInfo().also { + it.title = title + it.description = description + it.subtitle = subtitle + }, + kind = kind, + userId = USER_ID, + challenge = OPERATION_ID + ) + + val p = prompt as? BiometricPromptRequest.Credential + assertThat(p).isNotNull() + assertThat(p!!.title).isEqualTo(title) + assertThat(p.subtitle).isEqualTo(subtitle) + assertThat(p.description).isEqualTo(description) + assertThat(p.userInfo).isEqualTo(BiometricUserInfo(USER_ID)) + assertThat(p.operationInfo).isEqualTo(BiometricOperationInfo(OPERATION_ID)) + assertThat(p) + .isInstanceOf( + when (kind) { + Utils.CREDENTIAL_PIN -> BiometricPromptRequest.Credential.Pin::class.java + Utils.CREDENTIAL_PASSWORD -> + BiometricPromptRequest.Credential.Password::class.java + Utils.CREDENTIAL_PATTERN -> + BiometricPromptRequest.Credential.Pattern::class.java + else -> throw Exception("wrong kind") + } + ) + if (p is BiometricPromptRequest.Credential.Pattern) { + assertThat(p.stealthMode).isEqualTo(isStealth) + } + + interactor.resetPrompt() + + assertThat(prompt).isNull() + + job.cancel() + } + + @Test + fun checkCredential() = + runTest(dispatcher) { + val hat = ByteArray(4) + credentialInteractor.verifyCredentialResponse = { _ -> flowOf(verified(hat)) } + + val errors = mutableListOf() + val job = launch { interactor.verificationError.toList(errors) } + + val checked = + interactor.checkCredential(pinRequest(), text = "1234") + as? CredentialStatus.Success.Verified + + assertThat(checked).isNotNull() + assertThat(checked!!.hat).isSameInstanceAs(hat) + assertThat(errors.map { it?.error }).containsExactly(null) + + job.cancel() + } + + @Test + fun checkCredentialWhenBad() = + runTest(dispatcher) { + val errorMessage = "bad" + val remainingAttempts = 12 + credentialInteractor.verifyCredentialResponse = { _ -> + flowOf(error(errorMessage, remainingAttempts)) + } + + val errors = mutableListOf() + val job = launch { interactor.verificationError.toList(errors) } + + val checked = + interactor.checkCredential(pinRequest(), text = "1234") + as? CredentialStatus.Fail.Error + + assertThat(checked).isNotNull() + assertThat(checked!!.remainingAttempts).isEqualTo(remainingAttempts) + assertThat(checked.urgentMessage).isNull() + assertThat(errors.map { it?.error }).containsExactly(null, errorMessage).inOrder() + + job.cancel() + } + + @Test + fun checkCredentialWhenBadAndUrgentMessage() = + runTest(dispatcher) { + val error = "not so bad" + val urgentMessage = "really bad" + credentialInteractor.verifyCredentialResponse = { _ -> + flowOf(error(error, 10, urgentMessage)) + } + + val errors = mutableListOf() + val job = launch { interactor.verificationError.toList(errors) } + + val checked = + interactor.checkCredential(pinRequest(), text = "1234") + as? CredentialStatus.Fail.Error + + assertThat(checked).isNotNull() + assertThat(checked!!.urgentMessage).isEqualTo(urgentMessage) + assertThat(errors.map { it?.error }).containsExactly(null, error).inOrder() + assertThat(errors.last() as? CredentialStatus.Fail.Error) + .isEqualTo(error(error, 10, urgentMessage)) + + job.cancel() + } + + @Test + fun checkCredentialWhenBadAndThrottled() = + runTest(dispatcher) { + val remainingAttempts = 3 + val error = ":(" + val urgentMessage = ":D" + credentialInteractor.verifyCredentialResponse = { _ -> + flow { + for (i in 1..3) { + emit(throttled("$i")) + delay(100) + } + emit(error(error, remainingAttempts, urgentMessage)) + } + } + val errors = mutableListOf() + val job = launch { interactor.verificationError.toList(errors) } + + val checked = + interactor.checkCredential(pinRequest(), text = "1234") + as? CredentialStatus.Fail.Error + + assertThat(checked).isNotNull() + assertThat(checked!!.remainingAttempts).isEqualTo(remainingAttempts) + assertThat(checked.urgentMessage).isEqualTo(urgentMessage) + assertThat(errors.map { it?.error }) + .containsExactly(null, "1", "2", "3", error) + .inOrder() + + job.cancel() + } +} + +private fun pinRequest(): BiometricPromptRequest.Credential.Pin = + BiometricPromptRequest.Credential.Pin( + promptInfo(), + BiometricUserInfo(USER_ID), + BiometricOperationInfo(OPERATION_ID) + ) + +private fun verified(hat: ByteArray) = CredentialStatus.Success.Verified(hat) + +private fun throttled(error: String) = CredentialStatus.Fail.Throttled(error) + +private fun error(error: String? = null, remaining: Int? = null, urgentMessage: String? = null) = + CredentialStatus.Fail.Error(error, remaining, urgentMessage) diff --git a/packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/model/BiometricPromptRequestTest.kt b/packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/model/BiometricPromptRequestTest.kt new file mode 100644 index 0000000000000..2eeff9fcdd8a0 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/biometrics/domain/model/BiometricPromptRequestTest.kt @@ -0,0 +1,92 @@ +package com.android.systemui.biometrics.domain.model + +import androidx.test.filters.SmallTest +import com.android.systemui.biometrics.promptInfo +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +private const val USER_ID = 2 +private const val OPERATION_ID = 8L + +@SmallTest +@RunWith(JUnit4::class) +class BiometricPromptRequestTest { + + @Test + fun biometricRequestFromPromptInfo() { + val title = "what" + val subtitle = "a" + val description = "request" + + val request = + BiometricPromptRequest.Biometric( + promptInfo(title = title, subtitle = subtitle, description = description), + BiometricUserInfo(USER_ID), + BiometricOperationInfo(OPERATION_ID) + ) + + assertThat(request.title).isEqualTo(title) + assertThat(request.subtitle).isEqualTo(subtitle) + assertThat(request.description).isEqualTo(description) + assertThat(request.userInfo).isEqualTo(BiometricUserInfo(USER_ID)) + assertThat(request.operationInfo).isEqualTo(BiometricOperationInfo(OPERATION_ID)) + } + + @Test + fun credentialRequestFromPromptInfo() { + val title = "what" + val subtitle = "a" + val description = "request" + val stealth = true + + val toCheck = + listOf( + BiometricPromptRequest.Credential.Pin( + promptInfo( + title = title, + subtitle = subtitle, + description = description, + credentialTitle = null, + credentialSubtitle = null, + credentialDescription = null + ), + BiometricUserInfo(USER_ID), + BiometricOperationInfo(OPERATION_ID) + ), + BiometricPromptRequest.Credential.Password( + promptInfo( + credentialTitle = title, + credentialSubtitle = subtitle, + credentialDescription = description + ), + BiometricUserInfo(USER_ID), + BiometricOperationInfo(OPERATION_ID) + ), + BiometricPromptRequest.Credential.Pattern( + promptInfo( + subtitle = subtitle, + description = description, + credentialTitle = title, + credentialSubtitle = null, + credentialDescription = null + ), + BiometricUserInfo(USER_ID), + BiometricOperationInfo(OPERATION_ID), + stealth + ) + ) + + for (request in toCheck) { + assertThat(request.title).isEqualTo(title) + assertThat(request.subtitle).isEqualTo(subtitle) + assertThat(request.description).isEqualTo(description) + assertThat(request.userInfo).isEqualTo(BiometricUserInfo(USER_ID)) + assertThat(request.operationInfo).isEqualTo(BiometricOperationInfo(OPERATION_ID)) + if (request is BiometricPromptRequest.Credential.Pattern) { + assertThat(request.stealthMode).isEqualTo(stealth) + } + } + } +} diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/biometrics/domain/interactor/FakeCredentialInteractor.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/biometrics/domain/interactor/FakeCredentialInteractor.kt new file mode 100644 index 0000000000000..fbe291ebaf5d1 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/biometrics/domain/interactor/FakeCredentialInteractor.kt @@ -0,0 +1,31 @@ +package com.android.systemui.biometrics.domain.interactor + +import com.android.internal.widget.LockscreenCredential +import com.android.systemui.biometrics.domain.model.BiometricPromptRequest +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +/** Fake implementation of [CredentialInteractor] for tests. */ +class FakeCredentialInteractor : CredentialInteractor { + + /** Sets return value for [isStealthModeActive]. */ + var stealthMode: Boolean = false + + /** Sets return value for [getCredentialOwnerOrSelfId]. */ + var credentialOwnerId: Int? = null + + override fun isStealthModeActive(userId: Int): Boolean = stealthMode + + override fun getCredentialOwnerOrSelfId(userId: Int): Int = credentialOwnerId ?: userId + + override fun verifyCredential( + request: BiometricPromptRequest.Credential, + credential: LockscreenCredential, + ): Flow = verifyCredentialResponse(credential) + + /** Sets the result value for [verifyCredential]. */ + var verifyCredentialResponse: (credential: LockscreenCredential) -> Flow = + { _ -> + flowOf(CredentialStatus.Fail.Error("invalid")) + } +}