Skip to content
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
# Android Card Form Release Notes

## unreleased
* Bump `compileSdkVersion` and `targetSdkVersion` to API level 33
* Move all classes to `com.braintreepayments.api` package
* Remove Jetifier now that AndroidX is fully supported
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😍

* Make `CardType` a pure enum and make card parsing logic internal

## 5.4.0
* Update Visa card icon
Expand Down
3 changes: 2 additions & 1 deletion CardForm/build.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
plugins {
id 'com.android.library'
id 'kotlin-android'
id 'de.marcphilipp.nexus-publish'
id 'signing'
}
Expand Down Expand Up @@ -28,7 +29,7 @@ android {
}

dependencies {
implementation 'com.google.android.material:material:1.0.0'
implementation 'com.google.android.material:material:1.7.0'
implementation 'com.google.android.flexbox:flexbox:3.0.0'
implementation 'androidx.recyclerview:recyclerview:1.2.1'

Expand Down
171 changes: 171 additions & 0 deletions CardForm/src/main/java/com/braintreepayments/api/CardAttributes.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package com.braintreepayments.api

import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import com.braintreepayments.api.cardform.R
import java.util.regex.Pattern

/**
* @property pattern The regex matching this card type.
* @property relaxedPrefixPattern The relaxed prefix regex matching this card type. To be used in determining card type if no pattern matches.
* @property frontResource The android resource id for the front card image, highlighting card number format.
* @property securityCodeName The android resource id for the security code name for this card type.
* @property securityCodeLength The length of the current card's security code.
* @property minCardLength minimum length of a card for this {@link CardType}
* @property maxCardLength max length of a card for this {@link CardType}
* @property spaceIndices the locations where spaces should be inserted when formatting the card in a user friendly way. Only for display purposes.
*/
internal data class CardAttributes constructor(
val cardType: CardType,
@DrawableRes val frontResource: Int,
val maxCardLength: Int,
val minCardLength: Int,
val pattern: Pattern,
val relaxedPrefixPattern: Pattern? = null,
val securityCodeLength: Int,
@StringRes val securityCodeName: Int,
) {

val spaceIndices: IntArray =
if (cardType == CardType.AMEX) AMEX_SPACE_INDICES else DEFAULT_SPACE_INDICES

fun matches(cardNumber: String) = matchesStrict(cardNumber) || matchesRelaxed(cardNumber)
fun matchesStrict(cardNumber: String) = pattern.matcher(cardNumber).matches()
fun matchesRelaxed(cardNumber: String) =
relaxedPrefixPattern?.matcher(cardNumber)?.matches() ?: false

companion object {

private val AMEX_SPACE_INDICES = intArrayOf(4, 10)
private val DEFAULT_SPACE_INDICES = intArrayOf(4, 8, 12)

@JvmField
val EMPTY = CardAttributes(
cardType = CardType.EMPTY,
pattern = Pattern.compile("^$"),
frontResource = R.drawable.bt_ic_unknown,
minCardLength = 12,
maxCardLength = 19,
securityCodeLength = 3,
securityCodeName = R.string.bt_cvv,
)

val UNKNOWN = CardAttributes(
cardType = CardType.UNKNOWN,
pattern = Pattern.compile("\\d+"),
frontResource = R.drawable.bt_ic_unknown,
minCardLength = 12,
maxCardLength = 19,
securityCodeLength = 3,
securityCodeName = R.string.bt_cvv,
)

private val knownCardBrandAttributes = createCardAttributeMap(
CardAttributes(
cardType = CardType.HIPERCARD,
pattern = Pattern.compile("^606282\\d*"),
frontResource = R.drawable.bt_ic_hipercard,
minCardLength = 16,
maxCardLength = 16,
securityCodeLength = 3,
securityCodeName = R.string.bt_cvc,
),
CardAttributes(
cardType = CardType.HIPER,
pattern = Pattern.compile("^637(095|568|599|609|612)\\d*"),
frontResource = R.drawable.bt_ic_hiper,
minCardLength = 16,
maxCardLength = 16,
securityCodeLength = 3,
securityCodeName = R.string.bt_cvc,
),
CardAttributes(
cardType = CardType.UNIONPAY,
pattern = Pattern.compile("^62\\d*"),
frontResource = R.drawable.bt_ic_unionpay,
minCardLength = 16,
maxCardLength = 19,
securityCodeLength = 3,
securityCodeName = R.string.bt_cvn,
),
CardAttributes(
cardType = CardType.VISA,
pattern = Pattern.compile("^4\\d*"),
frontResource = R.drawable.bt_ic_visa,
minCardLength = 16,
maxCardLength = 16,
securityCodeLength = 3,
securityCodeName = R.string.bt_cvv,
),
CardAttributes(
cardType = CardType.MASTERCARD,
pattern = Pattern.compile("^(5[1-5]|222[1-9]|22[3-9]|2[3-6]|27[0-1]|2720)\\d*"),
frontResource = R.drawable.bt_ic_mastercard,
minCardLength = 16,
maxCardLength = 16,
securityCodeLength = 3,
securityCodeName = R.string.bt_cvc,
),
CardAttributes(
cardType = CardType.DISCOVER,
pattern = Pattern.compile("^(6011|65|64[4-9]|622)\\d*"),
frontResource = R.drawable.bt_ic_discover,
minCardLength = 16,
maxCardLength = 19,
securityCodeLength = 3,
securityCodeName = R.string.bt_cid,
),
CardAttributes(
cardType = CardType.AMEX,
pattern = Pattern.compile("^3[47]\\d*"),
frontResource = R.drawable.bt_ic_amex,
minCardLength = 15,
maxCardLength = 15,
securityCodeLength = 4,
securityCodeName = R.string.bt_cid,
),
CardAttributes(
cardType = CardType.DINERS_CLUB,
pattern = Pattern.compile("^(36|38|30[0-5])\\d*"),
frontResource = R.drawable.bt_ic_diners_club,
minCardLength = 14,
maxCardLength = 14,
securityCodeLength = 3,
securityCodeName = R.string.bt_cvv,
),
CardAttributes(
cardType = CardType.JCB,
pattern = Pattern.compile("^35\\d*"),
frontResource = R.drawable.bt_ic_jcb,
minCardLength = 16,
maxCardLength = 16,
securityCodeLength = 3,
securityCodeName = R.string.bt_cvv,
),
CardAttributes(
cardType = CardType.MAESTRO,
pattern = Pattern.compile("^(5018|5020|5038|5043|5[6-9]|6020|6304|6703|6759|676[1-3])\\d*"),
frontResource = R.drawable.bt_ic_maestro,
minCardLength = 12,
maxCardLength = 19,
securityCodeLength = 3,
securityCodeName = R.string.bt_cvc,
relaxedPrefixPattern = Pattern.compile("^6\\d*")
)
)

val allKnownCardBrands = knownCardBrandAttributes.values

private fun createCardAttributeMap(vararg items: CardAttributes): Map<CardType, CardAttributes> {
val result = mutableMapOf<CardType, CardAttributes>()
for (item in items) {
result[item.cardType] = item
}
return result
Comment on lines +160 to +164
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Take it or leave it comment - if there's a clean/easy way to just create a map literal for knownCardBrandAttributes, might be nice not to need this additional helper function.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I agree. It's a compile time vs. runtime tradeoff. I initially went for a when statement but duplicating CardType felt like a code smell so I refactored to creating the map at runtime.

mapOf(
  CardType.VISA to CardAttributes(CardType.VISA, "^4\\d*", R.drawable.bt_ic_visa, 16, 16, 3, R.string.bt_cvv, null)
)

}

@JvmStatic
fun forCardType(cardType: CardType): CardAttributes =
knownCardBrandAttributes[cardType] ?: UNKNOWN
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@ public interface OnCardTypeChangedListener {
void onCardTypeChanged(CardType cardType);
}

private boolean mDisplayCardIcon = true;
private boolean mMask = false;
private CardType mCardType;
private OnCardTypeChangedListener mOnCardTypeChangedListener;
private TransformationMethod mSavedTranformationMethod;
private boolean displayCardIcon = true;
private boolean mask = false;
private CardAttributes cardAttributes = CardAttributes.EMPTY;
private OnCardTypeChangedListener onCardTypeChangedListener;
private TransformationMethod savedTransformationMethod;

private CardParser cardParser = new CardParser();

public CardEditText(Context context) {
super(context);
Expand All @@ -50,7 +52,7 @@ private void init() {
setCardIcon(R.drawable.bt_ic_unknown);
addTextChangedListener(this);
updateCardType();
mSavedTranformationMethod = getTransformationMethod();
savedTransformationMethod = getTransformationMethod();
}

/**
Expand All @@ -61,9 +63,9 @@ private void init() {
* type icons.
*/
public void displayCardTypeIcon(boolean display) {
mDisplayCardIcon = display;
displayCardIcon = display;

if (!mDisplayCardIcon) {
if (!displayCardIcon) {
setCardIcon(-1);
}
}
Expand All @@ -73,7 +75,7 @@ public void displayCardTypeIcon(boolean display) {
* the {@link android.widget.EditText}
*/
public CardType getCardType() {
return mCardType;
return cardAttributes.getCardType();
}

/**
Expand All @@ -82,7 +84,7 @@ public CardType getCardType() {
* something like "4111111111111111" to "•••• 1111".
*/
public void setMask(boolean mask) {
mMask = mask;
this.mask = mask;
}

@Override
Expand All @@ -95,7 +97,7 @@ protected void onFocusChanged(boolean focused, int direction, Rect previouslyFoc
if (getText().toString().length() > 0) {
setSelection(getText().toString().length());
}
} else if (mMask && isValid()) {
} else if (mask && isValid()) {
maskNumber();
}
}
Expand All @@ -106,7 +108,7 @@ protected void onFocusChanged(boolean focused, int direction, Rect previouslyFoc
* changes
*/
public void setOnCardTypeChangedListener(OnCardTypeChangedListener listener) {
mOnCardTypeChangedListener = listener;
onCardTypeChangedListener = listener;
}

@Override
Expand All @@ -117,11 +119,11 @@ public void afterTextChanged(Editable editable) {
}

updateCardType();
setCardIcon(mCardType.getFrontResource());

addSpans(editable, mCardType.getSpaceIndices());
setCardIcon(cardAttributes.getFrontResource());
addSpans(editable, cardAttributes.getSpaceIndices());

if (mCardType.getMaxCardLength() == getSelectionStart()) {
if (cardAttributes.getMaxCardLength() == getSelectionStart()) {
validate();

if (isValid()) {
Expand All @@ -130,15 +132,15 @@ public void afterTextChanged(Editable editable) {
unmaskNumber();
}
} else if (!hasFocus()) {
if (mMask) {
if (mask) {
maskNumber();
}
}
}

@Override
public boolean isValid() {
return isOptional() || mCardType.validate(getText().toString());
return isOptional() || cardParser.validate(getText().toString());
}

@Override
Expand All @@ -152,29 +154,30 @@ public String getErrorMessage() {

private void maskNumber() {
if (!(getTransformationMethod() instanceof CardNumberTransformation)) {
mSavedTranformationMethod = getTransformationMethod();
savedTransformationMethod = getTransformationMethod();

setTransformationMethod(new CardNumberTransformation());
}
}

private void unmaskNumber() {
if (getTransformationMethod() != mSavedTranformationMethod) {
setTransformationMethod(mSavedTranformationMethod);
if (getTransformationMethod() != savedTransformationMethod) {
setTransformationMethod(savedTransformationMethod);
}
}

private void updateCardType() {
CardType type = CardType.forCardNumber(getText().toString());
if (mCardType != type) {
mCardType = type;
CardAttributes attrs = cardParser.parseCardAttributes(getText().toString());

if (cardAttributes.getCardType() != attrs.getCardType()) {
cardAttributes = attrs;

InputFilter[] filters = { new LengthFilter(mCardType.getMaxCardLength()) };
InputFilter[] filters = { new LengthFilter(cardAttributes.getMaxCardLength()) };
setFilters(filters);
invalidate();

if (mOnCardTypeChangedListener != null) {
mOnCardTypeChangedListener.onCardTypeChanged(mCardType);
if (onCardTypeChangedListener != null) {
onCardTypeChangedListener.onCardTypeChanged(cardAttributes.getCardType());
}
}
}
Expand All @@ -190,7 +193,7 @@ private void addSpans(Editable editable, int[] spaceIndices) {
}

private void setCardIcon(int icon) {
if (!mDisplayCardIcon || getText().length() == 0) {
if (!displayCardIcon || getText().length() == 0) {
TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(this, 0, 0, 0, 0);
} else {
TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(this, 0, 0, icon, 0);
Expand Down
Loading