Credit card validator implementations and spec tests.

This commit is contained in:
Alex Hart
2022-10-17 13:47:51 -03:00
parent 27c3607099
commit 1174bc8e07
21 changed files with 948 additions and 6 deletions

View File

@@ -49,6 +49,7 @@ import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.databinding.DonateToSignalFragmentBinding
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
import org.thoughtcrime.securesms.util.Projection
@@ -390,7 +391,7 @@ class DonateToSignalFragment : DSLSettingsFragment(
when (gatewayResponse.gateway) {
GatewayResponse.Gateway.GOOGLE_PAY -> launchGooglePay(gatewayResponse)
GatewayResponse.Gateway.PAYPAL -> error("PayPal is not currently supported.")
GatewayResponse.Gateway.CREDIT_CARD -> error("Credit cards are not currently supported.")
GatewayResponse.Gateway.CREDIT_CARD -> launchCreditCard(gatewayResponse)
}
}
@@ -426,7 +427,7 @@ class DonateToSignalFragment : DSLSettingsFragment(
}
private fun launchGooglePay(gatewayResponse: GatewayResponse) {
viewModel.provideGatewayRequest(gatewayResponse.request)
viewModel.provideGatewayRequestForGooglePay(gatewayResponse.request)
donationPaymentComponent.donationPaymentRepository.requestTokenFromGooglePay(
price = FiatMoney(gatewayResponse.request.price, Currency.getInstance(gatewayResponse.request.currencyCode)),
label = gatewayResponse.request.label,
@@ -434,10 +435,18 @@ class DonateToSignalFragment : DSLSettingsFragment(
)
}
private fun launchCreditCard(gatewayResponse: GatewayResponse) {
if (FeatureFlags.creditCardPayments()) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(gatewayResponse.request))
} else {
error("Credit cards are not currently enabled.")
}
}
private fun registerGooglePayCallback() {
donationPaymentComponent.googlePayResultPublisher.subscribeBy(
onNext = { paymentResult ->
viewModel.consumeGatewayRequest()?.let {
viewModel.consumeGatewayRequestForGooglePay()?.let {
donationPaymentComponent.donationPaymentRepository.onActivityResult(
paymentResult.requestCode,
paymentResult.resultCode,

View File

@@ -348,13 +348,13 @@ class DonateToSignalViewModel(
store.dispose()
}
fun provideGatewayRequest(request: GatewayRequest) {
fun provideGatewayRequestForGooglePay(request: GatewayRequest) {
Log.d(TAG, "Provided with a gateway request.")
Preconditions.checkState(gatewayRequest == null)
gatewayRequest = request
}
fun consumeGatewayRequest(): GatewayRequest? {
fun consumeGatewayRequestForGooglePay(): GatewayRequest? {
val request = gatewayRequest
gatewayRequest = null
return request

View File

@@ -0,0 +1,31 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card
import androidx.core.text.isDigitsOnly
object CreditCardCodeValidator {
fun getValidity(code: String, cardType: CreditCardType, isFocused: Boolean): Validity {
val validLength = if (cardType == CreditCardType.AMERICAN_EXPRESS) {
4
} else {
3
}
return when {
!code.isDigitsOnly() -> Validity.INVALID_CHARACTERS
code.length > validLength -> Validity.TOO_LONG
code.length < validLength && isFocused -> Validity.POTENTIALLY_VALID
code.isEmpty() -> Validity.POTENTIALLY_VALID
code.length < validLength -> Validity.TOO_SHORT
else -> Validity.FULLY_VALID
}
}
enum class Validity {
TOO_LONG,
TOO_SHORT,
INVALID_CHARACTERS,
POTENTIALLY_VALID,
FULLY_VALID
}
}

View File

@@ -0,0 +1,21 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card
data class CreditCardExpiration(
val month: String = "",
val year: String = ""
) {
fun isEmpty(): Boolean {
return month.isEmpty() && year.isEmpty()
}
companion object {
fun fromInput(expiration: String): CreditCardExpiration {
val expirationParts = expiration.split("/", limit = 2)
val month = expirationParts.first()
val year = expirationParts.drop(1).firstOrNull() ?: ""
return CreditCardExpiration(month, year)
}
}
}

View File

@@ -0,0 +1,98 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card
import androidx.core.text.isDigitsOnly
object CreditCardExpirationValidator {
fun getValidity(creditCardExpiration: CreditCardExpiration, currentMonth: Int, currentYear: Int, isFocused: Boolean): Validity {
if (creditCardExpiration.isEmpty()) {
return Validity.POTENTIALLY_VALID
}
val monthValidity = isExpirationMonthValid(creditCardExpiration.month, isFocused)
val yearValidity = isExpirationYearValid(creditCardExpiration.year, isFocused)
if (monthValidity.isInvalid) {
return monthValidity
}
if (yearValidity.isInvalid) {
return yearValidity
}
if (Validity.POTENTIALLY_VALID in listOf(monthValidity, yearValidity)) {
return Validity.POTENTIALLY_VALID
}
val inputMonthInt = creditCardExpiration.month.toInt()
val inputYearIntTwoDigits = creditCardExpiration.year.toInt()
val century = currentYear.floorDiv(100) * 100
var inputYearInt = inputYearIntTwoDigits + century
if (inputYearInt < currentYear) {
// This is for edge-of-century scenarios. If the year is 2099 and the user inputs '01' for the year, this lets us interpret that as 2101.
inputYearInt += 100
}
return if (inputYearInt == currentYear) {
if (inputMonthInt < currentMonth) {
Validity.INVALID_EXPIRED
} else {
Validity.FULLY_VALID
}
} else {
if (inputYearInt > currentYear + 20) {
Validity.INVALID_EXPIRED
} else {
Validity.FULLY_VALID
}
}
}
private fun isExpirationMonthValid(inputMonth: String, isFocused: Boolean): Validity {
if (inputMonth.length > 2 || !inputMonth.isDigitsOnly()) {
return Validity.INVALID_MONTH
}
if (inputMonth in listOf("", "0")) {
return if (isFocused) {
Validity.POTENTIALLY_VALID
} else {
Validity.INVALID_MONTH
}
}
val month = inputMonth.toInt()
if (month in 1..12) {
return Validity.FULLY_VALID
}
return Validity.INVALID_MONTH
}
private fun isExpirationYearValid(inputYear: String, isFocused: Boolean): Validity {
if (inputYear.length > 2 || !inputYear.isDigitsOnly()) {
return Validity.INVALID_YEAR
}
if (inputYear.length < 2) {
return if (isFocused) {
Validity.POTENTIALLY_VALID
} else if (inputYear.isEmpty()) {
Validity.INVALID_MISSING_YEAR
} else {
Validity.INVALID_YEAR
}
}
return Validity.FULLY_VALID
}
enum class Validity(val isInvalid: Boolean) {
INVALID_EXPIRED(true),
INVALID_MISSING_YEAR(true),
INVALID_MONTH(true),
INVALID_YEAR(true),
POTENTIALLY_VALID(false),
FULLY_VALID(false)
}
}

View File

@@ -0,0 +1,15 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card
data class CreditCardFormState(
val focusedField: FocusedField = FocusedField.NONE,
val number: String = "",
val expiration: CreditCardExpiration = CreditCardExpiration(),
val code: String = ""
) {
enum class FocusedField {
NONE,
NUMBER,
EXPIRATION,
CODE
}
}

View File

@@ -0,0 +1,121 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card
import android.content.Context
import android.os.Bundle
import android.view.View
import androidx.annotation.StringRes
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.navArgs
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.CreditCardFragmentBinding
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.ViewUtil
class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
private val binding by ViewBinderDelegate(CreditCardFragmentBinding::bind)
private val args: CreditCardFragmentArgs by navArgs()
private val viewModel: CreditCardViewModel by viewModels()
private val lifecycleDisposable = LifecycleDisposable()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.cardNumber.addTextChangedListener(afterTextChanged = {
viewModel.onNumberChanged(it?.toString() ?: "")
})
binding.cardNumber.setOnFocusChangeListener { v, hasFocus ->
viewModel.onNumberFocusChanged(hasFocus)
}
binding.cardCvv.addTextChangedListener(afterTextChanged = {
viewModel.onCodeChanged(it?.toString() ?: "")
})
binding.cardCvv.setOnFocusChangeListener { v, hasFocus ->
viewModel.onCodeFocusChanged(hasFocus)
}
binding.cardExpiry.addTextChangedListener(afterTextChanged = {
viewModel.onExpirationChanged(it?.toString() ?: "")
})
binding.cardExpiry.setOnFocusChangeListener { v, hasFocus ->
viewModel.onExpirationFocusChanged(hasFocus)
}
lifecycleDisposable.bindTo(viewLifecycleOwner)
lifecycleDisposable += viewModel.state.subscribe {
// TODO [alex] -- type
// TODO [alex] -- all fields valid
presentCardNumberWrapper(it.numberValidity)
presentCardExpiryWrapper(it.expirationValidity)
presentCardCodeWrapper(it.codeValidity)
}
}
override fun onResume() {
super.onResume()
when (viewModel.currentFocusField) {
CreditCardFormState.FocusedField.NONE -> ViewUtil.focusAndShowKeyboard(binding.cardNumber)
CreditCardFormState.FocusedField.NUMBER -> ViewUtil.focusAndShowKeyboard(binding.cardNumber)
CreditCardFormState.FocusedField.EXPIRATION -> ViewUtil.focusAndShowKeyboard(binding.cardExpiry)
CreditCardFormState.FocusedField.CODE -> ViewUtil.focusAndShowKeyboard(binding.cardCvv)
}
}
private fun presentCardNumberWrapper(validity: CreditCardNumberValidator.Validity) {
val errorState = when (validity) {
CreditCardNumberValidator.Validity.INVALID -> ErrorState(messageResId = R.string.CreditCardFragment__invalid_card_number)
CreditCardNumberValidator.Validity.POTENTIALLY_VALID -> NO_ERROR
CreditCardNumberValidator.Validity.FULLY_VALID -> NO_ERROR
}
binding.cardNumberWrapper.error = errorState.resolveErrorText(requireContext())
}
private fun presentCardExpiryWrapper(validity: CreditCardExpirationValidator.Validity) {
val errorState = when (validity) {
CreditCardExpirationValidator.Validity.INVALID_EXPIRED -> ErrorState(messageResId = R.string.CreditCardFragment__card_has_expired)
CreditCardExpirationValidator.Validity.INVALID_MISSING_YEAR -> ErrorState(messageResId = R.string.CreditCardFragment__year_required)
CreditCardExpirationValidator.Validity.INVALID_MONTH -> ErrorState(messageResId = R.string.CreditCardFragment__invalid_month)
CreditCardExpirationValidator.Validity.INVALID_YEAR -> ErrorState(messageResId = R.string.CreditCardFragment__invalid_year)
CreditCardExpirationValidator.Validity.POTENTIALLY_VALID -> NO_ERROR
CreditCardExpirationValidator.Validity.FULLY_VALID -> NO_ERROR
}
binding.cardExpiryWrapper.error = errorState.resolveErrorText(requireContext())
}
private fun presentCardCodeWrapper(validity: CreditCardCodeValidator.Validity) {
val errorState = when (validity) {
CreditCardCodeValidator.Validity.TOO_LONG -> ErrorState(messageResId = R.string.CreditCardFragment__code_is_too_long)
CreditCardCodeValidator.Validity.TOO_SHORT -> ErrorState(messageResId = R.string.CreditCardFragment__code_is_too_short)
CreditCardCodeValidator.Validity.INVALID_CHARACTERS -> ErrorState(messageResId = R.string.CreditCardFragment__invalid_code)
CreditCardCodeValidator.Validity.POTENTIALLY_VALID -> NO_ERROR
CreditCardCodeValidator.Validity.FULLY_VALID -> NO_ERROR
}
binding.cardCvvWrapper.error = errorState.resolveErrorText(requireContext())
}
private data class ErrorState(
private val isEnabled: Boolean = true,
@StringRes private val messageResId: Int
) {
fun resolveErrorText(context: Context): String? {
return if (isEnabled) {
context.getString(messageResId)
} else {
null
}
}
}
companion object {
private val NO_ERROR = ErrorState(false, -1)
}
}

View File

@@ -0,0 +1,56 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card
import androidx.core.text.isDigitsOnly
object CreditCardNumberValidator {
private const val MAX_CARD_NUMBER_LENGTH = 19
private const val MIN_CARD_NUMBER_LENGTH = 12
fun getValidity(cardNumber: String, isCardNumberFieldFocused: Boolean): Validity {
if (cardNumber.length > MAX_CARD_NUMBER_LENGTH || !cardNumber.isDigitsOnly()) {
return Validity.INVALID
}
if (cardNumber.length < MIN_CARD_NUMBER_LENGTH) {
return Validity.POTENTIALLY_VALID
}
val isValid = CreditCardType.fromCardNumber(cardNumber) == CreditCardType.UNIONPAY || isLuhnValid(cardNumber)
return when {
isValid -> Validity.FULLY_VALID
isCardNumberFieldFocused -> Validity.POTENTIALLY_VALID
else -> Validity.INVALID
}
}
/**
* An implementation of the [Luhn Algorithm](https://en.wikipedia.org/wiki/Luhn_algorithm) which
* performs a checksum to check for validity of non-Unionpay cards.
*/
private fun isLuhnValid(cardNumber: String): Boolean {
var checksum = 0
var double = false
cardNumber.reversed().forEach { char ->
var digit = char.digitToInt()
if (double) {
digit *= 2
}
double = !double
if (digit >= 10) {
digit -= 9
}
checksum += digit
}
return checksum % 10 == 0
}
enum class Validity {
INVALID,
POTENTIALLY_VALID,
FULLY_VALID
}
}

View File

@@ -0,0 +1,20 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card
enum class CreditCardType {
AMERICAN_EXPRESS,
UNIONPAY,
OTHER;
companion object {
private val AMERICAN_EXPRESS_PREFIXES = listOf("34", "37")
private val UNIONPAY_PREFIXES = listOf("62", "81")
fun fromCardNumber(cardNumber: String): CreditCardType {
return when {
AMERICAN_EXPRESS_PREFIXES.any { cardNumber.startsWith(it) } -> AMERICAN_EXPRESS
UNIONPAY_PREFIXES.any { cardNumber.startsWith(it) } -> UNIONPAY
else -> OTHER
}
}
}
}

View File

@@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card
data class CreditCardValidationState(
val type: CreditCardType,
val numberValidity: CreditCardNumberValidator.Validity,
val expirationValidity: CreditCardExpirationValidator.Validity,
val codeValidity: CreditCardCodeValidator.Validity
)

View File

@@ -0,0 +1,101 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.processors.BehaviorProcessor
import org.thoughtcrime.securesms.util.rx.RxStore
import java.util.Calendar
class CreditCardViewModel : ViewModel() {
private val formStore = RxStore(CreditCardFormState())
private val validationProcessor: BehaviorProcessor<CreditCardValidationState> = BehaviorProcessor.create()
private val currentYear: Int
private val currentMonth: Int
private val disposables = CompositeDisposable()
init {
val calendar = Calendar.getInstance()
currentYear = calendar.get(Calendar.YEAR)
currentMonth = calendar.get(Calendar.MONTH) + 1
disposables += formStore.stateFlowable.subscribe { formState ->
val type = CreditCardType.fromCardNumber(formState.number)
validationProcessor.onNext(
CreditCardValidationState(
type = type,
numberValidity = CreditCardNumberValidator.getValidity(formState.number, formState.focusedField == CreditCardFormState.FocusedField.NUMBER),
expirationValidity = CreditCardExpirationValidator.getValidity(formState.expiration, currentMonth, currentYear, formState.focusedField == CreditCardFormState.FocusedField.EXPIRATION),
codeValidity = CreditCardCodeValidator.getValidity(formState.code, type, formState.focusedField == CreditCardFormState.FocusedField.CODE)
)
)
}
}
val state: Flowable<CreditCardValidationState> = validationProcessor.distinctUntilChanged().observeOn(AndroidSchedulers.mainThread())
val currentFocusField: CreditCardFormState.FocusedField
get() = formStore.state.focusedField
override fun onCleared() {
disposables.clear()
formStore.dispose()
}
fun onNumberChanged(number: String) {
formStore.update {
it.copy(number = number)
}
}
fun onNumberFocusChanged(isFocused: Boolean) {
updateFocus(CreditCardFormState.FocusedField.NUMBER, isFocused)
}
fun onExpirationChanged(expiration: String) {
formStore.update {
it.copy(expiration = CreditCardExpiration.fromInput(expiration))
}
}
fun onExpirationFocusChanged(isFocused: Boolean) {
updateFocus(CreditCardFormState.FocusedField.EXPIRATION, isFocused)
}
fun onCodeChanged(code: String) {
formStore.update {
it.copy(code = code)
}
}
fun onCodeFocusChanged(isFocused: Boolean) {
updateFocus(CreditCardFormState.FocusedField.CODE, isFocused)
}
private fun updateFocus(
newFocusedField: CreditCardFormState.FocusedField,
isFocused: Boolean
) {
formStore.update {
it.copy(focusedField = getUpdatedFocus(it.focusedField, newFocusedField, isFocused))
}
}
private fun getUpdatedFocus(
currentFocusedField: CreditCardFormState.FocusedField,
newFocusedField: CreditCardFormState.FocusedField,
isFocused: Boolean
): CreditCardFormState.FocusedField {
return if (currentFocusedField == newFocusedField && !isFocused) {
CreditCardFormState.FocusedField.NONE
} else if (isFocused) {
newFocusedField
} else {
currentFocusedField
}
}
}

View File

@@ -77,7 +77,20 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
}
// PayPal
// Credit Card
if (state.isCreditCardAvailable) {
space(12.dp)
primaryButton(
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__credit_or_debit_card),
onClick = {
findNavController().popBackStack()
val response = GatewayResponse(GatewayResponse.Gateway.CREDIT_CARD, args.request)
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
}
)
}
space(16.dp)
}

View File

@@ -1,10 +1,11 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.util.FeatureFlags
data class GatewaySelectorState(
val badge: Badge,
val isGooglePayAvailable: Boolean = false,
val isPayPalAvailable: Boolean = false,
val isCreditCardAvailable: Boolean = false
val isCreditCardAvailable: Boolean = FeatureFlags.creditCardPayments()
)

View File

@@ -104,8 +104,15 @@ public final class FeatureFlags {
private static final String CDS_V2_COMPAT = "android.cdsV2Compat.4";
public static final String STORIES_LOCALE = "android.stories.locale.1";
private static final String HIDE_CONTACTS = "android.hide.contacts";
<<<<<<< HEAD
private static final String MEDIA_PREVIEW_V2 = "android.mediaPreviewV2";
private static final String SMS_EXPORT_MEGAPHONE_DELAY_DAYS = "android.smsExport.megaphoneDelayDays";
||||||| parent of 90b7447a79 (Credit card validator implementations and spec tests.)
public static final String MEDIA_PREVIEW_V2 = "android.mediaPreviewV2";
=======
public static final String MEDIA_PREVIEW_V2 = "android.mediaPreviewV2";
public static final String CREDIT_CARD_PAYMENTS = "android.credit.card.payments";
>>>>>>> 90b7447a79 (Credit card validator implementations and spec tests.)
/**
* We will only store remote values for flags in this set. If you want a flag to be controllable
@@ -162,8 +169,15 @@ public final class FeatureFlags {
CDS_V2_COMPAT,
STORIES_LOCALE,
HIDE_CONTACTS,
<<<<<<< HEAD
MEDIA_PREVIEW_V2,
SMS_EXPORT_MEGAPHONE_DELAY_DAYS
||||||| parent of 90b7447a79 (Credit card validator implementations and spec tests.)
MEDIA_PREVIEW_V2
=======
MEDIA_PREVIEW_V2,
CREDIT_CARD_PAYMENTS
>>>>>>> 90b7447a79 (Credit card validator implementations and spec tests.)
);
@VisibleForTesting
@@ -227,8 +241,15 @@ public final class FeatureFlags {
CDS_V2_LOAD_TEST,
CDS_V2_COMPAT,
STORIES,
<<<<<<< HEAD
MEDIA_PREVIEW_V2,
SMS_EXPORT_MEGAPHONE_DELAY_DAYS
||||||| parent of 90b7447a79 (Credit card validator implementations and spec tests.)
MEDIA_PREVIEW_V2
=======
MEDIA_PREVIEW_V2,
CREDIT_CARD_PAYMENTS
>>>>>>> 90b7447a79 (Credit card validator implementations and spec tests.)
);
/**
@@ -595,6 +616,15 @@ public final class FeatureFlags {
return getInteger(SMS_EXPORT_MEGAPHONE_DELAY_DAYS, 14);
}
/**
* Whether or not we should allow credit card payments for donations
*
* WARNING: This feature is not done, and this should not be enabled.
*/
public static boolean creditCardPayments() {
return getBoolean(CREDIT_CARD_PAYMENTS, Environment.IS_STAGING);
}
/** Only for rendering debug info. */
public static synchronized @NonNull Map<String, Object> getMemoryValues() {
return new TreeMap<>(REMOTE_VALUES);