Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
# 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
Collaborator

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
* Breaking Changes
* The following `CardType` methods are no longer supported:
* `getPattern()`
* `getRelaxedPrefixPattern()`
* `getFrontResource()`
* `getSecurityCodeName()`
* `getSecurityCodeLength()`
* `getMinCardLength()`
* `getMaxCardLength()`
* `getSpaceIndices()`
* `isLuhnValid()`
* `validate()`
* `CardType#forCardNumber()`

## 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
Collaborator

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