Skip to content

Commit ddf0515

Browse files
Update CardNumberEditText formatting logic (stripe#2757)
Update `panLength` to fall back to `StaticAccountRanges` if `accountRange` is null. Change `afterTextChanged()` logic to compare normalized card number instead of formatted. Attempt to correctly format pasted text based on limited static account ranges.
1 parent 14f3e06 commit ddf0515

File tree

3 files changed

+75
-19
lines changed

3 files changed

+75
-19
lines changed

stripe/src/main/java/com/stripe/android/view/CardNumberEditText.kt

+30-12
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ import com.stripe.android.R
1212
import com.stripe.android.StripeTextUtils
1313
import com.stripe.android.cards.CardAccountRangeRepository
1414
import com.stripe.android.cards.CardNumber
15+
import com.stripe.android.cards.DefaultStaticCardAccountRanges
1516
import com.stripe.android.cards.LegacyCardAccountRangeRepository
1617
import com.stripe.android.cards.StaticCardAccountRangeSource
18+
import com.stripe.android.cards.StaticCardAccountRanges
1719
import com.stripe.android.model.CardBrand
1820
import com.stripe.android.model.CardMetadata
1921
import kotlinx.coroutines.CoroutineDispatcher
@@ -34,7 +36,8 @@ class CardNumberEditText internal constructor(
3436
// TODO(mshafrir-stripe): make immutable after `CardWidgetViewModel` is integrated in `CardWidget` subclasses
3537
internal var workDispatcher: CoroutineDispatcher,
3638

37-
private val cardAccountRangeRepository: CardAccountRangeRepository
39+
private val cardAccountRangeRepository: CardAccountRangeRepository,
40+
private val staticCardAccountRanges: StaticCardAccountRanges = DefaultStaticCardAccountRanges()
3841
) : StripeEditText(context, attrs, defStyleAttr) {
3942

4043
@JvmOverloads
@@ -47,7 +50,8 @@ class CardNumberEditText internal constructor(
4750
attrs,
4851
defStyleAttr,
4952
Dispatchers.IO,
50-
LegacyCardAccountRangeRepository(StaticCardAccountRangeSource())
53+
LegacyCardAccountRangeRepository(StaticCardAccountRangeSource()),
54+
DefaultStaticCardAccountRanges()
5155
)
5256

5357
@VisibleForTesting
@@ -81,7 +85,16 @@ class CardNumberEditText internal constructor(
8185
return cardBrand.getMaxLengthWithSpacesForCardNumber(fieldText)
8286
}
8387

84-
private var panLength = CardNumber.DEFAULT_PAN_LENGTH
88+
private var accountRange: CardMetadata.AccountRange? = null
89+
set(value) {
90+
field = value
91+
updateLengthFilter()
92+
}
93+
94+
private val panLength: Int
95+
get() = accountRange?.panLength
96+
?: staticCardAccountRanges.match(unvalidatedCardNumber)?.panLength
97+
?: CardNumber.DEFAULT_PAN_LENGTH
8598

8699
private val formattedPanLength: Int
87100
get() = panLength + CardNumber.getSpacePositions(panLength).size
@@ -105,6 +118,9 @@ class CardNumberEditText internal constructor(
105118
null
106119
}
107120

121+
private val unvalidatedCardNumber: CardNumber.Unvalidated
122+
get() = CardNumber.Unvalidated(fieldText)
123+
108124
@VisibleForTesting
109125
internal var accountRangeRepositoryJob: Job? = null
110126

@@ -190,11 +206,11 @@ class CardNumberEditText internal constructor(
190206
private var newCursorPosition: Int? = null
191207
private var formattedNumber: String? = null
192208

193-
private var beforeCardNumber = CardNumber.Unvalidated("")
209+
private var beforeCardNumber = unvalidatedCardNumber
194210

195211
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
196212
if (!ignoreChanges) {
197-
beforeCardNumber = CardNumber.Unvalidated(s?.toString().orEmpty())
213+
beforeCardNumber = unvalidatedCardNumber
198214

199215
latestChangeStart = start
200216
latestInsertionSize = after
@@ -212,7 +228,7 @@ class CardNumberEditText internal constructor(
212228
).orEmpty()
213229

214230
val cardNumber = CardNumber.Unvalidated(spacelessNumber)
215-
updateCardBrand(cardNumber)
231+
updateAccountRange(cardNumber)
216232

217233
val formattedNumber = cardNumber.getFormatted(panLength)
218234
this.newCursorPosition = updateSelectionIndex(
@@ -242,7 +258,7 @@ class CardNumberEditText internal constructor(
242258

243259
ignoreChanges = false
244260

245-
if (fieldText.length == formattedPanLength) {
261+
if (unvalidatedCardNumber.length == panLength) {
246262
val wasCardNumberValid = isCardNumberValid
247263
isCardNumberValid = CardUtils.isValidCardNumber(fieldText)
248264
shouldShowError = !isCardNumberValid
@@ -263,15 +279,17 @@ class CardNumberEditText internal constructor(
263279
* Have digits been added in this text change.
264280
*/
265281
private val digitsAdded: Boolean
266-
get() = CardNumber.Unvalidated(fieldText).length > beforeCardNumber.length
282+
get() = unvalidatedCardNumber.length > beforeCardNumber.length
267283
})
268284
}
269285

270286
@JvmSynthetic
271-
internal fun updateCardBrand(cardNumber: CardNumber.Unvalidated) {
287+
internal fun updateAccountRange(cardNumber: CardNumber.Unvalidated) {
272288
// cancel in-flight job
273289
cancelAccountRangeRepositoryJob()
274290

291+
accountRange = null
292+
275293
accountRangeRepositoryJob = CoroutineScope(workDispatcher).launch {
276294
val bin = cardNumber.bin
277295
if (bin != null) {
@@ -292,10 +310,10 @@ class CardNumberEditText internal constructor(
292310

293311
@JvmSynthetic
294312
internal suspend fun onAccountRangeResult(
295-
accountRange: CardMetadata.AccountRange?
313+
newAccountRange: CardMetadata.AccountRange?
296314
) = withContext(Dispatchers.Main) {
297-
panLength = accountRange?.panLength ?: CardNumber.DEFAULT_PAN_LENGTH
298-
cardBrand = accountRange?.brand ?: CardBrand.Unknown
315+
accountRange = newAccountRange
316+
cardBrand = newAccountRange?.brand ?: CardBrand.Unknown
299317
isProcessingCallback(false)
300318
}
301319
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.stripe.android.cards
2+
3+
import com.stripe.android.model.CardMetadata
4+
5+
internal class NullCardAccountRangeRepository : CardAccountRangeRepository {
6+
override suspend fun getAccountRange(
7+
cardNumber: CardNumber.Unvalidated
8+
): CardMetadata.AccountRange? = null
9+
}

stripe/src/test/java/com/stripe/android/view/CardNumberEditTextTest.kt

+36-7
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import com.stripe.android.cards.AccountRangeFixtures
2323
import com.stripe.android.cards.CardAccountRangeRepository
2424
import com.stripe.android.cards.CardNumber
2525
import com.stripe.android.cards.LegacyCardAccountRangeRepository
26+
import com.stripe.android.cards.NullCardAccountRangeRepository
2627
import com.stripe.android.cards.StaticCardAccountRangeSource
2728
import com.stripe.android.model.BinFixtures
2829
import com.stripe.android.model.CardBrand
@@ -229,7 +230,24 @@ internal class CardNumberEditTextTest {
229230
}
230231

231232
@Test
232-
fun `valid Amex should change isCardNumberValid to true and invoke completion callback`() {
233+
fun `updating text with null account range should format text correctly but not set card brand`() {
234+
val cardNumberEditText = CardNumberEditText(
235+
context,
236+
workDispatcher = testDispatcher,
237+
cardAccountRangeRepository = NullCardAccountRangeRepository()
238+
)
239+
240+
cardNumberEditText.setText(AMEX_NO_SPACES)
241+
idle()
242+
243+
assertThat(cardNumberEditText.fieldText)
244+
.isEqualTo(AMEX_WITH_SPACES)
245+
assertThat(cardNumberEditText.cardBrand)
246+
.isEqualTo(CardBrand.Unknown)
247+
}
248+
249+
@Test
250+
fun `full Amex typed as BIN followed by remaining number should change isCardNumberValid to true and invoke completion callback`() {
233251
// type Amex BIN
234252
updateCardNumberAndIdle(CardNumberFixtures.AMEX_BIN)
235253
// type rest of card number
@@ -242,6 +260,17 @@ internal class CardNumberEditTextTest {
242260
.isEqualTo(1)
243261
}
244262

263+
@Test
264+
fun `full Amex typed typed at once should change isCardNumberValid to true and invoke completion callback`() {
265+
updateCardNumberAndIdle(AMEX_NO_SPACES)
266+
idle()
267+
268+
assertThat(cardNumberEditText.isCardNumberValid)
269+
.isTrue()
270+
assertThat(completionCallbackInvocations)
271+
.isEqualTo(1)
272+
}
273+
245274
@Test
246275
fun setText_whenTextChangesFromValidToInvalid_changesCardValidState() {
247276
updateCardNumberAndIdle(VISA_WITH_SPACES)
@@ -535,19 +564,19 @@ internal class CardNumberEditTextTest {
535564
}
536565

537566
@Test
538-
fun `updateCardBrand() should update cardBrand value`() {
539-
cardNumberEditText.updateCardBrand(CardNumberFixtures.DINERS_CLUB_14)
567+
fun `updateAccountRange() should update cardBrand value`() {
568+
cardNumberEditText.updateAccountRange(CardNumberFixtures.DINERS_CLUB_14)
540569
idle()
541570
assertEquals(CardBrand.DinersClub, lastBrandChangeCallbackInvocation)
542571

543-
cardNumberEditText.updateCardBrand(CardNumberFixtures.AMEX)
572+
cardNumberEditText.updateAccountRange(CardNumberFixtures.AMEX)
544573
idle()
545574
assertEquals(CardBrand.AmericanExpress, lastBrandChangeCallbackInvocation)
546575
}
547576

548577
@Test
549-
fun `updateCardBrand() with null bin should set cardBrand to Unknown`() {
550-
cardNumberEditText.updateCardBrand(CardNumber.Unvalidated(""))
578+
fun `updateAccountRange() with null bin should set cardBrand to Unknown`() {
579+
cardNumberEditText.updateAccountRange(CardNumber.Unvalidated(""))
551580
assertEquals(CardBrand.Unknown, lastBrandChangeCallbackInvocation)
552581
}
553582

@@ -569,7 +598,7 @@ internal class CardNumberEditTextTest {
569598
it.addView(cardNumberEditText)
570599
}
571600

572-
cardNumberEditText.setText(CardNumberFixtures.VISA_NO_SPACES)
601+
cardNumberEditText.setText(VISA_NO_SPACES)
573602
assertThat(cardNumberEditText.accountRangeRepositoryJob)
574603
.isNotNull()
575604

0 commit comments

Comments
 (0)