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

Add formatted content description for Expiration Date text field #10185

Merged
merged 21 commits into from
Feb 21, 2025
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
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
Copy link
Collaborator

Choose a reason for hiding this comment

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

which exceptions are we catching here and why is it ok to ignore them?

Can you consider:

  • catching specific exceptions here
  • adding a comment about why it is ok that we are ignoring the exceptions

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Updated to ParseException and left a comment on why it's fine to ignore. ParseException should never be thrown but since the text passed in here is coming directly from the TextField input we don't want to crash the app if somehow a non numeric string is entered

}
}
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
Loading