For #18264 - Add biometric prompt to credit card settings (#19505)

This commit is contained in:
Elise Richards 2021-05-27 02:45:35 -05:00 committed by GitHub
parent bca8b74263
commit 279d59897c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 311 additions and 51 deletions

View File

@ -60,15 +60,29 @@ fun Fragment.hideToolbar() {
}
/**
* Pops the backstack to force users to re-auth if they put the app in the background and return to it
* while being inside the saved logins flow
*
* Does nothing if the user is currently navigating to any of the [destinations] given as a parameter
* Pops the backstack to force users to re-auth if they put the app in the background and return to
* it while being inside a secured flow (e.g. logins or credit cards).
*
* Does nothing if the user is currently navigating to any of the [destinations] given as a
* parameter.
*/
fun Fragment.redirectToReAuth(destinations: List<Int>, currentDestination: Int?) {
fun Fragment.redirectToReAuth(
destinations: List<Int>,
currentDestination: Int?,
currentLocation: Int
) {
if (currentDestination !in destinations) {
findNavController().popBackStack(R.id.savedLoginsAuthFragment, false)
when (currentLocation) {
R.id.loginDetailFragment,
R.id.editLoginFragment,
R.id.savedLoginsFragment -> {
findNavController().popBackStack(R.id.savedLoginsAuthFragment, false)
}
R.id.creditCardEditorFragment,
R.id.creditCardsManagementFragment -> {
findNavController().popBackStack(R.id.creditCardsSettingFragment, false)
}
}
}
}

View File

@ -0,0 +1,120 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.biometric
import android.app.Activity.RESULT_OK
import android.app.KeyguardManager
import android.content.Context
import android.content.Intent
import android.view.View
import androidx.core.content.getSystemService
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.settings.requirePreference
/**
* Helper for creating and implementing the [BiometricPromptFeature]. Currently used
* for logins and credit cards.
*/
abstract class BiometricPromptPreferenceFragment : PreferenceFragmentCompat() {
private val biometricPromptFeature = ViewBoundFeatureWrapper<BiometricPromptFeature>()
/**
* Gets the string to be used for [BiometricPromptFeature.requestAuthentication] prompting to
* unlock the device.
*/
abstract fun unlockMessage(): String
/**
* Navigate when authentication is successful.
*/
abstract fun navigateOnSuccess()
/**
* Shows a dialog warning to set up a pin/password when the device is not secured. This is
* only used when BiometricPrompt is unavailable on the device.
*/
abstract fun showPinDialogWarning(context: Context)
/**
* Toggle preferences to enable or disable navigation during authentication flows.
*
* @param prefList a list of [Preference]s to toggle.
* @param enabled whether or not the preferences should be enabled.
*/
fun togglePrefsEnabled(prefList: List<Int>, enabled: Boolean) {
for (preference in prefList) {
requirePreference<Preference>(preference).isEnabled = enabled
}
}
/**
* Creates a prompt to verify the device's pin/password and start activity based on the result.
* This is only used when BiometricPrompt is unavailable on the device.
*/
@Suppress("Deprecation")
abstract fun showPinVerification(manager: KeyguardManager)
/**
* Sets the biometric prompt feature.
*
* @param view the view that the prompt will be associate with.
* @param prefList a list of [Preference]s to toggle.
*/
fun setBiometricPrompt(view: View, prefList: List<Int>) {
biometricPromptFeature.set(
feature = BiometricPromptFeature(
context = requireContext(),
fragment = this,
onAuthFailure = {
togglePrefsEnabled(prefList, true)
},
onAuthSuccess = ::navigateOnSuccess
),
owner = this,
view = view
)
}
/**
* Use [BiometricPromptFeature] or [KeyguardManager] to confirm device security.
*
* @param prefList a list of [Preference]s to disable while authentication is happening.
*/
fun verifyCredentialsOrShowSetupWarning(context: Context, prefList: List<Int>) {
// Use the BiometricPrompt if available
if (BiometricPromptFeature.canUseFeature(context)) {
togglePrefsEnabled(prefList, false)
biometricPromptFeature.get()?.requestAuthentication(unlockMessage())
return
}
// Fallback to prompting for password with the KeyguardManager
val manager = context.getSystemService<KeyguardManager>()
if (manager?.isKeyguardSecure == true) {
showPinVerification(manager)
} else {
// Warn that the device has not been secured
if (context.settings().shouldShowSecurityPinWarning) {
showPinDialogWarning(context)
} else {
navigateOnSuccess()
}
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == PIN_REQUEST && resultCode == RESULT_OK) {
navigateOnSuccess()
}
}
companion object {
const val PIN_REQUEST = 303
}
}

View File

@ -15,6 +15,7 @@ import androidx.navigation.fragment.navArgs
import org.mozilla.fenix.R
import org.mozilla.fenix.SecureFragment
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.redirectToReAuth
import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.settings.creditcards.controller.DefaultCreditCardEditorController
import org.mozilla.fenix.settings.creditcards.interactor.CreditCardEditorInteractor
@ -28,6 +29,8 @@ class CreditCardEditorFragment : SecureFragment(R.layout.fragment_credit_card_ed
private lateinit var creditCardEditorState: CreditCardEditorState
private lateinit var creditCardEditorView: CreditCardEditorView
private lateinit var menu: Menu
private val args by navArgs<CreditCardEditorFragmentArgs>()
/**
@ -64,8 +67,25 @@ class CreditCardEditorFragment : SecureFragment(R.layout.fragment_credit_card_ed
creditCardEditorView.bind(creditCardEditorState)
}
/**
* Close any open dialogs or menus and reauthenticate if the fragment is paused and
* the user is not navigating to [CreditCardsManagementFragment].
*/
override fun onPause() {
menu.close()
redirectToReAuth(
listOf(R.id.creditCardsManagementFragment),
findNavController().currentDestination?.id,
R.id.creditCardEditorFragment
)
super.onPause()
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.credit_card_editor, menu)
this.menu = menu
menu.findItem(R.id.delete_credit_card_button).isVisible = isEditing
}

View File

@ -19,6 +19,7 @@ import org.mozilla.fenix.R
import org.mozilla.fenix.SecureFragment
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.redirectToReAuth
import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.settings.creditcards.controller.DefaultCreditCardsManagementController
import org.mozilla.fenix.settings.creditcards.interactor.CreditCardsManagementInteractor
@ -75,6 +76,20 @@ class CreditCardsManagementFragment : SecureFragment() {
showToolbar(getString(R.string.credit_cards_saved_cards))
}
/**
* When the fragment is paused, navigate back to the settings page to reauthenticate.
*/
override fun onPause() {
// Don't redirect if the user is navigating to the credit card editor fragment.
redirectToReAuth(
listOf(R.id.creditCardEditorFragment),
findNavController().currentDestination?.id,
R.id.creditCardsManagementFragment
)
super.onPause()
}
/**
* Fetches all the credit cards from the autofill storage and updates the
* [CreditCardsFragmentStore] with the list of credit cards.

View File

@ -4,41 +4,76 @@
package org.mozilla.fenix.settings.creditcards
import android.app.KeyguardManager
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.os.Bundle
import android.provider.Settings
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreference
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import mozilla.components.lib.state.ext.consumeFrom
import mozilla.components.service.fxa.SyncEngine
import org.mozilla.fenix.NavGraphDirections
import org.mozilla.fenix.R
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.ext.navigateBlockingForAsyncNavGraph
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.runIfFragmentIsAttached
import org.mozilla.fenix.ext.secure
import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.settings.SharedPreferenceUpdater
import org.mozilla.fenix.settings.SyncPreferenceView
import org.mozilla.fenix.settings.biometric.BiometricPromptFeature
import org.mozilla.fenix.settings.biometric.BiometricPromptPreferenceFragment
import org.mozilla.fenix.settings.requirePreference
/**
* "Credit cards" settings fragment displays a list of settings related to autofilling, adding and
* syncing credit cards.
* syncing credit cards. Authentication for saved credit cards uses [BiometricPromptFeature]
* or [KeyguardManager].
*/
class CreditCardsSettingFragment : PreferenceFragmentCompat() {
@SuppressWarnings("TooManyFunctions")
class CreditCardsSettingFragment : BiometricPromptPreferenceFragment() {
private lateinit var creditCardsStore: CreditCardsFragmentStore
private var isCreditCardsListLoaded: Boolean = false
/**
* List of preferences to be enabled or disabled during authentication.
*/
private val creditCardPreferences: List<Int> = listOf(
R.string.pref_key_credit_cards_save_and_autofill_cards,
R.string.pref_key_credit_cards_sync_cards_across_devices,
R.string.pref_key_credit_cards_manage_cards
)
override fun unlockMessage() = getString(R.string.credit_cards_biometric_prompt_message)
override fun navigateOnSuccess() {
runIfFragmentIsAttached {
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) {
// Workaround for likely biometric library bug
// https://github.com/mozilla-mobile/fenix/issues/8438
delay(SHORT_DELAY_MS)
navigateToCreditCardManagementFragment()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
creditCardsStore = StoreProvider.get(this) {
@ -71,6 +106,8 @@ class CreditCardsSettingFragment : PreferenceFragmentCompat() {
consumeFrom(creditCardsStore) { state ->
updateCardManagementPreference(state.creditCards.isNotEmpty(), findNavController())
}
setBiometricPrompt(view, creditCardPreferences)
}
override fun onPause() {
@ -93,16 +130,18 @@ class CreditCardsSettingFragment : PreferenceFragmentCompat() {
loggedInTitle = requireContext()
.getString(R.string.preferences_credit_cards_sync_cards),
onSignInToSyncClicked = {
val directions =
CreditCardsSettingFragmentDirections.actionCreditCardsSettingFragmentToTurnOnSyncFragment()
findNavController().navigateBlockingForAsyncNavGraph(directions)
findNavController().navigateBlockingForAsyncNavGraph(
NavGraphDirections.actionGlobalTurnOnSync()
)
},
onReconnectClicked = {
val directions =
findNavController().navigateBlockingForAsyncNavGraph(
CreditCardsSettingFragmentDirections.actionGlobalAccountProblemFragment()
findNavController().navigateBlockingForAsyncNavGraph(directions)
)
}
)
togglePrefsEnabled(creditCardPreferences, true)
}
/**
@ -116,14 +155,6 @@ class CreditCardsSettingFragment : PreferenceFragmentCompat() {
val manageSavedCardsPreference =
requirePreference<Preference>(R.string.pref_key_credit_cards_manage_cards)
val directions = if (hasCreditCards) {
CreditCardsSettingFragmentDirections
.actionCreditCardsSettingFragmentToCreditCardsManagementFragment()
} else {
CreditCardsSettingFragmentDirections
.actionCreditCardsSettingFragmentToCreditCardEditorFragment()
}
if (hasCreditCards) {
manageSavedCardsPreference.icon = null
manageSavedCardsPreference.title =
@ -135,7 +166,14 @@ class CreditCardsSettingFragment : PreferenceFragmentCompat() {
}
manageSavedCardsPreference.setOnPreferenceClickListener {
navController.navigateBlockingForAsyncNavGraph(directions)
if (hasCreditCards) {
verifyCredentialsOrShowSetupWarning(requireContext(), creditCardPreferences)
} else {
navController.navigateBlockingForAsyncNavGraph(
CreditCardsSettingFragmentDirections
.actionCreditCardsSettingFragmentToCreditCardEditorFragment()
)
}
super.onPreferenceTreeClick(it)
}
}
@ -158,4 +196,54 @@ class CreditCardsSettingFragment : PreferenceFragmentCompat() {
isCreditCardsListLoaded = true
}
/**
* Shows a dialog warning to set up a pin/password when the device is not secured. This is
* only used when BiometricPrompt is unavailable on the device.
*/
override fun showPinDialogWarning(context: Context) {
AlertDialog.Builder(context).apply {
setTitle(getString(R.string.credit_cards_warning_dialog_title))
setMessage(getString(R.string.credit_cards_warning_dialog_message))
setNegativeButton(getString(R.string.credit_cards_warning_dialog_later)) { _: DialogInterface, _ ->
navigateToCreditCardManagementFragment()
}
setPositiveButton(getString(R.string.credit_cards_warning_dialog_set_up_now)) { it: DialogInterface, _ ->
it.dismiss()
val intent = Intent(Settings.ACTION_SECURITY_SETTINGS)
startActivity(intent)
}
create()
}.show().secure(activity)
context.settings().incrementSecureWarningCount()
}
/**
* Shows a prompt to verify the device's pin/password and start activity based on the result.
* This is only used when BiometricPrompt is unavailable on the device.
*
* @param manager The device [KeyguardManager]
*/
@Suppress("Deprecation")
override fun showPinVerification(manager: KeyguardManager) {
val intent = manager.createConfirmDeviceCredentialIntent(
getString(R.string.credit_cards_biometric_prompt_message_pin),
getString(R.string.credit_cards_biometric_prompt_message)
)
startActivityForResult(intent, BiometricPromptPreferenceFragment.PIN_REQUEST)
}
private fun navigateToCreditCardManagementFragment() {
val directions =
CreditCardsSettingFragmentDirections
.actionCreditCardsSettingFragmentToCreditCardsManagementFragment()
findNavController().navigateBlockingForAsyncNavGraph(directions)
}
companion object {
const val SHORT_DELAY_MS = 100L
}
}

View File

@ -266,7 +266,8 @@ class EditLoginFragment : Fragment(R.layout.fragment_edit_login) {
override fun onPause() {
redirectToReAuth(
listOf(R.id.loginDetailFragment),
findNavController().currentDestination?.id
findNavController().currentDestination?.id,
R.id.editLoginFragment
)
super.onPause()
}

View File

@ -131,7 +131,8 @@ class LoginDetailFragment : Fragment(R.layout.fragment_login_detail) {
menu.close()
redirectToReAuth(
listOf(R.id.editLoginFragment, R.id.savedLoginsFragment),
findNavController().currentDestination?.id
findNavController().currentDestination?.id,
R.id.loginDetailFragment
)
super.onPause()
}

View File

@ -188,7 +188,7 @@ class SavedLoginsAuthFragment : PreferenceFragmentCompat() {
}
create()
}.show().secure(activity)
context.settings().incrementShowLoginsSecureWarningCount()
context.settings().incrementSecureWarningCount()
}
@Suppress("Deprecation") // This is only used when BiometricPrompt is unavailable

View File

@ -148,7 +148,11 @@ class SavedLoginsFragment : Fragment() {
sortLoginsMenuRoot.setOnClickListener(null)
setHasOptionsMenu(false)
redirectToReAuth(listOf(R.id.loginDetailFragment), findNavController().currentDestination?.id)
redirectToReAuth(
listOf(R.id.loginDetailFragment),
findNavController().currentDestination?.id,
R.id.savedLoginsFragment
)
super.onPause()
}

View File

@ -249,7 +249,7 @@ class Settings(private val appContext: Context) : PreferencesHolder {
get() = loginsSecureWarningSyncCount.underMaxCount()
val shouldShowSecurityPinWarning: Boolean
get() = loginsSecureWarningCount.underMaxCount()
get() = secureWarningCount.underMaxCount()
var shouldShowPrivacyPopWindow by booleanPreference(
appContext.getPreferenceKey(R.string.pref_key_privacy_pop_window),
@ -652,12 +652,12 @@ class Settings(private val appContext: Context) : PreferencesHolder {
)
@VisibleForTesting(otherwise = PRIVATE)
internal val loginsSecureWarningCount = counterPreference(
appContext.getPreferenceKey(R.string.pref_key_logins_secure_warning),
internal val secureWarningCount = counterPreference(
appContext.getPreferenceKey(R.string.pref_key_secure_warning),
maxCount = 1
)
fun incrementShowLoginsSecureWarningCount() = loginsSecureWarningCount.increment()
fun incrementSecureWarningCount() = secureWarningCount.increment()
fun incrementShowLoginsSecureWarningSyncCount() = loginsSecureWarningSyncCount.increment()

View File

@ -1043,13 +1043,6 @@
android:id="@+id/creditCardsSettingFragment"
android:name="org.mozilla.fenix.settings.creditcards.CreditCardsSettingFragment"
android:label="@string/preferences_credit_cards">
<action
android:id="@+id/action_creditCardsSettingFragment_to_turnOnSyncFragment"
app:destination="@id/turnOnSyncFragment"
app:enterAnim="@anim/slide_in_right"
app:exitAnim="@anim/slide_out_left"
app:popEnterAnim="@anim/slide_in_left"
app:popExitAnim="@anim/slide_out_right" />
<action
android:id="@+id/action_creditCardsSettingFragment_to_creditCardEditorFragment"
app:destination="@id/creditCardEditorFragment"

View File

@ -171,7 +171,7 @@
<string name="pref_key_saved_logins" translatable="false">pref_key_saved_logins</string>
<string name="pref_key_password_sync_logins" translatable="false">pref_key_password_sync_logins</string>
<string name="pref_key_logins_secure_warning_sync" translatable="false">pref_key_logins_secure_warning_sync</string>
<string name="pref_key_logins_secure_warning" translatable="false">pref_key_logins_secure_warning</string>
<string name="pref_key_secure_warning" translatable="false">pref_key_secure_warning</string>
<!-- Credit Cards Settings -->
<!-- Key for the "Credit cards" preference in the "Settings" fragment -->

View File

@ -1555,6 +1555,18 @@
<string name="credit_cards_saved_cards">Saved cards</string>
<!-- Error message for credit card number validation -->
<string name="credit_cards_number_validation_error_message">Please enter a valid credit card number</string>
<!-- Message displayed in biometric prompt displayed for authentication before allowing users to view their saved credit cards -->
<string name="credit_cards_biometric_prompt_message">Unlock to view your saved cards</string>
<!-- Title of warning dialog if users have no device authentication set up -->
<string name="credit_cards_warning_dialog_title">Secure your credit cards</string>
<!-- Message of warning dialog if users have no device authentication set up -->
<string name="credit_cards_warning_dialog_message">Set up a device lock pattern, PIN, or password to protect your saved credit cards from being accessed if someone else has your device.</string>
<!-- Positive button to send users to set up a pin of warning dialog if users have no device authentication set up -->
<string name="credit_cards_warning_dialog_set_up_now">Set up now</string>
<!-- Negative button to ignore warning dialog if users have no device authentication set up -->
<string name="credit_cards_warning_dialog_later">Later</string>
<!-- Title of PIN verification dialog to direct users to re-enter their device credentials to access their credit cards -->
<string name="credit_cards_biometric_prompt_message_pin">Unlock your device</string>
<!-- Title of the Add search engine screen -->
<string name="search_engine_add_custom_search_engine_title">Add search engine</string>

View File

@ -53,10 +53,6 @@ class CreditCardsSettingFragmentTest {
creditCardsSettingFragment.getPreferenceKey(R.string.pref_key_credit_cards_manage_cards)
)
val directions =
CreditCardsSettingFragmentDirections
.actionCreditCardsSettingFragmentToCreditCardsManagementFragment()
val creditCards: List<CreditCard> = listOf(mockk(), mockk())
val creditCardsState = CreditCardsListState(creditCards = creditCards)
@ -69,10 +65,6 @@ class CreditCardsSettingFragmentTest {
assertNull(manageCardsPreference?.icon)
assertEquals(preferenceTitle, manageCardsPreference?.title)
manageCardsPreference?.performClick()
verify { navController.navigateBlockingForAsyncNavGraph(directions) }
}
@Test

View File

@ -183,13 +183,13 @@ class SettingsTest {
fun showLoginsDialogWarning() {
// When just created
// Then
assertEquals(0, settings.loginsSecureWarningCount.value)
assertEquals(0, settings.secureWarningCount.value)
// When
settings.incrementShowLoginsSecureWarningCount()
settings.incrementSecureWarningCount()
// Then
assertEquals(1, settings.loginsSecureWarningCount.value)
assertEquals(1, settings.secureWarningCount.value)
}
@Test
@ -199,7 +199,7 @@ class SettingsTest {
assertTrue(settings.shouldShowSecurityPinWarning)
// When
settings.incrementShowLoginsSecureWarningCount()
settings.incrementSecureWarningCount()
// Then
assertFalse(settings.shouldShowSecurityPinWarning)