Skip to content

Commit 4007c50

Browse files
committed
Add security code
COSDK-570
1 parent bb844ef commit 4007c50

File tree

14 files changed

+261
-6
lines changed

14 files changed

+261
-6
lines changed

card/src/main/java/com/adyen/checkout/card/internal/ui/CardComponent.kt

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -143,14 +143,30 @@ internal class CardComponent(
143143
}
144144
}
145145

146+
override fun onSecurityCodeChanged(newSecurityCode: String) {
147+
stateManager.updateViewStateAndValidate {
148+
copy(
149+
securityCode = securityCode.updateText(newSecurityCode),
150+
)
151+
}
152+
}
153+
154+
override fun onSecurityCodeFocusChanged(hasFocus: Boolean) {
155+
stateManager.updateViewState {
156+
copy(
157+
securityCode = securityCode.updateFocus(hasFocus),
158+
)
159+
}
160+
}
161+
146162
private fun CardViewState.toPaymentComponentState(): CardPaymentComponentState {
147163
val unencryptedCardBuilder = UnencryptedCard.Builder()
148164

149165
val encryptedCard: EncryptedCard = try {
150166
unencryptedCardBuilder.setNumber(cardNumber.text)
151167
// if (!isCvcHidden()) {
152-
// val cvc = outputData.securityCodeState.value
153-
// if (cvc.isNotEmpty()) unencryptedCardBuilder.setCvc(cvc)
168+
val cvc = securityCode.text
169+
if (cvc.isNotEmpty()) unencryptedCardBuilder.setCvc(cvc)
154170
// }
155171
if (expiryDate.text.isNotBlank()) {
156172
val expiryDate = ExpiryDate.from(expiryDate.text)
@@ -187,7 +203,7 @@ internal class CardComponent(
187203
encryptedExpiryYear = encryptedCard.encryptedExpiryYear
188204

189205
// if (!isCvcHidden()) {
190-
// encryptedSecurityCode = encryptedCard.encryptedSecurityCode
206+
encryptedSecurityCode = encryptedCard.encryptedSecurityCode
191207
// }
192208

193209
// if (isHolderNameRequired()) {
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/*
2+
* Copyright (c) 2025 Adyen N.V.
3+
*
4+
* This file is open source and available under the MIT license. See the LICENSE file for more info.
5+
*
6+
* Created by ozgur on 6/10/2025.
7+
*/
8+
9+
package com.adyen.checkout.card.internal.ui.model
10+
11+
import androidx.annotation.RestrictTo
12+
13+
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
14+
enum class InputFieldUIState {
15+
REQUIRED,
16+
OPTIONAL,
17+
HIDDEN
18+
}

card/src/main/java/com/adyen/checkout/card/internal/ui/state/CardChangeListener.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,8 @@ internal interface CardChangeListener {
1717
fun onExpiryDateChanged(newExpiryDate: String)
1818

1919
fun onExpiryDateFocusChanged(hasFocus: Boolean)
20+
21+
fun onSecurityCodeChanged(newSecurityCode: String)
22+
23+
fun onSecurityCodeFocusChanged(hasFocus: Boolean)
2024
}

card/src/main/java/com/adyen/checkout/card/internal/ui/state/CardValidationMapper.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ internal class CardValidationMapper {
2121
CardNumberValidation.INVALID_TOO_LONG -> CheckoutLocalizationKey.CARD_NUMBER_INVALID
2222
CardNumberValidation.INVALID_UNSUPPORTED_BRAND ->
2323
CheckoutLocalizationKey.CARD_NUMBER_INVALID_UNSUPPORTED_BRAND
24+
2425
CardNumberValidation.INVALID_OTHER_REASON -> CheckoutLocalizationKey.CARD_NUMBER_INVALID
2526
}
2627
}
@@ -33,8 +34,20 @@ internal class CardValidationMapper {
3334
CardExpiryDateValidation.VALID_NOT_REQUIRED -> null
3435
CardExpiryDateValidation.INVALID_TOO_FAR_IN_THE_FUTURE ->
3536
CheckoutLocalizationKey.CARD_EXPIRY_DATE_INVALID_TOO_FAR_IN_THE_FUTURE
37+
3638
CardExpiryDateValidation.INVALID_TOO_OLD -> CheckoutLocalizationKey.CARD_EXPIRY_DATE_INVALID_TOO_OLD
3739
CardExpiryDateValidation.INVALID_OTHER_REASON -> CheckoutLocalizationKey.CARD_EXPIRY_DATE_INVALID
3840
}
3941
}
42+
43+
fun mapSecurityCodeValidation(
44+
validation: CardSecurityCodeValidation
45+
): CheckoutLocalizationKey? {
46+
return when (validation) {
47+
CardSecurityCodeValidation.VALID -> null
48+
CardSecurityCodeValidation.VALID_HIDDEN -> null
49+
CardSecurityCodeValidation.VALID_OPTIONAL_EMPTY -> null
50+
CardSecurityCodeValidation.INVALID -> CheckoutLocalizationKey.CARD_SECURITY_CODE_INVALID
51+
}
52+
}
4053
}

card/src/main/java/com/adyen/checkout/card/internal/ui/state/CardValidationUtils.kt

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,15 @@ package com.adyen.checkout.card.internal.ui.state
1111
import androidx.annotation.RestrictTo
1212
import androidx.annotation.VisibleForTesting
1313
import com.adyen.checkout.card.internal.data.model.Brand
14+
import com.adyen.checkout.card.internal.data.model.DetectedCardType
15+
import com.adyen.checkout.card.internal.ui.model.InputFieldUIState
1416
import com.adyen.checkout.core.common.helper.CardExpiryDateValidationResult
1517
import com.adyen.checkout.core.common.helper.CardExpiryDateValidator
1618
import com.adyen.checkout.core.common.helper.CardNumberValidationResult
1719
import com.adyen.checkout.core.common.helper.CardNumberValidator
20+
import com.adyen.checkout.core.common.helper.CardSecurityCodeValidationResult
21+
import com.adyen.checkout.core.common.helper.CardSecurityCodeValidator
22+
import com.adyen.checkout.core.common.internal.helper.StringUtil
1823

1924
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
2025
object CardValidationUtils {
@@ -102,6 +107,39 @@ object CardValidationUtils {
102107
}
103108
}
104109
}
110+
111+
/**
112+
* Validate Security Code.
113+
*/
114+
internal fun validateSecurityCode(
115+
securityCode: String,
116+
detectedCardType: DetectedCardType?,
117+
uiState: InputFieldUIState
118+
): CardSecurityCodeValidation {
119+
val result = CardSecurityCodeValidator.validateSecurityCode(securityCode, detectedCardType?.cardBrand)
120+
return validateSecurityCode(securityCode, uiState, result)
121+
}
122+
123+
@VisibleForTesting
124+
internal fun validateSecurityCode(
125+
securityCode: String,
126+
uiState: InputFieldUIState,
127+
validationResult: CardSecurityCodeValidationResult,
128+
): CardSecurityCodeValidation {
129+
val normalizedSecurityCode = StringUtil.normalize(securityCode)
130+
val length = normalizedSecurityCode.length
131+
132+
return when {
133+
uiState == InputFieldUIState.HIDDEN -> CardSecurityCodeValidation.VALID_HIDDEN
134+
uiState == InputFieldUIState.OPTIONAL && length == 0 -> CardSecurityCodeValidation.VALID_OPTIONAL_EMPTY
135+
else -> {
136+
when (validationResult) {
137+
is CardSecurityCodeValidationResult.Invalid -> CardSecurityCodeValidation.INVALID
138+
is CardSecurityCodeValidationResult.Valid -> CardSecurityCodeValidation.VALID
139+
}
140+
}
141+
}
142+
}
105143
}
106144

