Implement basic Fingerprint functionality.
Test: Verified enroll/deletion/renaming/authentication flows.
Test: atest FingerprintSettingsViewModelTest
Test: atest FingerprintManagerInteractorTest
Bug: 280862076
Change-Id: Ic34fd89f01f24468d0f769ef0492e742d9330112
diff --git a/res/xml/security_settings_fingerprint_limbo.xml b/res/xml/security_settings_fingerprint_limbo.xml
index b0c06c7..02a3dfb 100644
--- a/res/xml/security_settings_fingerprint_limbo.xml
+++ b/res/xml/security_settings_fingerprint_limbo.xml
@@ -15,4 +15,37 @@
~ limitations under the License.
-->
-<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"/>
\ No newline at end of file
+<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>
+
diff --git a/src/com/android/settings/biometrics/fingerprint2/domain/interactor/FingerprintManagerInteractor.kt b/src/com/android/settings/biometrics/fingerprint2/domain/interactor/FingerprintManagerInteractor.kt
new file mode 100644
index 0000000..2fbdedf
--- /dev/null
+++ b/src/com/android/settings/biometrics/fingerprint2/domain/interactor/FingerprintManagerInteractor.kt
@@ -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
+ )
+ }
+}
diff --git a/src/com/android/settings/biometrics/fingerprint2/ui/binder/FingerprintSettingsViewBinder.kt b/src/com/android/settings/biometrics/fingerprint2/ui/binder/FingerprintSettingsViewBinder.kt
new file mode 100644
index 0000000..d9f3e43
--- /dev/null
+++ b/src/com/android/settings/biometrics/fingerprint2/ui/binder/FingerprintSettingsViewBinder.kt
@@ -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")
+ }
+ }
+ }
+ }
+}
diff --git a/src/com/android/settings/biometrics/fingerprint2/ui/binder/FingerprintViewBinder.kt b/src/com/android/settings/biometrics/fingerprint2/ui/binder/FingerprintViewBinder.kt
deleted file mode 100644
index d4249ff..0000000
--- a/src/com/android/settings/biometrics/fingerprint2/ui/binder/FingerprintViewBinder.kt
+++ /dev/null
@@ -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)
- }
- }
- }
-
-}
\ No newline at end of file
diff --git a/src/com/android/settings/biometrics/fingerprint2/ui/fragment/FingerprintDeletionDialog.kt b/src/com/android/settings/biometrics/fingerprint2/ui/fragment/FingerprintDeletionDialog.kt
new file mode 100644
index 0000000..42e2047
--- /dev/null
+++ b/src/com/android/settings/biometrics/fingerprint2/ui/fragment/FingerprintDeletionDialog.kt
@@ -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())
+ }
+ }
+}
diff --git a/src/com/android/settings/biometrics/fingerprint2/ui/fragment/FingerprintSettingsPreference.kt b/src/com/android/settings/biometrics/fingerprint2/ui/fragment/FingerprintSettingsPreference.kt
new file mode 100644
index 0000000..e12785d
--- /dev/null
+++ b/src/com/android/settings/biometrics/fingerprint2/ui/fragment/FingerprintSettingsPreference.kt
@@ -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)
+ }
+}
diff --git a/src/com/android/settings/biometrics/fingerprint2/ui/fragment/FingerprintSettingsRenameDialog.kt b/src/com/android/settings/biometrics/fingerprint2/ui/fragment/FingerprintSettingsRenameDialog.kt
new file mode 100644
index 0000000..a08b3db
--- /dev/null
+++ b/src/com/android/settings/biometrics/fingerprint2/ui/fragment/FingerprintSettingsRenameDialog.kt
@@ -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()
+ )
+ }
+ }
+}
diff --git a/src/com/android/settings/biometrics/fingerprint2/ui/fragment/FingerprintSettingsV2Fragment.kt b/src/com/android/settings/biometrics/fingerprint2/ui/fragment/FingerprintSettingsV2Fragment.kt
index 9b85564..b82f7c1 100644
--- a/src/com/android/settings/biometrics/fingerprint2/ui/fragment/FingerprintSettingsV2Fragment.kt
+++ b/src/com/android/settings/biometrics/fingerprint2/ui/fragment/FingerprintSettingsV2Fragment.kt
@@ -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 @@
* 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
}
-}
\ No newline at end of file
+ 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()
+ }
+}
diff --git a/src/com/android/settings/biometrics/fingerprint2/ui/viewmodel/FingerprintSettingsNavigationViewModel.kt b/src/com/android/settings/biometrics/fingerprint2/ui/viewmodel/FingerprintSettingsNavigationViewModel.kt
new file mode 100644
index 0000000..a3a5d3c
--- /dev/null
+++ b/src/com/android/settings/biometrics/fingerprint2/ui/viewmodel/FingerprintSettingsNavigationViewModel.kt
@@ -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
+ }
+ }
+}
diff --git a/src/com/android/settings/biometrics/fingerprint2/ui/viewmodel/FingerprintSettingsViewModel.kt b/src/com/android/settings/biometrics/fingerprint2/ui/viewmodel/FingerprintSettingsViewModel.kt
index 6cddb24..554f336 100644
--- a/src/com/android/settings/biometrics/fingerprint2/ui/viewmodel/FingerprintSettingsViewModel.kt
+++ b/src/com/android/settings/biometrics/fingerprint2/ui/viewmodel/FingerprintSettingsViewModel.kt
@@ -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,
+ )
+ }
}
diff --git a/src/com/android/settings/biometrics/fingerprint2/ui/viewmodel/FingerprintViewModel.kt b/src/com/android/settings/biometrics/fingerprint2/ui/viewmodel/FingerprintViewModel.kt
new file mode 100644
index 0000000..1df0e34
--- /dev/null
+++ b/src/com/android/settings/biometrics/fingerprint2/ui/viewmodel/FingerprintViewModel.kt
@@ -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()
+}
diff --git a/src/com/android/settings/biometrics/fingerprint2/ui/viewmodel/NextStepViewModel.kt b/src/com/android/settings/biometrics/fingerprint2/ui/viewmodel/NextStepViewModel.kt
index 1046f51..f9dbbff 100644
--- a/src/com/android/settings/biometrics/fingerprint2/ui/viewmodel/NextStepViewModel.kt
+++ b/src/com/android/settings/biometrics/fingerprint2/ui/viewmodel/NextStepViewModel.kt
@@ -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()
-
diff --git a/src/com/android/settings/biometrics/fingerprint2/ui/viewmodel/PreferenceViewModel.kt b/src/com/android/settings/biometrics/fingerprint2/ui/viewmodel/PreferenceViewModel.kt
new file mode 100644
index 0000000..05764a2
--- /dev/null
+++ b/src/com/android/settings/biometrics/fingerprint2/ui/viewmodel/PreferenceViewModel.kt
@@ -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()
+}
diff --git a/tests/unit/Android.bp b/tests/unit/Android.bp
index 3a3ca99..1587e00 100644
--- a/tests/unit/Android.bp
+++ b/tests/unit/Android.bp
@@ -21,6 +21,7 @@
],
static_libs: [
+ "androidx.arch.core_core-testing",
"androidx.test.core",
"androidx.test.rules",
"androidx.test.espresso.core",
diff --git a/tests/unit/src/com/android/settings/fingerprint2/domain/interactor/FakeFingerprintManagerInteractor.kt b/tests/unit/src/com/android/settings/fingerprint2/domain/interactor/FakeFingerprintManagerInteractor.kt
new file mode 100644
index 0000000..0509d8a
--- /dev/null
+++ b/tests/unit/src/com/android/settings/fingerprint2/domain/interactor/FakeFingerprintManagerInteractor.kt
@@ -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
+}
diff --git a/tests/unit/src/com/android/settings/fingerprint2/domain/interactor/FingerprintManagerInteractorTest.kt b/tests/unit/src/com/android/settings/fingerprint2/domain/interactor/FingerprintManagerInteractorTest.kt
new file mode 100644
index 0000000..7af740a
--- /dev/null
+++ b/tests/unit/src/com/android/settings/fingerprint2/domain/interactor/FingerprintManagerInteractorTest.kt
@@ -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)
+}
diff --git a/tests/unit/src/com/android/settings/fingerprint2/viewmodel/FingerprintSettingsNavigationViewModelTest.kt b/tests/unit/src/com/android/settings/fingerprint2/viewmodel/FingerprintSettingsNavigationViewModelTest.kt
new file mode 100644
index 0000000..4e1f6b1
--- /dev/null
+++ b/tests/unit/src/com/android/settings/fingerprint2/viewmodel/FingerprintSettingsNavigationViewModelTest.kt
@@ -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()
+ }
+}
diff --git a/tests/unit/src/com/android/settings/fingerprint2/viewmodel/FingerprintSettingsViewModelTest.kt b/tests/unit/src/com/android/settings/fingerprint2/viewmodel/FingerprintSettingsViewModelTest.kt
index 7389543..d430827 100644
--- a/tests/unit/src/com/android/settings/fingerprint2/viewmodel/FingerprintSettingsViewModelTest.kt
+++ b/tests/unit/src/com/android/settings/fingerprint2/viewmodel/FingerprintSettingsViewModelTest.kt
@@ -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()
- }
-}
\ No newline at end of file
+ underTest.deleteFingerprint(fingerprintToDelete)
+ underTest.onDeleteDialogFinished()
+ runCurrent()
+
+ assertThat(dialog).isNull()
+
+ dialogJob.cancel()
+ }
+}