Merge "Implement basic Fingerprint functionality." into udc-qpr-dev am: 1412f391d5

Original change: https://googleplex-android-review.googlesource.com/c/platform/packages/apps/Settings/+/23480676

Change-Id: If0de6ed25832d88a049f82ad86485fb4b1d027e5
Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
This commit is contained in:
Joshua Mccloskey
2023-07-18 16:45:52 +00:00
committed by Automerger Merge Worker
18 changed files with 2658 additions and 737 deletions

View File

@@ -15,4 +15,37 @@
~ limitations under the License.
-->
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"/>
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:settings="http://schemas.android.com/apk/res-auto"
android:title="@string/security_settings_fingerprint_preference_title">
<PreferenceCategory
android:key="security_settings_fingerprints_enrolled"
settings:controller="com.android.settings.biometrics.fingerprint.FingerprintsEnrolledCategoryPreferenceController">
</PreferenceCategory>
<androidx.preference.Preference
android:icon="@drawable/ic_add_24dp"
android:key="key_fingerprint_add"
android:title="@string/fingerprint_add_title" />
<PreferenceCategory
android:key="security_settings_fingerprint_unlock_category"
android:title="@string/security_settings_fingerprint_settings_preferences_category"
android:visibility="gone">
<com.android.settingslib.RestrictedSwitchPreference
android:key="security_settings_require_screen_on_to_auth"
android:title="@string/security_settings_require_screen_on_to_auth_title"
android:summary="@string/security_settings_require_screen_on_to_auth_description"
settings:keywords="@string/security_settings_require_screen_on_to_auth_keywords"
settings:controller="com.android.settings.biometrics.fingerprint.FingerprintSettingsRequireScreenOnToAuthPreferenceController" />
</PreferenceCategory>
<PreferenceCategory
android:key="security_settings_fingerprint_footer">
</PreferenceCategory>
</PreferenceScreen>

View File

@@ -0,0 +1,207 @@
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.settings.biometrics.fingerprint2.domain.interactor
import android.content.Context
import android.content.Intent
import android.hardware.fingerprint.FingerprintManager
import android.hardware.fingerprint.FingerprintManager.GenerateChallengeCallback
import android.hardware.fingerprint.FingerprintManager.RemovalCallback
import android.hardware.fingerprint.FingerprintSensorPropertiesInternal
import android.os.CancellationSignal
import android.util.Log
import com.android.settings.biometrics.GatekeeperPasswordProvider
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintAuthAttemptViewModel
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintViewModel
import com.android.settings.password.ChooseLockSettingsHelper
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
private const val TAG = "FingerprintManagerInteractor"
/** Encapsulates business logic related to managing fingerprints. */
interface FingerprintManagerInteractor {
/** Returns the list of current fingerprints. */
val enrolledFingerprints: Flow<List<FingerprintViewModel>>
/** Returns the max enrollable fingerprints, note during SUW this might be 1 */
val maxEnrollableFingerprints: Flow<Int>
/** Runs [FingerprintManager.authenticate] */
suspend fun authenticate(): FingerprintAuthAttemptViewModel
/**
* Generates a challenge with the provided [gateKeeperPasswordHandle] and on success returns a
* challenge and challenge token. This info can be used for secure operations such as
* [FingerprintManager.enroll]
*
* @param gateKeeperPasswordHandle GateKeeper password handle generated by a Confirm
* @return A [Pair] of the challenge and challenge token
*/
suspend fun generateChallenge(gateKeeperPasswordHandle: Long): Pair<Long, ByteArray>
/** Returns true if a user can enroll a fingerprint false otherwise. */
fun canEnrollFingerprints(numFingerprints: Int): Flow<Boolean>
/**
* Removes the given fingerprint, returning true if it was successfully removed and false
* otherwise
*/
suspend fun removeFingerprint(fp: FingerprintViewModel): Boolean
/** Renames the given fingerprint if one exists */
suspend fun renameFingerprint(fp: FingerprintViewModel, newName: String)
/** Indicates if the device has side fingerprint */
suspend fun hasSideFps(): Boolean
/** Indicates if the press to auth feature has been enabled */
suspend fun pressToAuthEnabled(): Boolean
/** Retrieves the sensor properties of a device */
suspend fun sensorPropertiesInternal(): List<FingerprintSensorPropertiesInternal>
}
class FingerprintManagerInteractorImpl(
applicationContext: Context,
private val backgroundDispatcher: CoroutineDispatcher,
private val fingerprintManager: FingerprintManager,
private val gatekeeperPasswordProvider: GatekeeperPasswordProvider,
private val pressToAuthProvider: () -> Boolean,
) : FingerprintManagerInteractor {
private val maxFingerprints =
applicationContext.resources.getInteger(
com.android.internal.R.integer.config_fingerprintMaxTemplatesPerUser
)
private val applicationContext = applicationContext.applicationContext
override suspend fun generateChallenge(gateKeeperPasswordHandle: Long): Pair<Long, ByteArray> =
suspendCoroutine {
val callback = GenerateChallengeCallback { _, userId, challenge ->
val intent = Intent()
intent.putExtra(ChooseLockSettingsHelper.EXTRA_KEY_GK_PW_HANDLE, gateKeeperPasswordHandle)
val challengeToken =
gatekeeperPasswordProvider.requestGatekeeperHat(intent, challenge, userId)
gatekeeperPasswordProvider.removeGatekeeperPasswordHandle(intent, false)
val p = Pair(challenge, challengeToken)
it.resume(p)
}
fingerprintManager.generateChallenge(applicationContext.userId, callback)
}
override val enrolledFingerprints: Flow<List<FingerprintViewModel>> = flow {
emit(
fingerprintManager
.getEnrolledFingerprints(applicationContext.userId)
.map { (FingerprintViewModel(it.name.toString(), it.biometricId, it.deviceId)) }
.toList()
)
}
override fun canEnrollFingerprints(numFingerprints: Int): Flow<Boolean> = flow {
emit(numFingerprints < maxFingerprints)
}
override val maxEnrollableFingerprints = flow { emit(maxFingerprints) }
override suspend fun removeFingerprint(fp: FingerprintViewModel): Boolean = suspendCoroutine {
val callback =
object : RemovalCallback() {
override fun onRemovalError(
fp: android.hardware.fingerprint.Fingerprint,
errMsgId: Int,
errString: CharSequence
) {
it.resume(false)
}
override fun onRemovalSucceeded(
fp: android.hardware.fingerprint.Fingerprint?,
remaining: Int
) {
it.resume(true)
}
}
fingerprintManager.remove(
android.hardware.fingerprint.Fingerprint(fp.name, fp.fingerId, fp.deviceId),
applicationContext.userId,
callback
)
}
override suspend fun renameFingerprint(fp: FingerprintViewModel, newName: String) {
withContext(backgroundDispatcher) {
fingerprintManager.rename(fp.fingerId, applicationContext.userId, newName)
}
}
override suspend fun hasSideFps(): Boolean = suspendCancellableCoroutine {
it.resume(fingerprintManager.isPowerbuttonFps)
}
override suspend fun pressToAuthEnabled(): Boolean = suspendCancellableCoroutine {
it.resume(pressToAuthProvider())
}
override suspend fun sensorPropertiesInternal(): List<FingerprintSensorPropertiesInternal> =
suspendCancellableCoroutine {
it.resume(fingerprintManager.sensorPropertiesInternal)
}
override suspend fun authenticate(): FingerprintAuthAttemptViewModel =
suspendCancellableCoroutine { c: CancellableContinuation<FingerprintAuthAttemptViewModel> ->
val authenticationCallback =
object : FingerprintManager.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
if (c.isCompleted) {
Log.d(TAG, "framework sent down onAuthError after finish")
return
}
c.resume(FingerprintAuthAttemptViewModel.Error(errorCode, errString.toString()))
}
override fun onAuthenticationSucceeded(result: FingerprintManager.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
if (c.isCompleted) {
Log.d(TAG, "framework sent down onAuthError after finish")
return
}
c.resume(FingerprintAuthAttemptViewModel.Success(result.fingerprint?.biometricId ?: -1))
}
}
val cancellationSignal = CancellationSignal()
c.invokeOnCancellation { cancellationSignal.cancel() }
fingerprintManager.authenticate(
null,
cancellationSignal,
authenticationCallback,
null,
applicationContext.userId
)
}
}

View File

@@ -0,0 +1,177 @@
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.settings.biometrics.fingerprint2.ui.binder
import android.hardware.fingerprint.FingerprintManager
import android.util.Log
import androidx.lifecycle.LifecycleCoroutineScope
import com.android.settings.biometrics.fingerprint2.ui.binder.FingerprintSettingsViewBinder.FingerprintView
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.EnrollAdditionalFingerprint
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.EnrollFirstFingerprint
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintAuthAttemptViewModel
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintSettingsNavigationViewModel
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintSettingsViewModel
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintStateViewModel
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintViewModel
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FinishSettings
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FinishSettingsWithResult
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.LaunchConfirmDeviceCredential
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.LaunchedActivity
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.PreferenceViewModel
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.ShowSettings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
private const val TAG = "FingerprintSettingsViewBinder"
/** Binds a [FingerprintSettingsViewModel] to a [FingerprintView] */
object FingerprintSettingsViewBinder {
interface FingerprintView {
/**
* Helper function to launch fingerprint enrollment(This should be the default behavior when a
* user enters their PIN/PATTERN/PASS and no fingerprints are enrolled).
*/
fun launchFullFingerprintEnrollment(
userId: Int,
gateKeeperPasswordHandle: Long?,
challenge: Long?,
challengeToken: ByteArray?
)
/** Helper to launch an add fingerprint request */
fun launchAddFingerprint(userId: Int, challengeToken: ByteArray?)
/**
* Helper function that will try and launch confirm lock, if that fails we will prompt user to
* choose a PIN/PATTERN/PASS.
*/
fun launchConfirmOrChooseLock(userId: Int)
/** Used to indicate that FingerprintSettings is finished. */
fun finish()
/** Indicates what result should be set for the returning callee */
fun setResultExternal(resultCode: Int)
/** Indicates the settings UI should be shown */
fun showSettings(state: FingerprintStateViewModel)
/** Indicates that a user has been locked out */
fun userLockout(authAttemptViewModel: FingerprintAuthAttemptViewModel.Error)
/** Indicates a fingerprint preference should be highlighted */
suspend fun highlightPref(fingerId: Int)
/** Indicates a user should be prompted to delete a fingerprint */
suspend fun askUserToDeleteDialog(fingerprintViewModel: FingerprintViewModel): Boolean
/** Indicates a user should be asked to renae ma dialog */
suspend fun askUserToRenameDialog(
fingerprintViewModel: FingerprintViewModel
): Pair<FingerprintViewModel, String>?
}
fun bind(
view: FingerprintView,
viewModel: FingerprintSettingsViewModel,
navigationViewModel: FingerprintSettingsNavigationViewModel,
lifecycleScope: LifecycleCoroutineScope,
) {
/** Result listener for launching enrollments **after** a user has reached the settings page. */
// Settings display flow
lifecycleScope.launch {
viewModel.fingerprintState.filterNotNull().collect { view.showSettings(it) }
}
// Dialog flow
lifecycleScope.launch {
viewModel.isShowingDialog.collectLatest {
if (it == null) {
return@collectLatest
}
when (it) {
is PreferenceViewModel.RenameDialog -> {
val willRename = view.askUserToRenameDialog(it.fingerprintViewModel)
if (willRename != null) {
Log.d(TAG, "renaming fingerprint $it")
viewModel.renameFingerprint(willRename.first, willRename.second)
}
viewModel.onRenameDialogFinished()
}
is PreferenceViewModel.DeleteDialog -> {
if (view.askUserToDeleteDialog(it.fingerprintViewModel)) {
Log.d(TAG, "deleting fingerprint $it")
viewModel.deleteFingerprint(it.fingerprintViewModel)
}
viewModel.onDeleteDialogFinished()
}
}
}
}
// Auth flow
lifecycleScope.launch {
viewModel.authFlow.filterNotNull().collect {
when (it) {
is FingerprintAuthAttemptViewModel.Success -> {
view.highlightPref(it.fingerId)
}
is FingerprintAuthAttemptViewModel.Error -> {
if (it.error == FingerprintManager.FINGERPRINT_ERROR_LOCKOUT) {
view.userLockout(it)
}
}
}
}
}
// Launch this on Dispatchers.Default and not main.
// Otherwise it takes too long for state transitions such as PIN/PATTERN/PASS
// to enrollment, which makes gives the user a janky experience.
lifecycleScope.launch(Dispatchers.Default) {
var settingsShowingJob: Job? = null
navigationViewModel.nextStep.filterNotNull().collect { nextStep ->
settingsShowingJob?.cancel()
settingsShowingJob = null
Log.d(TAG, "next step = $nextStep")
when (nextStep) {
is EnrollFirstFingerprint ->
view.launchFullFingerprintEnrollment(
nextStep.userId,
nextStep.gateKeeperPasswordHandle,
nextStep.challenge,
nextStep.challengeToken
)
is EnrollAdditionalFingerprint ->
view.launchAddFingerprint(nextStep.userId, nextStep.challengeToken)
is LaunchConfirmDeviceCredential -> view.launchConfirmOrChooseLock(nextStep.userId)
is FinishSettings -> {
Log.d(TAG, "Finishing due to ${nextStep.reason}")
view.finish()
}
is FinishSettingsWithResult -> {
Log.d(TAG, "Finishing with result ${nextStep.result} due to ${nextStep.reason}")
view.setResultExternal(nextStep.result)
view.finish()
}
is ShowSettings -> Log.d(TAG, "Showing settings")
is LaunchedActivity -> Log.d(TAG, "Launched activity, awaiting result")
}
}
}
}
}

View File