107145
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
@@ -123,3 +161,11 @@ enum class CardExpiryDateValidation {
123161
INVALID_TOO_OLD,
124162
INVALID_OTHER_REASON,
125163
}
164+
165+
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
166+
enum class CardSecurityCodeValidation {
167+
VALID,
168+
VALID_HIDDEN,
169+
VALID_OPTIONAL_EMPTY,
170+
INVALID,
171+
}

card/src/main/java/com/adyen/checkout/card/internal/ui/state/CardViewState.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import com.adyen.checkout.core.components.internal.ui.state.model.TextInputState
1414
internal data class CardViewState(
1515
val cardNumber: TextInputState,
1616
val expiryDate: TextInputState,
17+
val securityCode: TextInputState,
1718
val brandLogo: BrandLogo?,
1819
// TODO - Card. isAmex flag is added for simplicity, to be used in formatting.
1920
// should be removed once detected card types are available.

card/src/main/java/com/adyen/checkout/card/internal/ui/state/CardViewStateFactory.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ internal class CardViewStateFactory : ViewStateFactory<CardViewState> {
1717
override fun createDefaultViewState() = CardViewState(
1818
cardNumber = TextInputState(isFocused = true),
1919
expiryDate = TextInputState(),
20+
securityCode = TextInputState(),
2021
brandLogo = null,
2122
isAmex = false,
2223
isLoading = false,

card/src/main/java/com/adyen/checkout/card/internal/ui/state/CardViewStateValidator.kt

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
package com.adyen.checkout.card.internal.ui.state
1010

1111
import com.adyen.checkout.card.internal.data.model.DetectedCardType
12+
import com.adyen.checkout.card.internal.ui.model.InputFieldUIState
1213
import com.adyen.checkout.core.common.localization.CheckoutLocalizationKey
1314
import com.adyen.checkout.core.components.internal.ui.state.ViewStateValidator
1415
import com.adyen.checkout.core.components.internal.ui.state.model.TextInputState
@@ -28,22 +29,29 @@ internal class CardViewStateValidator(
2829
val expiryDate = viewState.expiryDate
2930
val expiryDateError = validateExpiryDate(expiryDate, selectedOrFirstCardType)
3031

32+
// TODO - Card. Security Code UI State.
33+
val securityCode = viewState.securityCode
34+
val securityCodeError = validateSecurityCode(securityCode, selectedOrFirstCardType, InputFieldUIState.REQUIRED)
35+
3136
// TODO - Card Full Validation
3237
return viewState.copy(
3338
cardNumber = cardNumber.copy(errorMessage = cardNumberError),
34-
expiryDate = expiryDate.copy(errorMessage = expiryDateError)
39+
expiryDate = expiryDate.copy(errorMessage = expiryDateError),
40+
securityCode = securityCode.copy(errorMessage = securityCodeError)
3541
)
3642
}
3743

3844
override fun isValid(viewState: CardViewState): Boolean {
3945
// TODO - Card Full Validation
4046
return viewState.cardNumber.errorMessage == null &&
41-
viewState.expiryDate.errorMessage == null
47+
viewState.expiryDate.errorMessage == null &&
48+
viewState.securityCode.errorMessage == null
4249
}
4350

4451
override fun highlightAllValidationErrors(viewState: CardViewState): CardViewState {
4552
val hasCardNumberError = viewState.cardNumber.errorMessage != null
4653
val hasExpiryDateError = viewState.expiryDate.errorMessage != null
54+
val hasSecurityCodeError = viewState.expiryDate.errorMessage != null
4755

4856
return viewState.copy(
4957
cardNumber = viewState.cardNumber.copy(
@@ -53,6 +61,10 @@ internal class CardViewStateValidator(
5361
expiryDate = viewState.expiryDate.copy(
5462
showError = hasExpiryDateError,
5563
isFocused = hasExpiryDateError && !hasCardNumberError,
64+
),
65+
securityCode = viewState.securityCode.copy(
66+
showError = hasSecurityCodeError,
67+
isFocused = hasSecurityCodeError && !hasCardNumberError && !hasExpiryDateError,
5668
)
5769
)
5870
}
@@ -92,4 +104,18 @@ internal class CardViewStateValidator(
92104

93105
return expiryDateError
94106
}
107+
108+
private fun validateSecurityCode(
109+
securityCode: TextInputState,
110+
selectedOrFirstCardType: DetectedCardType?,
111+
uiState: InputFieldUIState,
112+
): CheckoutLocalizationKey? {
113+
return cardValidationMapper.mapSecurityCodeValidation(
114+
validation = CardValidationUtils.validateSecurityCode(
115+
securityCode = securityCode.text,
116+
detectedCardType = selectedOrFirstCardType,
117+
uiState = uiState,
118+
)
119+
)
120+
}
95121
}

card/src/main/java/com/adyen/checkout/card/internal/ui/view/CardComponent.kt

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ internal fun CardComponent(
8888
},
8989
)
9090
ExpiryDate(Modifier, viewState, changeListener)
91+
SecurityCode(viewState, changeListener)
9192
}
9293
// TODO - Card Full UI
9394
}
@@ -126,6 +127,41 @@ private fun ExpiryDate(
126127
)
127128
}
128129

