Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Adds CardNumberElement and validator property to TextElement #10

Merged
merged 4 commits into from
Dec 2, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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()))
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
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
import com.basistheory.android.view.CardNumberElement
import com.basistheory.android.view.KeyboardType
import com.basistheory.android.view.TextElement
import com.google.gson.GsonBuilder
Expand All @@ -13,34 +16,67 @@ 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
private lateinit var orderNumberElement: TextElement
private lateinit var tokenizeResult: TextView
private lateinit var tokenizeButton: Button

override fun onCreate(savedInstanceState: Bundle?) {
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)
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 )
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)
orderNumberElement.mask =
listOf(charRegex, charRegex, charRegex, "-", digitRegex, digitRegex, digitRegex)

cardNumberElement.addChangeEventListener {
if (!it.isValid && it.isComplete) {
cardNumberElement.textColor = Color.RED
tokenizeButton.isEnabled = false
} else {
cardNumberElement.textColor = Color.BLACK
tokenizeButton.isEnabled = true
}
}
Comment on lines +65 to +73
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I almost deleted this, but I left it for now thinking we can move it into another activity later to demo validation

}

fun setText(button: View) {
assert(button.id == R.id.setTextButton)

cardNumberElement.setText("4242424242424242")
nameElement.setText("Manually Set Name")
phoneNumberElement.setText("2345678900")
socialSecurityNumberElement.setText("234567890")
Expand All @@ -65,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
Expand Down
11 changes: 11 additions & 0 deletions example/src/main/res/layout/activity_main.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,21 @@
android:layout_margin="20dp"
android:orientation="vertical">

<com.basistheory.android.view.CardNumberElement
android:id="@+id/cardNumber"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/rounded_edit_text"
android:padding="5dp"
bt:hint="Card Number"
bt:removeDefaultStyles="true"
bt:textColor="@color/purple_200" />

<com.basistheory.android.view.TextElement
android:id="@+id/name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:background="@drawable/rounded_edit_text"
android:padding="5dp"
bt:hint="Name"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.basistheory.android.event

data class ChangeEvent(
val complete: Boolean,
val empty: Boolean,
val errors: List<ElementEventError>
val isComplete: Boolean,
val isEmpty: Boolean,
val isValid: Boolean
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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.luhnValidator

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 = ::luhnValidator
}

companion object {
private val digit = Regex("""\d""")

val defaultMask: List<Any> =
(1..19).map {
if (it % 5 == 0 && it > 0) " " else digit
}
}
}
40 changes: 30 additions & 10 deletions lib/src/main/java/com/basistheory/android/view/TextElement.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -144,8 +154,18 @@ class TextElement : FrameLayout {
override fun onTextChanged(value: CharSequence?, p1: Int, p2: Int, p3: Int) {}

override fun afterTextChanged(editable: Editable?) {
eventListeners.change.forEach {
it(ChangeEvent(true, editable?.isEmpty() != false, listOf()))
// 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)
}
}
}
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import android.text.TextWatcher

internal class MaskWatcher(mask: List<Any>) : 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()
Expand All @@ -19,18 +21,18 @@ internal class MaskWatcher(mask: List<Any>) : 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

Expand Down

This file was deleted.

Loading