Refactor biometric prompt credential screens - domain layer

Step 2/3, add domain layer for business logic wrapping credential verification.

Bug: 251476085
Test: atest CredentialInteractorImplTest PromptCredentialInteractorTest BiometricPromptRequestTest
Test: manual (use test app and verify credential screens)
Change-Id: Ia26a19be35d466390aaaa7144203393a8197dc14
This commit is contained in:
Joe Bolinger
2022-10-17 17:55:30 +00:00
parent edefc7a7b2
commit 7dd4037f4d
12 changed files with 1250 additions and 32 deletions

View File

@@ -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

View File

@@ -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<CredentialStatus>
}
/** 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<CredentialStatus> = 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)
}
}

View File

@@ -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
}
}

View File

@@ -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<Boolean> = biometricPromptRepository.isShowing
/** Metadata about the current credential prompt, including app-supplied preferences. */
val prompt: Flow<BiometricPromptRequest?> =
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<CredentialStatus.Fail?>(null)
val verificationError: Flow<CredentialStatus.Fail?> = _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<LockPatternView.Cell>? = 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
}

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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,
)

View File

@@ -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<Int> = listOf(0)
): List<FingerprintSensorPropertiesInternal> {
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<Int> = listOf(1)
): List<FaceSensorPropertiesInternal> {
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
}

View File

@@ -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<CredentialStatus>()
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<CredentialStatus>()
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<CredentialStatus>()
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()
}

View File

@@ -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<CredentialStatus.Fail?>()
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<CredentialStatus.Fail?>()
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<CredentialStatus.Fail?>()
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<CredentialStatus.Fail?>()
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)

View File

@@ -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)
}
}
}
}

View File

@@ -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<CredentialStatus> = verifyCredentialResponse(credential)
/** Sets the result value for [verifyCredential]. */
var verifyCredentialResponse: (credential: LockscreenCredential) -> Flow<CredentialStatus> =
{ _ ->
flowOf(CredentialStatus.Fail.Error("invalid"))
}
}