130+
@Composable
131+
internal fun SecurityCode(
132+
viewState: CardViewState,
133+
changeListener: CardChangeListener,
134+
modifier: Modifier = Modifier,
135+
) {
136+
val showSecurityCodeError =
137+
viewState.securityCode.errorMessage != null && viewState.securityCode.showError
138+
val supportingTextSecurityCode = if (showSecurityCodeError) {
139+
viewState.securityCode.errorMessage?.let { resolveString(it) }
140+
} else {
141+
null
142+
}
143+
144+
CheckoutTextField(
145+
modifier = modifier
146+
.fillMaxWidth()
147+
.onFocusChanged { focusState ->
148+
changeListener.onSecurityCodeFocusChanged(focusState.isFocused)
149+
},
150+
label = resolveString(CheckoutLocalizationKey.CARD_SECURITY_CODE),
151+
initialValue = viewState.securityCode.text,
152+
isError = showSecurityCodeError,
153+
supportingText = supportingTextSecurityCode,
154+
onValueChange = { value ->
155+
changeListener.onSecurityCodeChanged(value)
156+
},
157+
inputTransformation = DigitOnlyInputTransformation().maxLength(MAX_LENGTH_SECURITY_CODE),
158+
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
159+
shouldFocus = viewState.securityCode.isFocused,
160+
)
161+
}
162+
163+
private const val MAX_LENGTH_SECURITY_CODE = 4
164+
129165
@Preview(showBackground = true)
130166
@Composable
131167
private fun CardComponentPreview() {
@@ -135,7 +171,10 @@ private fun CardComponentPreview() {
135171
"5555444433331111",
136172
),
137173
expiryDate = TextInputState(
138-
text = "12",
174+
text = "12/34",
175+
),
176+
securityCode = TextInputState(
177+
text = "737",
139178
),
140179
isAmex = false,
141180
isLoading = false,
@@ -152,6 +191,10 @@ private fun CardComponentPreview() {
152191
override fun onExpiryDateChanged(newExpiryDate: String) = Unit
153192

154193
override fun onExpiryDateFocusChanged(hasFocus: Boolean) = Unit
194+
195+
override fun onSecurityCodeChanged(newSecurityCode: String) = Unit
196+
197+
override fun onSecurityCodeFocusChanged(hasFocus: Boolean) = Unit
155198
},
156199
)
157200
}

