Skip to content

Commit

Permalink
Add formatted content description for Expiration Date text field (#10185
Browse files Browse the repository at this point in the history
)

* Add formatted content description for Expiration Date text field
  • Loading branch information
tjclawson-stripe authored Feb 21, 2025
1 parent 51dbaa9 commit fc31cc9
Show file tree
Hide file tree
Showing 20 changed files with 294 additions and 34 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ Dependencies updated in [9512](https://github.com/stripe/stripe-android/pull/951
### CardScan
* [FIXED][10181](https://github.com/stripe/stripe-android/pull/10181) Fixed a crash that happened on some devices with odd camera-to-screen ratios

### PaymentSheet
* [FIXED][10185](https://github.com/stripe/stripe-android/pull/10185) Improve accessibility for expiration date in `CardDetailsController`.

### Financial Connections
- [ADDED] Financial Connections now supports dark mode and will automatically adapt to the device's theme. [Learn more](https://docs.stripe.com/financial-connections/other-data-powered-products?platform=android#connections-customize-android) about configuring appearance settings.

Expand Down
12 changes: 4 additions & 8 deletions payments-ui-core/detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
<CurrentIssues>
<ID>ConstructorParameterNaming:CardNumberElement.kt$CardNumberElement$val _identifier: IdentifierSpec</ID>
<ID>ConstructorParameterNaming:CvcElement.kt$CvcElement$val _identifier: IdentifierSpec</ID>
<ID>CyclomaticComplexMethod:ExpiryDateContentDescriptionFormatter.kt$@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) fun formatExpirationDateForAccessibility(input: String): ResolvableString</ID>
<ID>CyclomaticComplexMethod:FormItemSpec.kt$FormItemSpecSerializer$override fun selectDeserializer(element: JsonElement): DeserializationStrategy&lt;FormItemSpec></ID>
<ID>CyclomaticComplexMethod:TransformGoogleToStripeAddress.kt$@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) fun Place.transformGoogleToStripeAddress( context: Context ): com.stripe.android.model.Address</ID>
<ID>ForbiddenComment:Menu.kt$// TODO: Make sure this gets the rounded corner values</ID>
<ID>LongMethod:FormUI.kt$@Composable private fun FormUIElement( element: FormElement, index: Int, maxIndex: Int, enabled: Boolean, hiddenIdentifiers: Set&lt;IdentifierSpec>, lastTextFieldIdentifier: IdentifierSpec?, )</ID>
<ID>LongMethod:Menu.kt$@Suppress("ModifierParameter") @Composable internal fun DropdownMenuContent( expandedStates: MutableTransitionState&lt;Boolean>, transformOriginState: MutableState&lt;TransformOrigin>, initialFirstVisibleItemIndex: Int, modifier: Modifier = Modifier, content: LazyListScope.() -> Unit )</ID>
<ID>LongMethod:TransformGoogleToStripeAddress.kt$@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) fun Place.transformGoogleToStripeAddress( context: Context ): com.stripe.android.model.Address</ID>
<ID>LongMethod:TransformGoogleToStripeAddressTest.kt$TransformGoogleToStripeAddressTest$@Test fun `test JP address`()</ID>
Expand Down Expand Up @@ -43,19 +43,15 @@
<ID>MagicNumber:CardNumberVisualTransformations.kt$CardNumberVisualTransformations.NineteenPanLength.&lt;no name provided>$4</ID>
<ID>MagicNumber:CardNumberVisualTransformations.kt$CardNumberVisualTransformations.NineteenPanLength.&lt;no name provided>$7</ID>
<ID>MagicNumber:CardNumberVisualTransformations.kt$CardNumberVisualTransformations.NineteenPanLength.&lt;no name provided>$9</ID>
<ID>MagicNumber:ExpiryDateContentDescriptionFormatter.kt$12</ID>
<ID>MagicNumber:ExpiryDateContentDescriptionFormatter.kt$2000</ID>
<ID>MagicNumber:ExpiryDateContentDescriptionFormatter.kt$9</ID>
<ID>MagicNumber:IbanConfig.kt$IbanConfig$10</ID>
<ID>MagicNumber:IbanConfig.kt$IbanConfig$4</ID>
<ID>MagicNumber:Menu.kt$0.8f</ID>
<ID>MaxLineLength:AddressElementTest.kt$AddressElementTest$fun</ID>
<ID>MaxLineLength:AndroidMenu.kt$*</ID>
<ID>MaxLineLength:CardDetailsControllerTest.kt$CardDetailsControllerTest$fun</ID>
<ID>MaxLineLength:CardNumberConfigTest.kt$CardNumberConfigTest$Truth.assertThat(cardNumberConfig.visualTransformation.filter(AnnotatedString(CardNumberFixtures.AMEX_NO_SPACES)).text)</ID>
<ID>MaxLineLength:CardNumberConfigTest.kt$CardNumberConfigTest$Truth.assertThat(cardNumberConfig.visualTransformation.filter(AnnotatedString(CardNumberFixtures.DINERS_CLUB_14_NO_SPACES)).text)</ID>
<ID>MaxLineLength:CardNumberConfigTest.kt$CardNumberConfigTest$Truth.assertThat(cardNumberConfig.visualTransformation.filter(AnnotatedString(CardNumberFixtures.DINERS_CLUB_16_NO_SPACES)).text)</ID>
<ID>MaxLineLength:CardNumberConfigTest.kt$CardNumberConfigTest$Truth.assertThat(cardNumberConfig.visualTransformation.filter(AnnotatedString(CardNumberFixtures.DISCOVER_NO_SPACES)).text)</ID>
<ID>MaxLineLength:CardNumberConfigTest.kt$CardNumberConfigTest$Truth.assertThat(cardNumberConfig.visualTransformation.filter(AnnotatedString(CardNumberFixtures.JCB_NO_SPACES)).text)</ID>
<ID>MaxLineLength:CardNumberConfigTest.kt$CardNumberConfigTest$Truth.assertThat(cardNumberConfig.visualTransformation.filter(AnnotatedString(CardNumberFixtures.UNIONPAY_NO_SPACES)).text)</ID>
<ID>MaxLineLength:CardNumberConfigTest.kt$CardNumberConfigTest$Truth.assertThat(cardNumberConfig.visualTransformation.filter(AnnotatedString(CardNumberFixtures.VISA_NO_SPACES)).text)</ID>
<ID>MaxLineLength:CardNumberControllerTest.kt$CardNumberControllerTest$fun</ID>
<ID>SpreadOperator:BsbElementUI.kt$( it.errorMessage, *args )</ID>
<ID>SpreadOperator:MandateTextElement.kt$MandateTextElement$(stringResId, *args.toTypedArray())</ID>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,12 @@ internal class CardDetailsController(
val expirationDateElement = SimpleTextElement(
IdentifierSpec.Generic("date"),
SimpleTextFieldController(
DateConfig(),
textFieldConfig = DateConfig(),
initialValue = initialValues[IdentifierSpec.CardExpMonth] +
initialValues[IdentifierSpec.CardExpYear]?.takeLast(2)
initialValues[IdentifierSpec.CardExpYear]?.takeLast(2),
overrideContentDescriptionProvider = ::formatExpirationDateForAccessibility,
shouldAnnounceFieldValue = false,
shouldAnnounceLabel = false
)
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import com.stripe.android.cards.CardAccountRangeService
import com.stripe.android.cards.CardNumber
import com.stripe.android.cards.DefaultStaticCardAccountRanges
import com.stripe.android.cards.StaticCardAccountRanges
import com.stripe.android.core.strings.plus
import com.stripe.android.core.strings.ResolvableString
import com.stripe.android.core.strings.resolvableString
import com.stripe.android.model.AccountRange
import com.stripe.android.model.CardBrand
Expand Down Expand Up @@ -111,7 +111,9 @@ internal class DefaultCardNumberController(
_fieldValue.mapAsStateFlow { cardTextFieldConfig.convertToRaw(it) }

// This makes the screen reader read out numbers digit by digit
override val contentDescription: StateFlow<String> = _fieldValue.mapAsStateFlow { it.asIndividualDigits() }
override val contentDescription: StateFlow<ResolvableString> = _fieldValue.mapAsStateFlow {
it.asIndividualDigits().resolvableString
}

private val isEligibleForCardBrandChoice = cardBrandChoiceConfig is CardBrandChoiceConfig.Eligible
private val brandChoices = MutableStateFlow<List<CardBrand>>(listOf())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.LayoutDirection
import com.stripe.android.core.strings.ResolvableString
import com.stripe.android.core.strings.resolvableString
import com.stripe.android.model.CardBrand
import com.stripe.android.ui.core.asIndividualDigits
import com.stripe.android.uicore.elements.FieldError
Expand Down Expand Up @@ -59,7 +61,9 @@ class CvcController constructor(
_fieldValue.mapAsStateFlow { cvcTextFieldConfig.convertToRaw(it) }

// This makes the screen reader read out numbers digit by digit
override val contentDescription: StateFlow<String> = _fieldValue.mapAsStateFlow { it.asIndividualDigits() }
override val contentDescription: StateFlow<ResolvableString> = _fieldValue.mapAsStateFlow {
it.asIndividualDigits().resolvableString
}

private val _fieldState = combineAsStateFlow(cardBrandFlow, _fieldValue) { brand, fieldValue ->
cvcTextFieldConfig.determineState(brand, fieldValue, brand.maxCvcLength)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.stripe.android.ui.core.elements

import androidx.annotation.RestrictTo
import androidx.appcompat.app.AppCompatDelegate
import com.stripe.android.core.strings.ResolvableString
import com.stripe.android.core.strings.resolvableString
import com.stripe.android.uicore.R
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Locale

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
fun formatExpirationDateForAccessibility(input: String): ResolvableString {
if (input.isEmpty()) {
return resolvableString(R.string.stripe_expiration_date_empty_content_description)
}

// Check if input is valid integer
if (input.toIntOrNull() == null) return input.resolvableString

val canOnlyBeSingleDigitMonth = input.isNotBlank() && !(input[0] == '0' || input[0] == '1')
val canOnlyBeJanuary = input.length > 1 && input.take(2).toInt() > 12
val isSingleDigitMonth = canOnlyBeSingleDigitMonth || canOnlyBeJanuary

val lastIndexOfMonth = if (isSingleDigitMonth) 0 else 1
val month = input.take(lastIndexOfMonth + 1).toIntOrNull()
val year = input.slice(lastIndexOfMonth + 1..input.lastIndex).toIntOrNull()

try {
if (month != null) {
val locale = AppCompatDelegate.getApplicationLocales()[0] ?: Locale.getDefault()
val monthName = SimpleDateFormat("MM", locale).parse("$month")?.let {
SimpleDateFormat("MMMM", locale).format(it)
}

return when (year) {
null -> return resolvableString(
R.string.stripe_expiration_date_month_complete_content_description,
monthName
)
in 0..9 -> resolvableString(
R.string.stripe_expiration_date_year_incomplete_content_description,
monthName
)
else -> resolvableString(
R.string.stripe_expiration_date_content_description,
monthName,
2000 + year
)
}
}

return input.resolvableString
} catch (e: ParseException) {
// ParseException should never be thrown so we can ignore but we want to prevent crash in the case it is thrown.
return input.resolvableString
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package com.stripe.android.utils

import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import com.google.common.truth.Truth.assertThat
import com.stripe.android.core.strings.resolvableString
import com.stripe.android.ui.core.elements.formatExpirationDateForAccessibility
import com.stripe.android.uicore.R
import org.junit.Test

class ExpiryDateContentDescriptionFormatterTest {

@Test
fun `formats correctly for empty input`() {
val result = formatExpirationDateForAccessibility("")
val expected = resolvableString(R.string.stripe_expiration_date_empty_content_description)

assertThat(result).isEqualTo(expected)
}

@Test
fun `formats correctly for month only`() {
val result = formatExpirationDateForAccessibility("4")
val expected = resolvableString(
R.string.stripe_expiration_date_month_complete_content_description,
"April"
)

assertThat(result).isEqualTo(expected)
}

@Test
fun `formats correctly for month and incomplete year`() {
val result = formatExpirationDateForAccessibility("55")
val expected = resolvableString(
R.string.stripe_expiration_date_year_incomplete_content_description,
"May"
)

assertThat(result).isEqualTo(expected)
}

@Test
fun `formats correctly for month and year`() {
val result = formatExpirationDateForAccessibility("555")
val expected = resolvableString(
R.string.stripe_expiration_date_content_description,
"May",
2055
)

assertThat(result).isEqualTo(expected)
}

@Test
fun `formats correctly for double digit month`() {
val result = formatExpirationDateForAccessibility("1255")
val expected = resolvableString(
R.string.stripe_expiration_date_content_description,
"December",
2055
)

assertThat(result).isEqualTo(expected)
}

@Test
fun `formats correctly for single digit month with leading 0`() {
val result = formatExpirationDateForAccessibility("0155")
val expected = resolvableString(
R.string.stripe_expiration_date_content_description,
"January",
2055
)

assertThat(result).isEqualTo(expected)
}

@Test
fun `formats first two numbers as month if less than 13`() {
val result = formatExpirationDateForAccessibility("126")
val expected = resolvableString(
R.string.stripe_expiration_date_year_incomplete_content_description,
"December"
)

assertThat(result).isEqualTo(expected)
}

@Test
fun `formats month correctly based on locale`() {
AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags("FR"))
val result = formatExpirationDateForAccessibility("126")
val expected = resolvableString(
R.string.stripe_expiration_date_year_incomplete_content_description,
"décembre"
)

assertThat(result).isEqualTo(expected)
// Clear FR locale
AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList())
}

@Test
fun `returns input as resolvable string if input is not numeric`() {
val result = formatExpirationDateForAccessibility("test")
val expected = "test".resolvableString

assertThat(result).isEqualTo(expected)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.test.espresso.Espresso
import androidx.test.platform.app.InstrumentationRegistry
import com.stripe.android.paymentsheet.example.playground.settings.CountrySettingsDefinition
import com.stripe.android.paymentsheet.example.playground.settings.DefaultBillingAddress
import com.stripe.android.paymentsheet.example.playground.settings.DefaultBillingAddressSettingsDefinition
import com.stripe.android.test.core.ui.Selectors
import com.stripe.android.ui.core.elements.TranslationId
import com.stripe.android.ui.core.elements.formatExpirationDateForAccessibility
import com.stripe.android.core.R as CoreR

internal class FieldPopulator(
Expand Down Expand Up @@ -275,11 +277,13 @@ internal class FieldPopulator(
fun verifyCard() {
val accessibleCardNumber = values.cardNumber.toCharArray().joinToString(" ")
val accessibleCvc = values.cardCvc.toCharArray().joinToString(" ")
val accessibleExpiryDate = formatExpirationDateForAccessibility(values.cardExpiration)
.resolve(InstrumentationRegistry.getInstrumentation().targetContext)

selectors.getCardNumber()
.ifExistsAssertContentDescriptionEquals(accessibleCardNumber)
selectors.getCardExpiration()
.ifExistsAssertContentDescriptionEquals(values.cardExpiration)
.ifExistsAssertContentDescriptionEquals(accessibleExpiryDate)
selectors.getCardCvc()
.ifExistsAssertContentDescriptionEquals(accessibleCvc)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package com.stripe.android.test.core.ui
import android.content.pm.PackageManager
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isToggleable
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
Expand Down Expand Up @@ -322,11 +324,14 @@ internal class Selectors(
)
)

fun getCardExpiration() = composeTestRule.onNodeWithTextAfterWaiting(
InstrumentationRegistry.getInstrumentation().targetContext.resources.getString(
UiCoreR.string.stripe_expiration_date_hint
)
)
fun getCardExpiration(): SemanticsNodeInteraction {
composeTestRule.waitUntil(timeoutMillis = DEFAULT_UI_TIMEOUT.inWholeMilliseconds) {
composeTestRule.onAllNodes(
hasContentDescription("Expiration date", true)
).fetchSemanticsNodes().isNotEmpty()
}
return composeTestRule.onNodeWithContentDescription(label = "Expiration date", substring = true)
}

fun getCardCvc() = composeTestRule.onNodeWithTextAfterWaiting(
InstrumentationRegistry.getInstrumentation().targetContext.resources.getString(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ internal class CustomerSheetPage(
cardNumber: String = CARD_NUMBER,
) {
replaceText("Card number", cardNumber)
replaceText("MM / YY", "$EXPIRY_MONTH/$${EXPIRY_YEAR.substring(startIndex = 2)}")
fillExpirationDate("$EXPIRY_MONTH/$${EXPIRY_YEAR.substring(startIndex = 2)}")
replaceText("CVC", CVC)
replaceText("ZIP Code", ZIP_CODE)
}
Expand Down Expand Up @@ -158,6 +158,11 @@ internal class CustomerSheetPage(
.performTextReplacement(text)
}

private fun fillExpirationDate(text: String) {
composeTestRule.onNode(hasContentDescription(value = "Expiration date", substring = true))
.performTextReplacement(text)
}

private fun clickDropdownMenu() {
waitForIdle()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ internal class PaymentSheetBillingConfigurationTest {
}

page.replaceText("123 Main Street", "123 Main Road")
page.replaceText("MM / YY", "12/34")
page.fillExpirationDate("12/34")

// Check that line 1 was not reset to default value
page.waitForText("123 Main Road")
Expand Down
Loading

0 comments on commit fc31cc9

Please sign in to comment.