From bfbc1a52456e639a60fed4f7647e2af8e58ba3b9 Mon Sep 17 00:00:00 2001 From: Drew Hudec Date: Thu, 1 Dec 2022 17:53:26 -0500 Subject: [PATCH 1/4] Adds CardNumberElement and validator property to TextElement --- .../android/example/MainActivity.kt | 11 +++++ example/src/main/res/layout/activity_main.xml | 11 +++++ .../basistheory/android/event/ChangeEvent.kt | 6 +-- .../android/view/CardNumberElement.kt | 37 ++++++++++++++++ .../basistheory/android/view/TextElement.kt | 34 +++++++++++---- .../view/transform/ElementTransform.kt | 8 ---- .../transform/RegexReplaceElementTransform.kt | 14 +++--- .../view/validation/CardNumberValidator.kt | 32 ++++++++++++++ .../android/event/ChangeEventTests.kt | 7 ++- .../android/view/TextElementTests.kt | 12 +++--- .../view/transform/ElementTransformTests.kt | 31 ------------- .../RegexReplaceElementTransformTests.kt | 20 ++++----- .../validation/CardNumberValidatorTests.kt | 43 +++++++++++++++++++ 13 files changed, 186 insertions(+), 80 deletions(-) create mode 100644 lib/src/main/java/com/basistheory/android/view/CardNumberElement.kt delete mode 100644 lib/src/main/java/com/basistheory/android/view/transform/ElementTransform.kt create mode 100644 lib/src/main/java/com/basistheory/android/view/validation/CardNumberValidator.kt delete mode 100644 lib/src/test/java/com/basistheory/android/view/transform/ElementTransformTests.kt create mode 100644 lib/src/test/java/com/basistheory/android/view/validation/CardNumberValidatorTests.kt diff --git a/example/src/main/java/com/basistheory/android/example/MainActivity.kt b/example/src/main/java/com/basistheory/android/example/MainActivity.kt index 8ca0bcca..0bf0343a 100644 --- a/example/src/main/java/com/basistheory/android/example/MainActivity.kt +++ b/example/src/main/java/com/basistheory/android/example/MainActivity.kt @@ -1,10 +1,12 @@ package com.basistheory.android.example +import android.graphics.Color import android.os.Bundle import android.view.View import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import com.basistheory.android.service.BasisTheoryElements +import com.basistheory.android.view.CardNumberElement import com.basistheory.android.view.KeyboardType import com.basistheory.android.view.TextElement import com.google.gson.GsonBuilder @@ -13,6 +15,7 @@ import org.threeten.bp.Instant import org.threeten.bp.temporal.ChronoUnit class MainActivity : AppCompatActivity() { + private lateinit var cardNumberElement: CardNumberElement private lateinit var nameElement: TextElement private lateinit var phoneNumberElement: TextElement private lateinit var socialSecurityNumberElement: TextElement @@ -23,6 +26,7 @@ class MainActivity : AppCompatActivity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + cardNumberElement = findViewById(R.id.cardNumber) nameElement = findViewById(R.id.name) phoneNumberElement = findViewById(R.id.phoneNumber) socialSecurityNumberElement = findViewById(R.id.socialSecurityNumber) @@ -36,6 +40,13 @@ class MainActivity : AppCompatActivity() { phoneNumberElement.mask = listOf("+", "1", "(", digitRegex,digitRegex,digitRegex, ")", " ", digitRegex, digitRegex, digitRegex, "-", digitRegex, digitRegex , digitRegex, digitRegex ) orderNumberElement.mask = listOf(charRegex, charRegex, charRegex, "-", digitRegex, digitRegex, digitRegex) + + cardNumberElement.addChangeEventListener { + if (it.isValid) + cardNumberElement.textColor = Color.BLACK + else + cardNumberElement.textColor = Color.RED + } } fun setText(button: View) { diff --git a/example/src/main/res/layout/activity_main.xml b/example/src/main/res/layout/activity_main.xml index 1a7cbdc4..6535b6cf 100644 --- a/example/src/main/res/layout/activity_main.xml +++ b/example/src/main/res/layout/activity_main.xml @@ -16,10 +16,21 @@ android:layout_margin="20dp" android:orientation="vertical"> + + + val isComplete: Boolean, + val isEmpty: Boolean, + val isValid: Boolean ) \ No newline at end of file diff --git a/lib/src/main/java/com/basistheory/android/view/CardNumberElement.kt b/lib/src/main/java/com/basistheory/android/view/CardNumberElement.kt new file mode 100644 index 00000000..658dbee7 --- /dev/null +++ b/lib/src/main/java/com/basistheory/android/view/CardNumberElement.kt @@ -0,0 +1,37 @@ +package com.basistheory.android.view + +import android.content.Context +import android.util.AttributeSet +import com.basistheory.android.view.transform.regexReplaceElementTransform +import com.basistheory.android.view.validation.cardNumberValidator + +class CardNumberElement : TextElement { + + constructor(context: Context) : super(context) { + init() + } + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { + init() + } + + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super( + context, + attrs, + defStyleAttr + ) { + init() + } + + fun init() { + super.keyboardType = KeyboardType.NUMBER + super.mask = defaultMask + super.transform = regexReplaceElementTransform(Regex("""\s"""), "") + super.validator = ::cardNumberValidator + } + + companion object { + private val digit = Regex("""\d""") + val defaultMask: List = (1..19).map { if (it % 5 == 0 && it > 0) " " else digit } + } +} \ No newline at end of file diff --git a/lib/src/main/java/com/basistheory/android/view/TextElement.kt b/lib/src/main/java/com/basistheory/android/view/TextElement.kt index 6442e421..8dd3cdea 100644 --- a/lib/src/main/java/com/basistheory/android/view/TextElement.kt +++ b/lib/src/main/java/com/basistheory/android/view/TextElement.kt @@ -17,9 +17,8 @@ import com.basistheory.android.event.ChangeEvent import com.basistheory.android.event.ElementEventListeners import com.basistheory.android.event.FocusEvent import com.basistheory.android.view.mask.MaskWatcher -import com.basistheory.android.view.transform.ElementTransform -class TextElement : FrameLayout { +open class TextElement : FrameLayout { private var attrs: AttributeSet? = null private var defStyleAttr: Int = androidx.appcompat.R.attr.editTextStyle private var input: AppCompatEditText = AppCompatEditText(context, attrs, defStyleAttr) @@ -50,13 +49,16 @@ class TextElement : FrameLayout { // this MUST be internal to prevent host apps from accessing the raw input values internal fun getText(): String? = - transform.apply(input.text?.toString()) + transform(input.text?.toString()) fun setText(value: String?) = input.setText(value) - var transform: ElementTransform = - ElementTransform() + internal var validator: (value: String?) -> Boolean = + { _ -> true } + + internal var transform: (value: String?) -> String? = + { value -> value } var textColor: Int get() = input.currentTextColor @@ -81,7 +83,9 @@ class TextElement : FrameLayout { var removeDefaultStyles: Boolean get() = input.background == null - set(value) { input.background = if (value) null else defaultBackground } + set(value) { + input.background = if (value) null else defaultBackground + } fun addChangeEventListener(listener: (ChangeEvent) -> Unit) { eventListeners.change.add(listener) @@ -107,8 +111,14 @@ class TextElement : FrameLayout { hint = getString(R.styleable.TextElement_hint) removeDefaultStyles = getBoolean(R.styleable.TextElement_removeDefaultStyles, false) - mask = getString(R.styleable.TextElement_mask)?.split("")?.filter { !it.isNullOrEmpty() } - keyboardType = KeyboardType.fromInt(getInt(R.styleable.TextElement_keyboardType, KeyboardType.TEXT.inputType)) + mask = getString(R.styleable.TextElement_mask)?.split("") + ?.filter { !it.isNullOrEmpty() } + keyboardType = KeyboardType.fromInt( + getInt( + R.styleable.TextElement_keyboardType, + KeyboardType.TEXT.inputType + ) + ) setText(getString(R.styleable.TextElement_text)) } finally { recycle() @@ -144,8 +154,14 @@ class TextElement : FrameLayout { override fun onTextChanged(value: CharSequence?, p1: Int, p2: Int, p3: Int) {} override fun afterTextChanged(editable: Editable?) { + val event = ChangeEvent( + true, // TODO - compute this from the mask + editable?.isEmpty() ?: false, + validator(getText()) // TODO - how do we prevent the element from switching between valid/invalid while typing? do we even need to prevent this? + ) + eventListeners.change.forEach { - it(ChangeEvent(true, editable?.isEmpty() != false, listOf())) + it(event) } } }) diff --git a/lib/src/main/java/com/basistheory/android/view/transform/ElementTransform.kt b/lib/src/main/java/com/basistheory/android/view/transform/ElementTransform.kt deleted file mode 100644 index 89b3fc19..00000000 --- a/lib/src/main/java/com/basistheory/android/view/transform/ElementTransform.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.basistheory.android.view.transform - -// open for extension internally within this package, but not by outside implementors -open class ElementTransform internal constructor() { - open fun apply(value: String?): String? { - return value - } -} \ No newline at end of file diff --git a/lib/src/main/java/com/basistheory/android/view/transform/RegexReplaceElementTransform.kt b/lib/src/main/java/com/basistheory/android/view/transform/RegexReplaceElementTransform.kt index 5ad6f43a..99b40565 100644 --- a/lib/src/main/java/com/basistheory/android/view/transform/RegexReplaceElementTransform.kt +++ b/lib/src/main/java/com/basistheory/android/view/transform/RegexReplaceElementTransform.kt @@ -1,12 +1,8 @@ package com.basistheory.android.view.transform -data class RegexReplaceElementTransform( - val regex: Regex, - val replacement: String = "" -) : ElementTransform() { - override fun apply(value: String?): String? { - if (value == null) return null - - return regex.replace(value, replacement) - } +fun regexReplaceElementTransform( + regex: Regex, + replacement: String = "" +): (value: String?) -> String? = { + if (it == null) null else regex.replace(it, replacement) } \ No newline at end of file diff --git a/lib/src/main/java/com/basistheory/android/view/validation/CardNumberValidator.kt b/lib/src/main/java/com/basistheory/android/view/validation/CardNumberValidator.kt new file mode 100644 index 00000000..2af52a9e --- /dev/null +++ b/lib/src/main/java/com/basistheory/android/view/validation/CardNumberValidator.kt @@ -0,0 +1,32 @@ +package com.basistheory.android.view.validation + +fun cardNumberValidator(value: String?): Boolean { + if (value.isNullOrEmpty() || value.any { !it.isDigit() }) return false + + var sum = 0 + var isDoubled = false + + for (i in value.length - 1 downTo 0) { + val digit: Int = value[i] - '0' + if (digit < 0 || digit > 9) { + // Ignore non-digits + continue + } + var addend: Int + if (isDoubled) { + addend = digit * 2 + if (addend > 9) { + addend -= 9 + } + } else { + addend = digit + } + sum += addend + isDoubled = !isDoubled + } + + return sum % 10 == 0 +} + +// TODO: return isComplete = true even if invalid +// is it invalid if not complete? diff --git a/lib/src/test/java/com/basistheory/android/event/ChangeEventTests.kt b/lib/src/test/java/com/basistheory/android/event/ChangeEventTests.kt index cb685e0a..92f196b7 100644 --- a/lib/src/test/java/com/basistheory/android/event/ChangeEventTests.kt +++ b/lib/src/test/java/com/basistheory/android/event/ChangeEventTests.kt @@ -10,7 +10,6 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.android.controller.ActivityController import strikt.api.expectThat import strikt.assertions.hasSize -import strikt.assertions.isEmpty import strikt.assertions.isFalse import strikt.assertions.isTrue @@ -54,9 +53,9 @@ class ChangeEventTests { textElement.setText(null) expectThat(changeEvents).hasSize(3).and { - get { elementAt(0).empty }.isFalse() - get { elementAt(1).empty }.isTrue() - get { elementAt(2).empty }.isTrue() + get { elementAt(0).isEmpty }.isFalse() + get { elementAt(1).isEmpty }.isTrue() + get { elementAt(2).isEmpty }.isTrue() } } diff --git a/lib/src/test/java/com/basistheory/android/view/TextElementTests.kt b/lib/src/test/java/com/basistheory/android/view/TextElementTests.kt index 64bc4286..267ddd56 100644 --- a/lib/src/test/java/com/basistheory/android/view/TextElementTests.kt +++ b/lib/src/test/java/com/basistheory/android/view/TextElementTests.kt @@ -1,7 +1,7 @@ package com.basistheory.android.view import android.app.Activity -import com.basistheory.android.view.transform.RegexReplaceElementTransform +import com.basistheory.android.view.transform.regexReplaceElementTransform import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -22,7 +22,7 @@ class TextElementTests { @Test fun `can set value to null`() { - textElement.transform = RegexReplaceElementTransform(Regex("[\\s]")) + textElement.transform = regexReplaceElementTransform(Regex("[\\s]")) textElement.setText(null) expectThat(textElement.getText()).isEqualTo("") // note: EditText transforms nulls to "" @@ -33,7 +33,7 @@ class TextElementTests { @Test fun `can apply transform`() { - textElement.transform = RegexReplaceElementTransform(Regex("[()\\-\\s]")) + textElement.transform = regexReplaceElementTransform(Regex("[()\\-\\s]")) textElement.setText("(123) 456-7890") expectThat(textElement.getText()).isEqualTo("1234567890") @@ -41,15 +41,15 @@ class TextElementTests { @Test fun `transform can be updated and text is transformed just in time`() { - textElement.transform = RegexReplaceElementTransform(Regex("[^\\d]")) + textElement.transform = regexReplaceElementTransform(Regex("[^\\d]")) textElement.setText("(1") expectThat(textElement.getText()).isEqualTo("1") - textElement.transform = RegexReplaceElementTransform(Regex("[()\\s2]")) + textElement.transform = regexReplaceElementTransform(Regex("[()\\s2]")) textElement.setText("(123) 4") expectThat(textElement.getText()).isEqualTo("134") - textElement.transform = RegexReplaceElementTransform(Regex("[()]")) + textElement.transform = regexReplaceElementTransform(Regex("[()]")) textElement.setText("(123) 456-7890") expectThat(textElement.getText()).isEqualTo("123 456-7890") } diff --git a/lib/src/test/java/com/basistheory/android/view/transform/ElementTransformTests.kt b/lib/src/test/java/com/basistheory/android/view/transform/ElementTransformTests.kt deleted file mode 100644 index 1efe2344..00000000 --- a/lib/src/test/java/com/basistheory/android/view/transform/ElementTransformTests.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.basistheory.android.view.transform - -import com.github.javafaker.Faker -import org.junit.Test -import strikt.api.expectThat -import strikt.assertions.isEqualTo -import strikt.assertions.isNull - -class ElementTransformTests { - @Test - fun `null values are not transformed`() { - val transform = ElementTransform() - - expectThat(transform.apply(null)).isNull() - } - - @Test - fun `empty values are not transformed`() { - val transform = ElementTransform() - - expectThat(transform.apply(null)).isNull() - } - - @Test - fun `non-empty values are not transformed`() { - val transform = ElementTransform() - val value = Faker().lorem().word() - - expectThat(transform.apply(value)).isEqualTo(value) - } -} diff --git a/lib/src/test/java/com/basistheory/android/view/transform/RegexReplaceElementTransformTests.kt b/lib/src/test/java/com/basistheory/android/view/transform/RegexReplaceElementTransformTests.kt index 0065746d..a655649d 100644 --- a/lib/src/test/java/com/basistheory/android/view/transform/RegexReplaceElementTransformTests.kt +++ b/lib/src/test/java/com/basistheory/android/view/transform/RegexReplaceElementTransformTests.kt @@ -8,33 +8,33 @@ import strikt.assertions.isEqualTo import strikt.assertions.isFailure import strikt.assertions.isNull -class RegexReplaceElementTransformTests { +class regexReplaceElementTransformTests { @Test fun `null values are not transformed`() { - val transform = RegexReplaceElementTransform(Regex("[\\s]")) + val transform = regexReplaceElementTransform(Regex("[\\s]")) - expectThat(transform.apply(null)).isNull() + expectThat(transform(null)).isNull() } @Test fun `empty values are not transformed`() { - val transform = RegexReplaceElementTransform(Regex("[\\s]")) + val transform = regexReplaceElementTransform(Regex("[\\s]")) - expectThat(transform.apply("")).isEqualTo("") + expectThat(transform("")).isEqualTo("") } @Test fun `text is not transformed when transform regex has no match`() { - val transform = RegexReplaceElementTransform(Regex("[\\s]")) + val transform = regexReplaceElementTransform(Regex("[\\s]")) - expectThat(transform.apply("foo123")).isEqualTo("foo123") + expectThat(transform("foo123")).isEqualTo("foo123") } @Test fun `text is transformed when transform regex has matches`() { - val transform = RegexReplaceElementTransform(Regex("[^\\d]")) + val transform = regexReplaceElementTransform(Regex("[^\\d]")) - expectThat(transform.apply("(123) 456-7890")).isEqualTo("1234567890") + expectThat(transform("(123) 456-7890")).isEqualTo("1234567890") } @Test @@ -44,7 +44,7 @@ class RegexReplaceElementTransformTests { // design/compile-time validation val invalidRegex = "*".plus("[") - expectCatching { RegexReplaceElementTransform(Regex(invalidRegex)) } + expectCatching { regexReplaceElementTransform(Regex(invalidRegex)) } .isFailure() .isA() } diff --git a/lib/src/test/java/com/basistheory/android/view/validation/CardNumberValidatorTests.kt b/lib/src/test/java/com/basistheory/android/view/validation/CardNumberValidatorTests.kt new file mode 100644 index 00000000..117277e7 --- /dev/null +++ b/lib/src/test/java/com/basistheory/android/view/validation/CardNumberValidatorTests.kt @@ -0,0 +1,43 @@ +package com.basistheory.android.view.validation + +import org.junit.Test +import strikt.api.expect +import strikt.assertions.isFalse +import strikt.assertions.isTrue + +class CardNumberValidatorTests { + + @Test + fun `should return true for valid card numbers`() { + expect { + that(cardNumberValidator("4242424242424242")).isTrue() + that(cardNumberValidator("5555555555554444")).isTrue() + that(cardNumberValidator("6011000990139424")).isTrue() + that(cardNumberValidator("378282246310005")).isTrue() + } + } + + @Test + fun `should return false for empty card numbers`() { + expect { + that(cardNumberValidator("")).isFalse() + that(cardNumberValidator(null)).isFalse() + } + } + + @Test + fun `should return false for non numeric values`() { + expect { + that(cardNumberValidator("foo")).isFalse() + that(cardNumberValidator("asdf123l;kj")).isFalse() + } + } + + @Test + fun `should return false for non-Luhn valid cards`() { + expect { + that(cardNumberValidator("5200828282828211")).isFalse() + that(cardNumberValidator("5555555555554443")).isFalse() + } + } +} \ No newline at end of file From 76cb8a878993f19a0130b497bb59cd512deec11d Mon Sep 17 00:00:00 2001 From: Drew Hudec Date: Fri, 2 Dec 2022 13:34:35 -0500 Subject: [PATCH 2/4] Adds unit tests around CardNumberElement and ChangeEvents --- .../android/example/MainActivity.kt | 39 +++++++-- .../basistheory/android/view/TextElement.kt | 20 +++-- .../android/view/mask/MaskWatcher.kt | 14 +-- .../android/event/ChangeEventTests.kt | 53 ++++++++++++ .../android/view/CardNumberElementTests.kt | 85 +++++++++++++++++++ .../android/view/TextElementTests.kt | 2 +- 6 files changed, 191 insertions(+), 22 deletions(-) create mode 100644 lib/src/test/java/com/basistheory/android/view/CardNumberElementTests.kt diff --git a/example/src/main/java/com/basistheory/android/example/MainActivity.kt b/example/src/main/java/com/basistheory/android/example/MainActivity.kt index 0bf0343a..e46119f0 100644 --- a/example/src/main/java/com/basistheory/android/example/MainActivity.kt +++ b/example/src/main/java/com/basistheory/android/example/MainActivity.kt @@ -3,6 +3,7 @@ package com.basistheory.android.example import android.graphics.Color import android.os.Bundle import android.view.View +import android.widget.Button import android.widget.TextView import androidx.appcompat.app.AppCompatActivity import com.basistheory.android.service.BasisTheoryElements @@ -21,6 +22,7 @@ class MainActivity : AppCompatActivity() { private lateinit var socialSecurityNumberElement: TextElement private lateinit var orderNumberElement: TextElement private lateinit var tokenizeResult: TextView + private lateinit var tokenizeButton: Button override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -32,26 +34,49 @@ class MainActivity : AppCompatActivity() { socialSecurityNumberElement = findViewById(R.id.socialSecurityNumber) orderNumberElement = findViewById(R.id.orderNumber) tokenizeResult = findViewById(R.id.tokenizeResult) + tokenizeButton = findViewById(R.id.tokenizeButton) val digitRegex = Regex("""\d""") val charRegex = Regex("""[A-Za-z]""") - phoneNumberElement.keyboardType = KeyboardType.NUMBER // illustrates that it can be set programmatically - phoneNumberElement.mask = listOf("+", "1", "(", digitRegex,digitRegex,digitRegex, ")", " ", digitRegex, digitRegex, digitRegex, "-", digitRegex, digitRegex , digitRegex, digitRegex ) - - orderNumberElement.mask = listOf(charRegex, charRegex, charRegex, "-", digitRegex, digitRegex, digitRegex) + phoneNumberElement.keyboardType = KeyboardType.NUMBER + phoneNumberElement.mask = listOf( + "+", + "1", + "(", + digitRegex, + digitRegex, + digitRegex, + ")", + " ", + digitRegex, + digitRegex, + digitRegex, + "-", + digitRegex, + digitRegex, + digitRegex, + digitRegex + ) + + orderNumberElement.mask = + listOf(charRegex, charRegex, charRegex, "-", digitRegex, digitRegex, digitRegex) cardNumberElement.addChangeEventListener { - if (it.isValid) - cardNumberElement.textColor = Color.BLACK - else + if (!it.isValid && it.isComplete) { cardNumberElement.textColor = Color.RED + tokenizeButton.isEnabled = false + } else { + cardNumberElement.textColor = Color.BLACK + tokenizeButton.isEnabled = true + } } } fun setText(button: View) { assert(button.id == R.id.setTextButton) + cardNumberElement.setText("4242424242424242") nameElement.setText("Manually Set Name") phoneNumberElement.setText("2345678900") socialSecurityNumberElement.setText("234567890") diff --git a/lib/src/main/java/com/basistheory/android/view/TextElement.kt b/lib/src/main/java/com/basistheory/android/view/TextElement.kt index 8dd3cdea..8689c183 100644 --- a/lib/src/main/java/com/basistheory/android/view/TextElement.kt +++ b/lib/src/main/java/com/basistheory/android/view/TextElement.kt @@ -154,14 +154,18 @@ open class TextElement : FrameLayout { override fun onTextChanged(value: CharSequence?, p1: Int, p2: Int, p3: Int) {} override fun afterTextChanged(editable: Editable?) { - val event = ChangeEvent( - true, // TODO - compute this from the mask - editable?.isEmpty() ?: false, - validator(getText()) // TODO - how do we prevent the element from switching between valid/invalid while typing? do we even need to prevent this? - ) - - eventListeners.change.forEach { - it(event) + // when a mask is applied, there are 2 change events raised: + // one with the raw user input, and a second after the mask has been applied + if (maskWatcher == null || maskWatcher?.isMaskApplied == true) { + val event = ChangeEvent( + maskWatcher?.isComplete ?: false, + editable?.isEmpty() ?: false, + validator(getText()) + ) + + eventListeners.change.forEach { + it(event) + } } } }) diff --git a/lib/src/main/java/com/basistheory/android/view/mask/MaskWatcher.kt b/lib/src/main/java/com/basistheory/android/view/mask/MaskWatcher.kt index 5882dafa..8084dd63 100644 --- a/lib/src/main/java/com/basistheory/android/view/mask/MaskWatcher.kt +++ b/lib/src/main/java/com/basistheory/android/view/mask/MaskWatcher.kt @@ -5,9 +5,11 @@ import android.text.TextWatcher internal class MaskWatcher(mask: List) : TextWatcher { private val mask: Mask = Mask(mask) - - private var selfChange: Boolean = false private var result: MaskResult? = null + private var isApplyingMask: Boolean = false + + val isMaskApplied: Boolean + get() = isApplyingMask val maskedValue: String get() = result?.maskedValue.orEmpty() @@ -19,18 +21,18 @@ internal class MaskWatcher(mask: List) : TextWatcher { get() = result?.isComplete ?: false override fun afterTextChanged(editable: Editable?) { - if (selfChange || editable.isNullOrEmpty()) return + if (isApplyingMask || editable.isNullOrEmpty()) return - selfChange = true + isApplyingMask = true result?.apply(editable) - selfChange = false + isApplyingMask = false } override fun beforeTextChanged(charSequence: CharSequence?, start: Int, count: Int, after: Int) { } override fun onTextChanged(charSequence: CharSequence?, start: Int, before: Int, count: Int) { - if (selfChange || charSequence.isNullOrEmpty()) return + if (isApplyingMask || charSequence.isNullOrEmpty()) return val action = if (before > 0 && count == 0) Action.DELETE else Action.INSERT diff --git a/lib/src/test/java/com/basistheory/android/event/ChangeEventTests.kt b/lib/src/test/java/com/basistheory/android/event/ChangeEventTests.kt index 92f196b7..761630d1 100644 --- a/lib/src/test/java/com/basistheory/android/event/ChangeEventTests.kt +++ b/lib/src/test/java/com/basistheory/android/event/ChangeEventTests.kt @@ -12,6 +12,7 @@ import strikt.api.expectThat import strikt.assertions.hasSize import strikt.assertions.isFalse import strikt.assertions.isTrue +import strikt.assertions.single @RunWith(RobolectricTestRunner::class) @@ -70,4 +71,56 @@ class ChangeEventTests { expectThat(changeEvents).hasSize(2) } + + @Test + fun `ChangeEvent sets isComplete to false when mask is undefined`() { + val changeEvents = mutableListOf() + + textElement.addChangeEventListener { changeEvents.add(it) } + textElement.setText("1") + + expectThat(changeEvents).single() + .get { isComplete }.isFalse() + } + + @Test + fun `ChangeEvent computes isComplete based on mask`() { + val changeEvents = mutableListOf() + + textElement.mask = listOf(Regex("""\d"""), Regex("""\d""")) + textElement.addChangeEventListener { changeEvents.add(it) } + textElement.setText("1") + textElement.setText("12") + + expectThat(changeEvents).hasSize(2).and { + get { elementAt(0).isComplete }.isFalse() + get { elementAt(1).isComplete }.isTrue() + } + } + + @Test + fun `ChangeEvent sets isValid to true when validator is undefined`() { + val changeEvents = mutableListOf() + + textElement.addChangeEventListener { changeEvents.add(it) } + textElement.setText("1") + + expectThat(changeEvents).single() + .get { isValid }.isTrue() + } + + @Test + fun `ChangeEvent computes isValid based on validator`() { + val changeEvents = mutableListOf() + + textElement.validator = { (it?.length ?: 0) % 2 == 0 } + textElement.addChangeEventListener { changeEvents.add(it) } + textElement.setText("1") + textElement.setText("12") + + expectThat(changeEvents).hasSize(2).and { + get { elementAt(0).isValid }.isFalse() + get { elementAt(1).isValid }.isTrue() + } + } } \ No newline at end of file diff --git a/lib/src/test/java/com/basistheory/android/view/CardNumberElementTests.kt b/lib/src/test/java/com/basistheory/android/view/CardNumberElementTests.kt new file mode 100644 index 00000000..1e168bdf --- /dev/null +++ b/lib/src/test/java/com/basistheory/android/view/CardNumberElementTests.kt @@ -0,0 +1,85 @@ +package com.basistheory.android.view + +import android.app.Activity +import com.basistheory.android.event.ChangeEvent +import com.basistheory.android.view.transform.regexReplaceElementTransform +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import strikt.api.expectThat +import strikt.assertions.isEqualTo +import strikt.assertions.isFalse +import strikt.assertions.isTrue +import strikt.assertions.single + +@RunWith(RobolectricTestRunner::class) +class CardNumberElementTests { + private lateinit var cardNumberElement: CardNumberElement + + @Before + fun setUp() { + val activity = Robolectric.buildActivity(Activity::class.java).get() + cardNumberElement = CardNumberElement(activity) + } + + @Test + fun `can clear the value`() { + cardNumberElement.transform = regexReplaceElementTransform(Regex("[\\s]")) + + cardNumberElement.setText(null) + expectThat(cardNumberElement.getText()).isEqualTo("") // note: EditText transforms nulls to "" + + cardNumberElement.setText("") + expectThat(cardNumberElement.getText()).isEqualTo("") + } + + @Test + fun `applies the transform when retrieving the value`() { + cardNumberElement.setText("4242 4242 4242 4242") + expectThat(cardNumberElement.getText()).isEqualTo("4242424242424242") + + cardNumberElement.setText("4242-4242-4242-4242") + expectThat(cardNumberElement.getText()).isEqualTo("4242424242424242") + } + + @Test + fun `ChangeEvent is computed properly for incomplete card numbers`() { + val changeEvents = mutableListOf() + cardNumberElement.addChangeEventListener { changeEvents.add(it) } + + cardNumberElement.setText("1234 56") + expectThat(changeEvents).single().and { + get { isValid }.isFalse() + get { isEmpty }.isFalse() + get { isComplete }.isFalse() + } + } + + @Test + fun `ChangeEvent is computed properly for valid complete card numbers`() { + val changeEvents = mutableListOf() + cardNumberElement.addChangeEventListener { changeEvents.add(it) } + + cardNumberElement.setText("4242 4242 4242 4242") + expectThat(changeEvents).single().and { + get { isValid }.isTrue() + get { isEmpty }.isFalse() + get { isComplete }.isTrue() + } + } + + @Test + fun `ChangeEvent is computed properly for invalid complete card numbers`() { + val changeEvents = mutableListOf() + cardNumberElement.addChangeEventListener { changeEvents.add(it) } + + cardNumberElement.setText("4242 4242 4242 4243") + expectThat(changeEvents).single().and { + get { isValid }.isFalse() + get { isEmpty }.isFalse() + get { isComplete }.isTrue() + } + } +} \ No newline at end of file diff --git a/lib/src/test/java/com/basistheory/android/view/TextElementTests.kt b/lib/src/test/java/com/basistheory/android/view/TextElementTests.kt index 267ddd56..5ca89e33 100644 --- a/lib/src/test/java/com/basistheory/android/view/TextElementTests.kt +++ b/lib/src/test/java/com/basistheory/android/view/TextElementTests.kt @@ -21,7 +21,7 @@ class TextElementTests { } @Test - fun `can set value to null`() { + fun `can clear the value`() { textElement.transform = regexReplaceElementTransform(Regex("[\\s]")) textElement.setText(null) From 051c3fd54367b6dbaebe02d796fb554e27490891 Mon Sep 17 00:00:00 2001 From: Drew Hudec Date: Fri, 2 Dec 2022 15:45:28 -0500 Subject: [PATCH 3/4] Fixes espresso tests --- .../android/example/TextElementTests.kt | 39 ++++++++++++------- .../android/example/MainActivity.kt | 3 +- .../android/service/BasisTheoryElements.kt | 5 +-- .../service/BasisTheoryElementsTests.kt | 24 +++++++++++- 4 files changed, 52 insertions(+), 19 deletions(-) diff --git a/example/src/androidTest/java/com/basistheory/android/example/TextElementTests.kt b/example/src/androidTest/java/com/basistheory/android/example/TextElementTests.kt index 76c7478b..623930c4 100644 --- a/example/src/androidTest/java/com/basistheory/android/example/TextElementTests.kt +++ b/example/src/androidTest/java/com/basistheory/android/example/TextElementTests.kt @@ -1,8 +1,7 @@ package com.basistheory.android.example import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.click -import androidx.test.espresso.action.ViewActions.typeText +import androidx.test.espresso.action.ViewActions.* import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.* import androidx.test.ext.junit.rules.activityScenarioRule @@ -17,7 +16,7 @@ import org.junit.runner.RunWith import org.junit.runners.MethodSorters @RunWith(AndroidJUnit4::class) -@FixMethodOrder(MethodSorters.NAME_ASCENDING) // TODO: why does order of these tests matter? +@FixMethodOrder(MethodSorters.NAME_ASCENDING) class TextElementTests { @get:Rule @@ -26,30 +25,42 @@ class TextElementTests { @Test fun canSetText() { onView(withId(R.id.setTextButton)).perform(click()) + onView(withText("4242 4242 4242 4242")).check(matches(isDisplayed())) onView(withText("Manually Set Name")).check(matches(isDisplayed())) onView(withText("+1(234) 567-8900")).check(matches(isDisplayed())) onView(withText("234-56-7890")).check(matches(isDisplayed())) + onView(withText("ABC-123")).check(matches(isDisplayed())) } @Test fun canTokenize() { + val cardNumber = "4242424242424242" val name = Faker().name().fullName() - val phoneNumber = "2345678900" // "+1(234) 567-8900" - val ssn = "123456789"// "123-45-6789" + val phoneNumber = "2345678900" + val ssn = "123456789" + val orderNumber = "ABC123" + // type values into elements + onView(withId(R.id.cardNumber)).perform(typeText(cardNumber)) onView(withId(R.id.name)).perform(typeText(name)) onView(withId(R.id.phoneNumber)).perform(typeText(phoneNumber)) onView(withId(R.id.socialSecurityNumber)).perform(typeText(ssn)) - onView(withText(name)).check(matches(isDisplayed())) - onView(withId(R.id.tokenizeButton)).perform(click()) - onView( - allOf( - withId(R.id.tokenizeResult), - withSubstring(name), - withSubstring("+1(234) 567-8900"), - withSubstring("123-45-6789") + onView(withId(R.id.orderNumber)).perform(typeText(orderNumber)) + + // click tokenize + onView(withId(R.id.tokenizeButton)).perform(scrollTo(), click()) + + // assertions on tokenize response + onView(withId(R.id.tokenizeResult)).check( + matches( + allOf( + withSubstring(cardNumber), // displayed with mask, but transformed back to this value + withSubstring(name), + withSubstring("+1(234) 567-8900"), + withSubstring("123-45-6789"), + withSubstring("ABC-123") + ) ) ) - .check(matches(isDisplayed())) } } \ No newline at end of file diff --git a/example/src/main/java/com/basistheory/android/example/MainActivity.kt b/example/src/main/java/com/basistheory/android/example/MainActivity.kt index e46119f0..2b62e749 100644 --- a/example/src/main/java/com/basistheory/android/example/MainActivity.kt +++ b/example/src/main/java/com/basistheory/android/example/MainActivity.kt @@ -101,7 +101,8 @@ class MainActivity : AppCompatActivity() { val tokenizeResponse = bt.tokenize(object { val type = "token" val data = object { - val myProp = "My Value" + val staticProp = "Static Value" + val cardNumber = cardNumberElement val name = nameElement val phoneNumber = phoneNumberElement val socialSecurityNumber = socialSecurityNumberElement diff --git a/lib/src/main/java/com/basistheory/android/service/BasisTheoryElements.kt b/lib/src/main/java/com/basistheory/android/service/BasisTheoryElements.kt index 5e21900e..b378278c 100644 --- a/lib/src/main/java/com/basistheory/android/service/BasisTheoryElements.kt +++ b/lib/src/main/java/com/basistheory/android/service/BasisTheoryElements.kt @@ -29,9 +29,8 @@ class BasisTheoryElements internal constructor( if (value == null) continue val fieldType = value::class.java if (!fieldType.isPrimitiveType()) { - if (fieldType == TextElement::class.java) { - val element = value as TextElement - map[key] = element.getText() + if (value is TextElement) { + map[key] = value.getText() } else { val children = value.toMap() map[key] = children diff --git a/lib/src/test/java/com/basistheory/android/service/BasisTheoryElementsTests.kt b/lib/src/test/java/com/basistheory/android/service/BasisTheoryElementsTests.kt index 16bf4c32..03c5fecd 100644 --- a/lib/src/test/java/com/basistheory/android/service/BasisTheoryElementsTests.kt +++ b/lib/src/test/java/com/basistheory/android/service/BasisTheoryElementsTests.kt @@ -2,6 +2,7 @@ package com.basistheory.android.service import android.app.Activity import com.basistheory.TokenizeApi +import com.basistheory.android.view.CardNumberElement import com.basistheory.android.view.TextElement import com.github.javafaker.Faker import io.mockk.every @@ -26,6 +27,7 @@ class BasisTheoryElementsTests { private val faker = Faker() private lateinit var nameElement: TextElement private lateinit var phoneNumberElement: TextElement + private lateinit var cardNumberElement: CardNumberElement @get:Rule val mockkRule = MockKRule(this) @@ -48,6 +50,7 @@ class BasisTheoryElementsTests { nameElement = TextElement(activity) phoneNumberElement = TextElement(activity) + cardNumberElement = CardNumberElement(activity) } @Test @@ -133,7 +136,7 @@ class BasisTheoryElementsTests { } @Test - fun `tokenize should replace top level Element ref with underlying data value`() = + fun `tokenize should replace top level TextElement ref with underlying data value`() = runBlocking { every { provider.getTokenizeApi(any()) } returns tokenizeApi @@ -145,6 +148,20 @@ class BasisTheoryElementsTests { verify { tokenizeApi.tokenize(name) } } + @Test + fun `tokenize should replace top level CardElement ref with underlying data value`() = + runBlocking { + every { provider.getTokenizeApi(any()) } returns tokenizeApi + + val cardNumber = faker.business().creditCardNumber() + cardNumberElement.setText(cardNumber) + + bt.tokenize(cardNumberElement) + + val expectedTokenizedCardNumber = cardNumber.replace(Regex("""[^\d]"""), "") + verify { tokenizeApi.tokenize(expectedTokenizedCardNumber) } + } + @Test fun `tokenize should replace Element refs within request object with underlying data values`() = runBlocking { @@ -156,11 +173,15 @@ class BasisTheoryElementsTests { val phoneNumber = faker.phoneNumber().phoneNumber() phoneNumberElement.setText(phoneNumber) + val cardNumber = faker.business().creditCardNumber() + cardNumberElement.setText(cardNumber) + val request = object { val type = "token" val data = object { val raw = faker.lorem().word() val name = nameElement + val cardNumber = cardNumberElement val nested = object { val raw = faker.lorem().word() val phoneNumber = phoneNumberElement @@ -175,6 +196,7 @@ class BasisTheoryElementsTests { "data" to mapOf( "raw" to request.data.raw, "name" to name, + "cardNumber" to cardNumber.replace(Regex("""[^\d]"""), ""), "nested" to mapOf( "raw" to request.data.nested.raw, "phoneNumber" to phoneNumber From 5af23b2fb8da5ea13b7964724953d9fe7c93fd0f Mon Sep 17 00:00:00 2001 From: Drew Hudec Date: Fri, 2 Dec 2022 16:25:21 -0500 Subject: [PATCH 4/4] Cleaning up LuhnValidator --- .../android/view/CardNumberElement.kt | 12 ++++-- ...ardNumberValidator.kt => LuhnValidator.kt} | 5 +-- .../validation/CardNumberValidatorTests.kt | 43 ------------------- .../view/validation/LuhnValidatorTests.kt | 43 +++++++++++++++++++ 4 files changed, 52 insertions(+), 51 deletions(-) rename lib/src/main/java/com/basistheory/android/view/validation/{CardNumberValidator.kt => LuhnValidator.kt} (82%) delete mode 100644 lib/src/test/java/com/basistheory/android/view/validation/CardNumberValidatorTests.kt create mode 100644 lib/src/test/java/com/basistheory/android/view/validation/LuhnValidatorTests.kt diff --git a/lib/src/main/java/com/basistheory/android/view/CardNumberElement.kt b/lib/src/main/java/com/basistheory/android/view/CardNumberElement.kt index 658dbee7..87b2a8ff 100644 --- a/lib/src/main/java/com/basistheory/android/view/CardNumberElement.kt +++ b/lib/src/main/java/com/basistheory/android/view/CardNumberElement.kt @@ -3,7 +3,7 @@ package com.basistheory.android.view import android.content.Context import android.util.AttributeSet import com.basistheory.android.view.transform.regexReplaceElementTransform -import com.basistheory.android.view.validation.cardNumberValidator +import com.basistheory.android.view.validation.luhnValidator class CardNumberElement : TextElement { @@ -27,11 +27,15 @@ class CardNumberElement : TextElement { super.keyboardType = KeyboardType.NUMBER super.mask = defaultMask super.transform = regexReplaceElementTransform(Regex("""\s"""), "") - super.validator = ::cardNumberValidator + super.validator = ::luhnValidator } companion object { private val digit = Regex("""\d""") - val defaultMask: List = (1..19).map { if (it % 5 == 0 && it > 0) " " else digit } + + val defaultMask: List = + (1..19).map { + if (it % 5 == 0 && it > 0) " " else digit + } } -} \ No newline at end of file +} diff --git a/lib/src/main/java/com/basistheory/android/view/validation/CardNumberValidator.kt b/lib/src/main/java/com/basistheory/android/view/validation/LuhnValidator.kt similarity index 82% rename from lib/src/main/java/com/basistheory/android/view/validation/CardNumberValidator.kt rename to lib/src/main/java/com/basistheory/android/view/validation/LuhnValidator.kt index 2af52a9e..1b3ec8e1 100644 --- a/lib/src/main/java/com/basistheory/android/view/validation/CardNumberValidator.kt +++ b/lib/src/main/java/com/basistheory/android/view/validation/LuhnValidator.kt @@ -1,6 +1,6 @@ package com.basistheory.android.view.validation -fun cardNumberValidator(value: String?): Boolean { +fun luhnValidator(value: String?): Boolean { if (value.isNullOrEmpty() || value.any { !it.isDigit() }) return false var sum = 0 @@ -27,6 +27,3 @@ fun cardNumberValidator(value: String?): Boolean { return sum % 10 == 0 } - -// TODO: return isComplete = true even if invalid -// is it invalid if not complete? diff --git a/lib/src/test/java/com/basistheory/android/view/validation/CardNumberValidatorTests.kt b/lib/src/test/java/com/basistheory/android/view/validation/CardNumberValidatorTests.kt deleted file mode 100644 index 117277e7..00000000 --- a/lib/src/test/java/com/basistheory/android/view/validation/CardNumberValidatorTests.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.basistheory.android.view.validation - -import org.junit.Test -import strikt.api.expect -import strikt.assertions.isFalse -import strikt.assertions.isTrue - -class CardNumberValidatorTests { - - @Test - fun `should return true for valid card numbers`() { - expect { - that(cardNumberValidator("4242424242424242")).isTrue() - that(cardNumberValidator("5555555555554444")).isTrue() - that(cardNumberValidator("6011000990139424")).isTrue() - that(cardNumberValidator("378282246310005")).isTrue() - } - } - - @Test - fun `should return false for empty card numbers`() { - expect { - that(cardNumberValidator("")).isFalse() - that(cardNumberValidator(null)).isFalse() - } - } - - @Test - fun `should return false for non numeric values`() { - expect { - that(cardNumberValidator("foo")).isFalse() - that(cardNumberValidator("asdf123l;kj")).isFalse() - } - } - - @Test - fun `should return false for non-Luhn valid cards`() { - expect { - that(cardNumberValidator("5200828282828211")).isFalse() - that(cardNumberValidator("5555555555554443")).isFalse() - } - } -} \ No newline at end of file diff --git a/lib/src/test/java/com/basistheory/android/view/validation/LuhnValidatorTests.kt b/lib/src/test/java/com/basistheory/android/view/validation/LuhnValidatorTests.kt new file mode 100644 index 00000000..496b26c7 --- /dev/null +++ b/lib/src/test/java/com/basistheory/android/view/validation/LuhnValidatorTests.kt @@ -0,0 +1,43 @@ +package com.basistheory.android.view.validation + +import org.junit.Test +import strikt.api.expect +import strikt.assertions.isFalse +import strikt.assertions.isTrue + +class LuhnValidatorTests { + + @Test + fun `should return true for valid card numbers`() { + expect { + that(luhnValidator("4242424242424242")).isTrue() + that(luhnValidator("5555555555554444")).isTrue() + that(luhnValidator("6011000990139424")).isTrue() + that(luhnValidator("378282246310005")).isTrue() + } + } + + @Test + fun `should return false for empty card numbers`() { + expect { + that(luhnValidator("")).isFalse() + that(luhnValidator(null)).isFalse() + } + } + + @Test + fun `should return false for non numeric values`() { + expect { + that(luhnValidator("foo")).isFalse() + that(luhnValidator("asdf123l;kj")).isFalse() + } + } + + @Test + fun `should return false for non-Luhn valid cards`() { + expect { + that(luhnValidator("5200828282828211")).isFalse() + that(luhnValidator("5555555555554443")).isFalse() + } + } +} \ No newline at end of file