Skip to content

Commit

Permalink
Update PaymentSheet card input to use compose (#4358)
Browse files Browse the repository at this point in the history
  • Loading branch information
michelleb-stripe authored Mar 31, 2022
1 parent fb45794 commit 397b486
Show file tree
Hide file tree
Showing 178 changed files with 3,464 additions and 1,846 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ This release patches a crash with payment launcher when there is a configuration

### Payments
* [FIXED] [4776](https://github.com/stripe/stripe-android/pull/4776) fix issue with PaymentLauncher configuration change
* [CHANGED] [4358](https://github.com/stripe/stripe-android/pull/4358) Updated the card element on PaymentSheet to use Compose.

## 19.3.1 - 2022-03-22
This release patches an issue with 3ds2 confirmation
Expand All @@ -16,6 +17,7 @@ This release enables a new configuration object to be defined for StripeCardScan

### PaymentSheet
* [FIXED] [4646](https://github.com/stripe/stripe-android/pull/4646) Update 3ds2 to latest version 6.1.4, see PR for specific issues addressed.
* [FIXED] [4669](https://github.com/stripe/stripe-android/pull/4669) Restrict the list of SEPA debit supported countries.

### CardScan
* [ADDED] [4689](https://github.com/stripe/stripe-android/pull/4689) The `CardImageVerificationSheet` initializer can now take an additional `Configuration` object.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,8 @@ private fun EmailCollectionSection(
listOf(emailElement.sectionFieldErrorController())
)
),
emptyList()
emptyList(),
emailElement.identifier
)
if (signUpState == SignUpState.VerifyingEmail) {
CircularProgressIndicator(
Expand Down
29 changes: 29 additions & 0 deletions payments-core/api/payments-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -878,6 +878,32 @@ public final class com/stripe/android/StripeKtxKt {
public static synthetic fun retrieveSource$default (Lcom/stripe/android/Stripe;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
}

public abstract interface class com/stripe/android/cards/CardAccountRangeRepository$Factory {
public abstract fun create ()Lcom/stripe/android/cards/CardAccountRangeRepository;
}

public abstract interface class com/stripe/android/cards/CardAccountRangeService$AccountRangeResultListener {
public abstract fun onAccountRangeResult (Lcom/stripe/android/model/AccountRange;)V
}

public final class com/stripe/android/cards/CardNumber$Companion {
}

public final class com/stripe/android/cards/CardNumber$Unvalidated : com/stripe/android/cards/CardNumber {
public static final field $stable I
public fun <init> (Ljava/lang/String;)V
public final fun copy (Ljava/lang/String;)Lcom/stripe/android/cards/CardNumber$Unvalidated;
public static synthetic fun copy$default (Lcom/stripe/android/cards/CardNumber$Unvalidated;Ljava/lang/String;ILjava/lang/Object;)Lcom/stripe/android/cards/CardNumber$Unvalidated;
public fun equals (Ljava/lang/Object;)Z
public final fun getBin ()Lcom/stripe/android/cards/Bin;
public final fun getLength ()I
public final fun getNormalized ()Ljava/lang/String;
public fun hashCode ()I
public final fun isMaxLength ()Z
public final fun isValidLuhn ()Z
public fun toString ()Ljava/lang/String;
}

public final class com/stripe/android/exception/AuthenticationException : com/stripe/android/core/exception/StripeException {
public static final field $stable I
}
Expand Down Expand Up @@ -5794,8 +5820,11 @@ public final class com/stripe/android/view/CardNumberEditText : com/stripe/andro
public fun <init> (Landroid/content/Context;Landroid/util/AttributeSet;)V
public fun <init> (Landroid/content/Context;Landroid/util/AttributeSet;I)V
public synthetic fun <init> (Landroid/content/Context;Landroid/util/AttributeSet;IILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getAccountRangeService ()Lcom/stripe/android/cards/CardAccountRangeService;
public final fun getCardBrand ()Lcom/stripe/android/model/CardBrand;
public final fun getWorkContext ()Lkotlin/coroutines/CoroutineContext;
public final fun isCardNumberValid ()Z
public final fun setWorkContext (Lkotlin/coroutines/CoroutineContext;)V
}

public abstract interface class com/stripe/android/view/CardValidCallback {
Expand Down
10 changes: 8 additions & 2 deletions payments-core/src/main/java/com/stripe/android/CardUtils.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.stripe.android

import androidx.annotation.RestrictTo
import com.stripe.android.cards.CardNumber
import com.stripe.android.model.CardBrand

Expand All @@ -13,7 +14,10 @@ object CardUtils {
* @return the [CardBrand] that matches the card number based on prefixes,
* or [CardBrand.Unknown] if it can't be determined
*/
@Deprecated("CardInputWidget and CardMultilineWidget handle card brand lookup. This method should not be relied on for determining CardBrand.")
@Deprecated(
"CardInputWidget and CardMultilineWidget handle card brand lookup. " +
"This method should not be relied on for determining CardBrand."
)
@JvmStatic
fun getPossibleCardBrand(cardNumber: String?): CardBrand {
return if (cardNumber.isNullOrBlank()) {
Expand All @@ -29,7 +33,9 @@ object CardUtils {
* @param cardNumber a String that may or may not represent a valid Luhn number
* @return `true` if and only if the input value is a valid Luhn number
*/
internal fun isValidLuhnNumber(cardNumber: String?): Boolean {
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
@SuppressWarnings("ReturnCount")
fun isValidLuhnNumber(cardNumber: String?): Boolean {
if (cardNumber == null) {
return false
}
Expand Down
6 changes: 4 additions & 2 deletions payments-core/src/main/java/com/stripe/android/cards/Bin.kt
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
package com.stripe.android.cards

import androidx.annotation.RestrictTo
import com.stripe.android.core.model.StripeModel
import kotlinx.parcelize.Parcelize

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
@Parcelize
internal data class Bin internal constructor(
data class Bin internal constructor(
internal val value: String
) : StripeModel {
override fun toString() = value

companion object {
internal companion object {
fun create(cardNumber: String): Bin? {
return cardNumber
.take(BIN_LENGTH)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package com.stripe.android.cards

import androidx.annotation.RestrictTo
import com.stripe.android.model.AccountRange
import kotlinx.coroutines.flow.Flow

internal interface CardAccountRangeRepository {
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
interface CardAccountRangeRepository {
suspend fun getAccountRange(
cardNumber: CardNumber.Unvalidated
): AccountRange?
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package com.stripe.android.cards

import androidx.annotation.RestrictTo
import androidx.annotation.VisibleForTesting
import com.stripe.android.model.AccountRange
import com.stripe.android.model.CardBrand
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.CoroutineContext

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
class CardAccountRangeService constructor(
private val cardAccountRangeRepository: CardAccountRangeRepository,
private val workContext: CoroutineContext,
val staticCardAccountRanges: StaticCardAccountRanges,
private val accountRangeResultListener: AccountRangeResultListener
) {

val isLoading: Flow<Boolean> = cardAccountRangeRepository.loading

var accountRange: AccountRange? = null
private set

@VisibleForTesting
var accountRangeRepositoryJob: Job? = null

fun onCardNumberChanged(cardNumber: CardNumber.Unvalidated) {
val staticAccountRange = staticCardAccountRanges.filter(cardNumber)
.let { accountRanges ->
if (accountRanges.size == 1) {
accountRanges.first()
} else {
null
}
}
if (staticAccountRange == null || shouldQueryRepository(staticAccountRange)) {
// query for AccountRange data
queryAccountRangeRepository(cardNumber)
} else {
// use static AccountRange data
updateAccountRangeResult(staticAccountRange)
}
}

@JvmSynthetic
fun queryAccountRangeRepository(cardNumber: CardNumber.Unvalidated) {
if (shouldQueryAccountRange(cardNumber)) {
// cancel in-flight job
cancelAccountRangeRepositoryJob()

// invalidate accountRange before fetching
accountRange = null

accountRangeRepositoryJob = CoroutineScope(workContext).launch {
val bin = cardNumber.bin
val accountRange = if (bin != null) {
cardAccountRangeRepository.getAccountRange(cardNumber)
} else {
null
}

withContext(Dispatchers.Main) {
updateAccountRangeResult(accountRange)
}
}
}
}

fun cancelAccountRangeRepositoryJob() {
accountRangeRepositoryJob?.cancel()
accountRangeRepositoryJob = null
}

@JvmSynthetic
fun updateAccountRangeResult(
newAccountRange: AccountRange?
) {
accountRange = newAccountRange
accountRangeResultListener.onAccountRangeResult(accountRange)
}

private fun shouldQueryRepository(
accountRange: AccountRange
) = when (accountRange.brand) {
CardBrand.Unknown,
CardBrand.UnionPay -> true
else -> false
}

private fun shouldQueryAccountRange(cardNumber: CardNumber.Unvalidated): Boolean {
return accountRange == null ||
cardNumber.bin == null ||
accountRange?.binRange?.matches(cardNumber) == false
}

interface AccountRangeResultListener {
fun onAccountRangeResult(newAccountRange: AccountRange?)
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
package com.stripe.android.cards

import androidx.annotation.RestrictTo
import com.stripe.android.CardUtils
import com.stripe.android.model.CardBrand

internal sealed class CardNumber {
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
sealed class CardNumber {

/**
* A representation of a partial or full card number that hasn't been validated.
*/
internal data class Unvalidated internal constructor(
data class Unvalidated constructor(
private val denormalized: String
) : CardNumber() {
val normalized = denormalized.filterNot { REJECT_CHARS.contains(it) }
Expand All @@ -21,7 +23,7 @@ internal sealed class CardNumber {

val isValidLuhn = CardUtils.isValidLuhnNumber(normalized)

fun validate(panLength: Int): Validated? {
internal fun validate(panLength: Int): Validated? {
return if (panLength >= MIN_PAN_LENGTH &&
normalized.length == panLength &&
isValidLuhn
Expand All @@ -41,7 +43,7 @@ internal sealed class CardNumber {
* `"424242"` with pan length `16` will return `"4242 42"`;
* `"4242424242424242"` with pan length `14` will return `"4242 424242 4242"`
*/
fun getFormatted(
internal fun getFormatted(
panLength: Int = DEFAULT_PAN_LENGTH
) = formatNumber(panLength)

Expand Down Expand Up @@ -96,17 +98,17 @@ internal sealed class CardNumber {
/**
* A representation of a client-side validated card number.
*/
internal data class Validated internal constructor(
internal data class Validated constructor(
internal val value: String
) : CardNumber()

internal companion object {
companion object {
internal fun getSpacePositions(panLength: Int) = SPACE_POSITIONS[panLength]
?: DEFAULT_SPACE_POSITIONS

internal const val MIN_PAN_LENGTH = 14
internal const val MAX_PAN_LENGTH = 19
internal const val DEFAULT_PAN_LENGTH = 16
const val DEFAULT_PAN_LENGTH = 16
private val DEFAULT_SPACE_POSITIONS = setOf(4, 9, 14)

private val SPACE_POSITIONS = mapOf(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.stripe.android.cards

import android.content.Context
import androidx.annotation.RestrictTo
import com.stripe.android.PaymentConfiguration
import com.stripe.android.core.networking.AnalyticsRequestExecutor
import com.stripe.android.core.networking.ApiRequest
Expand All @@ -17,7 +18,8 @@ import kotlinx.coroutines.flow.flowOf
*
* Will throw an exception if [PaymentConfiguration] has not been instantiated.
*/
internal class DefaultCardAccountRangeRepositoryFactory(
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
class DefaultCardAccountRangeRepositoryFactory(
context: Context,
private val analyticsRequestExecutor: AnalyticsRequestExecutor
) : CardAccountRangeRepository.Factory {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package com.stripe.android.cards

import androidx.annotation.RestrictTo
import com.stripe.android.model.AccountRange
import com.stripe.android.model.BinRange

internal class DefaultStaticCardAccountRanges : StaticCardAccountRanges {
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
class DefaultStaticCardAccountRanges : StaticCardAccountRanges {
override fun first(
cardNumber: CardNumber.Unvalidated
) = filter(cardNumber).firstOrNull()
Expand Down Expand Up @@ -99,9 +101,14 @@ internal class DefaultStaticCardAccountRanges : StaticCardAccountRanges {
)
}

private val UNIONPAY_ACCOUNTS = setOf(
private val UNIONPAY16_ACCOUNTS = setOf(
BinRange(
low = "6200000000000000",
high = "6216828049999999"
),

BinRange(
low = "6216828060000000",
high = "6299999999999999"
),

Expand All @@ -117,6 +124,19 @@ internal class DefaultStaticCardAccountRanges : StaticCardAccountRanges {
)
}

private val UNIONPAY19_ACCOUNTS = setOf(
BinRange(
low = "6216828050000000000",
high = "6216828059999999999"
)
).map {
AccountRange(
binRange = it,
panLength = 19,
brandInfo = AccountRange.BrandInfo.UnionPay
)
}

private val DINERSCLUB16_ACCOUNT_RANGES = setOf(
BinRange(
low = "3000000000000000",
Expand Down Expand Up @@ -159,7 +179,8 @@ internal class DefaultStaticCardAccountRanges : StaticCardAccountRanges {
.plus(AMEX_ACCOUNTS)
.plus(DISCOVER_ACCOUNTS)
.plus(JCB_ACCOUNTS)
.plus(UNIONPAY_ACCOUNTS)
.plus(UNIONPAY16_ACCOUNTS)
.plus(UNIONPAY19_ACCOUNTS)
.plus(DINERSCLUB16_ACCOUNT_RANGES)
.plus(DINERSCLUB14_ACCOUNT_RANGES)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package com.stripe.android.cards

import androidx.annotation.RestrictTo
import com.stripe.android.model.AccountRange
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf

/**
* A [CardAccountRangeSource] that uses a local, static source of BIN ranges.
*/
internal class StaticCardAccountRangeSource(
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
class StaticCardAccountRangeSource(
private val accountRanges: StaticCardAccountRanges = DefaultStaticCardAccountRanges()
) : CardAccountRangeSource {
override val loading: Flow<Boolean> = flowOf(false)
Expand Down
Loading

0 comments on commit 397b486

Please sign in to comment.