package com.reactnativestripesdk import android.annotation.SuppressLint import android.content.res.ColorStateList import android.os.Build import android.text.Editable import android.text.InputFilter import android.text.TextWatcher import android.util.Log import android.widget.FrameLayout import androidx.core.graphics.toColorInt import androidx.core.os.LocaleListCompat import com.facebook.react.bridge.ReadableMap import com.facebook.react.uimanager.PixelUtil import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.views.text.ReactTypefaceUtils import com.google.android.material.shape.CornerFamily import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.ShapeAppearanceModel import com.reactnativestripesdk.utils.PostalCodeUtilities import com.reactnativestripesdk.utils.getIntOr import com.reactnativestripesdk.utils.getIntOrNull import com.reactnativestripesdk.utils.getValOr import com.reactnativestripesdk.utils.hideSoftKeyboard import com.reactnativestripesdk.utils.mapCardBrand import com.reactnativestripesdk.utils.mapToPreferredNetworks import com.reactnativestripesdk.utils.showSoftKeyboard import com.stripe.android.core.model.CountryCode import com.stripe.android.core.model.CountryUtils import com.stripe.android.databinding.StripeCardInputWidgetBinding import com.stripe.android.model.Address import com.stripe.android.model.PaymentMethodCreateParams import com.stripe.android.view.CardInputListener import com.stripe.android.view.CardInputWidget import com.stripe.android.view.CardValidCallback import com.stripe.android.view.StripeEditText @SuppressLint("ViewConstructor") class CardFieldView( private val context: ThemedReactContext, ) : FrameLayout(context) { private var mCardWidget: CardInputWidget = CardInputWidget(context) private val cardInputWidgetBinding = StripeCardInputWidgetBinding.bind(mCardWidget) val cardDetails: MutableMap = mutableMapOf( "brand" to "", "last4" to "", "expiryMonth" to null, "expiryYear" to null, "postalCode" to "", "validNumber" to "Unknown", "validCVC" to "Unknown", "validExpiryDate" to "Unknown", ) var cardParams: PaymentMethodCreateParams.Card? = null var cardAddress: Address? = null private var dangerouslyGetFullCardDetails: Boolean = false private var currentFocusedField: String? = null private var isCardValid = false init { cardInputWidgetBinding.container.isFocusable = true cardInputWidgetBinding.container.isFocusableInTouchMode = true cardInputWidgetBinding.container.requestFocus() addView(mCardWidget) setListeners() viewTreeObserver.addOnGlobalLayoutListener { requestLayout() } } fun setAutofocus(value: Boolean) { if (value) { cardInputWidgetBinding.cardNumberEditText.requestFocus() cardInputWidgetBinding.cardNumberEditText.showSoftKeyboard() } } fun requestFocusFromJS() { cardInputWidgetBinding.cardNumberEditText.requestFocus() cardInputWidgetBinding.cardNumberEditText.showSoftKeyboard() } fun requestBlurFromJS() { cardInputWidgetBinding.cardNumberEditText.hideSoftKeyboard() cardInputWidgetBinding.cardNumberEditText.clearFocus() cardInputWidgetBinding.container.requestFocus() } fun requestClearFromJS() { cardInputWidgetBinding.cardNumberEditText.setText("") cardInputWidgetBinding.cvcEditText.setText("") cardInputWidgetBinding.expiryDateEditText.setText("") if (mCardWidget.postalCodeEnabled) { cardInputWidgetBinding.postalCodeEditText.setText("") } } private fun onChangeFocus() { UIManagerHelper .getEventDispatcherForReactTag(context, id) ?.dispatchEvent(CardFocusChangeEvent(context.surfaceId, id, currentFocusedField)) } fun setCardStyle(value: ReadableMap?) { val borderWidth = value.getIntOrNull("borderWidth") val backgroundColor = getValOr(value, "backgroundColor", null) val borderColor = getValOr(value, "borderColor", null) val borderRadius = value.getIntOr("borderRadius", 0) val textColor = getValOr(value, "textColor", null) val fontSize = value.getIntOrNull("fontSize") val fontFamily = getValOr(value, "fontFamily") val placeholderColor = getValOr(value, "placeholderColor", null) val textErrorColor = getValOr(value, "textErrorColor", null) val cursorColor = getValOr(value, "cursorColor", null) val bindings = setOf( cardInputWidgetBinding.cardNumberEditText, cardInputWidgetBinding.cvcEditText, cardInputWidgetBinding.expiryDateEditText, cardInputWidgetBinding.postalCodeEditText, ) textColor?.let { for (editTextBinding in bindings) { editTextBinding.setTextColor(it.toColorInt()) } } textErrorColor?.let { for (editTextBinding in bindings) { editTextBinding.setErrorColor(it.toColorInt()) } } placeholderColor?.let { for (editTextBinding in bindings) { editTextBinding.setHintTextColor(it.toColorInt()) } setCardBrandTint(it.toColorInt()) } fontSize?.let { for (editTextBinding in bindings) { editTextBinding.textSize = it.toFloat() } } fontFamily?.let { for (editTextBinding in bindings) { // Load custom font from assets, and fallback to default system font editTextBinding.typeface = ReactTypefaceUtils.applyStyles( null, -1, -1, it.takeIf { it.isNotEmpty() }, context.assets, ) } } cursorColor?.let { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val color = it.toColorInt() for (editTextBinding in bindings) { editTextBinding.textCursorDrawable?.setTint(color) editTextBinding.textSelectHandle?.setTint(color) editTextBinding.textSelectHandleLeft?.setTint(color) editTextBinding.textSelectHandleRight?.setTint(color) editTextBinding.highlightColor = color } } } mCardWidget.setPadding(20, 0, 20, 0) mCardWidget.background = MaterialShapeDrawable( ShapeAppearanceModel() .toBuilder() .setAllCorners(CornerFamily.ROUNDED, PixelUtil.toPixelFromDIP(borderRadius.toDouble())) .build(), ).also { shape -> shape.strokeWidth = 0.0f shape.strokeColor = ColorStateList.valueOf("#000000".toColorInt()) shape.fillColor = ColorStateList.valueOf("#FFFFFF".toColorInt()) borderWidth?.let { shape.strokeWidth = PixelUtil.toPixelFromDIP(it.toDouble()) } borderColor?.let { shape.strokeColor = ColorStateList.valueOf(it.toColorInt()) } backgroundColor?.let { shape.fillColor = ColorStateList.valueOf(it.toColorInt()) } } } private fun setCardBrandTint(color: Int) { try { cardInputWidgetBinding.cardBrandView::class.java .getDeclaredMethod("setTintColorInt\$payments_core_release", Int::class.java) .let { it(cardInputWidgetBinding.cardBrandView, color) } } catch (e: Exception) { Log.e( "StripeReactNative", "Unable to set card brand tint color: " + e.message, ) } } fun setPlaceHolders(value: ReadableMap?) { val numberPlaceholder = getValOr(value, "number", null) val expirationPlaceholder = getValOr(value, "expiration", null) val cvcPlaceholder = getValOr(value, "cvc", null) val postalCodePlaceholder = getValOr(value, "postalCode", null) numberPlaceholder?.let { cardInputWidgetBinding.cardNumberEditText.hint = it } expirationPlaceholder?.let { cardInputWidgetBinding.expiryDateEditText.hint = it } cvcPlaceholder?.let { mCardWidget.setCvcLabel(it) } postalCodePlaceholder?.let { cardInputWidgetBinding.postalCodeEditText.hint = it } } fun setDangerouslyGetFullCardDetails(isEnabled: Boolean) { dangerouslyGetFullCardDetails = isEnabled } fun setPostalCodeEnabled(isEnabled: Boolean) { mCardWidget.postalCodeEnabled = isEnabled if (!isEnabled) { mCardWidget.postalCodeRequired = false } } fun setDisabled(isDisabled: Boolean) { mCardWidget.isEnabled = !isDisabled } fun setPreferredNetworks(preferredNetworks: ArrayList?) { mCardWidget.setPreferredNetworks(mapToPreferredNetworks(preferredNetworks)) } fun setOnBehalfOf(onBehalfOf: String?) { mCardWidget.onBehalfOf = onBehalfOf } /** * We can reliable assume that setPostalCodeEnabled is called before * setCountryCode because of the order of the props in CardField.tsx */ @SuppressLint("RestrictedApi") fun setCountryCode(countryString: String?) { if (mCardWidget.postalCodeEnabled) { val countryCode = CountryCode.create( value = countryString ?: LocaleListCompat.getAdjustedDefault()[0]?.country ?: "US", ) mCardWidget.postalCodeRequired = CountryUtils.doesCountryUsePostalCode(countryCode) setPostalCodeFilter(countryCode) } } fun getValue(): MutableMap = cardDetails private fun onValidCardChange() { mCardWidget.paymentMethodCard?.let { cardParams = it cardAddress = Address .Builder() .setPostalCode(cardDetails["postalCode"] as String?) .build() } ?: run { cardParams = null cardAddress = null } mCardWidget.paymentMethodCreateParams?.let { cardDetails["brand"] = mapCardBrand(mCardWidget.brand) @SuppressLint("RestrictedApi") cardDetails["last4"] = it.cardLast4() } ?: run { cardDetails["brand"] = null cardDetails["last4"] = null } sendCardDetailsEvent() } private fun sendCardDetailsEvent() { UIManagerHelper .getEventDispatcherForReactTag(context, id) ?.dispatchEvent( CardChangeEvent( context.surfaceId, id, cardDetails, mCardWidget.postalCodeEnabled, isCardValid, dangerouslyGetFullCardDetails, ), ) } private fun setListeners() { cardInputWidgetBinding.cardNumberEditText.setOnFocusChangeListener { _, hasFocus -> currentFocusedField = if (hasFocus) CardInputListener.FocusField.CardNumber.name else null onChangeFocus() } cardInputWidgetBinding.expiryDateEditText.setOnFocusChangeListener { _, hasFocus -> currentFocusedField = if (hasFocus) CardInputListener.FocusField.ExpiryDate.name else null onChangeFocus() } cardInputWidgetBinding.cvcEditText.setOnFocusChangeListener { _, hasFocus -> currentFocusedField = if (hasFocus) CardInputListener.FocusField.Cvc.name else null onChangeFocus() } cardInputWidgetBinding.postalCodeEditText.setOnFocusChangeListener { _, hasFocus -> currentFocusedField = if (hasFocus) CardInputListener.FocusField.PostalCode.name else null onChangeFocus() } mCardWidget.setCardValidCallback { isValid, invalidFields -> isCardValid = isValid fun getCardValidationState( field: CardValidCallback.Fields, editTextField: StripeEditText, ): String { if (invalidFields.contains(field)) { return if (editTextField.shouldShowError) { "Invalid" } else { "Incomplete" } } return "Valid" } cardDetails["validNumber"] = getCardValidationState( CardValidCallback.Fields.Number, cardInputWidgetBinding.cardNumberEditText, ) cardDetails["validCVC"] = getCardValidationState(CardValidCallback.Fields.Cvc, cardInputWidgetBinding.cvcEditText) cardDetails["validExpiryDate"] = getCardValidationState( CardValidCallback.Fields.Expiry, cardInputWidgetBinding.expiryDateEditText, ) @SuppressLint("VisibleForTests") cardDetails["brand"] = mapCardBrand(cardInputWidgetBinding.cardNumberEditText.cardBrand) if (isValid) { onValidCardChange() } else { cardParams = null cardAddress = null sendCardDetailsEvent() } } mCardWidget.setCardInputListener( object : CardInputListener { override fun onCardComplete() {} override fun onExpirationComplete() {} override fun onCvcComplete() {} override fun onPostalCodeComplete() {} override fun onFocusChange(focusField: CardInputListener.FocusField) {} }, ) mCardWidget.setExpiryDateTextWatcher( object : TextWatcher { override fun beforeTextChanged( p0: CharSequence?, p1: Int, p2: Int, p3: Int, ) { } override fun afterTextChanged(p0: Editable?) {} override fun onTextChanged( var1: CharSequence?, var2: Int, var3: Int, var4: Int, ) { val splitText = var1.toString().split("/") cardDetails["expiryMonth"] = splitText[0].toIntOrNull() if (splitText.size == 2) { cardDetails["expiryYear"] = var1.toString().split("/")[1].toIntOrNull() } } }, ) mCardWidget.setPostalCodeTextWatcher( object : TextWatcher { override fun beforeTextChanged( p0: CharSequence?, p1: Int, p2: Int, p3: Int, ) { } override fun afterTextChanged(p0: Editable?) {} override fun onTextChanged( var1: CharSequence?, var2: Int, var3: Int, var4: Int, ) { cardDetails["postalCode"] = var1.toString() } }, ) mCardWidget.setCardNumberTextWatcher( object : TextWatcher { override fun beforeTextChanged( p0: CharSequence?, p1: Int, p2: Int, p3: Int, ) { } override fun afterTextChanged(p0: Editable?) {} override fun onTextChanged( var1: CharSequence?, var2: Int, var3: Int, var4: Int, ) { if (dangerouslyGetFullCardDetails) { cardDetails["number"] = var1.toString().replace(" ", "") } } }, ) mCardWidget.setCvcNumberTextWatcher( object : TextWatcher { override fun beforeTextChanged( p0: CharSequence?, p1: Int, p2: Int, p3: Int, ) { } override fun afterTextChanged(p0: Editable?) {} override fun onTextChanged( var1: CharSequence?, var2: Int, var3: Int, var4: Int, ) { if (dangerouslyGetFullCardDetails) { cardDetails["cvc"] = var1.toString() } } }, ) } @SuppressLint("RestrictedApi") private fun setPostalCodeFilter(countryCode: CountryCode) { cardInputWidgetBinding.postalCodeEditText.filters = arrayOf( *cardInputWidgetBinding.postalCodeEditText.filters, createPostalCodeInputFilter(countryCode), ) } @SuppressLint("RestrictedApi") private fun createPostalCodeInputFilter(countryCode: CountryCode): InputFilter { return InputFilter { charSequence, start, end, _, _, _ -> for (i in start until end) { val isValidCharacter = ( countryCode == CountryCode.US && PostalCodeUtilities.isValidUsPostalCodeCharacter( charSequence[i], ) ) || ( countryCode != CountryCode.US && PostalCodeUtilities.isValidGlobalPostalCodeCharacter( charSequence[i], ) ) if (!isValidCharacter) { return@InputFilter "" } } return@InputFilter null } } override fun requestLayout() { super.requestLayout() post(mLayoutRunnable) } private val mLayoutRunnable = Runnable { measure( MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY), ) layout(left, top, right, bottom) } }