@@ -1,128 +0,0 @@
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.settings.biometrics.fingerprint2.ui.binder
import androidx.lifecycle.LifecycleCoroutineScope
import com.android.settings.biometrics.fingerprint2.ui.fragment.FingerprintSettingsV2Fragment
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.EnrollAdditionalFingerprint
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.EnrollFirstFingerprint
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintSettingsViewModel
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FinishSettings
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FinishSettingsWithResult
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.LaunchConfirmDeviceCredential
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.ShowSettings
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
/**
* Binds a [FingerprintSettingsViewModel] to a [FingerprintSettingsV2Fragment]
*/
object FingerprintViewBinder {
interface Binding {
fun onConfirmDevice(wasSuccessful: Boolean, theGateKeeperPasswordHandle: Long?)
fun onEnrollSuccess()
fun onEnrollAdditionalFailure()
fun onEnrollFirstFailure(reason: String)
fun onEnrollFirstFailure(reason: String, resultCode: Int)
fun onEnrollFirst(token: ByteArray?, keyChallenge: Long?)
}
/** Initial listener for the first enrollment request */
fun bind(
viewModel: FingerprintSettingsViewModel,
lifecycleScope: LifecycleCoroutineScope,
token: ByteArray?,
challenge: Long?,
launchFullFingerprintEnrollment: (
userId: Int,
gateKeeperPasswordHandle: Long?,
challenge: Long?,
challengeToken: ByteArray?
) -> Unit,
launchAddFingerprint: (userId: Int, challengeToken: ByteArray?) -> Unit,
launchConfirmOrChooseLock: (userId: Int) -> Unit,
finish: () -> Unit,
setResultExternal: (resultCode: Int) -> Unit,
): Binding {
lifecycleScope.launch {
viewModel.nextStep.filterNotNull().collect { nextStep ->
when (nextStep) {
is EnrollFirstFingerprint -> launchFullFingerprintEnrollment(
nextStep.userId,
nextStep.gateKeeperPasswordHandle,
nextStep.challenge,
nextStep.challengeToken
)
is EnrollAdditionalFingerprint -> launchAddFingerprint(
nextStep.userId, nextStep.challengeToken
)
is LaunchConfirmDeviceCredential -> launchConfirmOrChooseLock(nextStep.userId)
is FinishSettings -> {
println("Finishing due to ${nextStep.reason}")
finish()
}
is FinishSettingsWithResult -> {
println("Finishing with result ${nextStep.result} due to ${nextStep.reason}")
setResultExternal(nextStep.result)
finish()
}
is ShowSettings -> println("show settings")
}
viewModel.onUiCommandExecuted()
}
}
viewModel.updateTokenAndChallenge(token, if (challenge == -1L) null else challenge)
return object : Binding {
override fun onConfirmDevice(
wasSuccessful: Boolean, theGateKeeperPasswordHandle: Long?
) {
viewModel.onConfirmDevice(wasSuccessful, theGateKeeperPasswordHandle)
}
override fun onEnrollSuccess() {
viewModel.onEnrollSuccess()
}
override fun onEnrollAdditionalFailure() {
viewModel.onEnrollAdditionalFailure()
}
override fun onEnrollFirstFailure(reason: String) {
viewModel.onEnrollFirstFailure(reason)
}
override fun onEnrollFirstFailure(reason: String, resultCode: Int) {
viewModel.onEnrollFirstFailure(reason, resultCode)
}
override fun onEnrollFirst(token: ByteArray?, keyChallenge: Long?) {
viewModel.onEnrollFirst(token, keyChallenge)
}
}
}
}

View File

@@ -0,0 +1,119 @@
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.settings.biometrics.fingerprint2.ui.fragment
import android.app.Dialog
import android.app.admin.DevicePolicyManager
import android.app.admin.DevicePolicyResources.Strings.Settings.WORK_PROFILE_FINGERPRINT_LAST_DELETE_MESSAGE
import android.app.admin.DevicePolicyResources.UNDEFINED
import android.app.settings.SettingsEnums
import android.content.DialogInterface
import android.os.Bundle
import android.os.UserManager
import androidx.appcompat.app.AlertDialog
import com.android.settings.R
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintViewModel
import com.android.settings.core.instrumentation.InstrumentedDialogFragment
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
private const val KEY_IS_LAST_FINGERPRINT = "IS_LAST_FINGERPRINT"
class FingerprintDeletionDialog : InstrumentedDialogFragment() {
private lateinit var fingerprintViewModel: FingerprintViewModel
private var isLastFingerprint: Boolean = false
private lateinit var alertDialog: AlertDialog
lateinit var onClickListener: DialogInterface.OnClickListener
lateinit var onNegativeClickListener: DialogInterface.OnClickListener
lateinit var onCancelListener: DialogInterface.OnCancelListener
override fun getMetricsCategory(): Int {
return SettingsEnums.DIALOG_FINGERPINT_EDIT
}
override fun onCancel(dialog: DialogInterface) {
onCancelListener.onCancel(dialog)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val fp = requireArguments().get(KEY_FINGERPRINT) as android.hardware.fingerprint.Fingerprint
fingerprintViewModel = FingerprintViewModel(fp.name.toString(), fp.biometricId, fp.deviceId)
isLastFingerprint = requireArguments().getBoolean(KEY_IS_LAST_FINGERPRINT)
val title = getString(R.string.fingerprint_delete_title, fingerprintViewModel.name)
var message = getString(R.string.fingerprint_v2_delete_message, fingerprintViewModel.name)
val context = requireContext()
if (isLastFingerprint) {
val isProfileChallengeUser = UserManager.get(context).isManagedProfile(context.userId)
val messageId =
if (isProfileChallengeUser) {
WORK_PROFILE_FINGERPRINT_LAST_DELETE_MESSAGE
} else {
UNDEFINED
}
val defaultMessageId =
if (isProfileChallengeUser) {
R.string.fingerprint_last_delete_message_profile_challenge
} else {
R.string.fingerprint_last_delete_message
}
val devicePolicyManager = requireContext().getSystemService(DevicePolicyManager::class.java)
message =
devicePolicyManager?.resources?.getString(messageId) {
message + "\n\n" + context.getString(defaultMessageId)
}
?: ""
}
alertDialog =
AlertDialog.Builder(requireActivity())
.setTitle(title)
.setMessage(message)
.setPositiveButton(
R.string.security_settings_fingerprint_enroll_dialog_delete,
onClickListener
)
.setNegativeButton(R.string.cancel, onNegativeClickListener)
.create()
return alertDialog
}
companion object {
private const val KEY_FINGERPRINT = "fingerprint"
suspend fun showInstance(
fp: FingerprintViewModel,
lastFingerprint: Boolean,
target: FingerprintSettingsV2Fragment,
) = suspendCancellableCoroutine { continuation ->
val dialog = FingerprintDeletionDialog()
dialog.onClickListener = DialogInterface.OnClickListener { _, _ -> continuation.resume(true) }
dialog.onNegativeClickListener =
DialogInterface.OnClickListener { _, _ -> continuation.resume(false) }
dialog.onCancelListener = DialogInterface.OnCancelListener { continuation.resume(false) }
continuation.invokeOnCancellation { dialog.dismiss() }
val bundle = Bundle()
bundle.putObject(
KEY_FINGERPRINT,
android.hardware.fingerprint.Fingerprint(fp.name, fp.fingerId, fp.deviceId)
)
bundle.putBoolean(KEY_IS_LAST_FINGERPRINT, lastFingerprint)
dialog.arguments = bundle
dialog.show(target.parentFragmentManager, FingerprintDeletionDialog::class.java.toString())
}
}
}

View File

@@ -0,0 +1,85 @@
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.settings.biometrics.fingerprint2.ui.fragment
import android.content.Context
import android.util.Log
import android.view.View
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceViewHolder
import com.android.settings.R
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintViewModel
import com.android.settingslib.widget.TwoTargetPreference
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private const val TAG = "FingerprintSettingsPreference"
class FingerprintSettingsPreference(
context: Context,
val fingerprintViewModel: FingerprintViewModel,
val fragment: FingerprintSettingsV2Fragment,
val isLastFingerprint: Boolean
) : TwoTargetPreference(context) {
private lateinit var myView: View
init {
key = "FINGERPRINT_" + fingerprintViewModel.fingerId
Log.d(TAG, "FingerprintPreference $this with frag $fragment $key")
title = fingerprintViewModel.name
isPersistent = false
setIcon(R.drawable.ic_fingerprint_24dp)
setOnPreferenceClickListener {
fragment.lifecycleScope.launch { fragment.onPrefClicked(fingerprintViewModel) }
true
}
}
override fun onBindViewHolder(view: PreferenceViewHolder) {
super.onBindViewHolder(view)
myView = view.itemView
view.itemView.findViewById<View>(R.id.delete_button)?.setOnClickListener {
fragment.lifecycleScope.launch { fragment.onDeletePrefClicked(fingerprintViewModel) }
}
}
/** Highlights this dialog. */
suspend fun highlight() {
fragment.activity?.getDrawable(R.drawable.preference_highlight)?.let { highlight ->
val centerX: Float = myView.width / 2.0f
val centerY: Float = myView.height / 2.0f
highlight.setHotspot(centerX, centerY)
myView.background = highlight
myView.isPressed = true
myView.isPressed = false
delay(300)
myView.background = null
}
}
override fun getSecondTargetResId(): Int {
return R.layout.preference_widget_delete
}
suspend fun askUserToDeleteDialog(): Boolean {
return FingerprintDeletionDialog.showInstance(fingerprintViewModel, isLastFingerprint, fragment)
}
suspend fun askUserToRenameDialog(): Pair<FingerprintViewModel, String>? {
return FingerprintSettingsRenameDialog.showInstance(fingerprintViewModel, fragment)
}
}

View File

@@ -0,0 +1,145 @@
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.settings.biometrics.fingerprint2.ui.fragment
import android.app.Dialog
import android.app.settings.SettingsEnums
import android.content.DialogInterface
import android.os.Bundle
import android.text.InputFilter
import android.text.Spanned
import android.text.TextUtils
import android.util.Log
import android.widget.ImeAwareEditText
import androidx.appcompat.app.AlertDialog
import com.android.settings.R
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintViewModel
import com.android.settings.core.instrumentation.InstrumentedDialogFragment
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
private const val TAG = "FingerprintSettingsRenameDialog"
class FingerprintSettingsRenameDialog : InstrumentedDialogFragment() {
lateinit var onClickListener: DialogInterface.OnClickListener
lateinit var onCancelListener: DialogInterface.OnCancelListener
override fun onCancel(dialog: DialogInterface) {
Log.d(TAG, "onCancel $dialog")
onCancelListener.onCancel(dialog)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
Log.d(TAG, "onCreateDialog $this")
val fp = requireArguments().get(KEY_FINGERPRINT) as android.hardware.fingerprint.Fingerprint
val fingerprintViewModel = FingerprintViewModel(fp.name.toString(), fp.biometricId, fp.deviceId)
val context = requireContext()
val alertDialog =
AlertDialog.Builder(context)
.setView(R.layout.fingerprint_rename_dialog)
.setPositiveButton(R.string.security_settings_fingerprint_enroll_dialog_ok, onClickListener)
.create()
alertDialog.setOnShowListener {
(dialog?.findViewById(R.id.fingerprint_rename_field) as ImeAwareEditText?)?.apply {
val name = fingerprintViewModel.name
setText(name)
filters = this@FingerprintSettingsRenameDialog.getFilters()
selectAll()
requestFocus()
scheduleShowSoftInput()
}
}
return alertDialog
}
private fun getFilters(): Array<InputFilter> {
val filter: InputFilter =
object : InputFilter {
override fun filter(
source: CharSequence,
start: Int,
end: Int,
dest: Spanned?,
dstart: Int,
dend: Int
): CharSequence? {
for (index in start until end) {
val c = source[index]
// KXMLSerializer does not allow these characters,
// see KXmlSerializer.java:162.
if (c.code < 0x20) {
return ""
}
}
return null
}
}
return arrayOf(filter)
}
override fun getMetricsCategory(): Int {
return SettingsEnums.DIALOG_FINGERPINT_EDIT
}
companion object {
private const val KEY_FINGERPRINT = "fingerprint"
suspend fun showInstance(fp: FingerprintViewModel, target: FingerprintSettingsV2Fragment) =
suspendCancellableCoroutine { continuation ->
val dialog = FingerprintSettingsRenameDialog()
val onClick =
DialogInterface.OnClickListener { _, _ ->
val dialogTextField =
dialog.requireDialog().findViewById(R.id.fingerprint_rename_field) as ImeAwareEditText
val newName = dialogTextField.text.toString()
if (!TextUtils.equals(newName, fp.name)) {
Log.d(TAG, "rename $fp.name to $newName for $dialog")
continuation.resume(Pair(fp, newName))
} else {
continuation.resume(null)
}
}
dialog.onClickListener = onClick
dialog.onCancelListener =
DialogInterface.OnCancelListener {
Log.d(TAG, "onCancelListener clicked $dialog")
continuation.resume(null)
}
continuation.invokeOnCancellation {
Log.d(TAG, "invokeOnCancellation $dialog")
dialog.dismiss()
}
val bundle = Bundle()
bundle.putObject(
KEY_FINGERPRINT,
android.hardware.fingerprint.Fingerprint(fp.name, fp.fingerId, fp.deviceId)
)
dialog.arguments = bundle
Log.d(TAG, "showing dialog $dialog")
dialog.show(
target.parentFragmentManager,
FingerprintSettingsRenameDialog::class.java.toString()
)
}
}
}

View File

