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
6 changes: 5 additions & 1 deletion .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ jobs:
target: default
arch: x86_64
profile: pixel_3a
script: ./gradlew connectedCheck
script: |
adb shell settings put global window_animation_scale 0
adb shell settings put global transition_animation_scale 0
adb shell settings put global animator_duration_scale 0
./gradlew connectedCheck

security:
name: Security scan
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ class CollectCardTests {

// assertions on tokenize response
onView(withId(R.id.result))
.perform(waitUntilVisible())
.perform(waitUntilVisible(15000L))
.check(
matches(
allOf(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ class CollectSocialSecurityNumberTests {

// assertions on tokenize response
onView(withId(R.id.result))
.perform(waitUntilVisible())
.perform(waitUntilVisible(15000L))
.check(matches(withSubstring("123-45-6789")))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import androidx.navigation.ui.setupActionBarWithNavController
import androidx.navigation.ui.setupWithNavController
import com.basistheory.elements.example.R
import com.basistheory.elements.example.databinding.ActivityMainBinding
import com.basistheory.elements.service.BasisTheoryElements
import com.google.android.material.navigation.NavigationView

class MainActivity : AppCompatActivity() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.basistheory.elements.example.view.card

import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
Expand All @@ -9,6 +10,7 @@ import androidx.fragment.app.viewModels
import com.basistheory.elements.example.databinding.FragmentCardBinding
import com.basistheory.elements.example.util.tokenExpirationTimestamp
import com.basistheory.elements.example.viewmodel.CardFragmentViewModel
import com.basistheory.elements.service.BasisTheoryElements

class CardFragment : Fragment() {
private val binding: FragmentCardBinding by lazy {
Expand All @@ -27,6 +29,10 @@ class CardFragment : Fragment() {

binding.cvc.cardNumberElement = binding.cardNumber

viewModel.setupCardNumberElement(binding.cardNumber)

binding.cardNumber.binLookup = true

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

Expand Down Expand Up @@ -58,8 +64,9 @@ class CardFragment : Fragment() {
* demonstrates how an application could potentially wire up custom validation behaviors
*/
private fun setValidationListeners() {
binding.cardNumber.addChangeEventListener {
viewModel.cardNumber.observe(it)
binding.cardNumber.addChangeEventListener { event ->
viewModel.cardNumber.observe(event)
Log.d("Event", event.toString())
}
binding.expirationDate.addChangeEventListener {
viewModel.cardExpiration.observe(it)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ open class ApiViewModel(application: Application) : AndroidViewModel(application
.apiKey(BuildConfig.BASIS_THEORY_API_KEY)
.build()

internal fun getBasisTheoryElements(): BasisTheoryElements = bt

val client = Client()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.app.Application
import androidx.lifecycle.MediatorLiveData
import androidx.lifecycle.MutableLiveData
import com.basistheory.elements.event.ChangeEvent
import com.basistheory.elements.view.CardNumberElement

class CardFragmentViewModel(application: Application) : ApiViewModel(application) {
val cardNumber = ElementViewModel()
Expand All @@ -24,6 +25,10 @@ class CardFragmentViewModel(application: Application) : ApiViewModel(application

private fun coalesce(vararg states: Boolean?): Boolean =
states.all { it == true }

fun setupCardNumberElement(cardNumberElement: CardNumberElement) {
cardNumberElement.setBasisTheoryElements(getBasisTheoryElements())
}
}

class ElementViewModel {
Expand Down
4 changes: 3 additions & 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 = "1.3.0"
basistheoryJava = "4.0.0"
commonsLang3 = "3.17.0"
constraintlayout = "2.2.0"
desugar_jdk_libs = "2.1.4"
Expand All @@ -16,6 +16,7 @@ junitVersion = "1.2.1"
kotlin = "2.1.0"
kotlinxCoroutinesCore = "1.7.3"
kotlinxCoroutinesAndroid = "1.7.3"
kotlinxCoroutinesTest = "1.7.3"
ktx = "1.15.0"
lifecycleRuntimeKtx = "2.8.7"
compose = "1.7.6"
Expand Down Expand Up @@ -59,6 +60,7 @@ junit = { module = "junit:junit", version.ref = "junit" }
junitparams = { module = "pl.pragmatists:JUnitParams", version.ref = "junitparams" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }
material = { module = "com.google.android.material:material", version.ref = "material" }
mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
Expand Down
1 change: 1 addition & 0 deletions lib/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ dependencies {
testImplementation(libs.mockk)
testImplementation(libs.mockwebserver)
testImplementation(libs.javafaker)
testImplementation(libs.kotlinx.coroutines.test)
coreLibraryDesugaring(libs.desugar.jdk.libs)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.basistheory.elements.event

import com.basistheory.elements.model.BinDetails

data class ChangeEvent(
val isComplete: Boolean,
val isEmpty: Boolean,
Expand All @@ -10,11 +12,13 @@ data class ChangeEvent(

data class EventDetails(
val type: String,
val message: String
val message: String,
val data: Any? = null
) {
companion object {
const val CardBrand = "cardBrand"
const val CardBin = "cardBin"
const val CardLast4 = "cardLast4"
const val BinDetails = "binDetails"
}
}
76 changes: 76 additions & 0 deletions lib/src/main/java/com/basistheory/elements/model/BinDetails.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.basistheory.elements.model

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

/**
* Represents detailed information about a card BIN (Bank Identification Number)
* Retrieved from the Basis Theory enrichments API when a card number reaches 6+ digits
*/
data class BinDetails(
@SerializedName("brand")
val brand: String? = null,

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

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

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

@SerializedName("additional")
val additional: List<AdditionalCardDetail>? = null
) {
data class CardIssuer(
@SerializedName("name")
val name: String? = null,

@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
)

companion object {
fun fromResponse(response: CardDetailsResponse): BinDetails {
return BinDetails(
brand = response.brand.orElse(null),
funding = response.funding.orElse(null),
segment = response.segment.orElse(null),
issuer = response.issuer.orElse(null)?.let { issuer ->
CardIssuer(
name = issuer.name.orElse(null),
country = issuer.country.orElse(null)
)
},
additional = response.additional.orElse(null)?.map { additional ->
AdditionalCardDetail(
brand = additional.brand.orElse(null),
funding = additional.funding.orElse(null),
segment = additional.segment.orElse(null),
issuer = additional.issuer.orElse(null)?.let { issuer ->
CardIssuer(
name = issuer.name.orElse(null),
country = issuer.country.orElse(null)
)
}
)
}
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ class CreateTokenRequest(
var tokenIntentId: String? = null,
)

internal fun CreateTokenRequest.toJava(): com.basistheory.resources.tokens.requests.CreateTokenRequest =
com.basistheory.resources.tokens.requests.CreateTokenRequest.builder()
internal fun CreateTokenRequest.toJava(): com.basistheory.types.CreateTokenRequest =
com.basistheory.types.CreateTokenRequest.builder()
.id([email protected])
.data([email protected])
.type([email protected])
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.basistheory.elements.service

import com.basistheory.BasisTheoryApiClient
import com.basistheory.resources.enrichments.EnrichmentsClient
import com.basistheory.resources.sessions.SessionsClient
import com.basistheory.resources.tokenintents.TokenIntentsClient
import com.basistheory.resources.tokens.TokensClient
Expand All @@ -20,6 +21,9 @@ internal class ApiClientProvider(
fun getTokenIntentsApi(apiKeyOverride: String? = null): TokenIntentsClient =
getApiClient(apiKeyOverride).tokenIntents()

fun getEnrichmentsApi(apiKeyOverride: String? = null): EnrichmentsClient =
getApiClient(apiKeyOverride).enrichments()

fun getProxyApi(dispatcher: CoroutineDispatcher = Dispatchers.IO): ProxyApi {
requireNotNull(defaultApiKey)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import com.basistheory.elements.model.exceptions.ApiException
import com.basistheory.elements.model.exceptions.EncryptTokenException
import com.basistheory.elements.model.toAndroid
import com.basistheory.elements.model.toJava
import com.basistheory.types.CardDetailsResponse
import com.basistheory.resources.enrichments.requests.EnrichmentsGetCardDetailsRequest
import com.basistheory.elements.util.JWEEncryption
import com.basistheory.elements.util.getElementsValues
import com.basistheory.elements.util.isPrimitiveType
Expand Down Expand Up @@ -181,6 +183,20 @@ class BasisTheoryElements internal constructor(
}


@JvmOverloads
suspend fun getCardDetails(bin: String, apiKeyOverride: String? = null): CardDetailsResponse? =
try {
withContext(dispatcher) {
val enrichmentsClient = apiClientProvider.getEnrichmentsApi(apiKeyOverride)
val request = EnrichmentsGetCardDetailsRequest.builder()
.bin(bin)
.build()
enrichmentsClient.getcarddetails(request)
}
} catch (e: com.basistheory.core.BasisTheoryApiApiException) {
throw ApiException(e.statusCode(), e.headers(), e.body().toString(), e.message)
}

companion object {
@JvmStatic
fun builder(): BasisTheoryElementsBuilder = BasisTheoryElementsBuilder()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.basistheory.elements.util

import com.basistheory.elements.model.BinDetails
import java.util.concurrent.ConcurrentHashMap

/**
* Thread-safe cache for BIN details to avoid redundant API calls
* Uses an in-memory cache with a maximum size limit
*/
internal object BinDetailsCache {
private const val MAX_CACHE_SIZE = 100
private val cache = ConcurrentHashMap<String, BinDetails>()

/**
* Retrieves cached bin details for the given BIN
* @param bin The BIN (first 6 digits of card number)
* @return Cached BinDetails or null if not found
*/
fun get(bin: String): BinDetails? {
return cache[bin]
}

/**
* Stores bin details in the cache
* @param bin The BIN (first 6 digits of card number)
* @param details The BinDetails to cache
*/
fun put(bin: String, details: BinDetails) {
// Implement simple LRU by clearing oldest entries when cache is full
if (cache.size >= MAX_CACHE_SIZE) {
val oldestKey = cache.keys.firstOrNull()
oldestKey?.let { cache.remove(it) }
}
cache[bin] = details
}

/**
* Clears all cached bin details
*/
fun clear() {
cache.clear()
}
}
Loading
Loading