You've successfully subscribed to VitraCash
Great! Next, complete checkout for full access to VitraCash
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.
Success! Your billing info is updated.
Billing info update failed.

Formatting credit card number input in Jetpack compose Android

Benyam Seifu
Benyam Seifu

Hello!

Today we’ll be looking at how we are able to detect card schemes from card numbers and then format that number dynamically.

For example, if a card number is from American Express, the input format will be: xxxx-xxxxxx-xxxxx, whereas if it’s a Visa/Mastercard number, we’ll format it like: xxxx-xxxx-xxxx-xxxx.  Finally, we validate the card number to ensure that it is valid or not using Luhn’s algorithm.

Let’s create our composable:

@Composable
fun CardNumberTextField() {

        var cardNumber by remember {mutableStateOf("")}
        
        OutlinedTextField(
            value = cardNumber,
            onValueChange = {it -> cardNumber = it},
            keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
}

Now that we have created the basic OutlinedTextField composable that has its own state and keyboard type of ‘Number’, as card numbers will always be numbers.

Our next step is to create a function that identifies the card scheme, so let’s do that now:

enum class CardScheme {
    JCB, AMEX, DINERS_CLUB, VISA, MASTERCARD, DISCOVER, MAESTRO, UNKNOWN
}

As you can see, there are 7 different defined card schemes, with the last one being when a card number is ‘unknown’.

Next, we’ll use RegEx to identify the scheme of any given card number, which will then return the enum of type CardScheme we defined earlier:

fun identifyCardScheme(cardNumber: String): CardScheme {

    val jcbRegex = Regex("^(?:2131|1800|35)[0-9]{0,}$")
    val ameRegex = Regex("^3[47][0-9]{0,}\$")
    val dinersRegex = Regex("^3(?:0[0-59]{1}|[689])[0-9]{0,}\$")
    val visaRegex = Regex("^4[0-9]{0,}\$")
    val masterCardRegex = Regex("^(5[1-5]|222[1-9]|22[3-9]|2[3-6]|27[01]|2720)[0-9]{0,}\$")
    val maestroRegex = Regex("^(5[06789]|6)[0-9]{0,}\$")
    val discoverRegex = Regex("^(6011|65|64[4-9]|62212[6-9]|6221[3-9]|622[2-8]|6229[01]|62292[0-5])[0-9]{0,}\$")

    val trimmedCardNumber = cardNumber.replace(" ", "")

    return when {
        
        trimmedCardNumber.matches(jcbRegex) -> JCB
        trimmedCardNumber.matches(ameRegex) -> AMEX
        trimmedCardNumber.matches(dinersRegex) -> DINERS_CLUB
        trimmedCardNumber.matches(visaRegex) -> VISA
        trimmedCardNumber.matches(masterCardRegex) -> MASTERCARD
        trimmedCardNumber.matches(discoverRegex) -> DISCOVER
        trimmedCardNumber.matches(maestroRegex) -> if (cardNumber[0] == '5') MASTERCARD else MAESTRO 
        
        else -> UNKNOWN
        }
    }

Now that we are able to identify the card scheme, our next step is to create a formatter that returns a TransformedText.

We’ll be using leverage from the visualTransformation’s capability of our OutlinedTextField which uses the TransformedText.

Based on my research, I have found that American Express and Diners Club are the only ones which have a different card number format as well length, while others (which we listed on our enum class) have the same format and length.

American Express is formatted in a 4-6-5 pattern, with a total length of 15.  (xxxx-xxxxxx-xxxxx).

Diners Club format is very similar to that of American Express, however is formatted in a 4-6-4 pattern, with a total length of 14. (xxxx-xxxxxx-xxxx).

Other card schemes, like Visa and Mastercard always follow the same format of 4-4-4-4, with a total length of 16 digits. (xxxx-xxxx-xxxx-xxxx).

Let’s see how we can format the American Express card number, then refactor the function to do the same for all of our other card schemes (I’ll leave that bit to you, though!)

fun formatAmex(text: AnnotatedString): TransformedText {

    val trimmed = if (text.text.length >= 15) text.text.substring(0..14) else text.text
    var out = ""
    for (i in trimmed.indices) {
        out += trimmed[i]
//        put - character at 3rd and 9th indicies
        if (i ==3 || i == 9 && i != 14) out += "-"
    }
//    original - 345678901234564
//    transformed - 3456-7890123-4564
//    xxxx-xxxxxx-xxxxx
/**
* The offset translator should ignore the hyphen characters, so conversion from
*  original offset to transformed text works like
*  - The 4th char of the original text is 5th char in the transformed text. (i.e original[4th] == transformed[5th]])
*  - The 11th char of the original text is 13th char in the transformed text. (i.e original[11th] == transformed[13th])
*  Similarly, the reverse conversion works like
*  - The 5th char of the transformed text is 4th char in the original text. (i.e  transformed[5th] == original[4th] )
*  - The 13th char of the transformed text is 11th char in the original text. (i.e transformed[13th] == original[11th])
*/

val creditCardOffsetTranslator = object : OffsetMapping {
    
    override fun originalToTransformed(offset: Int): Int {
        
        if (offset <= 3) return offset
        if (offset <= 9) return offset + 1
        if(offset <= 15) return offset + 2
        
        return 17
    }

    override fun transformedToOriginal(offset: Int): Int {
        
        if (offset <= 4) return offset
        if (offset <= 11) return offset - 1
        if(offset <= 17) return offset - 2
        
        return 15
    }
        
    }

    return TransformedText(AnnotatedString(out), creditCardOffsetTranslator)
}

We can also look at formatting Diners Club:

fun formatDinnersClub(text: AnnotatedString): TransformedText {

    val trimmed = if (text.text.length >= 14) text.text.substring(0..13) else text.text
    
    var out = ""
    
    for (i in trimmed.indices) {
        out += trimmed[i]
        if (i ==3 || i == 9 && i != 13) out += "-"
    }

//    xxxx-xxxxxx-xxxx
    val creditCardOffsetTranslator = object : OffsetMapping {
    
        override fun originalToTransformed(offset: Int): Int {
    
            if (offset <= 3) return offset
            if (offset <= 9) return offset + 1
            if(offset <= 14) return offset + 2
    
            return 16
        }
    
        override fun transformedToOriginal(offset: Int): Int {

            if (offset <= 4) return offset
            if (offset <= 11) return offset - 1
            if(offset <= 16) return offset - 2

            return 14
        }
        
  }
    return TransformedText(AnnotatedString(out), creditCardOffsetTranslator)
}

As I said previously, we can then easily refactor the ‘formatAmex’ function to format the Diners Club card scheme, as the only difference here is that of the length of the card number.

fun formatOtherCardNumbers(text: AnnotatedString): TransformedText {
    
    val trimmed = if (text.text.length >= 16) text.text.substring(0..15) else text.text
    
    var out = ""
    
    for (i in trimmed.indices) {
        out += trimmed[i]
        if (i % 4 == 3 && i != 15) out += "-"
    }
    
    val creditCardOffsetTranslator = object : OffsetMapping {
       
       override fun originalToTransformed(offset: Int): Int {
            
            if (offset <= 3) return offset
            if (offset <= 7) return offset + 1
            if (offset <= 11) return offset + 2
            if (offset <= 16) return offset + 3
            
            return 19
    }
        
    override fun transformedToOriginal(offset: Int): Int {
        
        if (offset <= 4) return offset
        if (offset <= 9) return offset - 1
        if (offset <= 14) return offset - 2
        if (offset <= 19) return offset - 3
        
        return 16
      }
    }
    
    return TransformedText(AnnotatedString(out), creditCardOffsetTranslator)
}

The above function formats all of the other card number schemes covered in our enum type of Card Scheme.

We now need to use our util functions to do a ‘detect and format card scheme’.  This is done by updating our OutlinedTextField to incorporate that:

@Composable
fun CardNumberTextField() {
  
  var cardNumber by remember {mutableStateOf("")}

    OutlinedTextField(
        value = cardNumber,
        onValueChange = {it -> cardNumber = it},
        keyboardOptions = KeyboardOptions(keyboardType =
        KeyboardType.Number),
        visualTransformation = VisualTransformation { number ->

        when (identifyCardScheme(cardNumber)) {
            CardScheme.AMEX -> formatAmex(number)
            CardScheme.DINERS_CLUB -> formatDinnersClub(number)
            else -> formatOtherCardNumbers(number)
            }
        },
    )
}

Now, as a user types, our TextField will detect and format the card number. Super cool right?

To add a further bonus to this, we can validate it using the earlier mentioned Luhn’s algorithm:

fun isValidCardNumber = { value ->
    
    var checksum: Int = 0
    
    for (i in value.length - 1 downTo 0 step 2) {
        checksum += value[i] - '0'
        }
        
    for (i in value.length - 2 downTo 0 step 2) {
        val n: Int = (value[i] - '0') * 2
        checksum += if (n > 9) n - 9 else n
        }
    
    checksum % 10 == 0
}

We can now add an ‘isError’(built in feature of TextField in compose) and call the ‘isValidCardNumber’ function with card number, where it will show the error.  It’s also possible to check if the OutlinedTextField has been focused first, prior to showing the error - but that’s a task left to you!

That’s it from me on looking at how we can format debit/credit card number input in Jetpack compose Android.

I hope you learnt something, and seeing this is my first article, I hope you won’t be too harsh on me!

Happy coding & learning.

Header Image: © Google/Android 2021

Technology

Benyam Seifu

Software Engineer