Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: computing cvc mask based on card brand #20

Merged
merged 7 commits into from
Dec 15, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ class CardFragment : Fragment() {
binding.lifecycleOwner = this
binding.viewModel = viewModel

binding.cvc.cardNumberElement = binding.cardNumber

binding.tokenizeButton.setOnClickListener { tokenize() }
binding.autofillButton.setOnClickListener { autofill() }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package com.basistheory.android.service

import com.basistheory.android.constants.CardBrands

internal class CardBrandEnricher {
class CardBrandEnricher {

object CardMasks {
const val MASK_4_8_12GAPS_19LENGTH = "#### #### #### #######"
Expand All @@ -21,18 +21,21 @@ internal class CardBrandEnricher {
var identifierRanges: List<Pair<String, String?>>,
var validLengths: IntArray,
var cvcMask: String,
var cardMask: String
)

class CardResult(
var cardDetails: CardDetails?,
var cardMask: String,
var cardLength: Int = -1,
var identifierLength: Int = -1,
) {
val complete: Boolean
get() = cardDetails?.validLengths?.contains(cardLength) ?: false
val isComplete: Boolean
get() = validLengths.contains(cardLength)
}

class CardMetadata(
val brand: String?,
val cvcMask: String?,
val cardMask: String?,
val isComplete: Boolean
)

private val cardBrands = listOf(
CardDetails(
CardBrands.VISA.label,
Expand Down Expand Up @@ -205,10 +208,10 @@ internal class CardBrandEnricher {
)
)

fun evaluateCard(number: String?): CardResult {
if (number.isNullOrBlank()) return CardResult(null)
fun evaluateCard(number: String?): CardMetadata? {
if (number.isNullOrBlank()) return null

var bestMatch = CardResult(null)
var bestMatch: CardDetails? = null

cardBrands.forEach { cardDetails ->
cardDetails.identifierRanges.forEach { range ->
Expand All @@ -222,19 +225,32 @@ internal class CardBrandEnricher {
}
}

return bestMatch
return with(bestMatch) {
CardMetadata(
this?.brand,
this?.cvcMask,
this?.cardMask,
this?.isComplete ?: false
)
}
}

private fun chooseBestMatch(
currentBestMatch: CardResult,
currentBestMatch: CardDetails?,
cardDetails: CardDetails,
identifierMatch: String,
number: String
): CardResult =
if (currentBestMatch.identifierLength < identifierMatch.length) CardResult(
cardDetails,
number.length,
identifierMatch.length
)
): CardDetails? =
if ((currentBestMatch?.identifierLength ?: -1) < identifierMatch.length) with(cardDetails) {
CardDetails(
this.brand,
this.identifierRanges,
this.validLengths,
this.cvcMask,
this.cardMask,
number.length,
identifierMatch.length
)
}
else currentBestMatch
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ class CardNumberElement @JvmOverloads constructor(
defStyleAttr: Int = 0
) : TextElement(context, attrs, defStyleAttr) {

var cardMetadata: CardBrandEnricher.CardMetadata? = null
private set

private val cardBrandEnricher: CardBrandEnricher = CardBrandEnricher()

init {
Expand All @@ -26,9 +29,10 @@ class CardNumberElement @JvmOverloads constructor(
}

override fun beforeTextChanged(value: String?): String? {
val cardResult = cardBrandEnricher.evaluateCard(getDigitsOnly(value))
if (cardResult.cardDetails?.cardMask != null)
mask = ElementMask(cardResult.cardDetails!!.cardMask)
cardMetadata = cardBrandEnricher.evaluateCard(getDigitsOnly(value))

if (cardMetadata?.cardMask != null)
mask = ElementMask(cardMetadata!!.cardMask!!)

return value
}
Expand All @@ -39,8 +43,7 @@ class CardNumberElement @JvmOverloads constructor(
isEmpty: Boolean,
isValid: Boolean
): ChangeEvent {
val cardResult = cardBrandEnricher.evaluateCard(getDigitsOnly(value))
val eventDetails = cardResult.cardDetails?.brand?.let { brand ->
val eventDetails = this.cardMetadata?.brand?.let { brand ->
mutableListOf(
EventDetails(
"cardBrand",
Expand All @@ -50,7 +53,7 @@ class CardNumberElement @JvmOverloads constructor(
} ?: mutableListOf()

return ChangeEvent(
cardResult.complete,
cardMetadata?.isComplete ?: false,
isEmpty,
isValid,
eventDetails
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,20 @@ import com.basistheory.android.view.validation.RegexValidator
class CardVerificationCodeElement @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0) : TextElement(context, attrs, defStyleAttr) {
defStyleAttr: Int = 0
) : TextElement(context, attrs, defStyleAttr) {

var cardNumberElement: CardNumberElement? = null
set(value) {
if (value != null && cardNumberElement !== value) {
field = value
super.mask =
cardNumberElement?.cardMetadata?.cvcMask?.let { ElementMask(it) } ?: defaultMask
field?.addChangeEventListener { updateMask() }
dhudec marked this conversation as resolved.
Show resolved Hide resolved
} else {
field = value
}
}

init {
super.keyboardType = KeyboardType.NUMBER
Expand All @@ -24,4 +37,9 @@ class CardVerificationCodeElement @JvmOverloads constructor(
listOf(digit, digit, digit)
)
}

private fun updateMask() {
super.mask =
cardNumberElement?.cardMetadata?.cvcMask?.let { ElementMask(it) } ?: defaultMask
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import com.basistheory.android.view.mask.ElementMask
import com.basistheory.android.view.transform.ElementTransform
import com.basistheory.android.view.validation.ElementValidator


open class TextElement @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,40 +37,36 @@ class CardBrandEnricherTests {
expectedBrand: String,
expectedCardMask: String
) {
with(cardBrandEnricher.evaluateCard(cardNumber).cardDetails) {
with(cardBrandEnricher.evaluateCard(cardNumber)) {
expectThat(this?.brand).isEqualTo(expectedBrand)
expectThat(this?.cardMask).isEqualTo(expectedCardMask)
}
}

@Test
fun `should pick best match based on identifier length`() {
expectThat(cardBrandEnricher.evaluateCard("4011784867543859").cardDetails?.brand).isEqualTo(
expectThat(cardBrandEnricher.evaluateCard("4011784867543859")?.brand).isEqualTo(
"elo"
)
}

@Test
fun `should set complete for best match card lengths`() {
fun `should set isComplete for best match card lengths`() {
// discover valid lengths are 16 or 19
val sixteenDigitsDiscoverCardNumber = "6582937163058334"

expectThat(cardBrandEnricher.evaluateCard(sixteenDigitsDiscoverCardNumber).complete).isTrue()
expectThat(cardBrandEnricher.evaluateCard("${sixteenDigitsDiscoverCardNumber}1").complete).isFalse()
expectThat(cardBrandEnricher.evaluateCard("${sixteenDigitsDiscoverCardNumber}12").complete).isFalse()
expectThat(cardBrandEnricher.evaluateCard("${sixteenDigitsDiscoverCardNumber}123").complete).isTrue()
expectThat(cardBrandEnricher.evaluateCard(sixteenDigitsDiscoverCardNumber)?.isComplete).isTrue()
expectThat(cardBrandEnricher.evaluateCard("${sixteenDigitsDiscoverCardNumber}1")?.isComplete).isFalse()
expectThat(cardBrandEnricher.evaluateCard("${sixteenDigitsDiscoverCardNumber}12")?.isComplete).isFalse()
expectThat(cardBrandEnricher.evaluateCard("${sixteenDigitsDiscoverCardNumber}123")?.isComplete).isTrue()
}

@Test
fun `should handle null or empty card number`() {
val nullCard = cardBrandEnricher.evaluateCard(null)

expectThat(nullCard.cardDetails).isNull()
expectThat(nullCard.complete).isFalse()
expectThat(nullCard).isNull()

val emptyCard = cardBrandEnricher.evaluateCard("")

expectThat(emptyCard.cardDetails).isNull()
expectThat(emptyCard.complete).isFalse()
expectThat(emptyCard).isNull()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ package com.basistheory.android.view

import android.app.Activity
import com.basistheory.android.event.ChangeEvent
import io.mockk.impl.annotations.RelaxedMockK
import io.mockk.impl.annotations.SpyK
import io.mockk.junit4.MockKRule
import io.mockk.spyk
import io.mockk.verify
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
Expand All @@ -17,10 +23,13 @@ import strikt.assertions.single
class CardVerificationCodeElementTests {
private lateinit var cvcElement: CardVerificationCodeElement

private lateinit var cardNumberElement: CardNumberElement

@Before
fun setUp() {
val activity = Robolectric.buildActivity(Activity::class.java).get()
cvcElement = CardVerificationCodeElement(activity)
cardNumberElement = CardNumberElement(activity)
}

@Test
Expand All @@ -33,11 +42,26 @@ class CardVerificationCodeElementTests {
}

@Test
fun `applies mask when setting the value`() {
fun `applies default mask when setting the value and no card number element is attached`() {
cvcElement.cardNumberElement = null

cvcElement.setText("1a2b3c")
expectThat(cvcElement.getText()).isEqualTo("123")
}

@Test
fun `updates mask depending on card brand`() {
cvcElement.cardNumberElement = cardNumberElement

cardNumberElement.setText("42")
cvcElement.setText("1a2b3c4d5e")
expectThat(cvcElement.getText()).isEqualTo("123")

cardNumberElement.setText("34")
cvcElement.setText("1a2b3c4d5e")
expectThat(cvcElement.getText()).isEqualTo("1234")
}

@Test
fun `ChangeEvent is computed properly for incomplete cvc values`() {
val changeEvents = mutableListOf<ChangeEvent>()
Expand All @@ -63,4 +87,13 @@ class CardVerificationCodeElementTests {
get { isComplete }.isTrue()
}
}

@Test
fun `setting card number element multiple times does not duplicate listeners`() {
val cardNumberElement = spyk(CardNumberElement(Robolectric.buildActivity(Activity::class.java).get()))
cvcElement.cardNumberElement = cardNumberElement
cvcElement.cardNumberElement = cardNumberElement

verify(exactly = 1) { cardNumberElement.addChangeEventListener(any()) }
}
}