diff --git a/res/xml/security_settings_fingerprint_limbo.xml b/res/xml/security_settings_fingerprint_limbo.xml index b0c06c7f800..02a3dfb8df8 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. --> - \ No newline at end of file + + + + + + + + + + + + + + + + + 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 00000000000..2fbdedfc7e4 --- /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> + + /** Returns the max enrollable fingerprints, note during SUW this might be 1 */ + val maxEnrollableFingerprints: Flow + + /** 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 + + /** Returns true if a user can enroll a fingerprint false otherwise. */ + fun canEnrollFingerprints(numFingerprints: Int): Flow + + /** + * 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 +} + +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 = + 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> = flow { + emit( + fingerprintManager + .getEnrolledFingerprints(applicationContext.userId) + .map { (FingerprintViewModel(it.name.toString(), it.biometricId, it.deviceId)) } + .toList() + ) + } + + override fun canEnrollFingerprints(numFingerprints: Int): Flow = 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 = + suspendCancellableCoroutine { + it.resume(fingerprintManager.sensorPropertiesInternal) + } + + override suspend fun authenticate(): FingerprintAuthAttemptViewModel = + suspendCancellableCoroutine { c: CancellableContinuation -> + 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 00000000000..d9f3e43fa6f --- /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? + } + + 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 d4249ffa127..00000000000 --- 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 00000000000..42e20477acc --- /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 00000000000..e12785d12f0 --- /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(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? { + 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 00000000000..a08b3db5b75 --- /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 { + 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 9b85564fe3e..b82f7c1aec3 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 @@ const val TAG = "FingerprintSettingsV2Fragment" * 3. Renaming a fingerprint * 4. Enabling/Disabling a feature */ -class FingerprintSettingsV2Fragment : DashboardFragment() { - private lateinit var binding: FingerprintViewBinder.Binding +class FingerprintSettingsV2Fragment : + DashboardFragment(), FingerprintSettingsViewBinder.FingerprintView { + private lateinit var settingsViewModel: FingerprintSettingsViewModel + private lateinit var navigationViewModel: FingerprintSettingsNavigationViewModel - private val launchFirstEnrollmentListener = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - - val resultCode = result.resultCode - val data = result.data - - Log.d( - TAG, "onEnrollFirstFingerprint($resultCode, $data)" - ) - if (resultCode != BiometricEnrollBase.RESULT_FINISHED || data == null) { - if (resultCode == BiometricEnrollBase.RESULT_TIMEOUT) { - binding.onEnrollFirstFailure( - "Received RESULT_TIMEOUT when enrolling", resultCode - ) - } else { - binding.onEnrollFirstFailure("Incorrect resultCode or data was null") - } - } else { - val token = - data.getByteArrayExtra(ChooseLockSettingsHelper.EXTRA_KEY_CHALLENGE_TOKEN) - val keyChallenge = data.getExtra(BiometricEnrollBase.EXTRA_KEY_CHALLENGE) as Long? - binding.onEnrollFirst(token, keyChallenge) - } - } - - /** Result listener for launching enrollments **after** a user has reached the settings page. */ - private val launchAdditionalFingerprintListener = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - val resultCode = result.resultCode - Log.d( - TAG, "onEnrollAdditionalFingerprint($resultCode)" - ) - - if (resultCode == BiometricEnrollBase.RESULT_TIMEOUT) { - binding.onEnrollAdditionalFailure() - } else { - binding.onEnrollSuccess() - } - } - - /** Result listener for ChooseLock activity flow. */ - private val confirmDeviceResultListener = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - val resultCode = result.resultCode - val data = result.data - onConfirmDevice(resultCode, data) - } - - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - // This is needed to support ChooseLockSettingBuilder...show(). All other activity - // calls should use the registerForActivity method call. - super.onActivityResult(requestCode, resultCode, data) - val wasSuccessful = - resultCode == BiometricEnrollBase.RESULT_FINISHED || resultCode == Activity.RESULT_OK - val gateKeeperPasswordHandle = - data?.getExtra(ChooseLockSettingsHelper.EXTRA_KEY_GK_PW_HANDLE) as Long? - binding.onConfirmDevice(wasSuccessful, gateKeeperPasswordHandle) + /** Result listener for ChooseLock activity flow. */ + private val confirmDeviceResultListener = + registerForActivityResult(StartActivityForResult()) { result -> + val resultCode = result.resultCode + val data = result.data + onConfirmDevice(resultCode, data) } + /** Result listener for launching enrollments **after** a user has reached the settings page. */ + private val launchAdditionalFingerprintListener: ActivityResultLauncher = + 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 = + 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() + 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? { + 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(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 { + 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 { + val mutable: MutableList = 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 00000000000..a3a5d3c9746 --- /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 = MutableStateFlow(null) + /** This flow represents the high level state for the FingerprintSettingsV2Fragment. */ + val nextStep: StateFlow = _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 create( + modelClass: Class, + ): 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 6cddb24d5eb..554f336a7ff 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 = 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 = MutableStateFlow(false) + private val fingerprintSensorPropertiesInternal: + MutableStateFlow?> = + 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 = 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 = + MutableStateFlow(null) + val fingerprintState: Flow = + _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 = + MutableStateFlow(null) - /** - * Notifies that the first enrollment failed. - */ - fun onEnrollFirstFailure(reason: String) { - launchFinishSettings(reason) - } + private val _authSucceeded: MutableSharedFlow = + MutableSharedFlow() - /** - * Notifies that first enrollment failed (with resultCode) - */ - fun onEnrollFirstFailure(reason: String, resultCode: Int) { - launchFinishSettings(reason, resultCode) - } + private val attemptsSoFar: MutableStateFlow = 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 = + 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 = + 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 create( - modelClass: Class, - ): 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 create( + modelClass: Class, + ): T { + + return FingerprintSettingsViewModel( + userId, + interactor, + backgroundDispatcher, + navigationViewModel, + ) + as T } + } +} + +private inline fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R +): Flow { + 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 00000000000..1df0e34f060 --- /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, + 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 1046f51b7f2..f9dbbffda33 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 00000000000..05764a217f1 --- /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 3a3ca99acfc..1587e0021c3 100644 --- a/tests/unit/Android.bp +++ b/tests/unit/Android.bp @@ -21,6 +21,7 @@ android_test { ], 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 00000000000..0509d8a91b0 --- /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 = mutableListOf() + var challengeToGenerate: Pair = 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 { + return challengeToGenerate + } + override val enrolledFingerprints: Flow> = flow { + emit(enrolledFingerprintsInternal) + } + + override fun canEnrollFingerprints(numFingerprints: Int): Flow = flow { + emit(numFingerprints < enrollableFingerprints) + } + + override val maxEnrollableFingerprints: Flow = 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 = + 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 00000000000..7af740adb2c --- /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 = emptyList() + assertThat(underTest.enrolledFingerprints.last()).isEqualTo(emptyFingerprintList) + } + + @Test + fun testOneFingerprint() = + testScope.runTest { + val expected = Fingerprint("Finger 1,", 2, 3L) + val fingerprintList: List = 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 = + ArgumentCaptor.forClass(FingerprintManager.GenerateChallengeCallback::class.java) + + var result: Pair? = 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 = + 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 = + 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 = + 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 = + 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 safeEq(value: T): T = eq(value) ?: value + private fun capture(argumentCaptor: ArgumentCaptor): T = argumentCaptor.capture() + private fun any(type: Class): T = Mockito.any(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 00000000000..4e1f6b191d4 --- /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 738954339d3..d4308273458 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() + } +}