From 1581ab7fca27bfa2b88edf1d25bfe981b8f6536f Mon Sep 17 00:00:00 2001 From: Gabriel Luong Date: Wed, 10 Nov 2021 14:32:51 -0500 Subject: [PATCH] Issue #11338: Add a SaveCreditCard dialog to handle saving and updating a credit card --- .../GeckoAutocompleteStorageDelegate.kt | 8 + components/concept/storage/build.gradle | 4 + .../storage/CreditCardsAddressesStorage.kt | 27 +- .../concept/storage/CreditCardEntryTest.kt | 40 +++ .../feature/prompts/PromptFeature.kt | 23 ++ .../creditcard/CreditCardItemViewHolder.kt | 25 +- .../CreditCardSaveDialogFragment.kt | 179 ++++++++++++ .../prompts/dialog/PromptDialogFragment.kt | 6 + ...feature_prompt_save_credit_card_prompt.xml | 134 +++++++++ .../prompts/src/main/res/values/strings.xml | 10 +- .../feature/prompts/PromptFeatureTest.kt | 127 +++++++++ .../CreditCardSaveDialogFragmentTest.kt | 256 ++++++++++++++++++ docs/changelog.md | 3 + 13 files changed, 817 insertions(+), 25 deletions(-) create mode 100644 components/concept/storage/src/test/java/mozilla/components/concept/storage/CreditCardEntryTest.kt create mode 100644 components/feature/prompts/src/main/java/mozilla/components/feature/prompts/creditcard/CreditCardSaveDialogFragment.kt create mode 100644 components/feature/prompts/src/main/res/layout/mozac_feature_prompt_save_credit_card_prompt.xml create mode 100644 components/feature/prompts/src/test/java/mozilla/components/feature/prompts/creditcard/CreditCardSaveDialogFragmentTest.kt diff --git a/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/autofill/GeckoAutocompleteStorageDelegate.kt b/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/autofill/GeckoAutocompleteStorageDelegate.kt index 6f766d00fc8..a1185497a87 100644 --- a/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/autofill/GeckoAutocompleteStorageDelegate.kt +++ b/components/browser/engine-gecko/src/main/java/mozilla/components/browser/engine/gecko/autofill/GeckoAutocompleteStorageDelegate.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import mozilla.components.browser.engine.gecko.ext.toCreditCardEntry import mozilla.components.browser.engine.gecko.ext.toLoginEntry import mozilla.components.concept.storage.CreditCard import mozilla.components.concept.storage.CreditCardsAddressesStorageDelegate @@ -63,6 +64,13 @@ class GeckoAutocompleteStorageDelegate( return result } + override fun onCreditCardSave(creditCard: Autocomplete.CreditCard) { + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(IO) { + creditCardsAddressesStorageDelegate.onCreditCardSave(creditCard.toCreditCardEntry()) + } + } + override fun onLoginSave(login: Autocomplete.LoginEntry) { loginStorageDelegate.onLoginSave(login.toLoginEntry()) } diff --git a/components/concept/storage/build.gradle b/components/concept/storage/build.gradle index 587ba4a5d1c..ec60671b4d9 100644 --- a/components/concept/storage/build.gradle +++ b/components/concept/storage/build.gradle @@ -29,6 +29,10 @@ dependencies { api Dependencies.kotlin_coroutines implementation project(':support-ktx') + + testImplementation project(':support-test') + testImplementation Dependencies.testing_junit + testImplementation Dependencies.testing_mockito } apply from: '../../../publish.gradle' diff --git a/components/concept/storage/src/main/java/mozilla/components/concept/storage/CreditCardsAddressesStorage.kt b/components/concept/storage/src/main/java/mozilla/components/concept/storage/CreditCardsAddressesStorage.kt index 663c03db27e..caa575036e7 100644 --- a/components/concept/storage/src/main/java/mozilla/components/concept/storage/CreditCardsAddressesStorage.kt +++ b/components/concept/storage/src/main/java/mozilla/components/concept/storage/CreditCardsAddressesStorage.kt @@ -11,6 +11,9 @@ import mozilla.components.concept.storage.CreditCard.Companion.ellipsesEnd import mozilla.components.concept.storage.CreditCard.Companion.ellipsesStart import mozilla.components.concept.storage.CreditCard.Companion.ellipsis import mozilla.components.support.ktx.kotlin.last4Digits +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale /** * An interface which defines read/write methods for credit card and address data. @@ -235,6 +238,7 @@ data class CreditCard( * @property expiryYear The credit card expiry year. * @property cardType The credit card network ID. */ +@Parcelize data class CreditCardEntry( val guid: String? = null, val name: String, @@ -242,12 +246,33 @@ data class CreditCardEntry( val expiryMonth: String, val expiryYear: String, val cardType: String -) { +) : Parcelable { val obfuscatedCardNumber: String get() = ellipsesStart + ellipsis + ellipsis + ellipsis + ellipsis + number.last4Digits() + ellipsesEnd + + /** + * Credit card expiry date formatted according to the locale. + */ + val expiryDate: String + get() { + val dateFormat = SimpleDateFormat(DATE_PATTERN, Locale.getDefault()) + + val calendar = Calendar.getInstance() + calendar.set(Calendar.DAY_OF_MONTH, 1) + // Subtract 1 from the expiry month since Calendar.Month is based on a 0-indexed. + calendar.set(Calendar.MONTH, expiryMonth.toInt() - 1) + calendar.set(Calendar.YEAR, expiryYear.toInt()) + + return dateFormat.format(calendar.time) + } + + companion object { + // Date format pattern for the credit card expiry date. + private const val DATE_PATTERN = "MM/yyyy" + } } /** diff --git a/components/concept/storage/src/test/java/mozilla/components/concept/storage/CreditCardEntryTest.kt b/components/concept/storage/src/test/java/mozilla/components/concept/storage/CreditCardEntryTest.kt new file mode 100644 index 00000000000..86a49028704 --- /dev/null +++ b/components/concept/storage/src/test/java/mozilla/components/concept/storage/CreditCardEntryTest.kt @@ -0,0 +1,40 @@ +/* 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 mozilla.components.concept.storage + +import mozilla.components.concept.storage.CreditCard.Companion.ellipsesEnd +import mozilla.components.concept.storage.CreditCard.Companion.ellipsesStart +import mozilla.components.concept.storage.CreditCard.Companion.ellipsis +import mozilla.components.support.ktx.kotlin.last4Digits +import org.junit.Assert.assertEquals +import org.junit.Test + +class CreditCardEntryTest { + + private val creditCard = CreditCardEntry( + guid = "1", + name = "Banana Apple", + number = "4111111111111110", + expiryMonth = "5", + expiryYear = "2030", + cardType = "amex" + ) + + @Test + fun `WHEN obfuscatedCardNumber getter is called THEN the expected obfuscated card number is returned`() { + assertEquals( + ellipsesStart + + ellipsis + ellipsis + ellipsis + ellipsis + + creditCard.number.last4Digits() + + ellipsesEnd, + creditCard.obfuscatedCardNumber + ) + } + + @Test + fun `WHEN expiryDdate getter is called THEN the expected expiry date string is returned`() { + assertEquals("0${creditCard.expiryMonth}/${creditCard.expiryYear}", creditCard.expiryDate) + } +} diff --git a/components/feature/prompts/src/main/java/mozilla/components/feature/prompts/PromptFeature.kt b/components/feature/prompts/src/main/java/mozilla/components/feature/prompts/PromptFeature.kt index 63133b03fc4..0efb517a727 100644 --- a/components/feature/prompts/src/main/java/mozilla/components/feature/prompts/PromptFeature.kt +++ b/components/feature/prompts/src/main/java/mozilla/components/feature/prompts/PromptFeature.kt @@ -42,11 +42,13 @@ import mozilla.components.concept.engine.prompt.PromptRequest.SingleChoice import mozilla.components.concept.engine.prompt.PromptRequest.TextPrompt import mozilla.components.concept.engine.prompt.PromptRequest.TimeSelection import mozilla.components.concept.storage.CreditCardEntry +import mozilla.components.concept.storage.CreditCardValidationDelegate import mozilla.components.concept.storage.Login import mozilla.components.concept.storage.LoginEntry import mozilla.components.concept.storage.LoginValidationDelegate import mozilla.components.feature.prompts.concept.SelectablePromptView import mozilla.components.feature.prompts.creditcard.CreditCardPicker +import mozilla.components.feature.prompts.creditcard.CreditCardSaveDialogFragment import mozilla.components.feature.prompts.dialog.AlertDialogFragment import mozilla.components.feature.prompts.dialog.AuthenticationDialogFragment import mozilla.components.feature.prompts.dialog.ChoiceDialogFragment @@ -138,6 +140,7 @@ class PromptFeature private constructor( private var customTabId: String?, private val fragmentManager: FragmentManager, private val shareDelegate: ShareDelegate, + override val creditCardValidationDelegate: CreditCardValidationDelegate? = null, override val loginValidationDelegate: LoginValidationDelegate? = null, private val isSaveLoginEnabled: () -> Boolean = { false }, private val isCreditCardAutofillEnabled: () -> Boolean = { false }, @@ -176,6 +179,7 @@ class PromptFeature private constructor( customTabId: String? = null, fragmentManager: FragmentManager, shareDelegate: ShareDelegate = DefaultShareDelegate(), + creditCardValidationDelegate: CreditCardValidationDelegate? = null, loginValidationDelegate: LoginValidationDelegate? = null, isSaveLoginEnabled: () -> Boolean = { false }, isCreditCardAutofillEnabled: () -> Boolean = { false }, @@ -192,6 +196,7 @@ class PromptFeature private constructor( customTabId = customTabId, fragmentManager = fragmentManager, shareDelegate = shareDelegate, + creditCardValidationDelegate = creditCardValidationDelegate, loginValidationDelegate = loginValidationDelegate, isSaveLoginEnabled = isSaveLoginEnabled, isCreditCardAutofillEnabled = isCreditCardAutofillEnabled, @@ -210,6 +215,7 @@ class PromptFeature private constructor( customTabId: String? = null, fragmentManager: FragmentManager, shareDelegate: ShareDelegate = DefaultShareDelegate(), + creditCardValidationDelegate: CreditCardValidationDelegate? = null, loginValidationDelegate: LoginValidationDelegate? = null, isSaveLoginEnabled: () -> Boolean = { false }, isCreditCardAutofillEnabled: () -> Boolean = { false }, @@ -226,6 +232,7 @@ class PromptFeature private constructor( customTabId = customTabId, fragmentManager = fragmentManager, shareDelegate = shareDelegate, + creditCardValidationDelegate = creditCardValidationDelegate, loginValidationDelegate = loginValidationDelegate, isSaveLoginEnabled = isSaveLoginEnabled, isCreditCardAutofillEnabled = isCreditCardAutofillEnabled, @@ -281,6 +288,8 @@ class PromptFeature private constructor( loginPicker?.dismissCurrentLoginSelect(activePromptRequest as SelectLoginPrompt) } else if (activePromptRequest is SaveLoginPrompt) { (activePrompt?.get() as? SaveLoginDialogFragment)?.dismissAllowingStateLoss() + } else if (activePromptRequest is SaveCreditCard) { + (activePrompt?.get() as? CreditCardSaveDialogFragment)?.dismissAllowingStateLoss() } else if (activePromptRequest is SelectCreditCard) { creditCardPicker?.dismissSelectCreditCardRequest( activePromptRequest as SelectCreditCard @@ -489,6 +498,7 @@ class PromptFeature private constructor( is Share -> it.onSuccess() + is SaveCreditCard -> it.onConfirm(value as CreditCardEntry) is SaveLoginPrompt -> it.onConfirm(value as LoginEntry) is Confirm -> { @@ -556,6 +566,9 @@ class PromptFeature private constructor( ) } + /** + * Called from on [onPromptRequested] to handle requests for showing native dialogs. + */ @Suppress("ComplexMethod", "LongMethod") @VisibleForTesting(otherwise = PRIVATE) internal fun handleDialogsRequest( @@ -564,6 +577,16 @@ class PromptFeature private constructor( ) { // Requests that are handled with dialogs val dialog = when (promptRequest) { + is SaveCreditCard -> { + if (!isCreditCardAutofillEnabled.invoke()) return + + CreditCardSaveDialogFragment.newInstance( + sessionId = session.id, + promptRequestUID = promptRequest.uid, + shouldDismissOnLoad = false, + creditCard = promptRequest.creditCard + ) + } is SaveLoginPrompt -> { if (!isSaveLoginEnabled.invoke()) return diff --git a/components/feature/prompts/src/main/java/mozilla/components/feature/prompts/creditcard/CreditCardItemViewHolder.kt b/components/feature/prompts/src/main/java/mozilla/components/feature/prompts/creditcard/CreditCardItemViewHolder.kt index e4ffe121c80..a1a89840538 100644 --- a/components/feature/prompts/src/main/java/mozilla/components/feature/prompts/creditcard/CreditCardItemViewHolder.kt +++ b/components/feature/prompts/src/main/java/mozilla/components/feature/prompts/creditcard/CreditCardItemViewHolder.kt @@ -11,9 +11,6 @@ import androidx.recyclerview.widget.RecyclerView import mozilla.components.concept.storage.CreditCardEntry import mozilla.components.feature.prompts.R import mozilla.components.support.utils.creditCardIssuerNetwork -import java.text.SimpleDateFormat -import java.util.Calendar -import java.util.Locale /** * View holder for displaying a credit card item. @@ -37,33 +34,15 @@ class CreditCardItemViewHolder( itemView.findViewById(R.id.credit_card_number).text = creditCard.obfuscatedCardNumber - bindCreditCardExpiryDate(creditCard) + itemView.findViewById(R.id.credit_card_expiration_date).text = + creditCard.expiryDate itemView.setOnClickListener { onCreditCardSelected(creditCard) } } - /** - * Set the credit card expiry date formatted according to the locale. - */ - private fun bindCreditCardExpiryDate(creditCard: CreditCardEntry) { - val dateFormat = SimpleDateFormat(DATE_PATTERN, Locale.getDefault()) - - val calendar = Calendar.getInstance() - calendar.set(Calendar.DAY_OF_MONTH, 1) - // Subtract 1 from the expiry month since Calendar.Month is based on a 0-indexed. - calendar.set(Calendar.MONTH, creditCard.expiryMonth.toInt() - 1) - calendar.set(Calendar.YEAR, creditCard.expiryYear.toInt()) - - itemView.findViewById(R.id.credit_card_expiration_date).text = - dateFormat.format(calendar.time) - } - companion object { val LAYOUT_ID = R.layout.mozac_feature_prompts_credit_card_list_item - - // Date format pattern for the credit card expiry date. - private const val DATE_PATTERN = "MM/yyyy" } } diff --git a/components/feature/prompts/src/main/java/mozilla/components/feature/prompts/creditcard/CreditCardSaveDialogFragment.kt b/components/feature/prompts/src/main/java/mozilla/components/feature/prompts/creditcard/CreditCardSaveDialogFragment.kt new file mode 100644 index 00000000000..70566d4c985 --- /dev/null +++ b/components/feature/prompts/src/main/java/mozilla/components/feature/prompts/creditcard/CreditCardSaveDialogFragment.kt @@ -0,0 +1,179 @@ +/* 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 mozilla.components.feature.prompts.creditcard + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.VisibleForTesting +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.view.isInvisible +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.Dispatchers.Main +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import mozilla.components.concept.storage.CreditCardEntry +import mozilla.components.concept.storage.CreditCardValidationDelegate.Result +import mozilla.components.feature.prompts.R +import mozilla.components.feature.prompts.dialog.KEY_PROMPT_UID +import mozilla.components.feature.prompts.dialog.KEY_SESSION_ID +import mozilla.components.feature.prompts.dialog.KEY_SHOULD_DISMISS_ON_LOAD +import mozilla.components.feature.prompts.dialog.PromptDialogFragment +import mozilla.components.support.ktx.android.view.toScope +import mozilla.components.support.utils.creditCardIssuerNetwork + +private const val KEY_CREDIT_CARD = "KEY_CREDIT_CARD" + +/** + * [android.support.v4.app.DialogFragment] implementation to display a dialog that allows + * user to save a new credit card or update an existing credit card. + */ +internal class CreditCardSaveDialogFragment : PromptDialogFragment() { + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal val creditCard by lazy { safeArguments.getParcelable(KEY_CREDIT_CARD)!! } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return BottomSheetDialog(requireContext(), R.style.MozDialogStyle).apply { + setCancelable(true) + setOnShowListener { + val bottomSheet = + findViewById(com.google.android.material.R.id.design_bottom_sheet) as FrameLayout + val behavior = BottomSheetBehavior.from(bottomSheet) + behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return LayoutInflater.from(requireContext()).inflate( + R.layout.mozac_feature_prompt_save_credit_card_prompt, + container, + false + ) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + view.findViewById(R.id.credit_card_logo) + .setImageResource(creditCard.cardType.creditCardIssuerNetwork().icon) + + view.findViewById(R.id.credit_card_number).text = creditCard.obfuscatedCardNumber + view.findViewById(R.id.credit_card_expiration_date).text = creditCard.expiryDate + + view.findViewById