diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 87a85fd..3afefaa 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -29,7 +29,7 @@ jobs: run: ./gradlew testDebugUnitTest acceptance: - runs-on: macos-13 + runs-on: macos-15-intel environment: PR env: diff --git a/example/src/androidTest/java/com/basistheory/elements/example/CardBrandSelectorTests.kt b/example/src/androidTest/java/com/basistheory/elements/example/CardBrandSelectorTests.kt index 79811bb..a66a9b9 100644 --- a/example/src/androidTest/java/com/basistheory/elements/example/CardBrandSelectorTests.kt +++ b/example/src/androidTest/java/com/basistheory/elements/example/CardBrandSelectorTests.kt @@ -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) @@ -68,7 +68,7 @@ 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) @@ -76,14 +76,14 @@ class CardBrandSelectorTests { 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) @@ -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) @@ -132,11 +132,11 @@ 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) } @@ -144,7 +144,7 @@ class CardBrandSelectorTests { @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) @@ -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) diff --git a/example/src/androidTest/java/com/basistheory/elements/example/CollectCardTests.kt b/example/src/androidTest/java/com/basistheory/elements/example/CollectCardTests.kt index a2d5b9f..f9f1fc4 100644 --- a/example/src/androidTest/java/com/basistheory/elements/example/CollectCardTests.kt +++ b/example/src/androidTest/java/com/basistheory/elements/example/CollectCardTests.kt @@ -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)) @@ -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)) @@ -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)}") @@ -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)}") @@ -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()))) + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 28ce45e..a2922de 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" diff --git a/lib/src/main/java/com/basistheory/elements/model/BinDetails.kt b/lib/src/main/java/com/basistheory/elements/model/BinDetails.kt index 4b3bade..59192af 100644 --- a/lib/src/main/java/com/basistheory/elements/model/BinDetails.kt +++ b/lib/src/main/java/com/basistheory/elements/model/BinDetails.kt @@ -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 @@ -20,6 +33,9 @@ data class BinDetails( @SerializedName("issuer") val issuer: CardIssuer? = null, + @SerializedName("binRange") + val binRange: List? = null, + @SerializedName("additional") val additional: List? = null ) { @@ -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? = null ) companion object { + private val gson = Gson() + + private fun parseBinRanges(jsonObject: JsonObject?): List? { + 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), @@ -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), @@ -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("") + ) } ) } diff --git a/lib/src/main/java/com/basistheory/elements/view/CardBrandSelector.kt b/lib/src/main/java/com/basistheory/elements/view/CardBrandSelector.kt index 420d0fc..deec65b 100644 --- a/lib/src/main/java/com/basistheory/elements/view/CardBrandSelector.kt +++ b/lib/src/main/java/com/basistheory/elements/view/CardBrandSelector.kt @@ -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) { @@ -97,7 +101,7 @@ class CardBrandSelector @JvmOverloads constructor( } selectedBrand = brandName - text = brandName + text = getBrandDisplayName(brandName) // Notify listeners sendBrandSelectionEvent(brandName) @@ -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 -> diff --git a/lib/src/main/java/com/basistheory/elements/view/CardNumberElement.kt b/lib/src/main/java/com/basistheory/elements/view/CardNumberElement.kt index 9243f9e..b4d1949 100644 --- a/lib/src/main/java/com/basistheory/elements/view/CardNumberElement.kt +++ b/lib/src/main/java/com/basistheory/elements/view/CardNumberElement.kt @@ -12,6 +12,7 @@ import com.basistheory.elements.constants.CoBadgedSupport import com.basistheory.elements.event.ChangeEvent import com.basistheory.elements.event.EventDetails import com.basistheory.elements.model.BinDetails +import com.basistheory.elements.model.BinRange import com.basistheory.elements.model.CardMetadata import com.basistheory.elements.model.InputType import com.basistheory.elements.service.ApiClientProvider @@ -41,6 +42,7 @@ class CardNumberElement @JvmOverloads constructor( var coBadgedSupport: List? = null internal var selectedNetwork: String? = null + private var lastBrandOptions: List = emptyList() private val brandSelectionReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { @@ -69,6 +71,8 @@ class CardNumberElement @JvmOverloads constructor( internal var cvcMask: String? = null + private var rawBinDetails: BinDetails? = null + var binDetails: BinDetails? = null private set @@ -100,8 +104,12 @@ class CardNumberElement @JvmOverloads constructor( ) cvcMask = cardBrandDetails?.cvcMask - if (binLookup && bin != null && bin.length == 6 && bin != lastFetchedBin) { - fetchBinDetails(bin) + if (bin != null && bin.length == 6 && (binLookup || !coBadgedSupport.isNullOrEmpty())) { + if (bin != lastFetchedBin) { + fetchBinDetails(bin) + } else if (!coBadgedSupport.isNullOrEmpty()) { + updateBrandSelectorOptions() + } } if (bin == null || bin.length < 6) { @@ -140,7 +148,7 @@ class CardNumberElement @JvmOverloads constructor( } private fun updateBinDetails(binDetails: BinDetails) { - this.binDetails = binDetails + this.rawBinDetails = binDetails if (!coBadgedSupport.isNullOrEmpty()) { updateBrandSelectorOptions() @@ -150,57 +158,107 @@ class CardNumberElement @JvmOverloads constructor( } private fun updateBrandSelectorOptions() { - val binDetails = this.binDetails ?: return - - val brands = mutableListOf() - binDetails.brand?.let { brands.add(it) } + val currentBrandOptions = getBrandOptions() - val additionalBrands = getValidAdditionalBrands() - brands.addAll(additionalBrands) - - val intent = Intent(CardBrandSelector.ACTION_BRAND_OPTIONS_UPDATED).apply { - putStringArrayListExtra(CardBrandSelector.EXTRA_BRAND_OPTIONS, ArrayList(brands)) + if (currentBrandOptions != lastBrandOptions) { + lastBrandOptions = currentBrandOptions + val intent = Intent(CardBrandSelector.ACTION_BRAND_OPTIONS_UPDATED).apply { + putStringArrayListExtra(CardBrandSelector.EXTRA_BRAND_OPTIONS, ArrayList(currentBrandOptions)) + } + LocalBroadcastManager.getInstance(context).sendBroadcast(intent) } - LocalBroadcastManager.getInstance(context).sendBroadcast(intent) } - private fun getValidAdditionalBrands(): List { - val binDetails = this.binDetails ?: return emptyList() - val coBadgedSupport = this.coBadgedSupport ?: return emptyList() + internal fun getBrandOptions(): List { + val binInfo = getFilteredBinDetails() ?: return emptyList() + val brands = mutableListOf() + + binInfo.brand?.let { brands.add(normalizeBrandName(it)) } - if (coBadgedSupport.isEmpty()) return emptyList() + val coBadgedSupport = this.coBadgedSupport + if (coBadgedSupport.isNullOrEmpty()) { + return brands + } val supportedValues = coBadgedSupport.map { it.value } - val validBrands = mutableListOf() - binDetails.additional?.forEach { additional -> - val brandName = additional.brand ?: return@forEach + binInfo.additional?.forEach { additional -> + val rawBrandName = additional.brand ?: return@forEach + val brandName = normalizeBrandName(rawBrandName) if (isValidBrand(brandName, supportedValues)) { - validBrands.add(brandName) + brands.add(brandName) } } - return validBrands + return brands } - private fun isValidBrand(brandName: String, supportedBy: List): Boolean { - val normalizedBrandName = brandName.lowercase().replace(" ", "-") + private fun normalizeBrandName(brandName: String): String { + return brandName.lowercase().replace("_", "-") + } - val isValid = CardBrands.values().any { it.label == normalizedBrandName } - val isSupported = supportedBy.contains(normalizedBrandName) + private fun isValidBrand(brandName: String, supportedBy: List): Boolean { + val isValid = CardBrands.values().any { it.label == brandName } + val isSupported = supportedBy.contains(brandName) return isValid && isSupported } + private fun getFilteredBinDetails(): BinDetails? { + val rawDetails = rawBinDetails ?: return null + val cardValue = getTransformedText() ?: return null + val primaryRanges = rawDetails.binRange ?: emptyList() + + val isValidPrimaryRange = primaryRanges.any { range -> + isCardInBinRange(cardValue, range) + } + + val filteredAdditionals = rawDetails.additional?.filter { additional -> + val ranges = additional.binRange ?: return@filter false + ranges.any { range -> isCardInBinRange(cardValue, range) } + } + + if (!isValidPrimaryRange && filteredAdditionals.isNullOrEmpty()) { + return null + } + + return BinDetails( + brand = if (isValidPrimaryRange) rawDetails.brand else null, + funding = if (isValidPrimaryRange) rawDetails.funding else null, + issuer = if (isValidPrimaryRange) rawDetails.issuer else null, + segment = if (isValidPrimaryRange) rawDetails.segment else null, + binRange = if (isValidPrimaryRange) rawDetails.binRange else null, + additional = filteredAdditionals?.map { additional -> + BinDetails.AdditionalCardDetail( + brand = additional.brand, + funding = additional.funding, + issuer = additional.issuer, + segment = additional.segment, + binRange = additional.binRange + ) + } + ) + } + + private fun isCardInBinRange(cardValue: String, range: BinRange): Boolean { + val binLength = minOf(range.binMin.length, cardValue.length) + val cardBin = cardValue.take(binLength).toLongOrNull() ?: return false + val binMin = range.binMin.take(binLength).toLongOrNull() ?: return false + val binMax = range.binMax.take(binLength).toLongOrNull() ?: return false + return cardBin in binMin..binMax + } + private fun clearBinInfo() { - if (binDetails == null && selectedNetwork == null) return + if (rawBinDetails == null && binDetails == null && selectedNetwork == null) return + rawBinDetails = null binDetails = null selectedNetwork = null lastFetchedBin = null - if (!coBadgedSupport.isNullOrEmpty()) { + if (!coBadgedSupport.isNullOrEmpty() && lastBrandOptions.isNotEmpty()) { + lastBrandOptions = emptyList() val intent = Intent(CardBrandSelector.ACTION_BRAND_OPTIONS_UPDATED).apply { putStringArrayListExtra(CardBrandSelector.EXTRA_BRAND_OPTIONS, ArrayList()) } @@ -237,7 +295,10 @@ class CardNumberElement @JvmOverloads constructor( ) } - this.binDetails?.let { + val filteredBinDetails = getFilteredBinDetails() + this.binDetails = filteredBinDetails + + filteredBinDetails?.let { eventDetails.add( EventDetails( EventDetails.BinDetails, @@ -247,7 +308,7 @@ class CardNumberElement @JvmOverloads constructor( ) } - val hasValidAdditionalBrands = getValidAdditionalBrands().isNotEmpty() + val hasValidAdditionalBrands = getBrandOptions().size > 1 val needsBrandSelection = !coBadgedSupport.isNullOrEmpty() && hasValidAdditionalBrands && selectedNetwork == null val complete = isMaskSatisfied && isValid && !needsBrandSelection @@ -257,7 +318,7 @@ class CardNumberElement @JvmOverloads constructor( isValid, isMaskSatisfied, eventDetails, - selectedNetwork + if (hasValidAdditionalBrands) selectedNetwork else null ) }