From 181423b70557a72043e2186483c0da4f8f8c9bf0 Mon Sep 17 00:00:00 2001 From: Michael Shafrir Date: Thu, 23 Jan 2020 09:56:24 -0500 Subject: [PATCH] Add CardValidCallback and add support in card forms Summary Add `CardInputWidget#setCardValidCallback()` and `CardMultilineWidget#setCardValidCallback()` Setting a callback instance will allow the integrating app to determine if the current form input is valid and which fields are invalid, if any. For example, when none of the fields are valid, `CardValidCallback#onInputChanged()` will be called with ``` false, setOf( CardValidCallback.Fields.Number, CardValidCallback.Fields.Expiry, CardValidCallback.Fields.Cvc ) ``` When all of the fields are valid, `CardValidCallback#onInputChanged()` will be called with ``` true, emptySet() ``` Testing Add unit tests and manually verified Motivation Fixes #1808 --- .../CreateCardPaymentMethodActivity.kt | 10 +++ .../activity/CreateCardTokenActivity.kt | 10 +++ .../stripe/android/view/CardInputWidget.kt | 35 +++++++++ .../android/view/CardMultilineWidget.kt | 67 +++++++++++++---- .../stripe/android/view/CardValidCallback.kt | 17 +++++ .../com/stripe/android/view/CardWidget.kt | 2 + .../android/view/CardInputWidgetTest.kt | 74 +++++++++++++++++-- .../android/view/CardMultilineWidgetTest.kt | 74 +++++++++++++++++-- 8 files changed, 259 insertions(+), 30 deletions(-) create mode 100644 stripe/src/main/java/com/stripe/android/view/CardValidCallback.kt diff --git a/example/src/main/java/com/stripe/example/activity/CreateCardPaymentMethodActivity.kt b/example/src/main/java/com/stripe/example/activity/CreateCardPaymentMethodActivity.kt index 9b576c7ed58..ad844e9735a 100644 --- a/example/src/main/java/com/stripe/example/activity/CreateCardPaymentMethodActivity.kt +++ b/example/src/main/java/com/stripe/example/activity/CreateCardPaymentMethodActivity.kt @@ -11,6 +11,7 @@ import androidx.recyclerview.widget.RecyclerView import com.stripe.android.PaymentConfiguration import com.stripe.android.Stripe import com.stripe.android.model.PaymentMethod +import com.stripe.android.view.CardValidCallback import com.stripe.example.R import io.reactivex.Observable import io.reactivex.android.schedulers.AndroidSchedulers @@ -40,6 +41,15 @@ class CreateCardPaymentMethodActivity : AppCompatActivity() { rv_payment_methods.layoutManager = LinearLayoutManager(this) rv_payment_methods.adapter = adapter + card_multiline_widget.setCardValidCallback(object : CardValidCallback { + override fun onInputChanged( + isValid: Boolean, + invalidFields: Set + ) { + // added as an example - no-op + } + }) + btn_create_payment_method.setOnClickListener { createPaymentMethod() } } diff --git a/example/src/main/java/com/stripe/example/activity/CreateCardTokenActivity.kt b/example/src/main/java/com/stripe/example/activity/CreateCardTokenActivity.kt index 360f89d4a7b..a78a1fceb11 100644 --- a/example/src/main/java/com/stripe/example/activity/CreateCardTokenActivity.kt +++ b/example/src/main/java/com/stripe/example/activity/CreateCardTokenActivity.kt @@ -20,6 +20,7 @@ import com.stripe.android.PaymentConfiguration import com.stripe.android.Stripe import com.stripe.android.model.Card import com.stripe.android.model.Token +import com.stripe.android.view.CardValidCallback import com.stripe.example.R import kotlinx.android.synthetic.main.card_token_activity.* @@ -60,6 +61,15 @@ class CreateCardTokenActivity : AppCompatActivity() { } } + card_input_widget.setCardValidCallback(object : CardValidCallback { + override fun onInputChanged( + isValid: Boolean, + invalidFields: Set + ) { + // added as an example - no-op + } + }) + card_input_widget.requestFocus() } diff --git a/stripe/src/main/java/com/stripe/android/view/CardInputWidget.kt b/stripe/src/main/java/com/stripe/android/view/CardInputWidget.kt index 1635721ee82..bc3079747d4 100644 --- a/stripe/src/main/java/com/stripe/android/view/CardInputWidget.kt +++ b/stripe/src/main/java/com/stripe/android/view/CardInputWidget.kt @@ -4,6 +4,7 @@ import android.content.Context import android.os.Build import android.os.Bundle import android.os.Parcelable +import android.text.Editable import android.text.Layout import android.text.TextPaint import android.text.TextWatcher @@ -62,6 +63,27 @@ class CardInputWidget @JvmOverloads constructor( private val postalCodeEditText: PostalCodeEditText private var cardInputListener: CardInputListener? = null + private var cardValidCallback: CardValidCallback? = null + private val cardValidTextWatcher = object : StripeTextWatcher() { + override fun afterTextChanged(s: Editable?) { + super.afterTextChanged(s) + cardValidCallback?.onInputChanged(invalidFields.isEmpty(), invalidFields) + } + } + private val invalidFields: Set + get() { + return listOfNotNull( + CardValidCallback.Fields.Number.takeIf { + cardNumberEditText.cardNumber == null + }, + CardValidCallback.Fields.Expiry.takeIf { + expiryDateEditText.validDateFields == null + }, + CardValidCallback.Fields.Cvc.takeIf { + this.cvcValue == null + } + ).toSet() + } @JvmSynthetic internal var cardNumberIsViewed = true @@ -252,6 +274,19 @@ class CardInputWidget @JvmOverloads constructor( initView(attrs) } + override fun setCardValidCallback(callback: CardValidCallback?) { + this.cardValidCallback = callback + standardFields.forEach { it.removeTextChangedListener(cardValidTextWatcher) } + + // only add the TextWatcher if it will be used + if (callback != null) { + standardFields.forEach { it.addTextChangedListener(cardValidTextWatcher) } + } + + // call immediately after setting + cardValidCallback?.onInputChanged(invalidFields.isEmpty(), invalidFields) + } + /** * Set a [CardInputListener] to be notified of card input events. * diff --git a/stripe/src/main/java/com/stripe/android/view/CardMultilineWidget.kt b/stripe/src/main/java/com/stripe/android/view/CardMultilineWidget.kt index bf1e8ad63dd..c8ce85d438a 100644 --- a/stripe/src/main/java/com/stripe/android/view/CardMultilineWidget.kt +++ b/stripe/src/main/java/com/stripe/android/view/CardMultilineWidget.kt @@ -4,6 +4,7 @@ import android.content.Context import android.graphics.Rect import android.graphics.drawable.Drawable import android.os.Build +import android.text.Editable import android.text.TextWatcher import android.util.AttributeSet import android.view.View @@ -52,6 +53,25 @@ class CardMultilineWidget @JvmOverloads constructor( private val postalInputLayout: TextInputLayout private var cardInputListener: CardInputListener? = null + private var cardValidCallback: CardValidCallback? = null + private val cardValidTextWatcher = object : StripeTextWatcher() { + override fun afterTextChanged(s: Editable?) { + super.afterTextChanged(s) + cardValidCallback?.onInputChanged(invalidFields.isEmpty(), invalidFields) + } + } + private val invalidFields: Set + get() { + return listOfNotNull( + CardValidCallback.Fields.Number.takeIf { + cardNumber == null + }, + CardValidCallback.Fields.Expiry.takeIf { + expiryDate == null + }, + CardValidCallback.Fields.Cvc.takeUnless { isCvcLengthValid } + ).toSet() + } private var isEnabled: Boolean = false private var hasAdjustedDrawable: Boolean = false @@ -75,7 +95,7 @@ class CardMultilineWidget @JvmOverloads constructor( return if (validateAllFields()) { expiryDateEditText.validDateFields?.let { (month, year) -> PaymentMethodCreateParams.Card( - number = cardNumberEditText.cardNumber, + number = cardNumber, cvc = cvcEditText.text?.toString(), expiryMonth = month, expiryYear = year @@ -139,7 +159,7 @@ class CardMultilineWidget @JvmOverloads constructor( return null } - val cardNumber = cardNumberEditText.cardNumber + val cardNumber = cardNumber val cardDate = requireNotNull(expiryDateEditText.validDateFields) val cvcValue = cvcEditText.text?.toString() val postalCode = postalCodeEditText.text?.toString() @@ -150,10 +170,24 @@ class CardMultilineWidget @JvmOverloads constructor( .loggingTokens(listOf(CARD_MULTILINE_TOKEN)) } + private val cardNumber: String? + get() { + return cardNumberEditText.cardNumber + } + private val expiryDate: Pair? + get() { + return expiryDateEditText.validDateFields + } private val isCvcLengthValid: Boolean get() { return cardBrand.isValidCvc(cvcEditText.rawCvcValue) } + private val allFields: Collection + get() { + return listOf( + cardNumberEditText, expiryDateEditText, cvcEditText, postalCodeEditText + ) + } private val cvcHelperText: Int @StringRes @@ -279,6 +313,19 @@ class CardMultilineWidget @JvmOverloads constructor( this.cardInputListener = listener } + override fun setCardValidCallback(callback: CardValidCallback?) { + this.cardValidCallback = callback + allFields.forEach { it.removeTextChangedListener(cardValidTextWatcher) } + + // only add the TextWatcher if it will be used + if (callback != null) { + allFields.forEach { it.addTextChangedListener(cardValidTextWatcher) } + } + + // call immediately after setting + cardValidCallback?.onInputChanged(invalidFields.isEmpty(), invalidFields) + } + override fun setCardHint(cardHint: String) { cardHintText = cardHint } @@ -289,22 +336,14 @@ class CardMultilineWidget @JvmOverloads constructor( * @return `true` if all shown fields are valid, `false` otherwise */ fun validateAllFields(): Boolean { - val cardNumberIsValid = CardUtils.isValidCardNumber(cardNumberEditText.cardNumber) - val expiryIsValid = expiryDateEditText.validDateFields != null + val cardNumberIsValid = CardUtils.isValidCardNumber(cardNumber) + val expiryIsValid = expiryDate != null val cvcIsValid = isCvcLengthValid cardNumberEditText.shouldShowError = !cardNumberIsValid expiryDateEditText.shouldShowError = !expiryIsValid cvcEditText.shouldShowError = !cvcIsValid - val fields = listOf( - cardNumberEditText, expiryDateEditText, cvcEditText, postalCodeEditText - ) - for (field in fields) { - if (field.shouldShowError) { - field.requestFocus() - break - } - } + allFields.firstOrNull { it.shouldShowError }?.requestFocus() return cardNumberIsValid && expiryIsValid && cvcIsValid } @@ -353,7 +392,7 @@ class CardMultilineWidget @JvmOverloads constructor( * Checks whether the current card number is valid */ fun validateCardNumber(): Boolean { - val cardNumberIsValid = CardUtils.isValidCardNumber(cardNumberEditText.cardNumber) + val cardNumberIsValid = CardUtils.isValidCardNumber(cardNumber) cardNumberEditText.shouldShowError = !cardNumberIsValid return cardNumberIsValid } diff --git a/stripe/src/main/java/com/stripe/android/view/CardValidCallback.kt b/stripe/src/main/java/com/stripe/android/view/CardValidCallback.kt new file mode 100644 index 00000000000..106aa145fb5 --- /dev/null +++ b/stripe/src/main/java/com/stripe/android/view/CardValidCallback.kt @@ -0,0 +1,17 @@ +package com.stripe.android.view + +interface CardValidCallback { + /** + * @param isValid if the current input is valid + * @param invalidFields if the current input is invalid, this [Set] will be populated with the + * fields that are invalid, represented by [Fields]; if the current input is valid, + * this [Set] will be empty + */ + fun onInputChanged(isValid: Boolean, invalidFields: Set) + + enum class Fields { + Number, + Expiry, + Cvc + } +} diff --git a/stripe/src/main/java/com/stripe/android/view/CardWidget.kt b/stripe/src/main/java/com/stripe/android/view/CardWidget.kt index 6acc0eda121..2fd97a11888 100644 --- a/stripe/src/main/java/com/stripe/android/view/CardWidget.kt +++ b/stripe/src/main/java/com/stripe/android/view/CardWidget.kt @@ -24,6 +24,8 @@ internal interface CardWidget { */ val paymentMethodCreateParams: PaymentMethodCreateParams? + fun setCardValidCallback(callback: CardValidCallback?) + fun setCardInputListener(listener: CardInputListener?) fun setCardHint(cardHint: String) diff --git a/stripe/src/test/java/com/stripe/android/view/CardInputWidgetTest.kt b/stripe/src/test/java/com/stripe/android/view/CardInputWidgetTest.kt index 19e2fcf3347..cf65b508468 100644 --- a/stripe/src/test/java/com/stripe/android/view/CardInputWidgetTest.kt +++ b/stripe/src/test/java/com/stripe/android/view/CardInputWidgetTest.kt @@ -4,6 +4,7 @@ import android.os.Build import android.text.TextPaint import android.view.ViewGroup import android.widget.ImageView +import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.reset import com.nhaarman.mockitokotlin2.verify import com.stripe.android.CardNumberFixtures.VALID_AMEX_NO_SPACES @@ -14,7 +15,6 @@ import com.stripe.android.CardNumberFixtures.VALID_VISA_NO_SPACES import com.stripe.android.CardNumberFixtures.VALID_VISA_WITH_SPACES import com.stripe.android.R import com.stripe.android.model.Address -import com.stripe.android.model.Card import com.stripe.android.model.CardBrand import com.stripe.android.model.PaymentMethod import com.stripe.android.model.PaymentMethodCreateParams @@ -36,8 +36,6 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import org.junit.runner.RunWith -import org.mockito.Mock -import org.mockito.MockitoAnnotations import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -66,14 +64,10 @@ internal class CardInputWidgetTest : BaseViewTest( private val postalCodeEditText: PostalCodeEditText by lazy { cardInputWidget.findViewById(R.id.et_postal_code) } - - @Mock - private lateinit var cardInputListener: CardInputListener + private val cardInputListener: CardInputListener = mock() @BeforeTest fun setup() { - MockitoAnnotations.initMocks(this) - cardInputWidget.setCardNumberTextWatcher(object : StripeTextWatcher() {}) cardInputWidget.setExpiryDateTextWatcher(object : StripeTextWatcher() {}) cardInputWidget.setCvcNumberTextWatcher(object : StripeTextWatcher() {}) @@ -1229,6 +1223,70 @@ internal class CardInputWidgetTest : BaseViewTest( assertNotEquals(cardInputWidget.standardFields, cardInputWidget.allFields) } + @Test + fun testCardValidCallback() { + var currentIsValid = false + var currentInvalidFields = emptySet() + cardInputWidget.setCardValidCallback(object : CardValidCallback { + override fun onInputChanged( + isValid: Boolean, + invalidFields: Set + ) { + currentIsValid = isValid + currentInvalidFields = invalidFields + } + }) + + assertFalse(currentIsValid) + assertEquals( + setOf( + CardValidCallback.Fields.Number, + CardValidCallback.Fields.Expiry, + CardValidCallback.Fields.Cvc + ), + currentInvalidFields + ) + + cardInputWidget.setCardNumber(VALID_VISA_NO_SPACES) + assertFalse(currentIsValid) + assertEquals( + setOf(CardValidCallback.Fields.Expiry, CardValidCallback.Fields.Cvc), + currentInvalidFields + ) + + expiryEditText.append("12") + assertFalse(currentIsValid) + assertEquals( + setOf(CardValidCallback.Fields.Expiry, CardValidCallback.Fields.Cvc), + currentInvalidFields + ) + + expiryEditText.append("50") + assertFalse(currentIsValid) + assertEquals( + setOf(CardValidCallback.Fields.Cvc), + currentInvalidFields + ) + + cvcEditText.append("12") + assertFalse(currentIsValid) + assertEquals( + setOf(CardValidCallback.Fields.Cvc), + currentInvalidFields + ) + + cvcEditText.append("3") + assertTrue(currentIsValid) + assertTrue(currentInvalidFields.isEmpty()) + + cvcEditText.setText("0") + assertFalse(currentIsValid) + assertEquals( + setOf(CardValidCallback.Fields.Cvc), + currentInvalidFields + ) + } + private companion object { // Every Card made by the CardInputView should have the card widget token. private val EXPECTED_LOGGING_ARRAY = arrayOf(LOGGING_TOKEN) diff --git a/stripe/src/test/java/com/stripe/android/view/CardMultilineWidgetTest.kt b/stripe/src/test/java/com/stripe/android/view/CardMultilineWidgetTest.kt index c8b1b789741..ee50a95c45c 100644 --- a/stripe/src/test/java/com/stripe/android/view/CardMultilineWidgetTest.kt +++ b/stripe/src/test/java/com/stripe/android/view/CardMultilineWidgetTest.kt @@ -35,9 +35,7 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import org.junit.runner.RunWith -import org.mockito.Mock import org.mockito.Mockito.reset -import org.mockito.MockitoAnnotations import org.robolectric.RobolectricTestRunner /** @@ -51,10 +49,8 @@ internal class CardMultilineWidgetTest { private lateinit var fullGroup: WidgetControlGroup private lateinit var noZipGroup: WidgetControlGroup - @Mock - private lateinit var fullCardListener: CardInputListener - @Mock - private lateinit var noZipCardListener: CardInputListener + private val fullCardListener: CardInputListener = mock() + private val noZipCardListener: CardInputListener = mock() private val context: Context by lazy { ApplicationProvider.getApplicationContext() @@ -65,8 +61,6 @@ internal class CardMultilineWidgetTest { @BeforeTest fun setup() { - MockitoAnnotations.initMocks(this) - CustomerSession.instance = mock() PaymentConfiguration.init(context, ApiKeyFixtures.FAKE_PUBLISHABLE_KEY) @@ -698,6 +692,70 @@ internal class CardMultilineWidgetTest { assertTrue(fullGroup.postalCodeEditText.isEnabled) } + @Test + fun testCardValidCallback() { + var currentIsValid = false + var currentInvalidFields = emptySet() + cardMultilineWidget.setCardValidCallback(object : CardValidCallback { + override fun onInputChanged( + isValid: Boolean, + invalidFields: Set + ) { + currentIsValid = isValid + currentInvalidFields = invalidFields + } + }) + + assertFalse(currentIsValid) + assertEquals( + setOf( + CardValidCallback.Fields.Number, + CardValidCallback.Fields.Expiry, + CardValidCallback.Fields.Cvc + ), + currentInvalidFields + ) + + cardMultilineWidget.setCardNumber(VALID_VISA_NO_SPACES) + assertFalse(currentIsValid) + assertEquals( + setOf(CardValidCallback.Fields.Expiry, CardValidCallback.Fields.Cvc), + currentInvalidFields + ) + + fullGroup.expiryDateEditText.append("12") + assertFalse(currentIsValid) + assertEquals( + setOf(CardValidCallback.Fields.Expiry, CardValidCallback.Fields.Cvc), + currentInvalidFields + ) + + fullGroup.expiryDateEditText.append("50") + assertFalse(currentIsValid) + assertEquals( + setOf(CardValidCallback.Fields.Cvc), + currentInvalidFields + ) + + fullGroup.cvcEditText.append("12") + assertFalse(currentIsValid) + assertEquals( + setOf(CardValidCallback.Fields.Cvc), + currentInvalidFields + ) + + fullGroup.cvcEditText.append("3") + assertTrue(currentIsValid) + assertTrue(currentInvalidFields.isEmpty()) + + fullGroup.cvcEditText.setText("0") + assertFalse(currentIsValid) + assertEquals( + setOf(CardValidCallback.Fields.Cvc), + currentInvalidFields + ) + } + internal class WidgetControlGroup(parentWidget: CardMultilineWidget) { val cardNumberEditText: CardNumberEditText = parentWidget.findViewById(R.id.et_card_number)