Skip to content
Open
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
Expand Up @@ -47,6 +47,12 @@ internal sealed class LogEvent {
val cardState: CardState,
) : LogEvent()

/** Describe a successful call to copy security code to clipboard */
internal data class CopyCVV(
val cardId: String,
val cardState: CardState,
) : LogEvent()

/** Describe a successful event where a card state change was completed */
internal data class StateManagement(
val cardId: String,
Expand Down Expand Up @@ -85,6 +91,7 @@ internal object LogEventSource {
internal const val GET_CVV = "Get Security Code"
internal const val GET_PAN_AND_CVV = "Get Pan and SecurityCode"
internal const val COPY_PAN = "Copy Pan"
internal const val COPY_CVV = "Copy Security Code"
internal const val CONFIGURE_PUSH_PROVISIONING = "Configure Push Provisioning"
internal const val GET_CARD_DIGITIZATION_STATE = "Get Card Digitization State"
internal const val PUSH_PROVISIONING = "Push Provisioning"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ internal class LogEventUtils {
is LogEvent.GetCVV -> "card_cvv"
is LogEvent.GetPanCVV -> "card_pan_cvv"
is LogEvent.CopyPan -> "card_copy_pan"
is LogEvent.CopyCVV -> "card_copy_cvv"
is LogEvent.StateManagement -> "card_state_change"
is LogEvent.ConfigurePushProvisioning -> "configure_push_provisioning"
is LogEvent.GetCardDigitizationState -> "get_card_digitization_state"
Expand All @@ -90,6 +91,7 @@ internal class LogEventUtils {
is LogEvent.GetCVV,
is LogEvent.GetPanCVV,
is LogEvent.CopyPan,
is LogEvent.CopyCVV,
is LogEvent.StateManagement,
is LogEvent.ConfigurePushProvisioning,
is LogEvent.GetCardDigitizationState,
Expand Down Expand Up @@ -139,6 +141,7 @@ internal class LogEventUtils {
is LogEvent.GetCVV,
is LogEvent.GetPanCVV,
is LogEvent.CopyPan,
is LogEvent.CopyCVV,
-> {
propertyMap[KEY_CARD_ID] = this.readProperty<String>("cardId")
propertyMap[KEY_CARD_STATE] =
Expand Down Expand Up @@ -210,8 +213,8 @@ private fun buildTextStyleMap(textStyle: TextStyle) =
putIfNotNullOrNotUnspecified<LocaleList>(textStyle, "localeList", null)
putIfNotNullOrNotUnspecified<TextDecoration>(textStyle, "textDecoration", null)
putIfNotNullOrNotUnspecified<Shadow>(textStyle, "shadow", null)
putIfNotNullOrNotUnspecified<TextAlign>(textStyle, "textAlign", null)
putIfNotNullOrNotUnspecified<TextDirection>(textStyle, "textDirection", null)
putIfNotNullOrNotUnspecified<TextAlign>(textStyle, "textAlign", TextAlign.Unspecified)
putIfNotNullOrNotUnspecified<TextDirection>(textStyle, "textDirection", TextDirection.Unspecified)
putIfNotNullOrNotUnspecified<TextIndent>(textStyle, "textIndent", null)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ package com.checkout.cardmanagement.model
*
* @see com.checkout.cardmanagement.CheckoutCardManager.getCards
* @see CardState
* @see CardType
* @see CardScheme
* @see CardExpiryDate
*/
public data class Card(
Expand All @@ -24,6 +26,9 @@ public data class Card(
public val cardholderName: String,
/** Unique identifier for this card, used for card operations and tracking (not the card PAN) */
public val id: String,
public val type: CardType = CardType.UNKNOWN,
/** Payment scheme of the card, e.g. Visa or Mastercard */
public val cardScheme: CardScheme = CardScheme.UNKNOWN,
/** A reference to the manager is required to enable sharing of the design system and the card service
* Enables object to carry operations that depend on it
*/
Expand All @@ -46,6 +51,8 @@ public data class Card(
),
cardholderName = networkCard.displayName ?: "",
id = networkCard.id,
type = networkCard.type.toCardType(),
cardScheme = networkCard.scheme.toCardScheme(),
manager = manager,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ public sealed class CardManagementError : Exception() {
/** Error when a pan is attempted to be copied without being viewed */
public object PanNotViewedFailure : CardManagementError()

/** Error when a security code is attempted to be copied without being viewed */
public object SecurityCodeNotViewedFailure : CardManagementError()

/** Requested to change card to an unavailable state */
public object InvalidStateRequested : CardManagementError()

Expand Down Expand Up @@ -98,6 +101,7 @@ internal fun Throwable.toCardManagementError(): CardManagementError =
CardNetworkError.Unauthenticated -> CardManagementError.Unauthenticated
CardNetworkError.SecureOperationsFailure -> CardManagementError.UnableToPerformSecureOperation
CardNetworkError.PanNotViewedFailure -> CardManagementError.PanNotViewedFailure
CardNetworkError.SecurityCodeNotViewedFailure -> CardManagementError.SecurityCodeNotViewedFailure
is CardNetworkError.PushProvisioningFailure ->
when (this.type) {
PushProvisioningFailureType.CANCELLED ->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.checkout.cardmanagement.model

/**
* Payment scheme of the [Card].
*
* This can be used to conditionally enable or disable actions based on the card's scheme,
* such as Visa tokenization flows.
*/
public enum class CardScheme {
/** A Visa card */
VISA,

/** A Mastercard card */
MASTERCARD,

/** A card scheme not yet supported by this SDK version */
UNKNOWN,
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.os.Build
import androidx.compose.ui.platform.AbstractComposeView
import com.checkout.cardmanagement.logging.CheckoutEventLogger
import com.checkout.cardmanagement.logging.LogEvent
import com.checkout.cardmanagement.logging.LogEventSource.COPY_CVV
import com.checkout.cardmanagement.logging.LogEventSource.COPY_PAN
import com.checkout.cardmanagement.logging.LogEventSource.GET_CVV
import com.checkout.cardmanagement.logging.LogEventSource.GET_PAN
Expand Down Expand Up @@ -308,6 +309,66 @@ internal suspend fun Card.copyPanImpl(
)
}

/**
* Copies the security code (CVV/CVC) to the device clipboard with security features.
*
* The security code must be viewed in the current session before copying (call [getSecurityCode] or
* [getPANAndSecurityCode] first). Not supported on Android API 29-32 (Android 10-12L).
*
* Example usage:
* ```kotlin
* when (val result = card.copySecurityCode(singleUseToken)) {
* is CardSecureDataResult.Success -> {
* showToast("Security code copied to clipboard")
* }
* is CardSecureDataResult.Error.SecurityCodeNotViewed -> {
* showError(result.message)
* promptToViewSecurityCodeFirst()
* }
* is CardSecureDataResult.Error.UnsupportedApiVersion -> {
* showError(result.message)
* disableCopyFeature()
* }
* is CardSecureDataResult.Error -> {
* showError(result.message)
* }
* }
* ```
*
* @param singleUseToken Single-use authentication token for this operation
* @return Success (Unit), or specific error (SecurityCodeNotViewed, UnsupportedApiVersion, etc.)
* @since 3.0.0
*/
public suspend fun Card.copySecurityCode(singleUseToken: String): CardSecureDataResult<Unit> =
copySecurityCodeImpl(singleUseToken, isLegacyRequest = false)

/**
* Internal implementation of [copySecurityCode] that accepts a legacy request flag for analytics.
*
* @param singleUseToken Single-use authentication token for this operation
* @param isLegacyRequest True if called from deprecated callback API, false if from suspend API
* @return Success (Unit), or specific error (SecurityCodeNotViewed, UnsupportedApiVersion, etc.)
*/
internal suspend fun Card.copySecurityCodeImpl(
singleUseToken: String,
isLegacyRequest: Boolean,
): CardSecureDataResult<Unit> {
validateApiVersionForCopyPan()?.let { return it }

return getSecureDataSuspend(
logger = manager.logger,
successLogEvent = LogEvent.CopyCVV(cardId = id, state),
logEventSource = COPY_CVV,
isLegacyRequest = isLegacyRequest,
displaySecureData = {
manager.service.copySecurityCode(
cardId = id,
singleUseToken = singleUseToken,
)
},
)
}

internal suspend fun <T> getSecureDataSuspend(
logger: CheckoutEventLogger,
logEventSource: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,33 @@
copyPanImpl(singleUseToken, isLegacy)
}

/**
* Copies security code (CVV/CVC) to clipboard with security features. The security code will only
* be copyable if it has first been viewed in the current session.
*
* @param singleUseToken Single-use authentication token for this operation
* @param completionHandler Callback that receives Result<Unit> indicating success or error
* @deprecated This callback-based API will be deprecated in a future version.
* Prefer the coroutine-based suspend copySecurityCode() which returns CardSecureDataResult.
*/
@Deprecated(
message =
"This callback-based API will be deprecated in a future version." +
" Use suspend copySecurityCode() instead",
replaceWith = ReplaceWith("copySecurityCode(singleUseToken)"),
level = DeprecationLevel.WARNING,
)
public fun Card.copySecurityCode(

Check warning on line 146 in cardmanagement/src/main/java/com/checkout/cardmanagement/model/CardSecureDataFlowDeprecated.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not forget to remove this deprecated code someday.

See more on https://sonarcloud.io/project/issues?id=checkout_CheckoutCardManagement-Android&issues=AZ79_yxAqLjdxWk5FORT&open=AZ79_yxAqLjdxWk5FORT&pullRequest=27
singleUseToken: String,
completionHandler: (Result<Unit>) -> Unit,
): Unit =
getSecureDataBridge(
completionHandler = completionHandler,
card = this,
) { isLegacy ->
copySecurityCodeImpl(singleUseToken, isLegacy)
}

/**
* Internal helper function that bridges between the new sealed result pattern and the old callback pattern.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ public sealed interface CardSecureDataResult<out T> {
override val message: String,
) : Error

public data class SecurityCodeNotViewed(
override val message: String,
) : Error

public data class UnableToPerformOperation(
override val message: String,
val cause: Throwable? = null,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.checkout.cardmanagement.model

/**
* Type of the [Card], indicating whether it is a physical or virtual card.
*
* This can be used to conditionally enable or disable actions, such as Reveal PIN,
* based on whether the card is physical or virtual.
*/
public enum class CardType {
/** A physical card that has been manufactured and issued to the cardholder */
PHYSICAL,

/** A virtual card that exists only in digital form */
VIRTUAL,

/** An unknown card type not yet supported by this SDK version */
UNKNOWN,
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
package com.checkout.cardmanagement.model

import java.util.Locale

// Parse to Environment in Sian
internal fun Environment.parse() =
when (this) {
Environment.SANDBOX -> com.checkout.cardnetwork.common.model.Environment.SANDBOX
Environment.PRODUCTION -> com.checkout.cardnetwork.common.model.Environment.PRODUCTION
}

internal fun String.fromNetworkCardType(): CardType =
if (this.equals("physical", ignoreCase = true)) CardType.PHYSICAL else CardType.VIRTUAL

internal fun com.checkout.cardnetwork.data.dto.CardState.fromNetworkCardState(): CardState =
CardState.values().find { state -> state.name == name } ?: CardState.INACTIVE

Expand All @@ -32,3 +37,17 @@ internal fun CardRevokeReason.toCardNetworkRevokeReason(): com.checkout.cardnetw
com.checkout.cardnetwork.data.dto.CardRevokeReason
.values()
.first { it.name == this.name }

internal fun String.toCardType(): CardType =
when (this.trim().lowercase(Locale.ROOT)) {
"physical" -> CardType.PHYSICAL
"virtual" -> CardType.VIRTUAL
else -> CardType.UNKNOWN
}

internal fun String?.toCardScheme(): CardScheme =
when (this?.let { it.trim().lowercase(Locale.ROOT) }) {
"visa" -> CardScheme.VISA
"mastercard" -> CardScheme.MASTERCARD
else -> CardScheme.UNKNOWN
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ package com.checkout.cardmanagement.model
* @param serviceRSAModulus RSA Modulus in [ByteArray], from the key exchanged during onboarding.
* @param serviceURL URL String for the Service endpoint
* @param digitalCardURL URL String for the Digital Service endpoint
* @param visaClientAppId A nullable string identifier that can be obtained from your Visa configuration
*/
public data class ProvisioningConfiguration(
internal val issuerID: String,
internal val serviceRSAExponent: ByteArray,
internal val serviceRSAModulus: ByteArray,
internal val serviceURL: String,
internal val digitalCardURL: String,
internal val visaClientAppId: String? = null,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
Expand All @@ -27,6 +29,7 @@ public data class ProvisioningConfiguration(
if (!serviceRSAModulus.contentEquals(other.serviceRSAModulus)) return false
if (serviceURL != other.serviceURL) return false
if (digitalCardURL != other.digitalCardURL) return false
if (visaClientAppId != other.visaClientAppId) return false

return true
}
Expand All @@ -37,6 +40,7 @@ public data class ProvisioningConfiguration(
result = 31 * result + serviceRSAModulus.contentHashCode()
result = 31 * result + serviceURL.hashCode()
result = 31 * result + digitalCardURL.hashCode()
result = 31 * result + visaClientAppId.hashCode()
return result
}

Expand All @@ -47,5 +51,6 @@ public data class ProvisioningConfiguration(
serviceRSAModulus = this.serviceRSAModulus,
serviceURL = this.serviceURL,
digitalCardURL = this.digitalCardURL,
visaClientAppId = this.visaClientAppId,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ internal fun Throwable.toCardSecureDataError(): CardSecureDataResult.Error =
message = error.message ?: "PAN not viewed",
)

CardManagementError.SecurityCodeNotViewedFailure ->
CardSecureDataResult.Error.SecurityCodeNotViewed(
message = error.message ?: "Security code not viewed",
)

CardManagementError.UnableToPerformSecureOperation ->
CardSecureDataResult.Error.UnableToPerformOperation(
message = this.message ?: error.message ?: "Secure operation failed",
Expand Down Expand Up @@ -68,6 +73,9 @@ internal fun <T> CardSecureDataResult<T>.getOrThrow(): T =
is CardSecureDataResult.Error.PanNotViewed ->
throw CardManagementError.PanNotViewedFailure

is CardSecureDataResult.Error.SecurityCodeNotViewed ->
throw CardManagementError.SecurityCodeNotViewedFailure

is CardSecureDataResult.Error.UnableToPerformOperation ->
throw CardManagementError.UnableToPerformSecureOperation

Expand Down
Loading