@@ -17,30 +17,65 @@
package com.android.settings.biometrics.fingerprint2.ui.fragment
import android.app.Activity
import android.app.admin.DevicePolicyManager
import android.app.admin.DevicePolicyResources.Strings.Settings.FINGERPRINT_UNLOCK_DISABLED_EXPLANATION
import android.app.settings.SettingsEnums
import android.content.Context.FINGERPRINT_SERVICE
import android.content.Intent
import android.hardware.fingerprint.FingerprintManager
import android.os.Bundle
import android.provider.Settings.Secure
import android.text.TextUtils
import android.util.FeatureFlagUtils
import android.util.Log
import androidx.activity.result.contract.ActivityResultContracts
import android.view.View
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference
import androidx.preference.PreferenceCategory
import com.android.internal.widget.LockPatternUtils
import com.android.settings.R
import com.android.settings.Utils
import com.android.settings.Utils.SETTINGS_PACKAGE_NAME
import com.android.settings.biometrics.BiometricEnrollBase
import com.android.settings.biometrics.BiometricEnrollBase.CONFIRM_REQUEST
import com.android.settings.biometrics.BiometricEnrollBase.EXTRA_FROM_SETTINGS_SUMMARY
import com.android.settings.biometrics.BiometricEnrollBase.RESULT_FINISHED
import com.android.settings.biometrics.GatekeeperPasswordProvider
import com.android.settings.biometrics.fingerprint.FingerprintEnrollEnrolling
import com.android.settings.biometrics.fingerprint.FingerprintEnrollIntroductionInternal
import com.android.settings.biometrics.fingerprint2.ui.binder.FingerprintViewBinder
import com.android.settings.biometrics.fingerprint2.domain.interactor.FingerprintManagerInteractorImpl
import com.android.settings.biometrics.fingerprint2.ui.binder.FingerprintSettingsViewBinder
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintAuthAttemptViewModel
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintSettingsNavigationViewModel
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintSettingsViewModel
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintStateViewModel
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintViewModel
import com.android.settings.core.SettingsBaseActivity
import com.android.settings.core.instrumentation.InstrumentedDialogFragment
import com.android.settings.dashboard.DashboardFragment
import com.android.settings.password.ChooseLockGeneric
import com.android.settings.password.ChooseLockSettingsHelper
import com.android.settings.password.ChooseLockSettingsHelper.EXTRA_KEY_GK_PW_HANDLE
import com.android.settingslib.HelpUtils
import com.android.settingslib.RestrictedLockUtils
import com.android.settingslib.RestrictedLockUtilsInternal
import com.android.settingslib.transition.SettingsTransitionHelper
import com.android.settingslib.widget.FooterPreference
import com.google.android.setupdesign.util.DeviceHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
const val TAG = "FingerprintSettingsV2Fragment"
private const val TAG = "FingerprintSettingsV2Fragment"
private const val KEY_FINGERPRINTS_ENROLLED_CATEGORY = "security_settings_fingerprints_enrolled"
private const val KEY_FINGERPRINT_SIDE_FPS_CATEGORY =
"security_settings_fingerprint_unlock_category"
private const val KEY_FINGERPRINT_ADD = "key_fingerprint_add"
private const val KEY_FINGERPRINT_SIDE_FPS_SCREEN_ON_TO_AUTH =
"security_settings_require_screen_on_to_auth"
private const val KEY_FINGERPRINT_FOOTER = "security_settings_fingerprint_footer"
/**
* A class responsible for showing FingerprintSettings. Typical activity Flows are
@@ -53,200 +88,494 @@ const val TAG = "FingerprintSettingsV2Fragment"
* 3. Renaming a fingerprint
* 4. Enabling/Disabling a feature
*/
class FingerprintSettingsV2Fragment : DashboardFragment() {
private lateinit var binding: FingerprintViewBinder.Binding
class FingerprintSettingsV2Fragment :
DashboardFragment(), FingerprintSettingsViewBinder.FingerprintView {
private lateinit var settingsViewModel: FingerprintSettingsViewModel
private lateinit var navigationViewModel: FingerprintSettingsNavigationViewModel
private val launchFirstEnrollmentListener =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val resultCode = result.resultCode
val data = result.data
Log.d(
TAG, "onEnrollFirstFingerprint($resultCode, $data)"
)
if (resultCode != BiometricEnrollBase.RESULT_FINISHED || data == null) {
if (resultCode == BiometricEnrollBase.RESULT_TIMEOUT) {
binding.onEnrollFirstFailure(
"Received RESULT_TIMEOUT when enrolling", resultCode
)
} else {
binding.onEnrollFirstFailure("Incorrect resultCode or data was null")
}
} else {
val token =
data.getByteArrayExtra(ChooseLockSettingsHelper.EXTRA_KEY_CHALLENGE_TOKEN)
val keyChallenge = data.getExtra(BiometricEnrollBase.EXTRA_KEY_CHALLENGE) as Long?
binding.onEnrollFirst(token, keyChallenge)
}
}
/** Result listener for launching enrollments **after** a user has reached the settings page. */
private val launchAdditionalFingerprintListener =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val resultCode = result.resultCode
Log.d(
TAG, "onEnrollAdditionalFingerprint($resultCode)"
)
if (resultCode == BiometricEnrollBase.RESULT_TIMEOUT) {
binding.onEnrollAdditionalFailure()
} else {
binding.onEnrollSuccess()
}
}
/** Result listener for ChooseLock activity flow. */
private val confirmDeviceResultListener =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val resultCode = result.resultCode
val data = result.data
onConfirmDevice(resultCode, data)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
// This is needed to support ChooseLockSettingBuilder...show(). All other activity
// calls should use the registerForActivity method call.
super.onActivityResult(requestCode, resultCode, data)
val wasSuccessful =
resultCode == BiometricEnrollBase.RESULT_FINISHED || resultCode == Activity.RESULT_OK
val gateKeeperPasswordHandle =
data?.getExtra(ChooseLockSettingsHelper.EXTRA_KEY_GK_PW_HANDLE) as Long?
binding.onConfirmDevice(wasSuccessful, gateKeeperPasswordHandle)
/** Result listener for ChooseLock activity flow. */
private val confirmDeviceResultListener =
registerForActivityResult(StartActivityForResult()) { result ->
val resultCode = result.resultCode
val data = result.data
onConfirmDevice(resultCode, data)
}
/** Result listener for launching enrollments **after** a user has reached the settings page. */
private val launchAdditionalFingerprintListener: ActivityResultLauncher<Intent> =
registerForActivityResult(StartActivityForResult()) { result ->
lifecycleScope.launch {
val resultCode = result.resultCode
Log.d(TAG, "onEnrollAdditionalFingerprint($resultCode)")
override fun onCreate(icicle: Bundle?) {
super.onCreate(icicle)
if (!FeatureFlagUtils.isEnabled(
context, FeatureFlagUtils.SETTINGS_BIOMETRICS2_FINGERPRINT_SETTINGS
)
) {
Log.d(
TAG, "Finishing due to feature not being enabled"
)
finish()
return
}
val viewModel = ViewModelProvider(
this, FingerprintSettingsViewModel.FingerprintSettingsViewModelFactory(
requireContext().applicationContext.userId, requireContext().getSystemService(
FINGERPRINT_SERVICE
) as FingerprintManager
)
)[FingerprintSettingsViewModel::class.java]
val token = intent.getByteArrayExtra(ChooseLockSettingsHelper.EXTRA_KEY_CHALLENGE_TOKEN)
val challenge = intent.getLongExtra(BiometricEnrollBase.EXTRA_KEY_CHALLENGE, -1L)
binding = FingerprintViewBinder.bind(
viewModel,
lifecycleScope,
token,
challenge,
::launchFullFingerprintEnrollment,
::launchAddFingerprint,
::launchConfirmOrChooseLock,
::finish,
::setResultExternal,
)
}
override fun getMetricsCategory(): Int {
return SettingsEnums.FINGERPRINT
}
override fun getPreferenceScreenResId(): Int {
return R.xml.security_settings_fingerprint_limbo
}
override fun getLogTag(): String {
return TAG
}
/**
* Helper function that will try and launch confirm lock, if that fails we will prompt user
* to choose a PIN/PATTERN/PASS.
*/
private fun launchConfirmOrChooseLock(userId: Int) {
val intent = Intent()
val builder = ChooseLockSettingsHelper.Builder(requireActivity(), this)
val launched = builder.setRequestCode(BiometricEnrollBase.CONFIRM_REQUEST)
.setTitle(getString(R.string.security_settings_fingerprint_preference_title))
.setRequestGatekeeperPasswordHandle(true).setUserId(userId).setForegroundOnly(true)
.setReturnCredentials(true).show()
if (!launched) {
intent.setClassName(
Utils.SETTINGS_PACKAGE_NAME, ChooseLockGeneric::class.java.name
)
intent.putExtra(
ChooseLockGeneric.ChooseLockGenericFragment.HIDE_INSECURE_OPTIONS, true
)
intent.putExtra(ChooseLockSettingsHelper.EXTRA_KEY_REQUEST_GK_PW_HANDLE, true)
intent.putExtra(Intent.EXTRA_USER_ID, userId)
confirmDeviceResultListener.launch(intent)
}
}
/**
* Helper for confirming a PIN/PATTERN/PASS
*/
private fun onConfirmDevice(resultCode: Int, data: Intent?) {
val wasSuccessful =
resultCode == BiometricEnrollBase.RESULT_FINISHED || resultCode == Activity.RESULT_OK
val gateKeeperPasswordHandle =
data?.getExtra(ChooseLockSettingsHelper.EXTRA_KEY_GK_PW_HANDLE) as Long?
binding.onConfirmDevice(wasSuccessful, gateKeeperPasswordHandle)
}
/**
* Helper function to launch fingerprint enrollment(This should be the default behavior
* when a user enters their PIN/PATTERN/PASS and no fingerprints are enrolled.
*/
private fun launchFullFingerprintEnrollment(
userId: Int,
gateKeeperPasswordHandle: Long?,
challenge: Long?,
challengeToken: ByteArray?,
) {
val intent = Intent()
intent.setClassName(
Utils.SETTINGS_PACKAGE_NAME, FingerprintEnrollIntroductionInternal::class.java.name
)
intent.putExtra(BiometricEnrollBase.EXTRA_FROM_SETTINGS_SUMMARY, true)
intent.putExtra(
SettingsBaseActivity.EXTRA_PAGE_TRANSITION_TYPE,
SettingsTransitionHelper.TransitionType.TRANSITION_SLIDE
)
intent.putExtra(Intent.EXTRA_USER_ID, userId)
if (gateKeeperPasswordHandle != null) {
intent.putExtra(
ChooseLockSettingsHelper.EXTRA_KEY_GK_PW_HANDLE, gateKeeperPasswordHandle
)
if (resultCode == BiometricEnrollBase.RESULT_TIMEOUT) {
navigationViewModel.onEnrollAdditionalFailure()
} else {
intent.putExtra(
ChooseLockSettingsHelper.EXTRA_KEY_CHALLENGE_TOKEN, challengeToken
)
intent.putExtra(BiometricEnrollBase.EXTRA_KEY_CHALLENGE, challenge)
navigationViewModel.onEnrollSuccess()
}
launchFirstEnrollmentListener.launch(intent)
}
}
private fun setResultExternal(resultCode: Int) {
setResult(resultCode)
/** Initial listener for the first enrollment request */
private val launchFirstEnrollmentListener: ActivityResultLauncher<Intent> =
registerForActivityResult(StartActivityForResult()) { result ->
lifecycleScope.launch {
val resultCode = result.resultCode
val data = result.data
Log.d(TAG, "onEnrollFirstFingerprint($resultCode, $data)")
if (resultCode != RESULT_FINISHED || data == null) {
if (resultCode == BiometricEnrollBase.RESULT_TIMEOUT) {
navigationViewModel.onEnrollFirstFailure(
"Received RESULT_TIMEOUT when enrolling",
resultCode
)
} else {
navigationViewModel.onEnrollFirstFailure(
"Incorrect resultCode or data was null",
resultCode
)
}
} else {
val token = data.getByteArrayExtra(ChooseLockSettingsHelper.EXTRA_KEY_CHALLENGE_TOKEN)
val challenge = data.getExtra(BiometricEnrollBase.EXTRA_KEY_CHALLENGE) as Long?
navigationViewModel.onEnrollFirst(token, challenge)
}
}
}
/** Helper to launch an add fingerprint request */
private fun launchAddFingerprint(userId: Int, challengeToken: ByteArray?) {
val intent = Intent()
intent.setClassName(
Utils.SETTINGS_PACKAGE_NAME, FingerprintEnrollEnrolling::class.qualifiedName.toString()
override fun userLockout(authAttemptViewModel: FingerprintAuthAttemptViewModel.Error) {
Toast.makeText(activity, authAttemptViewModel.message, Toast.LENGTH_SHORT).show()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
// This is needed to support ChooseLockSettingBuilder...show(). All other activity
// calls should use the registerForActivity method call.
super.onActivityResult(requestCode, resultCode, data)
onConfirmDevice(resultCode, data)
}
override fun onCreate(icicle: Bundle?) {
super.onCreate(icicle)
if (icicle != null) {
Log.d(TAG, "onCreateWithSavedState")
} else {
Log.d(TAG, "onCreate()")
}
if (
!FeatureFlagUtils.isEnabled(
context,
FeatureFlagUtils.SETTINGS_BIOMETRICS2_FINGERPRINT_SETTINGS
)
) {
Log.d(TAG, "Finishing due to feature not being enabled")
finish()
return
}
val context = requireContext()
val userId = context.userId
preferenceScreen.isVisible = false
val fingerprintManager = context.getSystemService(FINGERPRINT_SERVICE) as FingerprintManager
val backgroundDispatcher = Dispatchers.IO
val activity = requireActivity()
val userHandle = activity.user.identifier
val interactor =
FingerprintManagerInteractorImpl(
context.applicationContext,
backgroundDispatcher,
fingerprintManager,
GatekeeperPasswordProvider(LockPatternUtils(context.applicationContext))
) {
var toReturn: Int =
Secure.getIntForUser(
context.contentResolver,
Secure.SFPS_PERFORMANT_AUTH_ENABLED,
-1,
userHandle,
)
if (toReturn == -1) {
toReturn =
if (
context.resources.getBoolean(com.android.internal.R.bool.config_performantAuthDefault)
) {
1
} else {
0
}
Secure.putIntForUser(
context.contentResolver,
Secure.SFPS_PERFORMANT_AUTH_ENABLED,
toReturn,
userHandle
)
}
toReturn == 1
}
val token = intent.getByteArrayExtra(ChooseLockSettingsHelper.EXTRA_KEY_CHALLENGE_TOKEN)
val challenge = intent.getLongExtra(BiometricEnrollBase.EXTRA_KEY_CHALLENGE, -1L)
navigationViewModel =
ViewModelProvider(
this,
FingerprintSettingsNavigationViewModel.FingerprintSettingsNavigationModelFactory(
userId,
interactor,
backgroundDispatcher,
token,
challenge
)
intent.putExtra(Intent.EXTRA_USER_ID, userId)
intent.putExtra(ChooseLockSettingsHelper.EXTRA_KEY_CHALLENGE_TOKEN, challengeToken)
launchAdditionalFingerprintListener.launch(intent)
)[FingerprintSettingsNavigationViewModel::class.java]
settingsViewModel =
ViewModelProvider(
this,
FingerprintSettingsViewModel.FingerprintSettingsViewModelFactory(
userId,
interactor,
backgroundDispatcher,
navigationViewModel,
)
)[FingerprintSettingsViewModel::class.java]
FingerprintSettingsViewBinder.bind(
this,
settingsViewModel,
navigationViewModel,
lifecycleScope,
)
}
override fun getMetricsCategory(): Int {
return SettingsEnums.FINGERPRINT
}
override fun getPreferenceScreenResId(): Int {
return R.xml.security_settings_fingerprint_limbo
}
override fun getLogTag(): String {
return TAG
}
override fun onStop() {
super.onStop()
navigationViewModel.maybeFinishActivity(requireActivity().isChangingConfigurations)
}
override fun onPause() {
super.onPause()
settingsViewModel.shouldAuthenticate(false)
val transaction = parentFragmentManager.beginTransaction()
for (frag in parentFragmentManager.fragments) {
if (frag is InstrumentedDialogFragment) {
Log.d(TAG, "removing dialog settings fragment $frag")
frag.dismiss()
transaction.remove(frag)
}
}
transaction.commit()
}
override fun onResume() {
super.onResume()
settingsViewModel.shouldAuthenticate(true)
}
/** Used to indicate that preference has been clicked */
fun onPrefClicked(fingerprintViewModel: FingerprintViewModel) {
Log.d(TAG, "onPrefClicked(${fingerprintViewModel})")
settingsViewModel.onPrefClicked(fingerprintViewModel)
}
/** Used to indicate that a delete pref has been clicked */
fun onDeletePrefClicked(fingerprintViewModel: FingerprintViewModel) {
Log.d(TAG, "onDeletePrefClicked(${fingerprintViewModel})")
settingsViewModel.onDeleteClicked(fingerprintViewModel)
}
override fun showSettings(state: FingerprintStateViewModel) {
val category =
this@FingerprintSettingsV2Fragment.findPreference(KEY_FINGERPRINTS_ENROLLED_CATEGORY)
as PreferenceCategory?
category?.removeAll()
state.fingerprintViewModels.forEach { fingerprint ->
category?.addPreference(
FingerprintSettingsPreference(
requireContext(),
fingerprint,
this@FingerprintSettingsV2Fragment,
state.fingerprintViewModels.size == 1,
)
)
}
category?.isVisible = true
createFingerprintsFooterPreference(state.canEnroll, state.maxFingerprints)
preferenceScreen.isVisible = true
val sideFpsPref =
this@FingerprintSettingsV2Fragment.findPreference(KEY_FINGERPRINT_SIDE_FPS_CATEGORY)
as PreferenceCategory?
sideFpsPref?.isVisible = false
if (state.hasSideFps) {
sideFpsPref?.isVisible = state.fingerprintViewModels.isNotEmpty()
val otherPref =
this@FingerprintSettingsV2Fragment.findPreference(
KEY_FINGERPRINT_SIDE_FPS_SCREEN_ON_TO_AUTH
) as Preference?
otherPref?.isVisible = state.fingerprintViewModels.isNotEmpty()
}
addFooter(state.hasSideFps)
}
private fun addFooter(hasSideFps: Boolean) {
val footer =
this@FingerprintSettingsV2Fragment.findPreference(KEY_FINGERPRINT_FOOTER)
as PreferenceCategory?
val admin =
RestrictedLockUtilsInternal.checkIfKeyguardFeaturesDisabled(
activity,
DevicePolicyManager.KEYGUARD_DISABLE_FINGERPRINT,
requireActivity().userId
)
val activity = requireActivity()
val helpIntent =
HelpUtils.getHelpIntent(activity, getString(helpResource), activity::class.java.name)
val learnMoreClickListener =
View.OnClickListener { v: View? -> activity.startActivityForResult(helpIntent, 0) }
class FooterColumn {
var title: CharSequence? = null
var learnMoreOverrideText: CharSequence? = null
var learnMoreOnClickListener: View.OnClickListener? = null
}
}
var footerColumns = mutableListOf<FooterColumn>()
if (admin != null) {
val devicePolicyManager = getSystemService(DevicePolicyManager::class.java)
val column1 = FooterColumn()
column1.title =
devicePolicyManager.resources.getString(FINGERPRINT_UNLOCK_DISABLED_EXPLANATION) {
getString(R.string.security_fingerprint_disclaimer_lockscreen_disabled_1)
}
column1.learnMoreOnClickListener =
View.OnClickListener { _ ->
RestrictedLockUtils.sendShowAdminSupportDetailsIntent(activity, admin)
}
column1.learnMoreOverrideText = getText(R.string.admin_support_more_info)
footerColumns.add(column1)
val column2 = FooterColumn()
column2.title = getText(R.string.security_fingerprint_disclaimer_lockscreen_disabled_2)
if (hasSideFps) {
column2.learnMoreOverrideText =
getText(R.string.security_settings_fingerprint_settings_footer_learn_more)
}
column2.learnMoreOnClickListener = learnMoreClickListener
footerColumns.add(column2)
} else {
val column = FooterColumn()
column.title =
getString(
R.string.security_settings_fingerprint_enroll_introduction_v3_message,
DeviceHelper.getDeviceName(requireActivity())
)
column.learnMoreOnClickListener = learnMoreClickListener
if (hasSideFps) {
column.learnMoreOverrideText =
getText(R.string.security_settings_fingerprint_settings_footer_learn_more)
}
footerColumns.add(column)
}
footer?.removeAll()
for (i in 0 until footerColumns.size) {
val column = footerColumns[i]
val footerPrefToAdd: FooterPreference =
FooterPreference.Builder(requireContext()).setTitle(column.title).build()
if (i > 0) {
footerPrefToAdd.setIconVisibility(View.GONE)
}
if (column.learnMoreOnClickListener != null) {
footerPrefToAdd.setLearnMoreAction(column.learnMoreOnClickListener)
if (!TextUtils.isEmpty(column.learnMoreOverrideText)) {
footerPrefToAdd.setLearnMoreText(column.learnMoreOverrideText)
}
}
footer?.addPreference(footerPrefToAdd)
}
}
override suspend fun askUserToDeleteDialog(fingerprintViewModel: FingerprintViewModel): Boolean {
Log.d(TAG, "showing delete dialog for (${fingerprintViewModel})")
try {
val willDelete =
fingerprintPreferences()
.first { it?.fingerprintViewModel == fingerprintViewModel }
?.askUserToDeleteDialog()
?: false
if (willDelete) {
mMetricsFeatureProvider.action(
context,
SettingsEnums.ACTION_FINGERPRINT_DELETE,
fingerprintViewModel.fingerId
)
}
return willDelete
} catch (exception: Exception) {
Log.d(TAG, "askUserToDeleteDialog exception $exception")
return false
}
}
override suspend fun askUserToRenameDialog(
fingerprintViewModel: FingerprintViewModel
): Pair<FingerprintViewModel, String>? {
Log.d(TAG, "showing rename dialog for (${fingerprintViewModel})")
try {
val toReturn =
fingerprintPreferences()
.first { it?.fingerprintViewModel == fingerprintViewModel }
?.askUserToRenameDialog()
if (toReturn != null) {
mMetricsFeatureProvider.action(
context,
SettingsEnums.ACTION_FINGERPRINT_RENAME,
toReturn.first.fingerId
)
}
return toReturn
} catch (exception: Exception) {
Log.d(TAG, "askUserToRenameDialog exception $exception")
return null
}
}
override suspend fun highlightPref(fingerId: Int) {
fingerprintPreferences()
.first { pref -> pref?.fingerprintViewModel?.fingerId == fingerId }
?.highlight()
}
override fun launchConfirmOrChooseLock(userId: Int) {
lifecycleScope.launch(Dispatchers.Default) {
navigationViewModel.setStepToLaunched()
val intent = Intent()
val builder =
ChooseLockSettingsHelper.Builder(requireActivity(), this@FingerprintSettingsV2Fragment)
val launched =
builder
.setRequestCode(CONFIRM_REQUEST)
.setTitle(getString(R.string.security_settings_fingerprint_preference_title))
.setRequestGatekeeperPasswordHandle(true)
.setUserId(userId)
.setForegroundOnly(true)
.setReturnCredentials(true)
.show()
if (!launched) {
intent.setClassName(SETTINGS_PACKAGE_NAME, ChooseLockGeneric::class.java.name)
intent.putExtra(ChooseLockGeneric.ChooseLockGenericFragment.HIDE_INSECURE_OPTIONS, true)
intent.putExtra(ChooseLockSettingsHelper.EXTRA_KEY_REQUEST_GK_PW_HANDLE, true)
intent.putExtra(Intent.EXTRA_USER_ID, userId)
confirmDeviceResultListener.launch(intent)
}
}
}
override fun launchFullFingerprintEnrollment(
userId: Int,
gateKeeperPasswordHandle: Long?,
challenge: Long?,
challengeToken: ByteArray?,
) {
navigationViewModel.setStepToLaunched()
Log.d(TAG, "launchFullFingerprintEnrollment")
val intent = Intent()
intent.setClassName(
SETTINGS_PACKAGE_NAME,
FingerprintEnrollIntroductionInternal::class.java.name
)
intent.putExtra(EXTRA_FROM_SETTINGS_SUMMARY, true)
intent.putExtra(
SettingsBaseActivity.EXTRA_PAGE_TRANSITION_TYPE,
SettingsTransitionHelper.TransitionType.TRANSITION_SLIDE
)
intent.putExtra(Intent.EXTRA_USER_ID, userId)
if (gateKeeperPasswordHandle != null) {
intent.putExtra(EXTRA_KEY_GK_PW_HANDLE, gateKeeperPasswordHandle)
} else {
intent.putExtra(ChooseLockSettingsHelper.EXTRA_KEY_CHALLENGE_TOKEN, challengeToken)
intent.putExtra(BiometricEnrollBase.EXTRA_KEY_CHALLENGE, challenge)
}
launchFirstEnrollmentListener.launch(intent)
}
override fun setResultExternal(resultCode: Int) {
setResult(resultCode)
}
override fun launchAddFingerprint(userId: Int, challengeToken: ByteArray?) {
navigationViewModel.setStepToLaunched()
val intent = Intent()
intent.setClassName(
SETTINGS_PACKAGE_NAME,
FingerprintEnrollEnrolling::class.qualifiedName.toString()
)
intent.putExtra(Intent.EXTRA_USER_ID, userId)
intent.putExtra(ChooseLockSettingsHelper.EXTRA_KEY_CHALLENGE_TOKEN, challengeToken)
launchAdditionalFingerprintListener.launch(intent)
}
private fun onConfirmDevice(resultCode: Int, data: Intent?) {
val wasSuccessful = resultCode == RESULT_FINISHED || resultCode == Activity.RESULT_OK
val gateKeeperPasswordHandle = data?.getExtra(EXTRA_KEY_GK_PW_HANDLE) as Long?
lifecycleScope.launch {
navigationViewModel.onConfirmDevice(wasSuccessful, gateKeeperPasswordHandle)
}
}
private fun createFingerprintsFooterPreference(canEnroll: Boolean, maxFingerprints: Int) {
val pref = this@FingerprintSettingsV2Fragment.findPreference<Preference>(KEY_FINGERPRINT_ADD)
val maxSummary = context?.getString(R.string.fingerprint_add_max, maxFingerprints) ?: ""
pref?.summary = maxSummary
pref?.isEnabled = canEnroll
pref?.setOnPreferenceClickListener {
navigationViewModel.onAddFingerprintClicked()
true
}
pref?.isVisible = true
}
private fun fingerprintPreferences(): List<FingerprintSettingsPreference?> {
val category =
this@FingerprintSettingsV2Fragment.findPreference(KEY_FINGERPRINTS_ENROLLED_CATEGORY)
as PreferenceCategory?
return category?.let { cat ->
cat.childrenToList().map { it as FingerprintSettingsPreference? }
}
?: emptyList()
}
private fun PreferenceCategory.childrenToList(): List<Preference> {
val mutable: MutableList<Preference> = mutableListOf()
for (i in 0 until this.preferenceCount) {
mutable.add(this.getPreference(i))
}
return mutable.toList()
}
}

View File

@@ -0,0 +1,189 @@
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.settings.biometrics.fingerprint2.ui.viewmodel
import android.hardware.fingerprint.FingerprintManager
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.android.settings.biometrics.BiometricEnrollBase
import com.android.settings.biometrics.fingerprint2.domain.interactor.FingerprintManagerInteractor
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/** A Viewmodel that represents the navigation of the FingerprintSettings activity. */
class FingerprintSettingsNavigationViewModel(
private val userId: Int,
private val fingerprintManagerInteractor: FingerprintManagerInteractor,
private val backgroundDispatcher: CoroutineDispatcher,
tokenInit: ByteArray?,
challengeInit: Long?,
) : ViewModel() {
private var token = tokenInit
private var challenge = challengeInit
private val _nextStep: MutableStateFlow<NextStepViewModel?> = MutableStateFlow(null)
/** This flow represents the high level state for the FingerprintSettingsV2Fragment. */
val nextStep: StateFlow<NextStepViewModel?> = _nextStep.asStateFlow()
init {
if (challengeInit == null || tokenInit == null) {
_nextStep.update { LaunchConfirmDeviceCredential(userId) }
} else {
viewModelScope.launch { showSettingsHelper() }
}
}
/** Used to indicate that FingerprintSettings is complete. */
fun finish() {
_nextStep.update { null }
}
/** Used to finish settings in certain cases. */
fun maybeFinishActivity(changingConfig: Boolean) {
val isConfirmingOrEnrolling =
_nextStep.value is LaunchConfirmDeviceCredential ||
_nextStep.value is EnrollAdditionalFingerprint ||
_nextStep.value is EnrollFirstFingerprint ||
_nextStep.value is LaunchedActivity
if (!isConfirmingOrEnrolling && !changingConfig)
_nextStep.update {
FinishSettingsWithResult(BiometricEnrollBase.RESULT_TIMEOUT, "onStop finishing settings")
}
}
/** Used to indicate that we have launched another activity and we should await its result. */
fun setStepToLaunched() {
_nextStep.update { LaunchedActivity }
}
/** Indicates a successful enroll has occurred */
fun onEnrollSuccess() {
showSettingsHelper()
}
/** Add fingerprint clicked */
fun onAddFingerprintClicked() {
_nextStep.update { EnrollAdditionalFingerprint(userId, token) }
}
/** Enrolling of an additional fingerprint failed */
fun onEnrollAdditionalFailure() {
launchFinishSettings("Failed to enroll additional fingerprint")
}
/** The first fingerprint enrollment failed */
fun onEnrollFirstFailure(reason: String) {
launchFinishSettings(reason)
}
/** The first fingerprint enrollment failed with a result code */
fun onEnrollFirstFailure(reason: String, resultCode: Int) {
launchFinishSettings(reason, resultCode)
}
/** Notifies that a users first enrollment succeeded. */
fun onEnrollFirst(theToken: ByteArray?, theChallenge: Long?) {
if (theToken == null) {
launchFinishSettings("Error, empty token")
return
}
if (theChallenge == null) {
launchFinishSettings("Error, empty keyChallenge")
return
}
token = theToken!!
challenge = theChallenge!!
showSettingsHelper()
}
/**
* Indicates to the view model that a confirm device credential action has been completed with a
* [theGateKeeperPasswordHandle] which will be used for [FingerprintManager] operations such as
* [FingerprintManager.enroll].
*/
suspend fun onConfirmDevice(wasSuccessful: Boolean, theGateKeeperPasswordHandle: Long?) {
if (!wasSuccessful) {
launchFinishSettings("ConfirmDeviceCredential was unsuccessful")
return
}
if (theGateKeeperPasswordHandle == null) {
launchFinishSettings("ConfirmDeviceCredential gatekeeper password was null")
return
}
launchEnrollNextStep(theGateKeeperPasswordHandle)
}
private fun showSettingsHelper() {
_nextStep.update { ShowSettings }
}
private suspend fun launchEnrollNextStep(gateKeeperPasswordHandle: Long?) {
fingerprintManagerInteractor.enrolledFingerprints.collect {
if (it.isEmpty()) {
_nextStep.update { EnrollFirstFingerprint(userId, gateKeeperPasswordHandle, null, null) }
} else {
viewModelScope.launch(backgroundDispatcher) {
val challengePair =
fingerprintManagerInteractor.generateChallenge(gateKeeperPasswordHandle!!)
challenge = challengePair.first
token = challengePair.second
showSettingsHelper()
}
}
}
}
private fun launchFinishSettings(reason: String) {
_nextStep.update { FinishSettings(reason) }
}
private fun launchFinishSettings(reason: String, errorCode: Int) {
_nextStep.update { FinishSettingsWithResult(errorCode, reason) }
}
class FingerprintSettingsNavigationModelFactory(
private val userId: Int,
private val interactor: FingerprintManagerInteractor,
private val backgroundDispatcher: CoroutineDispatcher,
private val token: ByteArray?,
private val challenge: Long?,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(
modelClass: Class<T>,
): T {
return FingerprintSettingsNavigationViewModel(
userId,
interactor,
backgroundDispatcher,
token,
challenge,
)
as T
}
}
}

View File

@@ -17,171 +17,308 @@
package com.android.settings.biometrics.fingerprint2.ui.viewmodel
import android.hardware.fingerprint.FingerprintManager
import android.hardware.fingerprint.FingerprintSensorProperties.TYPE_UDFPS_OPTICAL
import android.hardware.fingerprint.FingerprintSensorProperties.TYPE_UDFPS_ULTRASONIC
import android.hardware.fingerprint.FingerprintSensorPropertiesInternal
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.android.settings.biometrics.fingerprint2.domain.interactor.FingerprintManagerInteractor
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* Models the UI state for fingerprint settings.
*/
private const val TAG = "FingerprintSettingsViewModel"
private const val DEBUG = false
/** Models the UI state for fingerprint settings. */
class FingerprintSettingsViewModel(
private val userId: Int,
gateKeeperPassword: Long?,
theChallenge: Long?,
theChallengeToken: ByteArray?,
private val fingerprintManager: FingerprintManager
private val userId: Int,
private val fingerprintManagerInteractor: FingerprintManagerInteractor,
private val backgroundDispatcher: CoroutineDispatcher,
private val navigationViewModel: FingerprintSettingsNavigationViewModel,
) : ViewModel() {
private val _nextStep: MutableStateFlow<NextStepViewModel?> = MutableStateFlow(null)
/**
* This flow represents the high level state for the FingerprintSettingsV2Fragment. The
* consumer of this flow should call [onUiCommandExecuted] which will set the state to null,
* confirming that the UI has consumed the last command and is ready to consume another
* command.
*/
val nextStep = _nextStep.asStateFlow()
private val _consumerShouldAuthenticate: MutableStateFlow<Boolean> = MutableStateFlow(false)
private val fingerprintSensorPropertiesInternal:
MutableStateFlow<List<FingerprintSensorPropertiesInternal>?> =
MutableStateFlow(null)
private var gateKeeperPasswordHandle: Long? = gateKeeperPassword
private var challenge: Long? = theChallenge
private var challengeToken: ByteArray? = theChallengeToken
/**
* Indicates to the view model that a confirm device credential action has been completed
* with a [theGateKeeperPasswordHandle] which will be used for [FingerprintManager]
* operations such as [FingerprintManager.enroll].
*/
fun onConfirmDevice(wasSuccessful: Boolean, theGateKeeperPasswordHandle: Long?) {
if (!wasSuccessful) {
launchFinishSettings("ConfirmDeviceCredential was unsuccessful")
return
}
if (theGateKeeperPasswordHandle == null) {
launchFinishSettings("ConfirmDeviceCredential gatekeeper password was null")
return
}
gateKeeperPasswordHandle = theGateKeeperPasswordHandle
launchEnrollNextStep()
private val _isShowingDialog: MutableStateFlow<PreferenceViewModel?> = MutableStateFlow(null)
val isShowingDialog =
_isShowingDialog.combine(navigationViewModel.nextStep) { dialogFlow, nextStep ->
if (nextStep is ShowSettings) {
return@combine dialogFlow
} else {
return@combine null
}
}
/**
* Notifies that enrollment was successful.
*/
fun onEnrollSuccess() {
_nextStep.update {
ShowSettings(userId)
init {
viewModelScope.launch {
fingerprintSensorPropertiesInternal.update {
fingerprintManagerInteractor.sensorPropertiesInternal()
}
}
viewModelScope.launch {
navigationViewModel.nextStep.filterNotNull().collect {
_isShowingDialog.update { null }
if (it is ShowSettings) {
// reset state
updateSettingsData()
}
}
}
}
private val _fingerprintStateViewModel: MutableStateFlow<FingerprintStateViewModel?> =
MutableStateFlow(null)
val fingerprintState: Flow<FingerprintStateViewModel?> =
_fingerprintStateViewModel.combineTransform(navigationViewModel.nextStep) {
settingsShowingViewModel,
currStep ->
if (currStep != null && currStep is ShowSettings) {
emit(settingsShowingViewModel)
}
}
/**
* Notifies that an additional enrollment failed.
*/
fun onEnrollAdditionalFailure() {
launchFinishSettings("Failed to enroll additional fingerprint")
}
private val _isLockedOut: MutableStateFlow<FingerprintAuthAttemptViewModel.Error?> =
MutableStateFlow(null)
/**
* Notifies that the first enrollment failed.
*/
fun onEnrollFirstFailure(reason: String) {
launchFinishSettings(reason)
}
private val _authSucceeded: MutableSharedFlow<FingerprintAuthAttemptViewModel.Success?> =
MutableSharedFlow()
/**
* Notifies that first enrollment failed (with resultCode)
*/
fun onEnrollFirstFailure(reason: String, resultCode: Int) {
launchFinishSettings(reason, resultCode)
}
private val attemptsSoFar: MutableStateFlow<Int> = MutableStateFlow(0)
/**
* Notifies that a users first enrollment succeeded.
*/
fun onEnrollFirst(token: ByteArray?, keyChallenge: Long?) {
if (token == null) {
launchFinishSettings("Error, empty token")
return
/**
* This is a very tricky flow. The current fingerprint manager APIs are not robust, and a proper
* implementation would take quite a lot of code to implement, it might be easier to rewrite
* FingerprintManager.
*
* The hack to note is the sample(400), if we call authentications in too close of proximity
* without waiting for a response, the fingerprint manager will send us the results of the
* previous attempt.
*/
private val canAuthenticate: Flow<Boolean> =
combine(
_isShowingDialog,
navigationViewModel.nextStep,
_consumerShouldAuthenticate,
_fingerprintStateViewModel,
_isLockedOut,
attemptsSoFar,
fingerprintSensorPropertiesInternal
) { dialogShowing, step, resume, fingerprints, isLockedOut, attempts, sensorProps ->
if (DEBUG) {
Log.d(
TAG,
"canAuthenticate(isShowingDialog=${dialogShowing != null}," +
"nextStep=${step}," +
"resumed=${resume}," +
"fingerprints=${fingerprints}," +
"lockedOut=${isLockedOut}," +
"attempts=${attempts}," +
"sensorProps=${sensorProps}"
)
}
if (keyChallenge == null) {
launchFinishSettings("Error, empty keyChallenge")
return
if (sensorProps.isNullOrEmpty()) {
return@combine false
}
challengeToken = token
challenge = keyChallenge
_nextStep.update {
ShowSettings(userId)
val sensorType = sensorProps[0].sensorType
if (listOf(TYPE_UDFPS_OPTICAL, TYPE_UDFPS_ULTRASONIC).contains(sensorType)) {
return@combine false
}
}
if (step != null && step is ShowSettings) {
if (fingerprints?.fingerprintViewModels?.isNotEmpty() == true) {
return@combine dialogShowing == null && isLockedOut == null && resume && attempts < 15
}
}
false
}
.sample(400)
.distinctUntilChanged()
/**
* Indicates if this settings activity has been called with correct token and challenge
* and that we do not need to launch confirm device credential.
*/
fun updateTokenAndChallenge(token: ByteArray?, theChallenge: Long?) {
challengeToken = token
challenge = theChallenge
if (challengeToken == null) {
_nextStep.update {
LaunchConfirmDeviceCredential(userId)
/** Represents a consistent stream of authentication attempts. */
val authFlow: Flow<FingerprintAuthAttemptViewModel> =
canAuthenticate
.transformLatest {
try {
Log.d(TAG, "canAuthenticate $it")
while (it && navigationViewModel.nextStep.value is ShowSettings) {
Log.d(TAG, "canAuthenticate authing")
attemptingAuth()
when (val authAttempt = fingerprintManagerInteractor.authenticate()) {
is FingerprintAuthAttemptViewModel.Success -> {
onAuthSuccess(authAttempt)
emit(authAttempt)
}
is FingerprintAuthAttemptViewModel.Error -> {
if (authAttempt.error == FingerprintManager.FINGERPRINT_ERROR_LOCKOUT) {
lockout(authAttempt)
emit(authAttempt)
return@transformLatest
}
}
}
} else {
launchEnrollNextStep()
}
} catch (exception: Exception) {
Log.d(TAG, "shouldAuthenticate exception $exception")
}
}
.flowOn(backgroundDispatcher)
/** The rename dialog has finished */
fun onRenameDialogFinished() {
_isShowingDialog.update { null }
}
/** The delete dialog has finished */
fun onDeleteDialogFinished() {
_isShowingDialog.update { null }
}
override fun toString(): String {
return "userId: $userId\n" + "fingerprintState: ${_fingerprintStateViewModel.value}\n"
}
/** The fingerprint delete button has been clicked. */
fun onDeleteClicked(fingerprintViewModel: FingerprintViewModel) {
viewModelScope.launch {
if (_isShowingDialog.value == null || navigationViewModel.nextStep.value != ShowSettings) {
_isShowingDialog.tryEmit(PreferenceViewModel.DeleteDialog(fingerprintViewModel))
} else {
Log.d(TAG, "Ignoring onDeleteClicked due to dialog showing ${_isShowingDialog.value}")
}
}
}
/**
* Indicates a UI command has been consumed by the UI, and the logic can send another
* UI command.
*/
fun onUiCommandExecuted() {
_nextStep.update {
null
}
/** The rename fingerprint dialog has been clicked. */
fun onPrefClicked(fingerprintViewModel: FingerprintViewModel) {
viewModelScope.launch {
if (_isShowingDialog.value == null || navigationViewModel.nextStep.value != ShowSettings) {
_isShowingDialog.tryEmit(PreferenceViewModel.RenameDialog(fingerprintViewModel))
} else {
Log.d(TAG, "Ignoring onPrefClicked due to dialog showing ${_isShowingDialog.value}")
}
}
}
private fun launchEnrollNextStep() {
if (fingerprintManager.getEnrolledFingerprints(userId).isEmpty()) {
_nextStep.update {
EnrollFirstFingerprint(userId, gateKeeperPasswordHandle, challenge, challengeToken)
}
} else {
_nextStep.update {
ShowSettings(userId)
}
}
/** A request to delete a fingerprint */
fun deleteFingerprint(fp: FingerprintViewModel) {
viewModelScope.launch(backgroundDispatcher) {
if (fingerprintManagerInteractor.removeFingerprint(fp)) {
updateSettingsData()
}
}
}
private fun launchFinishSettings(reason: String) {
_nextStep.update {
FinishSettings(reason)
}
/** A request to rename a fingerprint */
fun renameFingerprint(fp: FingerprintViewModel, newName: String) {
viewModelScope.launch {
fingerprintManagerInteractor.renameFingerprint(fp, newName)
updateSettingsData()
}
}
private fun launchFinishSettings(reason: String, errorCode: Int) {
_nextStep.update {
FinishSettingsWithResult(errorCode, reason)
}
private fun attemptingAuth() {
attemptsSoFar.update { it + 1 }
}
private suspend fun onAuthSuccess(success: FingerprintAuthAttemptViewModel.Success) {
_authSucceeded.emit(success)
attemptsSoFar.update { 0 }
}
private fun lockout(attemptViewModel: FingerprintAuthAttemptViewModel.Error) {
_isLockedOut.update { attemptViewModel }
}
/**
* This function is sort of a hack, it's used whenever we want to check for fingerprint state
* updates.
*/
private suspend fun updateSettingsData() {
Log.d(TAG, "update settings data called")
val fingerprints = fingerprintManagerInteractor.enrolledFingerprints.last()
val canEnrollFingerprint =
fingerprintManagerInteractor.canEnrollFingerprints(fingerprints.size).last()
val maxFingerprints = fingerprintManagerInteractor.maxEnrollableFingerprints.last()
val hasSideFps = fingerprintManagerInteractor.hasSideFps()
val pressToAuthEnabled = fingerprintManagerInteractor.pressToAuthEnabled()
_fingerprintStateViewModel.update {
FingerprintStateViewModel(
fingerprints,
canEnrollFingerprint,
maxFingerprints,
hasSideFps,
pressToAuthEnabled
)
}
}
class FingerprintSettingsViewModelFactory(
private val userId: Int,
private val fingerprintManager: FingerprintManager,
) : ViewModelProvider.Factory {
/** Used to indicate whether the consumer of the view model is ready for authentication. */
fun shouldAuthenticate(authenticate: Boolean) {
_consumerShouldAuthenticate.update { authenticate }
}
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(
modelClass: Class<T>,
): T {
class FingerprintSettingsViewModelFactory(
private val userId: Int,
private val interactor: FingerprintManagerInteractor,
private val backgroundDispatcher: CoroutineDispatcher,
private val navigationViewModel: FingerprintSettingsNavigationViewModel,
) : ViewModelProvider.Factory {
return FingerprintSettingsViewModel(
userId, null, null, null, fingerprintManager
) as T
}
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(
modelClass: Class<T>,
): T {
return FingerprintSettingsViewModel(
userId,
interactor,
backgroundDispatcher,
navigationViewModel,
)
as T
}
}
}
private inline fun <T1, T2, T3, T4, T5, T6, T7, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
flow7: Flow<T7>,
crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R
): Flow<R> {
return combine(flow, flow2, flow3, flow4, flow5, flow6, flow7) { args: Array<*> ->
@Suppress("UNCHECKED_CAST")
transform(
args[0] as T1,
args[1] as T2,
args[2] as T3,
args[3] as T4,
args[4] as T5,
args[5] as T6,
args[6] as T7,
)
}
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.settings.biometrics.fingerprint2.ui.viewmodel
/** Represents the fingerprint data nad the relevant state. */
data class FingerprintStateViewModel(
val fingerprintViewModels: List<FingerprintViewModel>,
val canEnroll: Boolean,
val maxFingerprints: Int,
val hasSideFps: Boolean,
val pressToAuth: Boolean,
)
data class FingerprintViewModel(
val name: String,
val fingerId: Int,
val deviceId: Long,
)
sealed class FingerprintAuthAttemptViewModel {
data class Success(
val fingerId: Int,
) : FingerprintAuthAttemptViewModel()
data class Error(
val error: Int,
val message: String,
) : FingerprintAuthAttemptViewModel()
}

View File

@@ -17,32 +17,29 @@
package com.android.settings.biometrics.fingerprint2.ui.viewmodel
/**
* A class to represent a next step for FingerprintSettings. This is typically to perform an action
* such that launches another activity such as EnrollFirstFingerprint() or
* LaunchConfirmDeviceCredential().
* A class to represent a high level step for FingerprintSettings. This is typically to perform an
* action like launching an activity.
*/
sealed class NextStepViewModel
data class EnrollFirstFingerprint(
val userId: Int, val gateKeeperPasswordHandle: Long?,
val challenge: Long?,
val challengeToken: ByteArray?,
val userId: Int,
val gateKeeperPasswordHandle: Long?,
val challenge: Long?,
val challengeToken: ByteArray?,
) : NextStepViewModel()
data class EnrollAdditionalFingerprint(
val userId: Int,
val challengeToken: ByteArray?,
val userId: Int,
val challengeToken: ByteArray?,
) : NextStepViewModel()
data class FinishSettings(
val reason: String
) : NextStepViewModel()
data class FinishSettings(val reason: String) : NextStepViewModel()
data class FinishSettingsWithResult(
val result: Int, val reason: String
) : NextStepViewModel()
data class FinishSettingsWithResult(val result: Int, val reason: String) : NextStepViewModel()
data class ShowSettings(val userId: Int) : NextStepViewModel()
object ShowSettings : NextStepViewModel()
object LaunchedActivity : NextStepViewModel()
data class LaunchConfirmDeviceCredential(val userId: Int) : NextStepViewModel()

View File

@@ -0,0 +1,28 @@
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.settings.biometrics.fingerprint2.ui.viewmodel
/** Classed use to represent a Dialogs state. */
sealed class PreferenceViewModel {
data class RenameDialog(
val fingerprintViewModel: FingerprintViewModel,
) : PreferenceViewModel()
data class DeleteDialog(
val fingerprintViewModel: FingerprintViewModel,
) : PreferenceViewModel()
}

View File

@@ -21,6 +21,7 @@ android_test {
],
static_libs: [
"androidx.arch.core_core-testing",
"androidx.test.core",
"androidx.test.rules",
"androidx.test.espresso.core",

View File

@@ -0,0 +1,82 @@
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.settings.fingerprint2.domain.interactor
import android.hardware.biometrics.SensorProperties
import android.hardware.fingerprint.FingerprintSensorProperties.TYPE_POWER_BUTTON
import android.hardware.fingerprint.FingerprintSensorPropertiesInternal
import com.android.settings.biometrics.fingerprint2.domain.interactor.FingerprintManagerInteractor
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintAuthAttemptViewModel
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
/** Fake to be used by other classes to easily fake the FingerprintManager implementation. */
class FakeFingerprintManagerInteractor : FingerprintManagerInteractor {
var enrollableFingerprints: Int = 5
var enrolledFingerprintsInternal: MutableList<FingerprintViewModel> = mutableListOf()
var challengeToGenerate: Pair<Long, ByteArray> = Pair(-1L, byteArrayOf())
var authenticateAttempt = FingerprintAuthAttemptViewModel.Success(1)
var pressToAuthEnabled = true
var sensorProps =
listOf(
FingerprintSensorPropertiesInternal(
0 /* sensorId */,
SensorProperties.STRENGTH_STRONG,
5 /* maxEnrollmentsPerUser */,
emptyList() /* ComponentInfoInternal */,
TYPE_POWER_BUTTON,
true /* resetLockoutRequiresHardwareAuthToken */
)
)
override suspend fun authenticate(): FingerprintAuthAttemptViewModel {
return authenticateAttempt
}
override suspend fun generateChallenge(gateKeeperPasswordHandle: Long): Pair<Long, ByteArray> {
return challengeToGenerate
}
override val enrolledFingerprints: Flow<List<FingerprintViewModel>> = flow {
emit(enrolledFingerprintsInternal)
}
override fun canEnrollFingerprints(numFingerprints: Int): Flow<Boolean> = flow {
emit(numFingerprints < enrollableFingerprints)
}
override val maxEnrollableFingerprints: Flow<Int> = flow { emit(enrollableFingerprints) }
override suspend fun removeFingerprint(fp: FingerprintViewModel): Boolean {
return enrolledFingerprintsInternal.remove(fp)
}
override suspend fun renameFingerprint(fp: FingerprintViewModel, newName: String) {}
override suspend fun hasSideFps(): Boolean {
return sensorProps.any { it.isAnySidefpsType }
}
override suspend fun pressToAuthEnabled(): Boolean {
return pressToAuthEnabled
}
override suspend fun sensorPropertiesInternal(): List<FingerprintSensorPropertiesInternal> =
sensorProps
}

View File

@@ -0,0 +1,287 @@
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.settings.fingerprint2.domain.interactor
import android.content.Context
import android.content.Intent
import android.content.res.Resources
import android.hardware.fingerprint.Fingerprint
import android.hardware.fingerprint.FingerprintManager
import android.hardware.fingerprint.FingerprintManager.CryptoObject
import android.hardware.fingerprint.FingerprintManager.FINGERPRINT_ERROR_LOCKOUT_PERMANENT
import android.os.CancellationSignal
import android.os.Handler
import androidx.test.core.app.ApplicationProvider
import com.android.settings.biometrics.GatekeeperPasswordProvider
import com.android.settings.biometrics.fingerprint2.domain.interactor.FingerprintManagerInteractor
import com.android.settings.biometrics.fingerprint2.domain.interactor.FingerprintManagerInteractorImpl
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintAuthAttemptViewModel
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintViewModel
import com.android.settings.password.ChooseLockSettingsHelper
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.last
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.ArgumentMatchers.anyLong
import org.mockito.ArgumentMatchers.eq
import org.mockito.ArgumentMatchers.nullable
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.junit.MockitoJUnit
import org.mockito.junit.MockitoJUnitRunner
@RunWith(MockitoJUnitRunner::class)
class FingerprintManagerInteractorTest {
@JvmField @Rule var rule = MockitoJUnit.rule()
private lateinit var underTest: FingerprintManagerInteractor
private var context: Context = ApplicationProvider.getApplicationContext()
private var backgroundDispatcher = StandardTestDispatcher()
@Mock private lateinit var fingerprintManager: FingerprintManager
@Mock private lateinit var gateKeeperPasswordProvider: GatekeeperPasswordProvider
private var testScope = TestScope(backgroundDispatcher)
private var pressToAuthProvider = { true }
@Before
fun setup() {
underTest =
FingerprintManagerInteractorImpl(
context,
backgroundDispatcher,
fingerprintManager,
gateKeeperPasswordProvider,
pressToAuthProvider,
)
}
@Test
fun testEmptyFingerprints() =
testScope.runTest {
Mockito.`when`(fingerprintManager.getEnrolledFingerprints(Mockito.anyInt()))
.thenReturn(emptyList())
val emptyFingerprintList: List<Fingerprint> = emptyList()
assertThat(underTest.enrolledFingerprints.last()).isEqualTo(emptyFingerprintList)
}
@Test
fun testOneFingerprint() =
testScope.runTest {
val expected = Fingerprint("Finger 1,", 2, 3L)
val fingerprintList: List<Fingerprint> = listOf(expected)
Mockito.`when`(fingerprintManager.getEnrolledFingerprints(Mockito.anyInt()))
.thenReturn(fingerprintList)
val list = underTest.enrolledFingerprints.last()
assertThat(list.size).isEqualTo(fingerprintList.size)
val actual = list[0]
assertThat(actual.name).isEqualTo(expected.name)
assertThat(actual.fingerId).isEqualTo(expected.biometricId)
assertThat(actual.deviceId).isEqualTo(expected.deviceId)
}
@Test
fun testCanEnrollFingerprint() =
testScope.runTest {
val mockContext = Mockito.mock(Context::class.java)
val resources = Mockito.mock(Resources::class.java)
Mockito.`when`(mockContext.resources).thenReturn(resources)
Mockito.`when`(resources.getInteger(anyInt())).thenReturn(3)
underTest =
FingerprintManagerInteractorImpl(
mockContext,
backgroundDispatcher,
fingerprintManager,
gateKeeperPasswordProvider,
pressToAuthProvider,
)
assertThat(underTest.canEnrollFingerprints(2).last()).isTrue()
assertThat(underTest.canEnrollFingerprints(3).last()).isFalse()
}
@Test
fun testGenerateChallenge() =
testScope.runTest {
val byteArray = byteArrayOf(5, 3, 2)
val challenge = 100L
val intent = Intent()
intent.putExtra(ChooseLockSettingsHelper.EXTRA_KEY_GK_PW_HANDLE, challenge)
Mockito.`when`(
gateKeeperPasswordProvider.requestGatekeeperHat(
any(Intent::class.java),
anyLong(),
anyInt()
)
)
.thenReturn(byteArray)
val generateChallengeCallback: ArgumentCaptor<FingerprintManager.GenerateChallengeCallback> =
ArgumentCaptor.forClass(FingerprintManager.GenerateChallengeCallback::class.java)
var result: Pair<Long, ByteArray?>? = null
val job = testScope.launch { result = underTest.generateChallenge(1L) }
runCurrent()
Mockito.verify(fingerprintManager)
.generateChallenge(anyInt(), capture(generateChallengeCallback))
generateChallengeCallback.value.onChallengeGenerated(1, 2, challenge)
runCurrent()
job.cancelAndJoin()
assertThat(result?.first).isEqualTo(challenge)
assertThat(result?.second).isEqualTo(byteArray)
}
@Test
fun testRemoveFingerprint_succeeds() =
testScope.runTest {
val fingerprintViewModelToRemove = FingerprintViewModel("Finger 2", 1, 2L)
val fingerprintToRemove = Fingerprint("Finger 2", 1, 2L)
val removalCallback: ArgumentCaptor<FingerprintManager.RemovalCallback> =
ArgumentCaptor.forClass(FingerprintManager.RemovalCallback::class.java)
var result: Boolean? = null
val job =
testScope.launch { result = underTest.removeFingerprint(fingerprintViewModelToRemove) }
runCurrent()
Mockito.verify(fingerprintManager)
.remove(any(Fingerprint::class.java), anyInt(), capture(removalCallback))
removalCallback.value.onRemovalSucceeded(fingerprintToRemove, 1)
runCurrent()
job.cancelAndJoin()
assertThat(result).isTrue()
}
@Test
fun testRemoveFingerprint_fails() =
testScope.runTest {
val fingerprintViewModelToRemove = FingerprintViewModel("Finger 2", 1, 2L)
val fingerprintToRemove = Fingerprint("Finger 2", 1, 2L)
val removalCallback: ArgumentCaptor<FingerprintManager.RemovalCallback> =
ArgumentCaptor.forClass(FingerprintManager.RemovalCallback::class.java)
var result: Boolean? = null
val job =
testScope.launch { result = underTest.removeFingerprint(fingerprintViewModelToRemove) }
runCurrent()
Mockito.verify(fingerprintManager)
.remove(any(Fingerprint::class.java), anyInt(), capture(removalCallback))
removalCallback.value.onRemovalError(
fingerprintToRemove,
100,
"Oh no, we couldn't find that one"
)
runCurrent()
job.cancelAndJoin()
assertThat(result).isFalse()
}
@Test
fun testRenameFingerprint_succeeds() =
testScope.runTest {
val fingerprintToRename = FingerprintViewModel("Finger 2", 1, 2L)
underTest.renameFingerprint(fingerprintToRename, "Woo")
Mockito.verify(fingerprintManager)
.rename(eq(fingerprintToRename.fingerId), anyInt(), safeEq("Woo"))
}
@Test
fun testAuth_succeeds() =
testScope.runTest {
val fingerprint = Fingerprint("Woooo", 100, 101L)
var result: FingerprintAuthAttemptViewModel? = null
val job = launch { result = underTest.authenticate() }
val authCallback: ArgumentCaptor<FingerprintManager.AuthenticationCallback> =
ArgumentCaptor.forClass(FingerprintManager.AuthenticationCallback::class.java)
runCurrent()
Mockito.verify(fingerprintManager)
.authenticate(
nullable(CryptoObject::class.java),
any(CancellationSignal::class.java),
capture(authCallback),
nullable(Handler::class.java),
anyInt()
)
authCallback.value.onAuthenticationSucceeded(
FingerprintManager.AuthenticationResult(null, fingerprint, 1, false)
)
runCurrent()
job.cancelAndJoin()
assertThat(result).isEqualTo(FingerprintAuthAttemptViewModel.Success(fingerprint.biometricId))
}
@Test
fun testAuth_lockout() =
testScope.runTest {
var result: FingerprintAuthAttemptViewModel? = null
val job = launch { result = underTest.authenticate() }
val authCallback: ArgumentCaptor<FingerprintManager.AuthenticationCallback> =
ArgumentCaptor.forClass(FingerprintManager.AuthenticationCallback::class.java)
runCurrent()
Mockito.verify(fingerprintManager)
.authenticate(
nullable(CryptoObject::class.java),
any(CancellationSignal::class.java),
capture(authCallback),
nullable(Handler::class.java),
anyInt()
)
authCallback.value.onAuthenticationError(FINGERPRINT_ERROR_LOCKOUT_PERMANENT, "Lockout!!")
runCurrent()
job.cancelAndJoin()
assertThat(result)
.isEqualTo(
FingerprintAuthAttemptViewModel.Error(FINGERPRINT_ERROR_LOCKOUT_PERMANENT, "Lockout!!")
)
}
private fun <T : Any> safeEq(value: T): T = eq(value) ?: value
private fun <T> capture(argumentCaptor: ArgumentCaptor<T>): T = argumentCaptor.capture()
private fun <T> any(type: Class<T>): T = Mockito.any<T>(type)
}

View File

@@ -0,0 +1,275 @@
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.settings.fingerprint2.viewmodel
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.EnrollFirstFingerprint
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintSettingsNavigationViewModel
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintViewModel
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FinishSettings
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FinishSettingsWithResult
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.LaunchConfirmDeviceCredential
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.NextStepViewModel
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.ShowSettings
import com.android.settings.fingerprint2.domain.interactor.FakeFingerprintManagerInteractor
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.junit.MockitoJUnit
import org.mockito.junit.MockitoJUnitRunner
@RunWith(MockitoJUnitRunner::class)
class FingerprintSettingsNavigationViewModelTest {
@JvmField @Rule var rule = MockitoJUnit.rule()
@get:Rule val instantTaskRule = InstantTaskExecutorRule()
private lateinit var underTest: FingerprintSettingsNavigationViewModel
private val defaultUserId = 0
private var backgroundDispatcher = StandardTestDispatcher()
private var testScope = TestScope(backgroundDispatcher)
private lateinit var fakeFingerprintManagerInteractor: FakeFingerprintManagerInteractor
@Before
fun setup() {
fakeFingerprintManagerInteractor = FakeFingerprintManagerInteractor()
backgroundDispatcher = StandardTestDispatcher()
testScope = TestScope(backgroundDispatcher)
Dispatchers.setMain(backgroundDispatcher)
underTest =
FingerprintSettingsNavigationViewModel.FingerprintSettingsNavigationModelFactory(
defaultUserId,
fakeFingerprintManagerInteractor,
backgroundDispatcher,
null,
null,
)
.create(FingerprintSettingsNavigationViewModel::class.java)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun testNoGateKeeper_launchesConfirmDeviceCredential() =
testScope.runTest {
var nextStep: NextStepViewModel? = null
val job = launch { underTest.nextStep.collect { nextStep = it } }
runCurrent()
assertThat(nextStep).isEqualTo(LaunchConfirmDeviceCredential(defaultUserId))
job.cancel()
}
@Test
fun testConfirmDevice_fails() =
testScope.runTest {
var nextStep: NextStepViewModel? = null
val job = launch { underTest.nextStep.collect { nextStep = it } }
underTest.onConfirmDevice(false, null)
runCurrent()
assertThat(nextStep).isInstanceOf(FinishSettings::class.java)
job.cancel()
}
@Test
fun confirmDeviceSuccess_noGateKeeper() =
testScope.runTest {
var nextStep: NextStepViewModel? = null
val job = launch { underTest.nextStep.collect { nextStep = it } }
underTest.onConfirmDevice(true, null)
runCurrent()
assertThat(nextStep).isInstanceOf(FinishSettings::class.java)
job.cancel()
}
@Test
fun confirmDeviceSuccess_launchesEnrollment_ifNoPreviousEnrollments() =
testScope.runTest {
fakeFingerprintManagerInteractor.enrolledFingerprintsInternal = mutableListOf()
var nextStep: NextStepViewModel? = null
val job = launch { underTest.nextStep.collect { nextStep = it } }
underTest.onConfirmDevice(true, 10L)
runCurrent()
assertThat(nextStep).isEqualTo(EnrollFirstFingerprint(defaultUserId, 10L, null, null))
job.cancel()
}
@Test
fun firstEnrollment_fails() =
testScope.runTest {
fakeFingerprintManagerInteractor.enrolledFingerprintsInternal = mutableListOf()
var nextStep: NextStepViewModel? = null
val job = launch { underTest.nextStep.collect { nextStep = it } }
underTest.onConfirmDevice(true, 10L)
underTest.onEnrollFirstFailure("We failed!!")
runCurrent()
assertThat(nextStep).isInstanceOf(FinishSettings::class.java)
job.cancel()
}
@Test
fun firstEnrollment_failsWithReason() =
testScope.runTest {
fakeFingerprintManagerInteractor.enrolledFingerprintsInternal = mutableListOf()
var nextStep: NextStepViewModel? = null
val job = launch { underTest.nextStep.collect { nextStep = it } }
val failStr = "We failed!!"
val failReason = 101
underTest.onConfirmDevice(true, 10L)
underTest.onEnrollFirstFailure(failStr, failReason)
runCurrent()
assertThat(nextStep).isEqualTo(FinishSettingsWithResult(failReason, failStr))
job.cancel()
}
@Test
fun firstEnrollmentSucceeds_noToken() =
testScope.runTest {
fakeFingerprintManagerInteractor.enrolledFingerprintsInternal = mutableListOf()
var nextStep: NextStepViewModel? = null
val job = launch { underTest.nextStep.collect { nextStep = it } }
underTest.onConfirmDevice(true, 10L)
underTest.onEnrollFirst(null, null)
runCurrent()
assertThat(nextStep).isEqualTo(FinishSettings("Error, empty token"))
job.cancel()
}
@Test
fun firstEnrollmentSucceeds_noKeyChallenge() =
testScope.runTest {
fakeFingerprintManagerInteractor.enrolledFingerprintsInternal = mutableListOf()
var nextStep: NextStepViewModel? = null
val job = launch { underTest.nextStep.collect { nextStep = it } }
val byteArray = ByteArray(1) { 3 }
underTest.onConfirmDevice(true, 10L)
underTest.onEnrollFirst(byteArray, null)
runCurrent()
assertThat(nextStep).isEqualTo(FinishSettings("Error, empty keyChallenge"))
job.cancel()
}
@Test
fun firstEnrollment_succeeds() =
testScope.runTest {
fakeFingerprintManagerInteractor.enrolledFingerprintsInternal = mutableListOf()
var nextStep: NextStepViewModel? = null
val job = testScope.launch { underTest.nextStep.collect { nextStep = it } }
val byteArray = ByteArray(1) { 3 }
val keyChallenge = 89L
underTest.onConfirmDevice(true, 10L)
underTest.onEnrollFirst(byteArray, keyChallenge)
runCurrent()
assertThat(nextStep).isEqualTo(ShowSettings)
job.cancel()
}
@Test
fun enrollAdditionalFingerprints_fails() =
testScope.runTest {
fakeFingerprintManagerInteractor.enrolledFingerprintsInternal =
mutableListOf(FingerprintViewModel("a", 1, 3L))
fakeFingerprintManagerInteractor.challengeToGenerate = Pair(4L, byteArrayOf(3, 3, 1))
var nextStep: NextStepViewModel? = null
val job = launch { underTest.nextStep.collect { nextStep = it } }
underTest.onConfirmDevice(true, 10L)
runCurrent()
underTest.onEnrollAdditionalFailure()
runCurrent()
assertThat(nextStep).isInstanceOf(FinishSettings::class.java)
job.cancel()
}
@Test
fun enrollAdditional_success() =
testScope.runTest {
fakeFingerprintManagerInteractor.enrolledFingerprintsInternal =
mutableListOf(FingerprintViewModel("a", 1, 3L))
var nextStep: NextStepViewModel? = null
val job = launch { underTest.nextStep.collect { nextStep = it } }
underTest.onConfirmDevice(true, 10L)
underTest.onEnrollSuccess()
runCurrent()
assertThat(nextStep).isEqualTo(ShowSettings)
job.cancel()
}
@Test
fun confirmDeviceCredential_withEnrolledFingerprint_showsSettings() =
testScope.runTest {
fakeFingerprintManagerInteractor.enrolledFingerprintsInternal =
mutableListOf(FingerprintViewModel("a", 1, 3L))
fakeFingerprintManagerInteractor.challengeToGenerate = Pair(10L, byteArrayOf(1, 2, 3))
var nextStep: NextStepViewModel? = null
val job = launch { underTest.nextStep.collect { nextStep = it } }
underTest.onConfirmDevice(true, 10L)
runCurrent()
assertThat(nextStep).isEqualTo(ShowSettings)
job.cancel()
}
}

View File

@@ -16,317 +16,232 @@
package com.android.settings.fingerprint2.viewmodel
import android.hardware.fingerprint.Fingerprint
import android.hardware.fingerprint.FingerprintManager
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.EnrollFirstFingerprint
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FinishSettings
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FinishSettingsWithResult
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.LaunchConfirmDeviceCredential
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.NextStepViewModel
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.ShowSettings
import android.hardware.biometrics.SensorProperties
import android.hardware.fingerprint.FingerprintSensorProperties
import android.hardware.fingerprint.FingerprintSensorPropertiesInternal
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintAuthAttemptViewModel
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintSettingsNavigationViewModel
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintSettingsViewModel
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.FingerprintViewModel
import com.android.settings.biometrics.fingerprint2.ui.viewmodel.PreferenceViewModel
import com.android.settings.fingerprint2.domain.interactor.FakeFingerprintManagerInteractor
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.Mockito.anyInt
import org.mockito.junit.MockitoJUnit
import org.mockito.junit.MockitoJUnitRunner
import org.mockito.Mockito.`when` as whenever
@RunWith(MockitoJUnitRunner::class)
class FingerprintSettingsViewModelTest {
@JvmField
@Rule
var rule = MockitoJUnit.rule()
@JvmField @Rule var rule = MockitoJUnit.rule()
@Mock
private lateinit var fingerprintManager: FingerprintManager
private lateinit var underTest: FingerprintSettingsViewModel
private val defaultUserId = 0
@get:Rule val instantTaskRule = InstantTaskExecutorRule()
@Before
fun setup() {
// @formatter:off
underTest = FingerprintSettingsViewModel.FingerprintSettingsViewModelFactory(
private lateinit var underTest: FingerprintSettingsViewModel
private lateinit var navigationViewModel: FingerprintSettingsNavigationViewModel
private val defaultUserId = 0
private var backgroundDispatcher = StandardTestDispatcher()
private var testScope = TestScope(backgroundDispatcher)
private lateinit var fakeFingerprintManagerInteractor: FakeFingerprintManagerInteractor
@Before
fun setup() {
fakeFingerprintManagerInteractor = FakeFingerprintManagerInteractor()
backgroundDispatcher = StandardTestDispatcher()
testScope = TestScope(backgroundDispatcher)
Dispatchers.setMain(backgroundDispatcher)
navigationViewModel =
FingerprintSettingsNavigationViewModel.FingerprintSettingsNavigationModelFactory(
defaultUserId,
fakeFingerprintManagerInteractor,
backgroundDispatcher,
null,
null,
)
.create(FingerprintSettingsNavigationViewModel::class.java)
underTest =
FingerprintSettingsViewModel.FingerprintSettingsViewModelFactory(
defaultUserId,
fakeFingerprintManagerInteractor,
backgroundDispatcher,
navigationViewModel,
)
.create(FingerprintSettingsViewModel::class.java)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun authenticate_DoesNotRun_ifOptical() =
testScope.runTest {
fakeFingerprintManagerInteractor.sensorProps =
listOf(
FingerprintSensorPropertiesInternal(
0 /* sensorId */,
SensorProperties.STRENGTH_STRONG,
5 /* maxEnrollmentsPerUser */,
emptyList() /* ComponentInfoInternal */,
FingerprintSensorProperties.TYPE_UDFPS_OPTICAL,
true /* resetLockoutRequiresHardwareAuthToken */
)
)
fakeFingerprintManagerInteractor.enrolledFingerprintsInternal =
mutableListOf(FingerprintViewModel("a", 1, 3L))
underTest =
FingerprintSettingsViewModel.FingerprintSettingsViewModelFactory(
defaultUserId,
fingerprintManager,
).create(FingerprintSettingsViewModel::class.java)
// @formatter:on
fakeFingerprintManagerInteractor,
backgroundDispatcher,
navigationViewModel,
)
.create(FingerprintSettingsViewModel::class.java)
var authAttempt: FingerprintAuthAttemptViewModel? = null
val job = launch { underTest.authFlow.take(5).collectLatest { authAttempt = it } }
underTest.shouldAuthenticate(true)
// Ensure we are showing settings
navigationViewModel.onConfirmDevice(true, 10L)
runCurrent()
advanceTimeBy(400)
assertThat(authAttempt).isNull()
job.cancel()
}
@Test
fun testNoGateKeeper_launchesConfirmDeviceCredential() = runTest {
var nextStep: NextStepViewModel? = null
val job = launch {
underTest.nextStep.collect {
nextStep = it
}
}
underTest.updateTokenAndChallenge(null, null)
runCurrent()
assertThat(nextStep).isEqualTo(LaunchConfirmDeviceCredential(defaultUserId))
job.cancel()
}
@Test
fun testConfirmDevice_fails() = runTest {
var nextStep: NextStepViewModel? = null
val job = launch {
underTest.nextStep.collect {
nextStep = it
}
}
underTest.updateTokenAndChallenge(null, null)
underTest.onConfirmDevice(false, null)
runCurrent()
assertThat(nextStep).isInstanceOf(FinishSettings::class.java)
job.cancel()
}
@Test
fun confirmDeviceSuccess_noGateKeeper() = runTest {
var nextStep: NextStepViewModel? = null
val job = launch {
underTest.nextStep.collect {
nextStep = it
}
}
underTest.updateTokenAndChallenge(null, null)
underTest.onConfirmDevice(true, null)
runCurrent()
assertThat(nextStep).isInstanceOf(FinishSettings::class.java)
job.cancel()
}
@Test
fun confirmDeviceSuccess_launchesEnrollment_ifNoPreviousEnrollments() = runTest {
whenever(fingerprintManager.getEnrolledFingerprints(anyInt())).thenReturn(emptyList())
var nextStep: NextStepViewModel? = null
val job = launch {
underTest.nextStep.collect {
nextStep = it
}
}
underTest.updateTokenAndChallenge(null, null)
underTest.onConfirmDevice(true, 10L)
runCurrent()
assertThat(nextStep).isEqualTo(EnrollFirstFingerprint(defaultUserId, 10L, null, null))
job.cancel()
}
@Test
fun firstEnrollment_fails() = runTest {
whenever(fingerprintManager.getEnrolledFingerprints(anyInt())).thenReturn(emptyList())
var nextStep: NextStepViewModel? = null
val job = launch {
underTest.nextStep.collect {
nextStep = it
}
}
underTest.updateTokenAndChallenge(null, null)
underTest.onConfirmDevice(true, 10L)
underTest.onEnrollFirstFailure("We failed!!")
runCurrent()
assertThat(nextStep).isInstanceOf(FinishSettings::class.java)
job.cancel()
}
@Test
fun firstEnrollment_failsWithReason() = runTest {
whenever(fingerprintManager.getEnrolledFingerprints(anyInt())).thenReturn(emptyList())
var nextStep: NextStepViewModel? = null
val job = launch {
underTest.nextStep.collect {
nextStep = it
}
}
val failStr = "We failed!!"
val failReason = 101
underTest.updateTokenAndChallenge(null, null)
underTest.onConfirmDevice(true, 10L)
underTest.onEnrollFirstFailure(failStr, failReason)
runCurrent()
assertThat(nextStep).isEqualTo(FinishSettingsWithResult(failReason, failStr))
job.cancel()
}
@Test
fun firstEnrollmentSucceeds_noToken() = runTest {
whenever(fingerprintManager.getEnrolledFingerprints(anyInt())).thenReturn(emptyList())
var nextStep: NextStepViewModel? = null
val job = launch {
underTest.nextStep.collect {
nextStep = it
}
}
underTest.updateTokenAndChallenge(null, null)
underTest.onConfirmDevice(true, 10L)
underTest.onEnrollFirst(null, null)
runCurrent()
assertThat(nextStep).isEqualTo(FinishSettings("Error, empty token"))
job.cancel()
}
@Test
fun firstEnrollmentSucceeds_noKeyChallenge() = runTest {
whenever(fingerprintManager.getEnrolledFingerprints(anyInt())).thenReturn(emptyList())
var nextStep: NextStepViewModel? = null
val job = launch {
underTest.nextStep.collect {
nextStep = it
}
}
val byteArray = ByteArray(1) {
3
}
underTest.updateTokenAndChallenge(null, null)
underTest.onConfirmDevice(true, 10L)
underTest.onEnrollFirst(byteArray, null)
runCurrent()
assertThat(nextStep).isEqualTo(FinishSettings("Error, empty keyChallenge"))
job.cancel()
}
@Test
fun firstEnrollment_succeeds() = runTest {
whenever(fingerprintManager.getEnrolledFingerprints(anyInt())).thenReturn(emptyList())
var nextStep: NextStepViewModel? = null
val job = launch {
underTest.nextStep.collect {
nextStep = it
}
}
val byteArray = ByteArray(1) {
3
}
val keyChallenge = 89L
underTest.updateTokenAndChallenge(null, null)
underTest.onConfirmDevice(true, 10L)
underTest.onEnrollFirst(byteArray, keyChallenge)
runCurrent()
assertThat(nextStep).isEqualTo(ShowSettings(defaultUserId))
job.cancel()
}
@Test
fun confirmDeviceCredential_withEnrolledFingerprint_showsSettings() = runTest {
whenever(fingerprintManager.getEnrolledFingerprints(anyInt())).thenReturn(
listOf(
Fingerprint(
"a", 1, 2, 3L
)
)
@Test
fun authenticate_DoesNotRun_ifUltrasonic() =
testScope.runTest {
fakeFingerprintManagerInteractor.sensorProps =
listOf(
FingerprintSensorPropertiesInternal(
0 /* sensorId */,
SensorProperties.STRENGTH_STRONG,
5 /* maxEnrollmentsPerUser */,
emptyList() /* ComponentInfoInternal */,
FingerprintSensorProperties.TYPE_UDFPS_ULTRASONIC,
true /* resetLockoutRequiresHardwareAuthToken */
)
)
fakeFingerprintManagerInteractor.enrolledFingerprintsInternal =
mutableListOf(FingerprintViewModel("a", 1, 3L))
var nextStep: NextStepViewModel? = null
val job = launch {
underTest.nextStep.collect {
nextStep = it
}
}
underTest =
FingerprintSettingsViewModel.FingerprintSettingsViewModelFactory(
defaultUserId,
fakeFingerprintManagerInteractor,
backgroundDispatcher,
navigationViewModel,
)
.create(FingerprintSettingsViewModel::class.java)
underTest.updateTokenAndChallenge(null, null)
underTest.onConfirmDevice(true, 10L)
var authAttempt: FingerprintAuthAttemptViewModel? = null
val job = launch { underTest.authFlow.take(5).collectLatest { authAttempt = it } }
runCurrent()
underTest.shouldAuthenticate(true)
navigationViewModel.onConfirmDevice(true, 10L)
advanceTimeBy(400)
runCurrent()
assertThat(nextStep).isEqualTo(ShowSettings(defaultUserId))
job.cancel()
assertThat(authAttempt).isNull()
job.cancel()
}
@Test
fun enrollAdditionalFingerprints_fails() = runTest {
whenever(fingerprintManager.getEnrolledFingerprints(anyInt())).thenReturn(
listOf(
Fingerprint(
"a", 1, 2, 3L
)
)
@Test
fun authenticate_DoesRun_ifNotUdfps() =
testScope.runTest {
fakeFingerprintManagerInteractor.sensorProps =
listOf(
FingerprintSensorPropertiesInternal(
0 /* sensorId */,
SensorProperties.STRENGTH_STRONG,
5 /* maxEnrollmentsPerUser */,
emptyList() /* ComponentInfoInternal */,
FingerprintSensorProperties.TYPE_POWER_BUTTON,
true /* resetLockoutRequiresHardwareAuthToken */
)
)
fakeFingerprintManagerInteractor.enrolledFingerprintsInternal =
mutableListOf(FingerprintViewModel("a", 1, 3L))
val success = FingerprintAuthAttemptViewModel.Success(1)
fakeFingerprintManagerInteractor.authenticateAttempt = success
var nextStep: NextStepViewModel? = null
val job = launch {
underTest.nextStep.collect {
nextStep = it
}
}
underTest =
FingerprintSettingsViewModel.FingerprintSettingsViewModelFactory(
defaultUserId,
fakeFingerprintManagerInteractor,
backgroundDispatcher,
navigationViewModel,
)
.create(FingerprintSettingsViewModel::class.java)
underTest.updateTokenAndChallenge(null, null)
underTest.onConfirmDevice(true, 10L)
underTest.onEnrollAdditionalFailure()
var authAttempt: FingerprintAuthAttemptViewModel? = null
runCurrent()
val job = launch { underTest.authFlow.take(5).collectLatest { authAttempt = it } }
underTest.shouldAuthenticate(true)
navigationViewModel.onConfirmDevice(true, 10L)
advanceTimeBy(400)
runCurrent()
assertThat(nextStep).isInstanceOf(FinishSettings::class.java)
job.cancel()
assertThat(authAttempt).isEqualTo(success)
job.cancel()
}
@Test
fun enrollAdditional_success() = runTest {
whenever(fingerprintManager.getEnrolledFingerprints(anyInt())).thenReturn(
listOf(
Fingerprint(
"a", 1, 2, 3L
)
)
@Test
fun deleteDialog_showAndDismiss() = runTest {
val fingerprintToDelete = FingerprintViewModel("A", 1, 10L)
fakeFingerprintManagerInteractor.enrolledFingerprintsInternal = mutableListOf(fingerprintToDelete)
underTest =
FingerprintSettingsViewModel.FingerprintSettingsViewModelFactory(
defaultUserId,
fakeFingerprintManagerInteractor,
backgroundDispatcher,
navigationViewModel,
)
.create(FingerprintSettingsViewModel::class.java)
var nextStep: NextStepViewModel? = null
val job = launch {
underTest.nextStep.collect {
nextStep = it
}
}
var dialog: PreferenceViewModel? = null
val dialogJob = launch { underTest.isShowingDialog.collect { dialog = it } }
underTest.updateTokenAndChallenge(null, null)
underTest.onConfirmDevice(true, 10L)
underTest.onEnrollSuccess()
// Move to the ShowSettings state
navigationViewModel.onConfirmDevice(true, 10L)
runCurrent()
underTest.onDeleteClicked(fingerprintToDelete)
runCurrent()
runCurrent()
assertThat(dialog is PreferenceViewModel.DeleteDialog)
assertThat(dialog).isEqualTo(PreferenceViewModel.DeleteDialog(fingerprintToDelete))
assertThat(nextStep).isEqualTo(ShowSettings(defaultUserId))
job.cancel()
}
}
underTest.deleteFingerprint(fingerprintToDelete)
underTest.onDeleteDialogFinished()
runCurrent()
assertThat(dialog).isNull()
dialogJob.cancel()
}
}