core/api/core.api

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,26 @@ public final class com/adyen/checkout/core/common/helper/CardNumberValidator {
407407
public final fun validateCardNumber (Ljava/lang/String;Z)Lcom/adyen/checkout/core/common/helper/CardNumberValidationResult;
408408
}
409409

410+
public abstract interface class com/adyen/checkout/core/common/helper/CardSecurityCodeValidationResult {
411+
}
412+
413+
public final class com/adyen/checkout/core/common/helper/CardSecurityCodeValidationResult$Invalid : com/adyen/checkout/core/common/helper/CardSecurityCodeValidationResult {
414+
public static final field $stable I
415+
public fun <init> ()V
416+
}
417+
418+
public final class com/adyen/checkout/core/common/helper/CardSecurityCodeValidationResult$Valid : com/adyen/checkout/core/common/helper/CardSecurityCodeValidationResult {
419+
public static final field $stable I
420+
public fun <init> ()V
421+
}
422+
423+
public final class com/adyen/checkout/core/common/helper/CardSecurityCodeValidator {
424+
public static final field $stable I
425+
public static final field INSTANCE Lcom/adyen/checkout/core/common/helper/CardSecurityCodeValidator;
426+
public final fun validateSecurityCode (Ljava/lang/String;Lcom/adyen/checkout/core/common/CardBrand;)Lcom/adyen/checkout/core/common/helper/CardSecurityCodeValidationResult;
427+
public static synthetic fun validateSecurityCode$default (Lcom/adyen/checkout/core/common/helper/CardSecurityCodeValidator;Ljava/lang/String;Lcom/adyen/checkout/core/common/CardBrand;ILjava/lang/Object;)Lcom/adyen/checkout/core/common/helper/CardSecurityCodeValidationResult;
428+
}
429+
410430
public final class com/adyen/checkout/core/common/internal/DefaultImageLoader$Companion {
411431
}
412432

@@ -462,6 +482,8 @@ public final class com/adyen/checkout/core/common/localization/CheckoutLocalizat
462482
public static final field CARD_NUMBER Lcom/adyen/checkout/core/common/localization/CheckoutLocalizationKey;
463483
public static final field CARD_NUMBER_INVALID Lcom/adyen/checkout/core/common/localization/CheckoutLocalizationKey;
464484
public static final field CARD_NUMBER_INVALID_UNSUPPORTED_BRAND Lcom/adyen/checkout/core/common/localization/CheckoutLocalizationKey;
485+
public static final field CARD_SECURITY_CODE Lcom/adyen/checkout/core/common/localization/CheckoutLocalizationKey;
486+
public static final field CARD_SECURITY_CODE_INVALID Lcom/adyen/checkout/core/common/localization/CheckoutLocalizationKey;
465487
public static final field MBWAY_COUNTRY_CODE Lcom/adyen/checkout/core/common/localization/CheckoutLocalizationKey;
466488
public static final field MBWAY_INVALID_PHONE_NUMBER Lcom/adyen/checkout/core/common/localization/CheckoutLocalizationKey;
467489
public static final field MBWAY_PHONE_NUMBER Lcom/adyen/checkout/core/common/localization/CheckoutLocalizationKey;

0 commit comments

Comments
 (0)