Skip to content

Commit

Permalink
Add CardValidCallback and add support in card forms
Browse files Browse the repository at this point in the history
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
  • Loading branch information
mshafrir-stripe committed Jan 23, 2020
1 parent 9902e37 commit 3ed36fe
Show file tree
Hide file tree
Showing 8 changed files with 259 additions and 30 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<CardValidCallback.Fields>
) {
// added as an example - no-op
}
})

btn_create_payment_method.setOnClickListener { createPaymentMethod() }
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.*

Expand Down Expand Up @@ -60,6 +61,15 @@ class CreateCardTokenActivity : AppCompatActivity() {
}
}

card_input_widget.setCardValidCallback(object : CardValidCallback {
override fun onInputChanged(
isValid: Boolean,
invalidFields: Set<CardValidCallback.Fields>
) {
// added as an example - no-op
}
})

card_input_widget.requestFocus()
}

Expand Down
35 changes: 35 additions & 0 deletions stripe/src/main/java/com/stripe/android/view/CardInputWidget.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<CardValidCallback.Fields>
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
Expand Down Expand Up @@ -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.
*
Expand Down
67 changes: 53 additions & 14 deletions stripe/src/main/java/com/stripe/android/view/CardMultilineWidget.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<CardValidCallback.Fields>
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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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<Int, Int>?
get() {
return expiryDateEditText.validDateFields
}
private val isCvcLengthValid: Boolean
get() {
return cardBrand.isValidCvc(cvcEditText.rawCvcValue)
}
private val allFields: Collection<StripeEditText>
get() {
return listOf(
cardNumberEditText, expiryDateEditText, cvcEditText, postalCodeEditText
)
}

private val cvcHelperText: Int
@StringRes
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
17 changes: 17 additions & 0 deletions stripe/src/main/java/com/stripe/android/view/CardValidCallback.kt
Original file line number Diff line number Diff line change
@@ -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<Fields>)

enum class Fields {
Number,
Expiry,
Cvc
}
}
2 changes: 2 additions & 0 deletions stripe/src/main/java/com/stripe/android/view/CardWidget.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ internal interface CardWidget {
*/
val paymentMethodCreateParams: PaymentMethodCreateParams?

fun setCardValidCallback(callback: CardValidCallback?)

fun setCardInputListener(listener: CardInputListener?)

fun setCardHint(cardHint: String)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -66,14 +64,10 @@ internal class CardInputWidgetTest : BaseViewTest<CardInputTestActivity>(
private val postalCodeEditText: PostalCodeEditText by lazy {
cardInputWidget.findViewById<PostalCodeEditText>(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() {})
Expand Down Expand Up @@ -1229,6 +1223,70 @@ internal class CardInputWidgetTest : BaseViewTest<CardInputTestActivity>(
assertNotEquals(cardInputWidget.standardFields, cardInputWidget.allFields)
}

@Test
fun testCardValidCallback() {
var currentIsValid = false
var currentInvalidFields = emptySet<CardValidCallback.Fields>()
cardInputWidget.setCardValidCallback(object : CardValidCallback {
override fun onTextChange(
isValid: Boolean,
invalidFields: Set<CardValidCallback.Fields>
) {
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)
Expand Down
Loading

0 comments on commit 3ed36fe

Please sign in to comment.