From 5a4211ec8291a402bad6e5d7ec1f01e951a9b0c4 Mon Sep 17 00:00:00 2001 From: Joshua McCloskey Date: Tue, 30 May 2023 20:38:24 +0000 Subject: [PATCH 1/6] Implement basic Fingerprint functionality. Test: Verified enroll/deletion/renaming/authentication flows. Test: atest FingerprintSettingsViewModelTest Test: atest FingerprintManagerInteractorTest Bug: 280862076 Change-Id: Ic34fd89f01f24468d0f769ef0492e742d9330112 --- .../security_settings_fingerprint_limbo.xml | 35 +- .../FingerprintManagerInteractor.kt | 207 +++++ .../binder/FingerprintSettingsViewBinder.kt | 177 +++++ .../ui/binder/FingerprintViewBinder.kt | 128 ---- .../ui/fragment/FingerprintDeletionDialog.kt | 119 +++ .../fragment/FingerprintSettingsPreference.kt | 85 +++ .../FingerprintSettingsRenameDialog.kt | 145 ++++ .../fragment/FingerprintSettingsV2Fragment.kt | 705 +++++++++++++----- .../FingerprintSettingsNavigationViewModel.kt | 189 +++++ .../viewmodel/FingerprintSettingsViewModel.kt | 395 ++++++---- .../ui/viewmodel/FingerprintViewModel.kt | 43 ++ .../ui/viewmodel/NextStepViewModel.kt | 29 +- .../ui/viewmodel/PreferenceViewModel.kt | 28 + tests/unit/Android.bp | 1 + .../FakeFingerprintManagerInteractor.kt | 82 ++ .../FingerprintManagerInteractorTest.kt | 287 +++++++ ...gerprintSettingsNavigationViewModelTest.kt | 275 +++++++ .../FingerprintSettingsViewModelTest.kt | 465 +++++------- 18 files changed, 2658 insertions(+), 737 deletions(-) create mode 100644 src/com/android/settings/biometrics/fingerprint2/domain/interactor/FingerprintManagerInteractor.kt create mode 100644 src/com/android/settings/biometrics/fingerprint2/ui/binder/FingerprintSettingsViewBinder.kt delete mode 100644 src/com/android/settings/biometrics/fingerprint2/ui/binder/FingerprintViewBinder.kt create mode 100644 src/com/android/settings/biometrics/fingerprint2/ui/fragment/FingerprintDeletionDialog.kt create mode 100644 src/com/android/settings/biometrics/fingerprint2/ui/fragment/FingerprintSettingsPreference.kt create mode 100644 src/com/android/settings/biometrics/fingerprint2/ui/fragment/FingerprintSettingsRenameDialog.kt create mode 100644 src/com/android/settings/biometrics/fingerprint2/ui/viewmodel/FingerprintSettingsNavigationViewModel.kt create mode 100644 src/com/android/settings/biometrics/fingerprint2/ui/viewmodel/FingerprintViewModel.kt create mode 100644 src/com/android/settings/biometrics/fingerprint2/ui/viewmodel/PreferenceViewModel.kt create mode 100644 tests/unit/src/com/android/settings/fingerprint2/domain/interactor/FakeFingerprintManagerInteractor.kt create mode 100644 tests/unit/src/com/android/settings/fingerprint2/domain/interactor/FingerprintManagerInteractorTest.kt create mode 100644 tests/unit/src/com/android/settings/fingerprint2/viewmodel/FingerprintSettingsNavigationViewModelTest.kt 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() + } +} From 73dcb47ecb679cc291ebbbfaeb0ea54aa7f41b88 Mon Sep 17 00:00:00 2001 From: JW Wang Date: Wed, 28 Jun 2023 14:53:14 +0800 Subject: [PATCH 2/6] Extract common code to SaveChosenLockWorkerBase * Extract common code of ChooseLockPassword.SaveAndFinishWorker and ChooseLockPattern.SaveAndFinishWorker to the parent class. * Make setters return this to make it easy to chain setter calls. * Rename SaveChosenLockWorkerBase to SaveAndFinishWorker. This will make the code changes in the next CL much easier. Bug: 271968977 Bug: 277561275 Test: 1. Add screen lock (password/PIN/pattern) using Settings 2. check screen lock works correctly Change-Id: I98acd25f2dd81ab4608cc6943e4f238070003c17 --- .../settings/password/ChooseLockPassword.java | 54 +------------ .../settings/password/ChooseLockPattern.java | 55 +------------ .../password/ConfirmLockPassword.java | 10 +-- .../settings/password/ConfirmLockPattern.java | 10 +-- ...rkerBase.java => SaveAndFinishWorker.java} | 79 ++++++++++++++----- .../exempt_not_implementing_instrumentable | 3 +- 6 files changed, 79 insertions(+), 132 deletions(-) rename src/com/android/settings/password/{SaveChosenLockWorkerBase.java => SaveAndFinishWorker.java} (57%) diff --git a/src/com/android/settings/password/ChooseLockPassword.java b/src/com/android/settings/password/ChooseLockPassword.java index 16c4f5bb1ab..6d5ce905cff 100644 --- a/src/com/android/settings/password/ChooseLockPassword.java +++ b/src/com/android/settings/password/ChooseLockPassword.java @@ -65,7 +65,6 @@ import android.text.Spannable; import android.text.TextUtils; import android.text.TextWatcher; import android.util.Log; -import android.util.Pair; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.View; @@ -87,7 +86,6 @@ import com.android.internal.widget.LockPatternUtils; import com.android.internal.widget.LockscreenCredential; import com.android.internal.widget.PasswordValidationError; import com.android.internal.widget.TextViewInputDisabler; -import com.android.internal.widget.VerifyCredentialResponse; import com.android.settings.R; import com.android.settings.SettingsActivity; import com.android.settings.SetupWizardUtils; @@ -1009,7 +1007,9 @@ public class ChooseLockPassword extends SettingsActivity { setNextEnabled(false); mSaveAndFinishWorker = new SaveAndFinishWorker(); - mSaveAndFinishWorker.setListener(this); + mSaveAndFinishWorker + .setListener(this) + .setRequestGatekeeperPasswordHandle(mRequestGatekeeperPassword); getFragmentManager().beginTransaction().add(mSaveAndFinishWorker, FRAGMENT_TAG_SAVE_AND_FINISH).commit(); @@ -1029,7 +1029,7 @@ public class ChooseLockPassword extends SettingsActivity { (mAutoPinConfirmOption != null && mAutoPinConfirmOption.isChecked()), mUserId); - mSaveAndFinishWorker.start(mLockPatternUtils, mRequestGatekeeperPassword, + mSaveAndFinishWorker.start(mLockPatternUtils, mChosenPassword, mCurrentCredential, mUserId); } @@ -1082,50 +1082,4 @@ public class ChooseLockPassword extends SettingsActivity { } } } - - public static class SaveAndFinishWorker extends SaveChosenLockWorkerBase { - - private LockscreenCredential mChosenPassword; - private LockscreenCredential mCurrentCredential; - - public void start(LockPatternUtils utils, boolean requestGatekeeperPassword, - LockscreenCredential chosenPassword, LockscreenCredential currentCredential, - int userId) { - prepare(utils, requestGatekeeperPassword, userId); - - mChosenPassword = chosenPassword; - mCurrentCredential = currentCredential != null ? currentCredential - : LockscreenCredential.createNone(); - mUserId = userId; - - start(); - } - - @Override - protected Pair saveAndVerifyInBackground() { - final boolean success = mUtils.setLockCredential( - mChosenPassword, mCurrentCredential, mUserId); - if (success) { - unifyProfileCredentialIfRequested(); - } - Intent result = null; - if (success && mRequestGatekeeperPassword) { - // If a Gatekeeper Password was requested, invoke the LockSettingsService code - // path to return a Gatekeeper Password based on the credential that the user - // chose. This should only be run if the credential was successfully set. - final VerifyCredentialResponse response = mUtils.verifyCredential(mChosenPassword, - mUserId, LockPatternUtils.VERIFY_FLAG_REQUEST_GK_PW_HANDLE); - - if (!response.isMatched() || !response.containsGatekeeperPasswordHandle()) { - Log.e(TAG, "critical: bad response or missing GK PW handle for known good" - + " password: " + response.toString()); - } - - result = new Intent(); - result.putExtra(ChooseLockSettingsHelper.EXTRA_KEY_GK_PW_HANDLE, - response.getGatekeeperPasswordHandle()); - } - return Pair.create(success, result); - } - } } diff --git a/src/com/android/settings/password/ChooseLockPattern.java b/src/com/android/settings/password/ChooseLockPattern.java index a5d04cc4715..e309a606abd 100644 --- a/src/com/android/settings/password/ChooseLockPattern.java +++ b/src/com/android/settings/password/ChooseLockPattern.java @@ -34,7 +34,6 @@ import android.os.Bundle; import android.os.UserHandle; import android.os.UserManager; import android.util.Log; -import android.util.Pair; import android.util.TypedValue; import android.view.KeyEvent; import android.view.LayoutInflater; @@ -53,7 +52,6 @@ import com.android.internal.widget.LockPatternView; import com.android.internal.widget.LockPatternView.Cell; import com.android.internal.widget.LockPatternView.DisplayMode; import com.android.internal.widget.LockscreenCredential; -import com.android.internal.widget.VerifyCredentialResponse; import com.android.settings.R; import com.android.settings.SettingsActivity; import com.android.settings.SetupWizardUtils; @@ -827,7 +825,9 @@ public class ChooseLockPattern extends SettingsActivity { setRightButtonEnabled(false); mSaveAndFinishWorker = new SaveAndFinishWorker(); - mSaveAndFinishWorker.setListener(this); + mSaveAndFinishWorker + .setListener(this) + .setRequestGatekeeperPasswordHandle(mRequestGatekeeperPassword); getFragmentManager().beginTransaction().add(mSaveAndFinishWorker, FRAGMENT_TAG_SAVE_AND_FINISH).commit(); @@ -843,7 +843,7 @@ public class ChooseLockPattern extends SettingsActivity { profileCredential); } } - mSaveAndFinishWorker.start(mLockPatternUtils, mRequestGatekeeperPassword, + mSaveAndFinishWorker.start(mLockPatternUtils, mChosenPattern, mCurrentCredential, mUserId); } @@ -867,51 +867,4 @@ public class ChooseLockPattern extends SettingsActivity { getActivity().finish(); } } - - public static class SaveAndFinishWorker extends SaveChosenLockWorkerBase { - - private LockscreenCredential mChosenPattern; - private LockscreenCredential mCurrentCredential; - - public void start(LockPatternUtils utils, boolean requestGatekeeperPassword, - LockscreenCredential chosenPattern, LockscreenCredential currentCredential, - int userId) { - prepare(utils, requestGatekeeperPassword, userId); - - mCurrentCredential = currentCredential != null ? currentCredential - : LockscreenCredential.createNone(); - mChosenPattern = chosenPattern; - mUserId = userId; - - start(); - } - - @Override - protected Pair saveAndVerifyInBackground() { - final int userId = mUserId; - final boolean success = mUtils.setLockCredential(mChosenPattern, mCurrentCredential, - userId); - if (success) { - unifyProfileCredentialIfRequested(); - } - Intent result = null; - if (success && mRequestGatekeeperPassword) { - // If a Gatekeeper Password was requested, invoke the LockSettingsService code - // path to return a Gatekeeper Password based on the credential that the user - // chose. This should only be run if the credential was successfully set. - final VerifyCredentialResponse response = mUtils.verifyCredential(mChosenPattern, - userId, LockPatternUtils.VERIFY_FLAG_REQUEST_GK_PW_HANDLE); - - if (!response.isMatched() || !response.containsGatekeeperPasswordHandle()) { - Log.e(TAG, "critical: bad response or missing GK PW handle for known good" - + " pattern: " + response.toString()); - } - - result = new Intent(); - result.putExtra(ChooseLockSettingsHelper.EXTRA_KEY_GK_PW_HANDLE, - response.getGatekeeperPasswordHandle()); - } - return Pair.create(success, result); - } - } } diff --git a/src/com/android/settings/password/ConfirmLockPassword.java b/src/com/android/settings/password/ConfirmLockPassword.java index c6022b5d3ce..8d0ff14ae8e 100644 --- a/src/com/android/settings/password/ConfirmLockPassword.java +++ b/src/com/android/settings/password/ConfirmLockPassword.java @@ -125,7 +125,7 @@ public class ConfirmLockPassword extends ConfirmDeviceCredentialBaseActivity { public static class ConfirmLockPasswordFragment extends ConfirmDeviceCredentialBaseFragment implements OnClickListener, OnEditorActionListener, - CredentialCheckResultTracker.Listener, SaveChosenLockWorkerBase.Listener, + CredentialCheckResultTracker.Listener, SaveAndFinishWorker.Listener, RemoteLockscreenValidationFragment.Listener { private static final String FRAGMENT_TAG_CHECK_LOCK_RESULT = "check_lock_result"; private ImeAwareEditText mPasswordEntry; @@ -633,15 +633,15 @@ public class ConfirmLockPassword extends ConfirmDeviceCredentialBaseActivity { if (mCheckBox.isChecked() && mRemoteLockscreenValidationFragment .getLockscreenCredential() != null) { Log.i(TAG, "Setting device screen lock to the other device's screen lock."); - ChooseLockPassword.SaveAndFinishWorker saveAndFinishWorker = - new ChooseLockPassword.SaveAndFinishWorker(); + SaveAndFinishWorker saveAndFinishWorker = new SaveAndFinishWorker(); getFragmentManager().beginTransaction().add(saveAndFinishWorker, null) .commit(); getFragmentManager().executePendingTransactions(); - saveAndFinishWorker.setListener(this); + saveAndFinishWorker + .setListener(this) + .setRequestGatekeeperPasswordHandle(true); saveAndFinishWorker.start( mLockPatternUtils, - /* requestGatekeeperPassword= */ true, mRemoteLockscreenValidationFragment.getLockscreenCredential(), /* currentCredential= */ null, mEffectiveUserId); diff --git a/src/com/android/settings/password/ConfirmLockPattern.java b/src/com/android/settings/password/ConfirmLockPattern.java index a2bcb5af510..ffd7c64c5ea 100644 --- a/src/com/android/settings/password/ConfirmLockPattern.java +++ b/src/com/android/settings/password/ConfirmLockPattern.java @@ -93,7 +93,7 @@ public class ConfirmLockPattern extends ConfirmDeviceCredentialBaseActivity { public static class ConfirmLockPatternFragment extends ConfirmDeviceCredentialBaseFragment implements AppearAnimationCreator, CredentialCheckResultTracker.Listener, - SaveChosenLockWorkerBase.Listener, RemoteLockscreenValidationFragment.Listener { + SaveAndFinishWorker.Listener, RemoteLockscreenValidationFragment.Listener { private static final String FRAGMENT_TAG_CHECK_LOCK_RESULT = "check_lock_result"; @@ -630,15 +630,15 @@ public class ConfirmLockPattern extends ConfirmDeviceCredentialBaseActivity { if (mCheckBox.isChecked() && mRemoteLockscreenValidationFragment .getLockscreenCredential() != null) { Log.i(TAG, "Setting device screen lock to the other device's screen lock."); - ChooseLockPattern.SaveAndFinishWorker saveAndFinishWorker = - new ChooseLockPattern.SaveAndFinishWorker(); + SaveAndFinishWorker saveAndFinishWorker = new SaveAndFinishWorker(); getFragmentManager().beginTransaction().add(saveAndFinishWorker, null) .commit(); getFragmentManager().executePendingTransactions(); - saveAndFinishWorker.setListener(this); + saveAndFinishWorker + .setListener(this) + .setRequestGatekeeperPasswordHandle(true); saveAndFinishWorker.start( mLockPatternUtils, - /* requestGatekeeperPassword= */ true, mRemoteLockscreenValidationFragment.getLockscreenCredential(), /* currentCredential= */ null, mEffectiveUserId); diff --git a/src/com/android/settings/password/SaveChosenLockWorkerBase.java b/src/com/android/settings/password/SaveAndFinishWorker.java similarity index 57% rename from src/com/android/settings/password/SaveChosenLockWorkerBase.java rename to src/com/android/settings/password/SaveAndFinishWorker.java index 48649412a9c..1af3b15d312 100644 --- a/src/com/android/settings/password/SaveChosenLockWorkerBase.java +++ b/src/com/android/settings/password/SaveAndFinishWorker.java @@ -20,6 +20,7 @@ import android.content.Intent; import android.os.AsyncTask; import android.os.Bundle; import android.os.UserHandle; +import android.util.Log; import android.util.Pair; import android.widget.Toast; @@ -27,6 +28,7 @@ import androidx.fragment.app.Fragment; import com.android.internal.widget.LockPatternUtils; import com.android.internal.widget.LockscreenCredential; +import com.android.internal.widget.VerifyCredentialResponse; import com.android.settings.R; import com.android.settings.safetycenter.LockScreenSafetySource; @@ -34,18 +36,21 @@ import com.android.settings.safetycenter.LockScreenSafetySource; * An invisible retained worker fragment to track the AsyncWork that saves (and optionally * verifies if a challenge is given) the chosen lock credential (pattern/pin/password). */ -abstract class SaveChosenLockWorkerBase extends Fragment { +public class SaveAndFinishWorker extends Fragment { + private static final String TAG = "SaveAndFinishWorker"; private Listener mListener; private boolean mFinished; private Intent mResultData; - protected LockPatternUtils mUtils; - protected boolean mRequestGatekeeperPassword; - protected boolean mWasSecureBefore; - protected int mUserId; - protected int mUnificationProfileId = UserHandle.USER_NULL; - protected LockscreenCredential mUnificationProfileCredential; + private LockPatternUtils mUtils; + private boolean mRequestGatekeeperPassword; + private boolean mWasSecureBefore; + private int mUserId; + private int mUnificationProfileId = UserHandle.USER_NULL; + private LockscreenCredential mUnificationProfileCredential; + private LockscreenCredential mChosenCredential; + private LockscreenCredential mCurrentCredential; private boolean mBlocking; @@ -55,28 +60,31 @@ abstract class SaveChosenLockWorkerBase extends Fragment { setRetainInstance(true); } - public void setListener(Listener listener) { + public SaveAndFinishWorker setListener(Listener listener) { if (mListener == listener) { - return; + return this; } mListener = listener; if (mFinished && mListener != null) { mListener.onChosenLockSaveFinished(mWasSecureBefore, mResultData); } + return this; } - protected void prepare(LockPatternUtils utils, boolean requestGatekeeperPassword, int userId) { + public void start(LockPatternUtils utils, LockscreenCredential chosenCredential, + LockscreenCredential currentCredential, int userId) { mUtils = utils; mUserId = userId; - mRequestGatekeeperPassword = requestGatekeeperPassword; // This will be a no-op for non managed profiles. mWasSecureBefore = mUtils.isSecure(mUserId); mFinished = false; mResultData = null; - } - protected void start() { + mChosenCredential = chosenCredential; + mCurrentCredential = currentCredential != null ? currentCredential + : LockscreenCredential.createNone(); + if (mBlocking) { finish(saveAndVerifyInBackground().second); } else { @@ -89,9 +97,34 @@ abstract class SaveChosenLockWorkerBase extends Fragment { * @return pair where the first is a boolean confirming whether the change was successful or not * and second is the Intent which has the challenge token or is null. */ - protected abstract Pair saveAndVerifyInBackground(); + private Pair saveAndVerifyInBackground() { + final int userId = mUserId; + final boolean success = mUtils.setLockCredential(mChosenCredential, mCurrentCredential, + userId); + if (success) { + unifyProfileCredentialIfRequested(); + } + Intent result = null; + if (success && mRequestGatekeeperPassword) { + // If a Gatekeeper Password was requested, invoke the LockSettingsService code + // path to return a Gatekeeper Password based on the credential that the user + // chose. This should only be run if the credential was successfully set. + final VerifyCredentialResponse response = mUtils.verifyCredential(mChosenCredential, + userId, LockPatternUtils.VERIFY_FLAG_REQUEST_GK_PW_HANDLE); - protected void finish(Intent resultData) { + if (!response.isMatched() || !response.containsGatekeeperPasswordHandle()) { + Log.e(TAG, "critical: bad response or missing GK PW handle for known good" + + " credential: " + response.toString()); + } + + result = new Intent(); + result.putExtra(ChooseLockSettingsHelper.EXTRA_KEY_GK_PW_HANDLE, + response.getGatekeeperPasswordHandle()); + } + return Pair.create(success, result); + } + + private void finish(Intent resultData) { mFinished = true; mResultData = resultData; if (mListener != null) { @@ -103,16 +136,24 @@ abstract class SaveChosenLockWorkerBase extends Fragment { LockScreenSafetySource.onLockScreenChange(getContext()); } - public void setBlocking(boolean blocking) { - mBlocking = blocking; + public SaveAndFinishWorker setRequestGatekeeperPasswordHandle(boolean value) { + mRequestGatekeeperPassword = value; + return this; } - public void setProfileToUnify(int profileId, LockscreenCredential credential) { + public SaveAndFinishWorker setBlocking(boolean blocking) { + mBlocking = blocking; + return this; + } + + public SaveAndFinishWorker setProfileToUnify( + int profileId, LockscreenCredential credential) { mUnificationProfileId = profileId; mUnificationProfileCredential = credential.duplicate(); + return this; } - protected void unifyProfileCredentialIfRequested() { + private void unifyProfileCredentialIfRequested() { if (mUnificationProfileId != UserHandle.USER_NULL) { mUtils.setSeparateProfileChallengeEnabled(mUnificationProfileId, false, mUnificationProfileCredential); diff --git a/tests/robotests/assets/exempt_not_implementing_instrumentable b/tests/robotests/assets/exempt_not_implementing_instrumentable index 04ef0ef9594..28e1e7382b1 100644 --- a/tests/robotests/assets/exempt_not_implementing_instrumentable +++ b/tests/robotests/assets/exempt_not_implementing_instrumentable @@ -1,8 +1,7 @@ com.android.settings.deletionhelper.ActivationWarningFragment com.android.settings.applications.appops.AppOpsCategory com.android.settings.CustomListPreference$CustomListPreferenceDialogFragment -com.android.settings.password.ChooseLockPassword$SaveAndFinishWorker -com.android.settings.password.ChooseLockPattern$SaveAndFinishWorker +com.android.settings.password.SaveAndFinishWorker com.android.settings.RestrictedListPreference$RestrictedListPreferenceDialogFragment com.android.settings.password.ConfirmDeviceCredentialBaseFragment$LastTryDialog com.android.settings.password.CredentialCheckResultTracker From 088bb6a35e3d5b27f1fc7d48893cc689bd7a482c Mon Sep 17 00:00:00 2001 From: Zoey Chen Date: Wed, 5 Jul 2023 10:34:53 +0000 Subject: [PATCH 3/6] [Language] Do not back to previous page if dialog is displaying. Bug: 288827218 Test: make RunSettingsRoboTests -j128 ROBOTEST_FILTER=LocaleDialogFragmentTest Change-Id: I914b1e1d96aacf5369e5149f34968ef625548525 --- .../localepicker/LocaleDialogFragment.java | 48 ++++++-- .../LocaleDialogFragmentTest.java | 107 ++++++++++++++++++ 2 files changed, 146 insertions(+), 9 deletions(-) create mode 100644 tests/robotests/src/com/android/settings/localepicker/LocaleDialogFragmentTest.java diff --git a/src/com/android/settings/localepicker/LocaleDialogFragment.java b/src/com/android/settings/localepicker/LocaleDialogFragment.java index f54446af063..6c37e380c77 100644 --- a/src/com/android/settings/localepicker/LocaleDialogFragment.java +++ b/src/com/android/settings/localepicker/LocaleDialogFragment.java @@ -16,6 +16,8 @@ package com.android.settings.localepicker; +import static android.window.OnBackInvokedDispatcher.PRIORITY_DEFAULT; + import android.app.Activity; import android.app.Dialog; import android.app.settings.SettingsEnums; @@ -23,15 +25,17 @@ import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.os.Bundle; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; +import android.window.OnBackInvokedCallback; +import android.window.OnBackInvokedDispatcher; import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; import androidx.appcompat.app.AlertDialog; -import androidx.fragment.app.FragmentManager; import com.android.internal.app.LocaleStore; import com.android.settings.R; @@ -53,6 +57,12 @@ public class LocaleDialogFragment extends InstrumentedDialogFragment { static final String ARG_SHOW_DIALOG = "arg_show_dialog"; private boolean mShouldKeepDialog; + private AlertDialog mAlertDialog; + private OnBackInvokedDispatcher mBackDispatcher; + + private OnBackInvokedCallback mBackCallback = () -> { + Log.d(TAG, "Do not back to previous page if the dialog is displaying."); + }; public static LocaleDialogFragment newInstance() { return new LocaleDialogFragment(); @@ -108,9 +118,15 @@ public class LocaleDialogFragment extends InstrumentedDialogFragment { if (!dialogContent.mNegativeButton.isEmpty()) { builder.setNegativeButton(dialogContent.mNegativeButton, controller); } - AlertDialog alertDialog = builder.create(); - alertDialog.setCanceledOnTouchOutside(false); - return alertDialog; + mAlertDialog = builder.create(); + getOnBackInvokedDispatcher().registerOnBackInvokedCallback(PRIORITY_DEFAULT, mBackCallback); + mAlertDialog.setCanceledOnTouchOutside(false); + mAlertDialog.setOnDismissListener(dialogInterface -> { + mAlertDialog.getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback( + mBackCallback); + }); + + return mAlertDialog; } private static void setDialogTitle(View root, String content) { @@ -129,6 +145,25 @@ public class LocaleDialogFragment extends InstrumentedDialogFragment { textView.setText(content); } + @VisibleForTesting + public OnBackInvokedCallback getBackInvokedCallback() { + return mBackCallback; + } + + @VisibleForTesting + public void setBackDispatcher(OnBackInvokedDispatcher dispatcher) { + mBackDispatcher = dispatcher; + } + + @VisibleForTesting + public OnBackInvokedDispatcher getOnBackInvokedDispatcher() { + if (mBackDispatcher != null) { + return mBackDispatcher; + } else { + return mAlertDialog.getOnBackInvokedDispatcher(); + } + } + @VisibleForTesting LocaleDialogController getLocaleDialogController(Context context, LocaleDialogFragment dialogFragment, LocaleListEditor parentFragment) { @@ -155,11 +190,6 @@ public class LocaleDialogFragment extends InstrumentedDialogFragment { mParent = parentFragment; } - LocaleDialogController(@NonNull LocaleDialogFragment dialogFragment, - LocaleListEditor parent) { - this(dialogFragment.getContext(), dialogFragment, parent); - } - @Override public void onClick(DialogInterface dialog, int which) { if (mDialogType == DIALOG_CONFIRM_SYSTEM_DEFAULT) { diff --git a/tests/robotests/src/com/android/settings/localepicker/LocaleDialogFragmentTest.java b/tests/robotests/src/com/android/settings/localepicker/LocaleDialogFragmentTest.java new file mode 100644 index 00000000000..57f2b01579b --- /dev/null +++ b/tests/robotests/src/com/android/settings/localepicker/LocaleDialogFragmentTest.java @@ -0,0 +1,107 @@ +/* + * 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.localepicker; + +import static com.android.settings.localepicker.LocaleDialogFragment.ARG_DIALOG_TYPE; +import static com.android.settings.localepicker.LocaleDialogFragment.ARG_TARGET_LOCALE; +import static com.android.settings.localepicker.LocaleDialogFragment.DIALOG_CONFIRM_SYSTEM_DEFAULT; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; + +import android.os.Bundle; +import android.window.OnBackInvokedDispatcher; + +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.FragmentActivity; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentTransaction; + +import com.android.internal.app.LocaleStore; +import com.android.settings.testutils.shadow.ShadowAlertDialogCompat; +import com.android.settings.utils.ActivityControllerWrapper; +import com.android.settingslib.core.instrumentation.MetricsFeatureProvider; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +import java.util.Locale; + +@RunWith(RobolectricTestRunner.class) +@Config(shadows = {ShadowAlertDialogCompat.class}) +public class LocaleDialogFragmentTest { + + @Mock + private OnBackInvokedDispatcher mOnBackInvokedDispatcher; + + private FragmentActivity mActivity; + private LocaleDialogFragment mDialogFragment; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + + mActivity = (FragmentActivity) ActivityControllerWrapper.setup( + Robolectric.buildActivity(FragmentActivity.class)).get(); + mDialogFragment = LocaleDialogFragment.newInstance(); + LocaleStore.LocaleInfo localeInfo = LocaleStore.getLocaleInfo(Locale.ENGLISH); + Bundle args = new Bundle(); + args.putInt(ARG_DIALOG_TYPE, DIALOG_CONFIRM_SYSTEM_DEFAULT); + args.putSerializable(ARG_TARGET_LOCALE, localeInfo); + mDialogFragment.setArguments(args); + FragmentManager fragmentManager = mActivity.getSupportFragmentManager(); + FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); + fragmentTransaction.add(mDialogFragment, null); + fragmentTransaction.commit(); + } + + @Test + public void onCreateDialog_onBackInvokedCallbackIsRegistered() { + mDialogFragment.setBackDispatcher(mOnBackInvokedDispatcher); + mDialogFragment.onCreateDialog(null); + + verify(mOnBackInvokedDispatcher).registerOnBackInvokedCallback( + eq(OnBackInvokedDispatcher.PRIORITY_DEFAULT), any()); + } + + @Test + public void onBackInvoked_dialogIsStillDisplaying() { + mDialogFragment.setBackDispatcher(mOnBackInvokedDispatcher); + AlertDialog alertDialog = (AlertDialog) mDialogFragment.onCreateDialog(null); + alertDialog.show(); + assertThat(alertDialog).isNotNull(); + assertThat(alertDialog.isShowing()).isTrue(); + + mOnBackInvokedDispatcher.registerOnBackInvokedCallback( + eq(OnBackInvokedDispatcher.PRIORITY_DEFAULT), any()); + + mDialogFragment.getBackInvokedCallback().onBackInvoked(); + + assertThat(alertDialog.isShowing()).isTrue(); + + } +} From 4900bcfa5c1517d7f794e580422b8bd7f4564448 Mon Sep 17 00:00:00 2001 From: Zoey Chen Date: Fri, 14 Jul 2023 07:47:02 +0000 Subject: [PATCH 4/6] [Language] Should show confirm dialog when deleting the system language and making the second language automatically become the system language Bug: 286508825 Test: make RunSettingsRoboTests -j128 ROBOTEST_FILTER=LocaleListEditorTest Change-Id: I556eecc9eec1ddcdb8947d5ba69d0db2c016e33c --- .../LocaleDragAndDropAdapter.java | 11 ++++-- .../localepicker/LocaleListEditor.java | 35 +++++++++++------ .../localepicker/LocaleListEditorTest.java | 38 +++++++++++++++++++ 3 files changed, 68 insertions(+), 16 deletions(-) diff --git a/src/com/android/settings/localepicker/LocaleDragAndDropAdapter.java b/src/com/android/settings/localepicker/LocaleDragAndDropAdapter.java index 3d7976ab624..f703c83d8a7 100644 --- a/src/com/android/settings/localepicker/LocaleDragAndDropAdapter.java +++ b/src/com/android/settings/localepicker/LocaleDragAndDropAdapter.java @@ -395,10 +395,13 @@ class LocaleDragAndDropAdapter // drag locale's original position to the top. mDragLocale = (LocaleStore.LocaleInfo) savedInstanceState.getSerializable( CFGKEY_DRAG_LOCALE); - mFeedItemList.removeIf( - localeInfo -> TextUtils.equals(localeInfo.getId(), mDragLocale.getId())); - mFeedItemList.add(0, mDragLocale); - notifyItemRangeChanged(0, mFeedItemList.size()); + if (mDragLocale != null) { + mFeedItemList.removeIf( + localeInfo -> TextUtils.equals(localeInfo.getId(), + mDragLocale.getId())); + mFeedItemList.add(0, mDragLocale); + notifyItemRangeChanged(0, mFeedItemList.size()); + } } } } diff --git a/src/com/android/settings/localepicker/LocaleListEditor.java b/src/com/android/settings/localepicker/LocaleListEditor.java index dfdb9428f9c..65563ada1bc 100644 --- a/src/com/android/settings/localepicker/LocaleListEditor.java +++ b/src/com/android/settings/localepicker/LocaleListEditor.java @@ -322,7 +322,13 @@ public class LocaleListEditor extends RestrictedSettingsFragment implements View // to remove. mRemoveMode = false; mShowingRemoveDialog = false; + LocaleStore.LocaleInfo firstLocale = + mAdapter.getFeedItemList().get(0); mAdapter.removeChecked(); + boolean isFirstRemoved = + firstLocale != mAdapter.getFeedItemList().get(0); + showConfirmDialog(isFirstRemoved, isFirstRemoved ? firstLocale + : mAdapter.getFeedItemList().get(0)); setRemoveMode(false); } }) @@ -388,22 +394,27 @@ public class LocaleListEditor extends RestrictedSettingsFragment implements View public boolean onTouch(View v, MotionEvent event) { if (event.getAction() == MotionEvent.ACTION_UP || event.getAction() == MotionEvent.ACTION_CANCEL) { - LocaleStore.LocaleInfo localeInfo = mAdapter.getFeedItemList().get(0); - if (!localeInfo.getLocale().equals(LocalePicker.getLocales().get(0))) { - final LocaleDialogFragment localeDialogFragment = - LocaleDialogFragment.newInstance(); - Bundle args = new Bundle(); - args.putInt(LocaleDialogFragment.ARG_DIALOG_TYPE, DIALOG_CONFIRM_SYSTEM_DEFAULT); - args.putSerializable(LocaleDialogFragment.ARG_TARGET_LOCALE, localeInfo); - localeDialogFragment.setArguments(args); - localeDialogFragment.show(mFragmentManager, TAG_DIALOG_CONFIRM_SYSTEM_DEFAULT); - } else { - mAdapter.doTheUpdate(); - } + showConfirmDialog(false, mAdapter.getFeedItemList().get(0)); } return false; } + private void showConfirmDialog(boolean isFirstRemoved, LocaleStore.LocaleInfo localeInfo) { + Locale currentSystemLocale = LocalePicker.getLocales().get(0); + if (!localeInfo.getLocale().equals(currentSystemLocale)) { + final LocaleDialogFragment localeDialogFragment = + LocaleDialogFragment.newInstance(); + Bundle args = new Bundle(); + args.putInt(LocaleDialogFragment.ARG_DIALOG_TYPE, DIALOG_CONFIRM_SYSTEM_DEFAULT); + args.putSerializable(LocaleDialogFragment.ARG_TARGET_LOCALE, + isFirstRemoved ? LocaleStore.getLocaleInfo(currentSystemLocale) : localeInfo); + localeDialogFragment.setArguments(args); + localeDialogFragment.show(mFragmentManager, TAG_DIALOG_CONFIRM_SYSTEM_DEFAULT); + } else { + mAdapter.doTheUpdate(); + } + } + // Hide the "Remove" menu if there is only one locale in the list, show it otherwise // This is called when the menu is first created, and then one add / remove locale private void updateVisibilityOfRemoveMenu() { diff --git a/tests/robotests/src/com/android/settings/localepicker/LocaleListEditorTest.java b/tests/robotests/src/com/android/settings/localepicker/LocaleListEditorTest.java index 16d51beca64..5a529f832e5 100644 --- a/tests/robotests/src/com/android/settings/localepicker/LocaleListEditorTest.java +++ b/tests/robotests/src/com/android/settings/localepicker/LocaleListEditorTest.java @@ -27,6 +27,7 @@ import static org.mockito.Mockito.when; import android.app.Activity; import android.app.IActivityManager; import android.content.Context; +import android.content.DialogInterface; import android.content.Intent; import android.content.res.Configuration; import android.os.Bundle; @@ -57,6 +58,7 @@ import org.robolectric.RobolectricTestRunner; import org.robolectric.RuntimeEnvironment; import org.robolectric.annotation.Config; import org.robolectric.util.ReflectionHelpers; +import com.android.settingslib.core.instrumentation.MetricsFeatureProvider; import java.util.ArrayList; import java.util.List; @@ -91,6 +93,8 @@ public class LocaleListEditorTest { private View mView; @Mock private IActivityManager mActivityService; + @Mock + private MetricsFeatureProvider mMetricsFeatureProvider; @Before public void setUp() throws Exception { @@ -108,6 +112,8 @@ public class LocaleListEditorTest { RuntimeEnvironment.application.getSystemService(Context.USER_SERVICE)); ReflectionHelpers.setField(mLocaleListEditor, "mAdapter", mAdapter); ReflectionHelpers.setField(mLocaleListEditor, "mFragmentManager", mFragmentManager); + ReflectionHelpers.setField(mLocaleListEditor, "mMetricsFeatureProvider", + mMetricsFeatureProvider); when(mFragmentManager.beginTransaction()).thenReturn(mFragmentTransaction); FakeFeatureFactory.setupForTest(); } @@ -199,6 +205,38 @@ public class LocaleListEditorTest { assertThat(shadowDialog.getMessage()).isNull(); } + @Test + public void showConfirmDialog_systemLocaleSelected_shouldShowLocaleChangeDialog() + throws Exception { + //pre-condition + setUpLocaleConditions(); + final Configuration config = new Configuration(); + config.setLocales((LocaleList.forLanguageTags("zh-TW,en-US"))); + when(mActivityService.getConfiguration()).thenReturn(config); + when(mAdapter.getFeedItemList()).thenReturn(mLocaleList); + when(mAdapter.getCheckedCount()).thenReturn(1); + when(mAdapter.getItemCount()).thenReturn(2); + when(mAdapter.isFirstLocaleChecked()).thenReturn(true); + ReflectionHelpers.setField(mLocaleListEditor, "mRemoveMode", true); + ReflectionHelpers.setField(mLocaleListEditor, "mShowingRemoveDialog", true); + + //launch the first dialog + mLocaleListEditor.showRemoveLocaleWarningDialog(); + + final AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); + + assertThat(dialog).isNotNull(); + + // click the remove button + dialog.getButton(DialogInterface.BUTTON_POSITIVE).performClick(); + + assertThat(dialog.isShowing()).isFalse(); + + // check the second dialog is showing + verify(mFragmentTransaction).add(any(LocaleDialogFragment.class), + eq(TAG_DIALOG_CONFIRM_SYSTEM_DEFAULT)); + } + @Test public void mayAppendUnicodeTags_appendUnicodeTags_success() { LocaleStore.LocaleInfo localeInfo = LocaleStore.fromLocale(Locale.forLanguageTag("en-US")); From 072a0e00687e9e74bc11a22fe3411ef941739419 Mon Sep 17 00:00:00 2001 From: Shawn Lin Date: Mon, 17 Jul 2023 12:20:42 +0000 Subject: [PATCH 5/6] Revert "Fix lock pattern is truncated during SUW in folded state" This reverts commit a2e032bb8efc7df06a443d431ae4640b3f5b16b2. Reason for revert: b/290721507 Change-Id: Ifaaecde6c168ebe22d3b2245df1fd5b0a9c1b8c2 --- .../settings/password/SetupChooseLockPattern.java | 6 ------ .../settings/password/SetupChooseLockPatternTest.java | 9 --------- 2 files changed, 15 deletions(-) diff --git a/src/com/android/settings/password/SetupChooseLockPattern.java b/src/com/android/settings/password/SetupChooseLockPattern.java index 4424b4f6c2d..2cad1813568 100644 --- a/src/com/android/settings/password/SetupChooseLockPattern.java +++ b/src/com/android/settings/password/SetupChooseLockPattern.java @@ -90,12 +90,6 @@ public class SetupChooseLockPattern extends ChooseLockPattern { } // Show the skip button during SUW but not during Settings > Biometric Enrollment mSkipOrClearButton.setOnClickListener(this::onSkipOrClearButtonClick); - - final View headerView = view.findViewById(R.id.sud_layout_header); - final ViewGroup.MarginLayoutParams lp = - (ViewGroup.MarginLayoutParams) headerView.getLayoutParams(); - lp.bottomMargin = 0; - view.setLayoutParams(lp); return view; } diff --git a/tests/robotests/src/com/android/settings/password/SetupChooseLockPatternTest.java b/tests/robotests/src/com/android/settings/password/SetupChooseLockPatternTest.java index 2f469867d0d..c5e08137863 100644 --- a/tests/robotests/src/com/android/settings/password/SetupChooseLockPatternTest.java +++ b/tests/robotests/src/com/android/settings/password/SetupChooseLockPatternTest.java @@ -28,7 +28,6 @@ import android.content.res.Resources; import android.os.UserHandle; import android.util.TypedValue; import android.view.View; -import android.view.ViewGroup; import android.widget.Button; import android.widget.TextView; @@ -115,14 +114,6 @@ public class SetupChooseLockPatternTest { assertThat(button.getVisibility()).isEqualTo(View.VISIBLE); } - @Test - public void headerView_noBottomMargin() { - final View header = mActivity.findViewById(R.id.sud_layout_header); - final ViewGroup.MarginLayoutParams lp = - (ViewGroup.MarginLayoutParams) header.getLayoutParams(); - assertThat(lp.bottomMargin).isEqualTo(0); - } - private void verifyScreenLockOptionsShown() { final Button button = mActivity.findViewById(R.id.screen_lock_options); assertThat(button).isNotNull(); From c726bd48b44337bab10b0978c890e6d15e212066 Mon Sep 17 00:00:00 2001 From: Chun-Wei Wang Date: Mon, 10 Jul 2023 23:22:32 +0000 Subject: [PATCH 6/6] Handle EXTRA_KEY_REQUEST_WRITE_REPAIR_MODE_PW So the new password can be saved per caller's request. This will remove the additional step to ask the user to enter the new credential again and thus simplifying the UI flow. Bug: 271968977 Bug: 277561275 Test: atest SettingsUnitTests:SaveAndFinishWorkerTest Test: atest ChooseLockPasswordTest Change-Id: I20232619225b17edda0a72dad43b120d5a249203 --- .../settings/password/ChooseLockGeneric.java | 4 + .../settings/password/ChooseLockPassword.java | 7 +- .../settings/password/ChooseLockPattern.java | 7 +- .../password/ChooseLockSettingsHelper.java | 2 + .../password/SaveAndFinishWorker.java | 70 ++++++--- .../password/SaveAndFinishWorkerTest.java | 136 ++++++++++++++++++ 6 files changed, 204 insertions(+), 22 deletions(-) create mode 100644 tests/unit/src/com/android/settings/password/SaveAndFinishWorkerTest.java diff --git a/src/com/android/settings/password/ChooseLockGeneric.java b/src/com/android/settings/password/ChooseLockGeneric.java index 4c4795cbd84..0bf1255b3b9 100644 --- a/src/com/android/settings/password/ChooseLockGeneric.java +++ b/src/com/android/settings/password/ChooseLockGeneric.java @@ -33,6 +33,7 @@ import static com.android.settings.password.ChooseLockSettingsHelper.EXTRA_KEY_C import static com.android.settings.password.ChooseLockSettingsHelper.EXTRA_KEY_DEVICE_PASSWORD_REQUIREMENT_ONLY; import static com.android.settings.password.ChooseLockSettingsHelper.EXTRA_KEY_IS_CALLING_APP_ADMIN; import static com.android.settings.password.ChooseLockSettingsHelper.EXTRA_KEY_REQUESTED_MIN_COMPLEXITY; +import static com.android.settings.password.ChooseLockSettingsHelper.EXTRA_KEY_REQUEST_WRITE_REPAIR_MODE_PW; import android.app.Activity; import android.app.Dialog; @@ -795,6 +796,9 @@ public class ChooseLockGeneric extends SettingsActivity { if (getIntent().getBooleanExtra(EXTRA_SHOW_OPTIONS_BUTTON, false)) { intent.putExtra(EXTRA_SHOW_OPTIONS_BUTTON, chooseLockSkipped); } + if (getIntent().getBooleanExtra(EXTRA_KEY_REQUEST_WRITE_REPAIR_MODE_PW, false)) { + intent.putExtra(EXTRA_KEY_REQUEST_WRITE_REPAIR_MODE_PW, true); + } intent.putExtra(EXTRA_CHOOSE_LOCK_GENERIC_EXTRAS, getIntent().getExtras()); // If the caller requested Gatekeeper Password Handle to be returned, we assume it // came from biometric enrollment. onActivityResult will put the LockSettingsService diff --git a/src/com/android/settings/password/ChooseLockPassword.java b/src/com/android/settings/password/ChooseLockPassword.java index 6d5ce905cff..f8f4345f893 100644 --- a/src/com/android/settings/password/ChooseLockPassword.java +++ b/src/com/android/settings/password/ChooseLockPassword.java @@ -232,6 +232,7 @@ public class ChooseLockPassword extends SettingsActivity { private LockscreenCredential mCurrentCredential; private LockscreenCredential mChosenPassword; private boolean mRequestGatekeeperPassword; + private boolean mRequestWriteRepairModePassword; private ImeAwareEditText mPasswordEntry; private TextViewInputDisabler mPasswordEntryInputDisabler; @@ -559,6 +560,8 @@ public class ChooseLockPassword extends SettingsActivity { ChooseLockSettingsHelper.EXTRA_KEY_PASSWORD); mRequestGatekeeperPassword = intent.getBooleanExtra( ChooseLockSettingsHelper.EXTRA_KEY_REQUEST_GK_PW_HANDLE, false); + mRequestWriteRepairModePassword = intent.getBooleanExtra( + ChooseLockSettingsHelper.EXTRA_KEY_REQUEST_WRITE_REPAIR_MODE_PW, false); if (savedInstanceState == null) { updateStage(Stage.Introduction); if (confirmCredentials) { @@ -568,6 +571,7 @@ public class ChooseLockPassword extends SettingsActivity { .setTitle(getString(R.string.unlock_set_unlock_launch_picker_title)) .setReturnCredentials(true) .setRequestGatekeeperPasswordHandle(mRequestGatekeeperPassword) + .setRequestWriteRepairModePassword(mRequestWriteRepairModePassword) .setUserId(mUserId) .show(); } @@ -1009,7 +1013,8 @@ public class ChooseLockPassword extends SettingsActivity { mSaveAndFinishWorker = new SaveAndFinishWorker(); mSaveAndFinishWorker .setListener(this) - .setRequestGatekeeperPasswordHandle(mRequestGatekeeperPassword); + .setRequestGatekeeperPasswordHandle(mRequestGatekeeperPassword) + .setRequestWriteRepairModePassword(mRequestWriteRepairModePassword); getFragmentManager().beginTransaction().add(mSaveAndFinishWorker, FRAGMENT_TAG_SAVE_AND_FINISH).commit(); diff --git a/src/com/android/settings/password/ChooseLockPattern.java b/src/com/android/settings/password/ChooseLockPattern.java index e309a606abd..7569c1596ec 100644 --- a/src/com/android/settings/password/ChooseLockPattern.java +++ b/src/com/android/settings/password/ChooseLockPattern.java @@ -204,6 +204,7 @@ public class ChooseLockPattern extends SettingsActivity { private LockscreenCredential mCurrentCredential; private boolean mRequestGatekeeperPassword; + private boolean mRequestWriteRepairModePassword; protected TextView mHeaderText; protected LockPatternView mLockPatternView; protected TextView mFooterText; @@ -561,6 +562,8 @@ public class ChooseLockPattern extends SettingsActivity { intent.getParcelableExtra(ChooseLockSettingsHelper.EXTRA_KEY_PASSWORD); mRequestGatekeeperPassword = intent.getBooleanExtra( ChooseLockSettingsHelper.EXTRA_KEY_REQUEST_GK_PW_HANDLE, false); + mRequestWriteRepairModePassword = intent.getBooleanExtra( + ChooseLockSettingsHelper.EXTRA_KEY_REQUEST_WRITE_REPAIR_MODE_PW, false); if (savedInstanceState == null) { if (confirmCredentials) { @@ -574,6 +577,7 @@ public class ChooseLockPattern extends SettingsActivity { .setTitle(getString(R.string.unlock_set_unlock_launch_picker_title)) .setReturnCredentials(true) .setRequestGatekeeperPasswordHandle(mRequestGatekeeperPassword) + .setRequestWriteRepairModePassword(mRequestWriteRepairModePassword) .setUserId(mUserId) .show(); @@ -827,7 +831,8 @@ public class ChooseLockPattern extends SettingsActivity { mSaveAndFinishWorker = new SaveAndFinishWorker(); mSaveAndFinishWorker .setListener(this) - .setRequestGatekeeperPasswordHandle(mRequestGatekeeperPassword); + .setRequestGatekeeperPasswordHandle(mRequestGatekeeperPassword) + .setRequestWriteRepairModePassword(mRequestWriteRepairModePassword); getFragmentManager().beginTransaction().add(mSaveAndFinishWorker, FRAGMENT_TAG_SAVE_AND_FINISH).commit(); diff --git a/src/com/android/settings/password/ChooseLockSettingsHelper.java b/src/com/android/settings/password/ChooseLockSettingsHelper.java index 9533314c8a2..e5fc5507815 100644 --- a/src/com/android/settings/password/ChooseLockSettingsHelper.java +++ b/src/com/android/settings/password/ChooseLockSettingsHelper.java @@ -73,6 +73,8 @@ public final class ChooseLockSettingsHelper { public static final String EXTRA_KEY_GK_PW_HANDLE = "gk_pw_handle"; public static final String EXTRA_KEY_REQUEST_WRITE_REPAIR_MODE_PW = "request_write_repair_mode_pw"; + public static final String EXTRA_KEY_WROTE_REPAIR_MODE_CREDENTIAL = + "wrote_repair_mode_credential"; /** * When EXTRA_KEY_UNIFICATION_PROFILE_CREDENTIAL and EXTRA_KEY_UNIFICATION_PROFILE_ID are diff --git a/src/com/android/settings/password/SaveAndFinishWorker.java b/src/com/android/settings/password/SaveAndFinishWorker.java index 1af3b15d312..df679e5f6cd 100644 --- a/src/com/android/settings/password/SaveAndFinishWorker.java +++ b/src/com/android/settings/password/SaveAndFinishWorker.java @@ -24,6 +24,7 @@ import android.util.Log; import android.util.Pair; import android.widget.Toast; +import androidx.annotation.VisibleForTesting; import androidx.fragment.app.Fragment; import com.android.internal.widget.LockPatternUtils; @@ -45,6 +46,7 @@ public class SaveAndFinishWorker extends Fragment { private LockPatternUtils mUtils; private boolean mRequestGatekeeperPassword; + private boolean mRequestWriteRepairModePassword; private boolean mWasSecureBefore; private int mUserId; private int mUnificationProfileId = UserHandle.USER_NULL; @@ -72,7 +74,8 @@ public class SaveAndFinishWorker extends Fragment { return this; } - public void start(LockPatternUtils utils, LockscreenCredential chosenCredential, + @VisibleForTesting + void prepare(LockPatternUtils utils, LockscreenCredential chosenCredential, LockscreenCredential currentCredential, int userId) { mUtils = utils; mUserId = userId; @@ -84,7 +87,11 @@ public class SaveAndFinishWorker extends Fragment { mChosenCredential = chosenCredential; mCurrentCredential = currentCredential != null ? currentCredential : LockscreenCredential.createNone(); + } + public void start(LockPatternUtils utils, LockscreenCredential chosenCredential, + LockscreenCredential currentCredential, int userId) { + prepare(utils, chosenCredential, currentCredential, userId); if (mBlocking) { finish(saveAndVerifyInBackground().second); } else { @@ -97,31 +104,49 @@ public class SaveAndFinishWorker extends Fragment { * @return pair where the first is a boolean confirming whether the change was successful or not * and second is the Intent which has the challenge token or is null. */ - private Pair saveAndVerifyInBackground() { + @VisibleForTesting + Pair saveAndVerifyInBackground() { final int userId = mUserId; - final boolean success = mUtils.setLockCredential(mChosenCredential, mCurrentCredential, - userId); - if (success) { - unifyProfileCredentialIfRequested(); + if (!mUtils.setLockCredential(mChosenCredential, mCurrentCredential, userId)) { + return Pair.create(false, null); } - Intent result = null; - if (success && mRequestGatekeeperPassword) { + + unifyProfileCredentialIfRequested(); + + @LockPatternUtils.VerifyFlag int flags = 0; + if (mRequestGatekeeperPassword) { // If a Gatekeeper Password was requested, invoke the LockSettingsService code // path to return a Gatekeeper Password based on the credential that the user // chose. This should only be run if the credential was successfully set. - final VerifyCredentialResponse response = mUtils.verifyCredential(mChosenCredential, - userId, LockPatternUtils.VERIFY_FLAG_REQUEST_GK_PW_HANDLE); - - if (!response.isMatched() || !response.containsGatekeeperPasswordHandle()) { - Log.e(TAG, "critical: bad response or missing GK PW handle for known good" - + " credential: " + response.toString()); - } - - result = new Intent(); - result.putExtra(ChooseLockSettingsHelper.EXTRA_KEY_GK_PW_HANDLE, - response.getGatekeeperPasswordHandle()); + flags |= LockPatternUtils.VERIFY_FLAG_REQUEST_GK_PW_HANDLE; } - return Pair.create(success, result); + if (mRequestWriteRepairModePassword) { + flags |= LockPatternUtils.VERIFY_FLAG_WRITE_REPAIR_MODE_PW; + } + if (flags == 0) { + return Pair.create(true, null); + } + + Intent result = new Intent(); + final VerifyCredentialResponse response = mUtils.verifyCredential(mChosenCredential, + userId, flags); + if (response.isMatched()) { + if (mRequestGatekeeperPassword && response.containsGatekeeperPasswordHandle()) { + result.putExtra(ChooseLockSettingsHelper.EXTRA_KEY_GK_PW_HANDLE, + response.getGatekeeperPasswordHandle()); + } else if (mRequestGatekeeperPassword) { + Log.e(TAG, "critical: missing GK PW handle for known good credential: " + response); + } + } else { + Log.e(TAG, "critical: bad response for known good credential: " + response); + } + if (mRequestWriteRepairModePassword) { + // Notify the caller if repair mode credential is saved successfully + result.putExtra(ChooseLockSettingsHelper.EXTRA_KEY_WROTE_REPAIR_MODE_CREDENTIAL, + response.isMatched()); + } + + return Pair.create(true, result); } private void finish(Intent resultData) { @@ -141,6 +166,11 @@ public class SaveAndFinishWorker extends Fragment { return this; } + public SaveAndFinishWorker setRequestWriteRepairModePassword(boolean value) { + mRequestWriteRepairModePassword = value; + return this; + } + public SaveAndFinishWorker setBlocking(boolean blocking) { mBlocking = blocking; return this; diff --git a/tests/unit/src/com/android/settings/password/SaveAndFinishWorkerTest.java b/tests/unit/src/com/android/settings/password/SaveAndFinishWorkerTest.java new file mode 100644 index 00000000000..88e31508155 --- /dev/null +++ b/tests/unit/src/com/android/settings/password/SaveAndFinishWorkerTest.java @@ -0,0 +1,136 @@ +/* + * 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.password; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import com.android.internal.widget.LockPatternUtils; +import com.android.internal.widget.LockscreenCredential; +import com.android.internal.widget.VerifyCredentialResponse; + +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class SaveAndFinishWorkerTest { + @Test + public void testSetRequestWriteRepairModePassword_setLockCredentialFail() { + int userId = 0; + int flags = LockPatternUtils.VERIFY_FLAG_WRITE_REPAIR_MODE_PW; + var chosenCredential = LockscreenCredential.createPassword("1234"); + var currentCredential = LockscreenCredential.createNone(); + var worker = new SaveAndFinishWorker(); + var lpu = mock(LockPatternUtils.class); + + when(lpu.setLockCredential(chosenCredential, currentCredential, userId)).thenReturn(false); + + worker.setRequestWriteRepairModePassword(true); + worker.prepare(lpu, chosenCredential, currentCredential, userId); + var result = worker.saveAndVerifyInBackground(); + + verify(lpu).setLockCredential(chosenCredential, currentCredential, userId); + verify(lpu, never()).verifyCredential(chosenCredential, userId, flags); + assertThat(result.first).isFalse(); + } + + @Test + public void testSetRequestWriteRepairModePassword_verifyCredentialFail() { + int userId = 0; + int flags = LockPatternUtils.VERIFY_FLAG_WRITE_REPAIR_MODE_PW; + var chosenCredential = LockscreenCredential.createPassword("1234"); + var currentCredential = LockscreenCredential.createNone(); + var worker = new SaveAndFinishWorker(); + var lpu = mock(LockPatternUtils.class); + var response = VerifyCredentialResponse.fromError(); + + when(lpu.setLockCredential(chosenCredential, currentCredential, userId)).thenReturn(true); + when(lpu.verifyCredential(chosenCredential, userId, flags)).thenReturn(response); + + worker.setRequestWriteRepairModePassword(true); + worker.prepare(lpu, chosenCredential, currentCredential, userId); + var result = worker.saveAndVerifyInBackground(); + + verify(lpu).setLockCredential(chosenCredential, currentCredential, userId); + verify(lpu).verifyCredential(chosenCredential, userId, flags); + assertThat(result.first).isTrue(); + assertThat(result.second.getBooleanExtra( + ChooseLockSettingsHelper.EXTRA_KEY_WROTE_REPAIR_MODE_CREDENTIAL, true)) + .isFalse(); + } + + @Test + public void testSetRequestWriteRepairModePassword_verifyCredentialSucceed() { + int userId = 0; + int flags = LockPatternUtils.VERIFY_FLAG_WRITE_REPAIR_MODE_PW; + var chosenCredential = LockscreenCredential.createPassword("1234"); + var currentCredential = LockscreenCredential.createNone(); + var worker = new SaveAndFinishWorker(); + var lpu = mock(LockPatternUtils.class); + var response = new VerifyCredentialResponse.Builder().build(); + + when(lpu.setLockCredential(chosenCredential, currentCredential, userId)).thenReturn(true); + when(lpu.verifyCredential(chosenCredential, userId, flags)).thenReturn(response); + + worker.setRequestWriteRepairModePassword(true); + worker.prepare(lpu, chosenCredential, currentCredential, userId); + var result = worker.saveAndVerifyInBackground(); + + verify(lpu).setLockCredential(chosenCredential, currentCredential, userId); + verify(lpu).verifyCredential(chosenCredential, userId, flags); + assertThat(result.first).isTrue(); + assertThat(result.second.getBooleanExtra( + ChooseLockSettingsHelper.EXTRA_KEY_WROTE_REPAIR_MODE_CREDENTIAL, false)) + .isTrue(); + } + + @Test + public void testSetRequestWriteRepairModePassword_verifyCredentialSucceed_noGkPwHandle() { + int userId = 0; + int flags = LockPatternUtils.VERIFY_FLAG_WRITE_REPAIR_MODE_PW + | LockPatternUtils.VERIFY_FLAG_REQUEST_GK_PW_HANDLE; + var chosenCredential = LockscreenCredential.createPassword("1234"); + var currentCredential = LockscreenCredential.createNone(); + var worker = new SaveAndFinishWorker(); + var lpu = mock(LockPatternUtils.class); + var response = new VerifyCredentialResponse.Builder().build(); + + when(lpu.setLockCredential(chosenCredential, currentCredential, userId)).thenReturn(true); + when(lpu.verifyCredential(chosenCredential, userId, flags)).thenReturn(response); + + worker.setRequestWriteRepairModePassword(true); + worker.setRequestGatekeeperPasswordHandle(true); + worker.prepare(lpu, chosenCredential, currentCredential, userId); + var result = worker.saveAndVerifyInBackground(); + + verify(lpu).setLockCredential(chosenCredential, currentCredential, userId); + verify(lpu).verifyCredential(chosenCredential, userId, flags); + assertThat(result.first).isTrue(); + assertThat(result.second.getBooleanExtra( + ChooseLockSettingsHelper.EXTRA_KEY_WROTE_REPAIR_MODE_CREDENTIAL, false)) + .isTrue(); + assertThat(result.second.getLongExtra( + ChooseLockSettingsHelper.EXTRA_KEY_GK_PW_HANDLE, -1)) + .isEqualTo(-1); + } +}