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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user