Skip to content
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
2 changes: 1 addition & 1 deletion .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
run: ./gradlew testDebugUnitTest

acceptance:
runs-on: macos-13
runs-on: macos-15-intel
environment: PR

env:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ class CardBrandSelectorTests {
cardBrandSelector.setConfig(options)

val intent = Intent(CardBrandSelector.ACTION_BRAND_OPTIONS_UPDATED).apply {
putStringArrayListExtra(CardBrandSelector.EXTRA_BRAND_OPTIONS, arrayListOf("VISA"))
putStringArrayListExtra(CardBrandSelector.EXTRA_BRAND_OPTIONS, arrayListOf("visa"))
}
LocalBroadcastManager.getInstance(ApplicationProvider.getApplicationContext()).sendBroadcast(intent)

Expand All @@ -68,22 +68,22 @@ class CardBrandSelectorTests {
@Test
fun testBrandSelectorVisibleWithMultipleSupportedBrands() {
val intent = Intent(CardBrandSelector.ACTION_BRAND_OPTIONS_UPDATED).apply {
putStringArrayListExtra(CardBrandSelector.EXTRA_BRAND_OPTIONS, arrayListOf("VISA", "CARTES BANCAIRES"))
putStringArrayListExtra(CardBrandSelector.EXTRA_BRAND_OPTIONS, arrayListOf("visa", "cartes-bancaires"))
}
LocalBroadcastManager.getInstance(ApplicationProvider.getApplicationContext()).sendBroadcast(intent)

InstrumentationRegistry.getInstrumentation().waitForIdleSync()

assertEquals(View.VISIBLE, cardBrandSelector.visibility)
assertEquals(2, cardBrandSelector.getAvailableCardBrands().size)
assertTrue(cardBrandSelector.getAvailableCardBrands().contains("VISA"))
assertTrue(cardBrandSelector.getAvailableCardBrands().contains("CARTES BANCAIRES"))
assertTrue(cardBrandSelector.getAvailableCardBrands().contains("visa"))
assertTrue(cardBrandSelector.getAvailableCardBrands().contains("cartes-bancaires"))
}

@Test
fun testBrandSelectorHiddenWithSingleBrand() {
val intent = Intent(CardBrandSelector.ACTION_BRAND_OPTIONS_UPDATED).apply {
putStringArrayListExtra(CardBrandSelector.EXTRA_BRAND_OPTIONS, arrayListOf("VISA"))
putStringArrayListExtra(CardBrandSelector.EXTRA_BRAND_OPTIONS, arrayListOf("visa"))
}
LocalBroadcastManager.getInstance(ApplicationProvider.getApplicationContext()).sendBroadcast(intent)

Expand All @@ -96,27 +96,27 @@ class CardBrandSelectorTests {
@Test
fun testBrandSelectionUpdatesSelectedBrand() {
val intent = Intent(CardBrandSelector.ACTION_BRAND_OPTIONS_UPDATED).apply {
putStringArrayListExtra(CardBrandSelector.EXTRA_BRAND_OPTIONS, arrayListOf("VISA", "CARTES BANCAIRES"))
putStringArrayListExtra(CardBrandSelector.EXTRA_BRAND_OPTIONS, arrayListOf("visa", "cartes-bancaires"))
}
LocalBroadcastManager.getInstance(ApplicationProvider.getApplicationContext()).sendBroadcast(intent)

InstrumentationRegistry.getInstrumentation().waitForIdleSync()

assertEquals(2, cardBrandSelector.getAvailableCardBrands().size)
assertTrue(cardBrandSelector.getAvailableCardBrands().contains("VISA"))
assertTrue(cardBrandSelector.getAvailableCardBrands().contains("CARTES BANCAIRES"))
assertTrue(cardBrandSelector.getAvailableCardBrands().contains("visa"))
assertTrue(cardBrandSelector.getAvailableCardBrands().contains("cartes-bancaires"))

cardBrandSelector.setSelectedBrand("CARTES BANCAIRES")
cardBrandSelector.setSelectedBrand("cartes-bancaires")

InstrumentationRegistry.getInstrumentation().waitForIdleSync()

assertEquals("CARTES BANCAIRES", cardBrandSelector.getSelectedCardBrand())
assertEquals("cartes-bancaires", cardBrandSelector.getSelectedCardBrand())
}

@Test
fun testBrandSelectionSendsBroadcast() {
val intent = Intent(CardBrandSelector.ACTION_BRAND_OPTIONS_UPDATED).apply {
putStringArrayListExtra(CardBrandSelector.EXTRA_BRAND_OPTIONS, arrayListOf("VISA", "CARTES BANCAIRES"))
putStringArrayListExtra(CardBrandSelector.EXTRA_BRAND_OPTIONS, arrayListOf("visa", "cartes-bancaires"))
}
LocalBroadcastManager.getInstance(ApplicationProvider.getApplicationContext()).sendBroadcast(intent)

Expand All @@ -132,19 +132,19 @@ class CardBrandSelectorTests {
LocalBroadcastManager.getInstance(ApplicationProvider.getApplicationContext())
.registerReceiver(receiver, android.content.IntentFilter(CardBrandSelector.ACTION_BRAND_SELECTED))

cardBrandSelector.setSelectedBrand("CARTES BANCAIRES")
cardBrandSelector.setSelectedBrand("cartes-bancaires")

InstrumentationRegistry.getInstrumentation().waitForIdleSync()

assertEquals("CARTES BANCAIRES", receivedBrand)
assertEquals("cartes-bancaires", receivedBrand)

LocalBroadcastManager.getInstance(ApplicationProvider.getApplicationContext()).unregisterReceiver(receiver)
}

@Test
fun testBrandSelectionTriggersCallback() {
val intent = Intent(CardBrandSelector.ACTION_BRAND_OPTIONS_UPDATED).apply {
putStringArrayListExtra(CardBrandSelector.EXTRA_BRAND_OPTIONS, arrayListOf("VISA", "CARTES BANCAIRES"))
putStringArrayListExtra(CardBrandSelector.EXTRA_BRAND_OPTIONS, arrayListOf("visa", "cartes-bancaires"))
}
LocalBroadcastManager.getInstance(ApplicationProvider.getApplicationContext()).sendBroadcast(intent)

Expand All @@ -155,17 +155,17 @@ class CardBrandSelectorTests {
callbackBrand = selectedBrand
}

cardBrandSelector.setSelectedBrand("CARTES BANCAIRES")
cardBrandSelector.setSelectedBrand("cartes-bancaires")

InstrumentationRegistry.getInstrumentation().waitForIdleSync()

assertEquals("CARTES BANCAIRES", callbackBrand)
assertEquals("cartes-bancaires", callbackBrand)
}

@Test
fun testBrandSelectorClearedWhenBinInfoCleared() {
val intent1 = Intent(CardBrandSelector.ACTION_BRAND_OPTIONS_UPDATED).apply {
putStringArrayListExtra(CardBrandSelector.EXTRA_BRAND_OPTIONS, arrayListOf("VISA", "CARTES BANCAIRES"))
putStringArrayListExtra(CardBrandSelector.EXTRA_BRAND_OPTIONS, arrayListOf("visa", "cartes-bancaires"))
}
LocalBroadcastManager.getInstance(ApplicationProvider.getApplicationContext()).sendBroadcast(intent1)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ class CollectCardTests {

@Test
fun cobadgeWorkflowShowsMultipleBrands() {
onView(withId(R.id.card_number)).perform(scrollTo(), typeText("4020971234567899"))
onView(withId(R.id.card_number)).perform(scrollTo(), typeText("4020978034567896"))

onView(withId(R.id.card_brand_selector))
.perform(waitUntilVisible(10000L))
Expand All @@ -123,7 +123,7 @@ class CollectCardTests {

@Test
fun cobadgeWorkflowClearsWhenCardNumberCleared() {
onView(withId(R.id.card_number)).perform(scrollTo(), typeText("4020971234567899"))
onView(withId(R.id.card_number)).perform(scrollTo(), typeText("4020978034567896"))

onView(withId(R.id.card_brand_selector))
.perform(waitUntilVisible(10000L))
Expand All @@ -142,7 +142,7 @@ class CollectCardTests {
val expYear = (LocalDate.now().year + 1).toString()
val cvc = "123"

onView(withId(R.id.card_number)).perform(scrollTo(), typeText("4020971234567899"))
onView(withId(R.id.card_number)).perform(scrollTo(), typeText("4020978034567896"))
onView(withId(R.id.expiration_date)).perform(
scrollTo(),
typeText("$expMonth/${expYear.takeLast(2)}")
Expand All @@ -161,7 +161,7 @@ class CollectCardTests {
val expYear = (LocalDate.now().year + 1).toString()
val cvc = "123"

onView(withId(R.id.card_number)).perform(scrollTo(), typeText("4020971234567899"))
onView(withId(R.id.card_number)).perform(scrollTo(), typeText("4020978034567896"))
onView(withId(R.id.expiration_date)).perform(
scrollTo(),
typeText("$expMonth/${expYear.takeLast(2)}")
Expand All @@ -176,4 +176,20 @@ class CollectCardTests {

onView(withId(R.id.tokenize_button)).check(matches(isEnabled()))
}

@Test
fun cobadgeWorkflowFiltersBinInfo() {
onView(withId(R.id.card_number)).perform(scrollTo(), typeText("4020978"))

onView(withId(R.id.card_brand_selector))
.perform(waitUntilVisible(10000L))
.check(matches(isDisplayed()))

onView(withId(R.id.card_number)).perform(scrollTo(), clearTextElement())
onView(withId(R.id.card_number)).perform(scrollTo(), typeText("4020977"))

onView(withId(R.id.card_brand_selector))
.perform(waitUntilGone(5000L))
.check(matches(not(isDisplayed())))
}
}
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ activityCompose = "1.9.3"
androidGifDrawable = "1.2.27"
androidGradlePlugin = "8.9.1"
appcompat = "1.7.0"
basistheoryJava = "4.0.0"
basistheoryJava = "4.2.0"
commonsLang3 = "3.17.0"
constraintlayout = "2.2.0"
desugar_jdk_libs = "2.1.4"
Expand Down
57 changes: 52 additions & 5 deletions lib/src/main/java/com/basistheory/elements/model/BinDetails.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
package com.basistheory.elements.model

import com.basistheory.types.CardDetailsResponse
import com.google.gson.Gson
import com.google.gson.JsonObject
import com.google.gson.annotations.SerializedName

/**
* Represents a BIN range with minimum and maximum values
*/
data class BinRange(
@SerializedName("binMin")
val binMin: String,

@SerializedName("binMax")
val binMax: String
)

/**
* Represents detailed information about a card BIN (Bank Identification Number)
* Retrieved from the Basis Theory enrichments API when a card number reaches 6+ digits
Expand All @@ -20,6 +33,9 @@ data class BinDetails(
@SerializedName("issuer")
val issuer: CardIssuer? = null,

@SerializedName("binRange")
val binRange: List<BinRange>? = null,

@SerializedName("additional")
val additional: List<AdditionalCardDetail>? = null
) {
Expand All @@ -30,22 +46,41 @@ data class BinDetails(
@SerializedName("country")
val country: String? = null
)

data class AdditionalCardDetail(
@SerializedName("brand")
val brand: String? = null,

@SerializedName("funding")
val funding: String? = null,

@SerializedName("segment")
val segment: String? = null,

@SerializedName("issuer")
val issuer: CardIssuer? = null
val issuer: CardIssuer? = null,

@SerializedName("binRange")
val binRange: List<BinRange>? = null
)

companion object {
private val gson = Gson()

private fun parseBinRanges(jsonObject: JsonObject?): List<BinRange>? {
return try {
jsonObject?.getAsJsonArray("binRange")?.map { element ->
val rangeObj = element.asJsonObject
BinRange(
binMin = rangeObj.get("binMin")?.asString ?: "",
binMax = rangeObj.get("binMax")?.asString ?: ""
)
}
} catch (e: Exception) {
null
}
}

fun fromResponse(response: CardDetailsResponse): BinDetails {
return BinDetails(
brand = response.brand.orElse(null),
Expand All @@ -57,6 +92,12 @@ data class BinDetails(
country = issuer.country.orElse(null)
)
},
binRange = response.binRange.orElse(null)?.map { range ->
BinRange(
binMin = range.binMin.orElse(""),
binMax = range.binMax.orElse("")
)
},
additional = response.additional.orElse(null)?.map { additional ->
AdditionalCardDetail(
brand = additional.brand.orElse(null),
Expand All @@ -67,6 +108,12 @@ data class BinDetails(
name = issuer.name.orElse(null),
country = issuer.country.orElse(null)
)
},
binRange = additional.binRange.orElse(null)?.map { range ->
BinRange(
binMin = range.binMin.orElse(""),
binMax = range.binMax.orElse("")
)
}
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ class CardBrandSelector @JvmOverloads constructor(
private var brandSelectionCallback: ((String) -> Unit)? = null
private var defaultTitle: String? = null

private fun getBrandDisplayName(brandKey: String): String {
return brandKey.uppercase().replace("-", " ")
}

private val brandOptionsReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
Expand Down Expand Up @@ -97,7 +101,7 @@ class CardBrandSelector @JvmOverloads constructor(
}

selectedBrand = brandName
text = brandName
text = getBrandDisplayName(brandName)

// Notify listeners
sendBrandSelectionEvent(brandName)
Expand Down Expand Up @@ -159,7 +163,7 @@ class CardBrandSelector @JvmOverloads constructor(
val popup = PopupMenu(context, this)

availableBrands.forEachIndexed { index, brand ->
popup.menu.add(0, index, index, brand)
popup.menu.add(0, index, index, getBrandDisplayName(brand))
}

popup.setOnMenuItemClickListener { menuItem ->
Expand Down
Loading
Loading