Skip to content

Commit 4b1e482

Browse files
Correctly validate country field on shipping address form (#1845)
- Validation was being incorrectly skipped in `PaymentFlowActivity`. Use `ShippingInfoWidget#shippingInformation` instead of `rawShippingInformation`. - Implement a `AutoCompleteTextView.Validator` in `CountryAutoCompleteTextView` and show an error message if the text is an invalid country. - When a user submits their shipping information in `PaymentFlowActivity`, perform validation using the `AutoCompleteTextView.Validator`. - Add string resource `address_country_invalid`. Create JIRA for translation. ANDROID-451
1 parent bf88f55 commit 4b1e482

File tree

9 files changed

+105
-38
lines changed

9 files changed

+105
-38
lines changed

stripe/res/layout/country_autocomplete_textview.xml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
<?xml version="1.0" encoding="utf-8"?>
22
<merge xmlns:android="http://schemas.android.com/apk/res/android"
3-
android:layout_width="match_parent"
4-
android:layout_height="wrap_content"
5-
>
3+
android:layout_width="match_parent"
4+
android:layout_height="wrap_content">
65

76
<com.google.android.material.textfield.TextInputLayout
87
android:id="@+id/tl_country_cat"
98
android:layout_width="match_parent"
109
android:layout_height="wrap_content"
1110
android:hint="@string/address_label_country"
12-
>
11+
android:labelFor="@id/autocomplete_country_cat">
1312

1413
<AutoCompleteTextView
1514
android:id="@+id/autocomplete_country_cat"

stripe/res/values/strings.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@
7171
<string name="address_postal_code_invalid">Your postal code is invalid</string>
7272
<!--Error text indicating zip/ postal code is invalid, used for international addresses-->
7373
<string name="address_zip_postal_invalid">Your ZIP/Postal code is invalid</string>
74+
75+
<!-- TODO(mshafrir-stripe): translate string (ANDROID-450) -->
76+
<!--Error text indicating country is invalid-->
77+
<string name="address_country_invalid" tools:ignore="MissingTranslation">Your country is invalid</string>
78+
7479
<!--Label for input requesting province, used for canadian addresses-->
7580
<string name="address_label_province">Province</string>
7681
<!--Label for input requesting province where province is optional, used for canadian addresses-->

stripe/src/main/java/com/stripe/android/view/CountryAdapter.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import java.util.Locale
1818
*/
1919
internal class CountryAdapter(
2020
context: Context,
21-
private var unfilteredCountries: List<Country>
21+
internal var unfilteredCountries: List<Country>
2222
) : ArrayAdapter<Country>(context, R.layout.country_text_view) {
2323
private val countryFilter: CountryFilter = CountryFilter(
2424
unfilteredCountries,

stripe/src/main/java/com/stripe/android/view/CountryAutoCompleteTextView.kt

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,21 @@ import androidx.annotation.VisibleForTesting
1111
import androidx.core.os.ConfigurationCompat
1212
import com.stripe.android.R
1313
import java.util.Locale
14+
import kotlinx.android.synthetic.main.country_autocomplete_textview.view.*
1415

1516
internal class CountryAutoCompleteTextView @JvmOverloads constructor(
1617
context: Context,
1718
attrs: AttributeSet? = null,
1819
defStyleAttr: Int = 0
1920
) : FrameLayout(context, attrs, defStyleAttr) {
20-
private val countryAutocomplete: AutoCompleteTextView
21+
@VisibleForTesting
22+
internal val countryAutocomplete: AutoCompleteTextView
2123

2224
/**
2325
* @return 2 digit country code of the country selected by this input.
2426
*/
2527
@VisibleForTesting
26-
var selectedCountry: Country
28+
var selectedCountry: Country?
2729

2830
@JvmSynthetic
2931
internal var countryChangeCallback: (Country) -> Unit = {}
@@ -58,6 +60,30 @@ internal class CountryAutoCompleteTextView @JvmOverloads constructor(
5860

5961
selectedCountry = countryAdapter.firstItem
6062
updateInitialCountry()
63+
64+
val errorMessage = resources.getString(R.string.address_country_invalid)
65+
countryAutocomplete.validator = object : AutoCompleteTextView.Validator {
66+
override fun fixText(invalidText: CharSequence?): CharSequence {
67+
return invalidText ?: ""
68+
}
69+
70+
override fun isValid(text: CharSequence?): Boolean {
71+
val validCountry = countryAdapter.unfilteredCountries.firstOrNull {
72+
it.name == text.toString()
73+
}
74+
75+
selectedCountry = validCountry
76+
77+
if (validCountry != null) {
78+
clearError()
79+
} else {
80+
tl_country_cat.error = errorMessage
81+
tl_country_cat.isErrorEnabled = true
82+
}
83+
84+
return validCountry != null
85+
}
86+
}
6187
}
6288

6389
private fun updateInitialCountry() {
@@ -94,12 +120,13 @@ internal class CountryAutoCompleteTextView @JvmOverloads constructor(
94120
val displayCountry = country?.let {
95121
updatedSelectedCountryCode(it)
96122
displayCountryEntered
97-
} ?: selectedCountry.name
123+
} ?: selectedCountry?.name
98124

99125
countryAutocomplete.setText(displayCountry)
100126
}
101127

102128
private fun updatedSelectedCountryCode(country: Country) {
129+
clearError()
103130
if (selectedCountry != country) {
104131
selectedCountry = country
105132
countryChangeCallback(country)
@@ -110,4 +137,13 @@ internal class CountryAutoCompleteTextView @JvmOverloads constructor(
110137
return CountryUtils.getCountryByCode(countryCode)?.name
111138
?: Locale("", countryCode).displayCountry
112139
}
140+
141+
internal fun validateCountry() {
142+
countryAutocomplete.performValidation()
143+
}
144+
145+
private fun clearError() {
146+
tl_country_cat.error = null
147+
tl_country_cat.isErrorEnabled = false
148+
}
113149
}

stripe/src/main/java/com/stripe/android/view/PaymentFlowActivity.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import com.stripe.android.model.Customer
1919
import com.stripe.android.model.ShippingInformation
2020
import com.stripe.android.model.ShippingMethod
2121
import java.lang.ref.WeakReference
22+
import kotlinx.android.synthetic.main.activity_enter_shipping_info.*
2223
import kotlinx.android.synthetic.main.activity_shipping_flow.*
2324

2425
/**
@@ -192,8 +193,7 @@ class PaymentFlowActivity : StripeActivity() {
192193

193194
private val shippingInfo: ShippingInformation?
194195
get() {
195-
val shippingInfoWidget: ShippingInfoWidget = findViewById(R.id.shipping_info_widget)
196-
return shippingInfoWidget.rawShippingInformation
196+
return shipping_info_widget.shippingInformation
197197
}
198198

199199
private val selectedShippingMethod: ShippingMethod?

stripe/src/main/java/com/stripe/android/view/ShippingInfoWidget.kt

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -57,18 +57,17 @@ class ShippingInfoWidget @JvmOverloads constructor(
5757
/**
5858
* Return [ShippingInformation] based on user input.
5959
*/
60-
internal val rawShippingInformation: ShippingInformation
60+
private val rawShippingInformation: ShippingInformation
6161
get() {
62-
val address = Address.Builder()
63-
.setCity(cityEditText.text?.toString())
64-
.setCountry(countryAutoCompleteTextView.selectedCountry.code)
65-
.setLine1(addressEditText.text?.toString())
66-
.setLine2(addressEditText2.text?.toString())
67-
.setPostalCode(postalCodeEditText.text?.toString())
68-
.setState(stateEditText.text?.toString())
69-
.build()
7062
return ShippingInformation(
71-
address,
63+
Address.Builder()
64+
.setCity(cityEditText.text?.toString())
65+
.setCountry(countryAutoCompleteTextView.selectedCountry?.code)
66+
.setLine1(addressEditText.text?.toString())
67+
.setLine2(addressEditText2.text?.toString())
68+
.setPostalCode(postalCodeEditText.text?.toString())
69+
.setState(stateEditText.text?.toString())
70+
.build(),
7271
nameEditText.text?.toString(),
7372
phoneNumberEditText.text?.toString()
7473
)
@@ -136,7 +135,7 @@ class ShippingInfoWidget @JvmOverloads constructor(
136135
optionalShippingInfoFields = optionalAddressFields.orEmpty()
137136
renderLabels()
138137

139-
countryAutoCompleteTextView.selectedCountry.let(::renderCountrySpecificLabels)
138+
countryAutoCompleteTextView.selectedCountry?.let(::renderCountrySpecificLabels)
140139
}
141140

142141
/**
@@ -147,7 +146,7 @@ class ShippingInfoWidget @JvmOverloads constructor(
147146
hiddenShippingInfoFields = hiddenAddressFields.orEmpty()
148147
renderLabels()
149148

150-
countryAutoCompleteTextView.selectedCountry.let(::renderCountrySpecificLabels)
149+
countryAutoCompleteTextView.selectedCountry?.let(::renderCountrySpecificLabels)
151150
}
152151

153152
/**
@@ -191,9 +190,12 @@ class ShippingInfoWidget @JvmOverloads constructor(
191190
val postalCode = postalCodeEditText.text?.toString() ?: return false
192191
val phoneNumber = phoneNumberEditText.text?.toString() ?: return false
193192

193+
countryAutoCompleteTextView.validateCountry()
194+
val selectedCountry = countryAutoCompleteTextView.selectedCountry
195+
194196
val isPostalCodeValid = shippingPostalCodeValidator.isValid(
195197
postalCode,
196-
countryAutoCompleteTextView.selectedCountry.code,
198+
selectedCountry?.code,
197199
optionalShippingInfoFields,
198200
hiddenShippingInfoFields
199201
)
@@ -219,7 +221,8 @@ class ShippingInfoWidget @JvmOverloads constructor(
219221
phoneNumberEditText.shouldShowError = requiredPhoneNumberEmpty
220222

221223
return isPostalCodeValid && !requiredAddressLine1Empty && !requiredCityEmpty &&
222-
!requiredStateEmpty && !requiredNameEmpty && !requiredPhoneNumberEmpty
224+
!requiredStateEmpty && !requiredNameEmpty && !requiredPhoneNumberEmpty &&
225+
selectedCountry != null
223226
}
224227

225228
private fun isFieldRequired(@CustomizableShippingField field: String): Boolean {
@@ -241,7 +244,7 @@ class ShippingInfoWidget @JvmOverloads constructor(
241244
setupErrorHandling()
242245
renderLabels()
243246

244-
countryAutoCompleteTextView.selectedCountry.let(::renderCountrySpecificLabels)
247+
countryAutoCompleteTextView.selectedCountry?.let(::renderCountrySpecificLabels)
245248
}
246249

247250
private fun setupErrorHandling() {

stripe/src/main/java/com/stripe/android/view/ShippingPostalCodeValidator.kt

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,26 @@ internal class ShippingPostalCodeValidator {
1010

1111
fun isValid(
1212
postalCode: String,
13-
countryCode: String,
13+
countryCode: String?,
1414
optionalShippingInfoFields: List<String>,
1515
hiddenShippingInfoFields: List<String>
1616
): Boolean {
17+
if (countryCode == null) {
18+
return false
19+
}
20+
1721
val postalCodePattern = POSTAL_CODE_PATTERNS[countryCode]
1822
return if (postalCode.isEmpty() &&
1923
isPostalCodeOptional(optionalShippingInfoFields, hiddenShippingInfoFields)) {
2024
true
21-
} else postalCodePattern?.matcher(postalCode)?.matches()
22-
?: if (CountryUtils.doesCountryUsePostalCode(countryCode)) {
23-
postalCode.isNotEmpty()
24-
} else {
25-
true
26-
}
25+
} else {
26+
postalCodePattern?.matcher(postalCode)?.matches()
27+
?: if (CountryUtils.doesCountryUsePostalCode(countryCode)) {
28+
postalCode.isNotEmpty()
29+
} else {
30+
true
31+
}
32+
}
2733
}
2834

2935
private companion object {

stripe/src/test/java/com/stripe/android/view/CountryAutoCompleteTextViewTest.kt

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import kotlin.test.Test
99
import kotlin.test.assertEquals
1010
import kotlin.test.assertFalse
1111
import kotlin.test.assertNotEquals
12+
import kotlin.test.assertNotNull
1213
import kotlin.test.assertNull
1314
import kotlin.test.assertTrue
1415
import org.junit.runner.RunWith
@@ -37,13 +38,14 @@ class CountryAutoCompleteTextViewTest : BaseViewTest<ShippingInfoTestActivity>(
3738

3839
@Test
3940
fun countryAutoCompleteTextView_whenInitialized_displaysDefaultLocaleDisplayName() {
40-
assertEquals(Locale.US.country, countryAutoCompleteTextView.selectedCountry.code)
41+
assertEquals(Locale.US.country, countryAutoCompleteTextView.selectedCountry?.code)
4142
assertEquals(Locale.US.displayCountry, autoCompleteTextView.text.toString())
4243
}
4344

4445
@Test
4546
fun updateUIForCountryEntered_whenInvalidCountry_revertsToLastCountry() {
46-
val previousValidCountryCode = countryAutoCompleteTextView.selectedCountry.code
47+
val previousValidCountryCode =
48+
countryAutoCompleteTextView.selectedCountry?.code.orEmpty()
4749
countryAutoCompleteTextView.setCountrySelected("FAKE COUNTRY CODE")
4850
assertNull(autoCompleteTextView.error)
4951
assertEquals(autoCompleteTextView.text.toString(),
@@ -56,9 +58,9 @@ class CountryAutoCompleteTextViewTest : BaseViewTest<ShippingInfoTestActivity>(
5658

5759
@Test
5860
fun updateUIForCountryEntered_whenValidCountry_UIUpdates() {
59-
assertEquals(Locale.US.country, countryAutoCompleteTextView.selectedCountry.code)
61+
assertEquals(Locale.US.country, countryAutoCompleteTextView.selectedCountry?.code)
6062
countryAutoCompleteTextView.setCountrySelected(Locale.UK.country)
61-
assertEquals(Locale.UK.country, countryAutoCompleteTextView.selectedCountry.code)
63+
assertEquals(Locale.UK.country, countryAutoCompleteTextView.selectedCountry?.code)
6264
}
6365

6466
@Test
@@ -74,10 +76,26 @@ class CountryAutoCompleteTextViewTest : BaseViewTest<ShippingInfoTestActivity>(
7476
countryAutoCompleteTextView.setAllowedCountryCodes(setOf("fr", "de"))
7577
assertEquals(
7678
"FR",
77-
countryAutoCompleteTextView.selectedCountry.code
79+
countryAutoCompleteTextView.selectedCountry?.code
7880
)
7981
}
8082

83+
@Test
84+
fun validateCountry_withInvalidCountry_setsSelectedCountryToNull() {
85+
assertNotNull(countryAutoCompleteTextView.selectedCountry)
86+
countryAutoCompleteTextView.countryAutocomplete.setText("invalid country")
87+
countryAutoCompleteTextView.validateCountry()
88+
assertNull(countryAutoCompleteTextView.selectedCountry)
89+
}
90+
91+
@Test
92+
fun validateCountry_withValidCountry_setsSelectedCountry() {
93+
assertNotNull(countryAutoCompleteTextView.selectedCountry)
94+
countryAutoCompleteTextView.countryAutocomplete.setText("Canada")
95+
countryAutoCompleteTextView.validateCountry()
96+
assertEquals("Canada", countryAutoCompleteTextView.selectedCountry?.name)
97+
}
98+
8199
@AfterTest
82100
fun teardown() {
83101
Locale.setDefault(Locale.US)

stripe/src/test/java/com/stripe/android/view/ShippingInfoWidgetTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ class ShippingInfoWidgetTest : BaseViewTest<ShippingInfoTestActivity>(
226226
assertEquals(phoneEditText.text.toString(), "(123) 456 - 7890")
227227
assertEquals(postalEditText.text.toString(), "12345")
228228
assertEquals(nameEditText.text.toString(), "Fake Name")
229-
assertEquals(countryAutoCompleteTextView.selectedCountry.code, "US")
229+
assertEquals(countryAutoCompleteTextView.selectedCountry?.code, "US")
230230
}
231231

232232
private companion object {

0 commit comments

Comments
 (0)