From 1174bc8e070c68da0836d17329af919266f853ba Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Mon, 17 Oct 2022 13:47:51 -0300 Subject: [PATCH] Credit card validator implementations and spec tests. --- .../donate/DonateToSignalFragment.kt | 15 ++- .../donate/DonateToSignalViewModel.kt | 4 +- .../donate/card/CreditCardCodeValidator.kt | 31 +++++ .../donate/card/CreditCardExpiration.kt | 21 +++ .../card/CreditCardExpirationValidator.kt | 98 ++++++++++++++ .../donate/card/CreditCardFormState.kt | 15 +++ .../donate/card/CreditCardFragment.kt | 121 ++++++++++++++++++ .../donate/card/CreditCardNumberValidator.kt | 56 ++++++++ .../donate/card/CreditCardType.kt | 20 +++ .../donate/card/CreditCardValidationState.kt | 8 ++ .../donate/card/CreditCardViewModel.kt | 101 +++++++++++++++ .../gateway/GatewaySelectorBottomSheet.kt | 13 ++ .../donate/gateway/GatewaySelectorState.kt | 3 +- .../securesms/util/FeatureFlags.java | 30 +++++ .../main/res/layout/credit_card_fragment.xml | 117 +++++++++++++++++ .../main/res/navigation/donate_to_signal.xml | 15 +++ app/src/main/res/values/strings.xml | 29 +++++ .../card/CreditCardCodeValidatorTest.kt | 65 ++++++++++ .../card/CreditCardExpirationValidatorTest.kt | 105 +++++++++++++++ .../card/CreditCardNumberValidatorTest.kt | 43 +++++++ .../donate/card/CreditCardTypeTest.kt | 44 +++++++ 21 files changed, 948 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardCodeValidator.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardExpiration.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardExpirationValidator.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFormState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardNumberValidator.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardType.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardValidationState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardViewModel.kt create mode 100644 app/src/main/res/layout/credit_card_fragment.xml create mode 100644 app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardCodeValidatorTest.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardExpirationValidatorTest.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardNumberValidatorTest.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardTypeTest.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt index a51f6eb168..2e0bee1e31 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt index bd5b5f78cc..297638f745 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardCodeValidator.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardCodeValidator.kt new file mode 100644 index 0000000000..37344df7ea --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardCodeValidator.kt @@ -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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardExpiration.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardExpiration.kt new file mode 100644 index 0000000000..e7c74f04a4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardExpiration.kt @@ -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) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardExpirationValidator.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardExpirationValidator.kt new file mode 100644 index 0000000000..b1a559837f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardExpirationValidator.kt @@ -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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFormState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFormState.kt new file mode 100644 index 0000000000..4e82ea7169 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFormState.kt @@ -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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFragment.kt new file mode 100644 index 0000000000..98ac9ce001 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFragment.kt @@ -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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardNumberValidator.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardNumberValidator.kt new file mode 100644 index 0000000000..b1bace1431 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardNumberValidator.kt @@ -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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardType.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardType.kt new file mode 100644 index 0000000000..a0f08c6859 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardType.kt @@ -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 + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardValidationState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardValidationState.kt new file mode 100644 index 0000000000..485972dd25 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardValidationState.kt @@ -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 +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardViewModel.kt new file mode 100644 index 0000000000..7606420be4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardViewModel.kt @@ -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 = 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 = 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 + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt index beaec82175..d91a61594f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorState.kt index 96c50b5879..16878744d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorState.kt @@ -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() ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 64d61e934c..ca9bf335a7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -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 getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/app/src/main/res/layout/credit_card_fragment.xml b/app/src/main/res/layout/credit_card_fragment.xml new file mode 100644 index 0000000000..9f909e96b6 --- /dev/null +++ b/app/src/main/res/layout/credit_card_fragment.xml @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/donate_to_signal.xml b/app/src/main/res/navigation/donate_to_signal.xml index 0c4507de49..f04336314a 100644 --- a/app/src/main/res/navigation/donate_to_signal.xml +++ b/app/src/main/res/navigation/donate_to_signal.xml @@ -36,6 +36,9 @@ + @@ -107,4 +110,16 @@ android:label="subscribe_learn_more_bottom_sheet_dialog" tools:layout="@layout/subscribe_learn_more_bottom_sheet_dialog_fragment" /> + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b2c189fa78..d2c4cffd98 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -125,6 +125,33 @@ Do you want to unblock \"%1$s\"? Unblock + + + Donation amount: %1$s + + Enter your card information below + + Card number + + MM/YY + + CVV + + Invalid card number + + Card has expired + + Code is too short + + Code is too long + + Invalid code + + Invalid month + + Year required + + Invalid year Block and leave %1$s? @@ -5502,6 +5529,8 @@ Donate %1$s to Signal Get a %1$s badge for %2$d days + + Credit or debit card Cancelling… diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardCodeValidatorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardCodeValidatorTest.kt new file mode 100644 index 0000000000..b9a1376e1f --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardCodeValidatorTest.kt @@ -0,0 +1,65 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card + +import android.app.Application +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(ParameterizedRobolectricTestRunner::class) +@Config(application = Application::class) +class CreditCardCodeValidatorTest( + private val code: String, + private val cardType: CreditCardType, + private val isFocused: Boolean, + private val validity: CreditCardCodeValidator.Validity +) { + + @Test + fun getValidity() { + assertEquals(validity, CreditCardCodeValidator.getValidity(code, cardType, isFocused)) + } + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "{index}: getValidity(..) = {0}, {1}, {2}, {3}") + fun data(): Iterable> = arrayListOf( + // Unfocused + arrayOf("", CreditCardType.UNIONPAY, false, CreditCardCodeValidator.Validity.POTENTIALLY_VALID), + arrayOf("1", CreditCardType.UNIONPAY, false, CreditCardCodeValidator.Validity.TOO_SHORT), + arrayOf("12", CreditCardType.UNIONPAY, false, CreditCardCodeValidator.Validity.TOO_SHORT), + arrayOf("123", CreditCardType.UNIONPAY, false, CreditCardCodeValidator.Validity.FULLY_VALID), + arrayOf("1234", CreditCardType.UNIONPAY, false, CreditCardCodeValidator.Validity.TOO_LONG), + arrayOf("", CreditCardType.OTHER, false, CreditCardCodeValidator.Validity.POTENTIALLY_VALID), + arrayOf("1", CreditCardType.OTHER, false, CreditCardCodeValidator.Validity.TOO_SHORT), + arrayOf("12", CreditCardType.OTHER, false, CreditCardCodeValidator.Validity.TOO_SHORT), + arrayOf("123", CreditCardType.OTHER, false, CreditCardCodeValidator.Validity.FULLY_VALID), + arrayOf("1234", CreditCardType.OTHER, false, CreditCardCodeValidator.Validity.TOO_LONG), + arrayOf("", CreditCardType.AMERICAN_EXPRESS, false, CreditCardCodeValidator.Validity.POTENTIALLY_VALID), + arrayOf("1", CreditCardType.AMERICAN_EXPRESS, false, CreditCardCodeValidator.Validity.TOO_SHORT), + arrayOf("12", CreditCardType.AMERICAN_EXPRESS, false, CreditCardCodeValidator.Validity.TOO_SHORT), + arrayOf("123", CreditCardType.AMERICAN_EXPRESS, false, CreditCardCodeValidator.Validity.TOO_SHORT), + arrayOf("1234", CreditCardType.AMERICAN_EXPRESS, false, CreditCardCodeValidator.Validity.FULLY_VALID), + arrayOf("12345", CreditCardType.AMERICAN_EXPRESS, false, CreditCardCodeValidator.Validity.TOO_LONG), + + // Focused + arrayOf("", CreditCardType.UNIONPAY, true, CreditCardCodeValidator.Validity.POTENTIALLY_VALID), + arrayOf("1", CreditCardType.UNIONPAY, true, CreditCardCodeValidator.Validity.POTENTIALLY_VALID), + arrayOf("12", CreditCardType.UNIONPAY, true, CreditCardCodeValidator.Validity.POTENTIALLY_VALID), + arrayOf("123", CreditCardType.UNIONPAY, true, CreditCardCodeValidator.Validity.FULLY_VALID), + arrayOf("1234", CreditCardType.UNIONPAY, true, CreditCardCodeValidator.Validity.TOO_LONG), + arrayOf("", CreditCardType.OTHER, true, CreditCardCodeValidator.Validity.POTENTIALLY_VALID), + arrayOf("1", CreditCardType.OTHER, true, CreditCardCodeValidator.Validity.POTENTIALLY_VALID), + arrayOf("12", CreditCardType.OTHER, true, CreditCardCodeValidator.Validity.POTENTIALLY_VALID), + arrayOf("123", CreditCardType.OTHER, true, CreditCardCodeValidator.Validity.FULLY_VALID), + arrayOf("1234", CreditCardType.OTHER, true, CreditCardCodeValidator.Validity.TOO_LONG), + arrayOf("", CreditCardType.AMERICAN_EXPRESS, true, CreditCardCodeValidator.Validity.POTENTIALLY_VALID), + arrayOf("1", CreditCardType.AMERICAN_EXPRESS, true, CreditCardCodeValidator.Validity.POTENTIALLY_VALID), + arrayOf("12", CreditCardType.AMERICAN_EXPRESS, true, CreditCardCodeValidator.Validity.POTENTIALLY_VALID), + arrayOf("123", CreditCardType.AMERICAN_EXPRESS, true, CreditCardCodeValidator.Validity.POTENTIALLY_VALID), + arrayOf("1234", CreditCardType.AMERICAN_EXPRESS, true, CreditCardCodeValidator.Validity.FULLY_VALID), + arrayOf("12345", CreditCardType.AMERICAN_EXPRESS, true, CreditCardCodeValidator.Validity.TOO_LONG) + ) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardExpirationValidatorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardExpirationValidatorTest.kt new file mode 100644 index 0000000000..e88c05110b --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardExpirationValidatorTest.kt @@ -0,0 +1,105 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card + +import android.app.Application +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(ParameterizedRobolectricTestRunner::class) +@Config(application = Application::class) +class CreditCardExpirationValidatorTest( + private val creditCardExpiration: CreditCardExpiration, + private val currentYear: Int, + private val isFocused: Boolean, + private val validity: CreditCardExpirationValidator.Validity +) { + + @Test + fun getValidity() { + assertEquals(validity, CreditCardExpirationValidator.getValidity(creditCardExpiration, 3, currentYear, isFocused)) + } + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "{index}: getValidity(..) = {0}, {1}, {2}, {3}, {4}") + fun data(): Iterable> = arrayListOf( + // Unfocused + arrayOf(CreditCardExpiration("", ""), 2020, false, CreditCardExpirationValidator.Validity.POTENTIALLY_VALID), + arrayOf(CreditCardExpiration("0", ""), 2020, false, CreditCardExpirationValidator.Validity.INVALID_MONTH), + arrayOf(CreditCardExpiration("1", ""), 2020, false, CreditCardExpirationValidator.Validity.INVALID_MISSING_YEAR), + arrayOf(CreditCardExpiration("9", ""), 2020, false, CreditCardExpirationValidator.Validity.INVALID_MISSING_YEAR), + arrayOf(CreditCardExpiration("01", ""), 2020, false, CreditCardExpirationValidator.Validity.INVALID_MISSING_YEAR), + arrayOf(CreditCardExpiration("09", ""), 2020, false, CreditCardExpirationValidator.Validity.INVALID_MISSING_YEAR), + arrayOf(CreditCardExpiration("12", ""), 2020, false, CreditCardExpirationValidator.Validity.INVALID_MISSING_YEAR), + arrayOf(CreditCardExpiration("", "0"), 2020, false, CreditCardExpirationValidator.Validity.INVALID_MONTH), + arrayOf(CreditCardExpiration("", "1"), 2020, false, CreditCardExpirationValidator.Validity.INVALID_MONTH), + arrayOf(CreditCardExpiration("", "9"), 2020, false, CreditCardExpirationValidator.Validity.INVALID_MONTH), + arrayOf(CreditCardExpiration("", "00"), 2020, false, CreditCardExpirationValidator.Validity.INVALID_MONTH), + arrayOf(CreditCardExpiration("", "01"), 2020, false, CreditCardExpirationValidator.Validity.INVALID_MONTH), + arrayOf(CreditCardExpiration("", "99"), 2020, false, CreditCardExpirationValidator.Validity.INVALID_MONTH), + arrayOf(CreditCardExpiration("3", "20"), 2020, false, CreditCardExpirationValidator.Validity.FULLY_VALID), + arrayOf(CreditCardExpiration("03", "20"), 2020, false, CreditCardExpirationValidator.Validity.FULLY_VALID), + arrayOf(CreditCardExpiration("4", "20"), 2020, false, CreditCardExpirationValidator.Validity.FULLY_VALID), + arrayOf(CreditCardExpiration("12", "20"), 2020, false, CreditCardExpirationValidator.Validity.FULLY_VALID), + arrayOf(CreditCardExpiration("3", "21"), 2020, false, CreditCardExpirationValidator.Validity.FULLY_VALID), + arrayOf(CreditCardExpiration("3", "40"), 2020, false, CreditCardExpirationValidator.Validity.FULLY_VALID), + arrayOf(CreditCardExpiration("2", "20"), 2020, false, CreditCardExpirationValidator.Validity.INVALID_EXPIRED), + arrayOf(CreditCardExpiration("3", "41"), 2020, false, CreditCardExpirationValidator.Validity.INVALID_EXPIRED), + arrayOf(CreditCardExpiration("3", "41"), 2021, false, CreditCardExpirationValidator.Validity.FULLY_VALID), + arrayOf(CreditCardExpiration("01", "99"), 2098, false, CreditCardExpirationValidator.Validity.FULLY_VALID), + arrayOf(CreditCardExpiration("03", "00"), 2099, false, CreditCardExpirationValidator.Validity.FULLY_VALID), + arrayOf(CreditCardExpiration("04", "00"), 2099, false, CreditCardExpirationValidator.Validity.FULLY_VALID), + arrayOf(CreditCardExpiration("03", "19"), 2099, false, CreditCardExpirationValidator.Validity.FULLY_VALID), + arrayOf(CreditCardExpiration("12", "19"), 2099, false, CreditCardExpirationValidator.Validity.FULLY_VALID), + arrayOf(CreditCardExpiration("00", "20"), 2020, false, CreditCardExpirationValidator.Validity.INVALID_MONTH), + arrayOf(CreditCardExpiration("X", ""), 2020, false, CreditCardExpirationValidator.Validity.INVALID_MONTH), + arrayOf(CreditCardExpiration("1X", ""), 2020, false, CreditCardExpirationValidator.Validity.INVALID_MONTH), + arrayOf(CreditCardExpiration("123", ""), 2020, false, CreditCardExpirationValidator.Validity.INVALID_MONTH), + arrayOf(CreditCardExpiration("", "X"), 2020, false, CreditCardExpirationValidator.Validity.INVALID_MONTH), + arrayOf(CreditCardExpiration("", "2X"), 2020, false, CreditCardExpirationValidator.Validity.INVALID_MONTH), + arrayOf(CreditCardExpiration("", "202"), 2020, false, CreditCardExpirationValidator.Validity.INVALID_MONTH), + arrayOf(CreditCardExpiration("", "2020"), 2020, false, CreditCardExpirationValidator.Validity.INVALID_MONTH), + arrayOf(CreditCardExpiration("X", "X"), 2020, false, CreditCardExpirationValidator.Validity.INVALID_MONTH), + + // Focused + arrayOf(CreditCardExpiration("", ""), 2020, true, CreditCardExpirationValidator.Validity.POTENTIALLY_VALID), + arrayOf(CreditCardExpiration("0", ""), 2020, true, CreditCardExpirationValidator.Validity.POTENTIALLY_VALID), + arrayOf(CreditCardExpiration("1", ""), 2020, true, CreditCardExpirationValidator.Validity.POTENTIALLY_VALID), + arrayOf(CreditCardExpiration("9", ""), 2020, true, CreditCardExpirationValidator.Validity.POTENTIALLY_VALID), + arrayOf(CreditCardExpiration("01", ""), 2020, true, CreditCardExpirationValidator.Validity.POTENTIALLY_VALID), + arrayOf(CreditCardExpiration("09", ""), 2020, true, CreditCardExpirationValidator.Validity.POTENTIALLY_VALID), + arrayOf(CreditCardExpiration("12", ""), 2020, true, CreditCardExpirationValidator.Validity.POTENTIALLY_VALID), + arrayOf(CreditCardExpiration("", "0"), 2020, true, CreditCardExpirationValidator.Validity.POTENTIALLY_VALID), + arrayOf(CreditCardExpiration("", "1"), 2020, true, CreditCardExpirationValidator.Validity.POTENTIALLY_VALID), + arrayOf(CreditCardExpiration("", "9"), 2020, true, CreditCardExpirationValidator.Validity.POTENTIALLY_VALID), + arrayOf(CreditCardExpiration("", "00"), 2020, true, CreditCardExpirationValidator.Validity.POTENTIALLY_VALID), + arrayOf(CreditCardExpiration("", "01"), 2020, true, CreditCardExpirationValidator.Validity.POTENTIALLY_VALID), + arrayOf(CreditCardExpiration("", "99"), 2020, true, CreditCardExpirationValidator.Validity.POTENTIALLY_VALID), + arrayOf(CreditCardExpiration("3", "20"), 2020, true, CreditCardExpirationValidator.Validity.FULLY_VALID), + arrayOf(CreditCardExpiration("03", "20"), 2020, true, CreditCardExpirationValidator.Validity.FULLY_VALID), + arrayOf(CreditCardExpiration("4", "20"), 2020, true, CreditCardExpirationValidator.Validity.FULLY_VALID), + arrayOf(CreditCardExpiration("12", "20"), 2020, true, CreditCardExpirationValidator.Validity.FULLY_VALID), + arrayOf(CreditCardExpiration("3", "21"), 2020, true, CreditCardExpirationValidator.Validity.FULLY_VALID), + arrayOf(CreditCardExpiration("3", "40"), 2020, true, CreditCardExpirationValidator.Validity.FULLY_VALID), + arrayOf(CreditCardExpiration("2", "20"), 2020, true, CreditCardExpirationValidator.Validity.INVALID_EXPIRED), + arrayOf(CreditCardExpiration("3", "41"), 2020, true, CreditCardExpirationValidator.Validity.INVALID_EXPIRED), + arrayOf(CreditCardExpiration("3", "41"), 2021, true, CreditCardExpirationValidator.Validity.FULLY_VALID), + arrayOf(CreditCardExpiration("01", "99"), 2098, true, CreditCardExpirationValidator.Validity.FULLY_VALID), + arrayOf(CreditCardExpiration("03", "00"), 2099, true, CreditCardExpirationValidator.Validity.FULLY_VALID), + arrayOf(CreditCardExpiration("04", "00"), 2099, true, CreditCardExpirationValidator.Validity.FULLY_VALID), + arrayOf(CreditCardExpiration("03", "19"), 2099, true, CreditCardExpirationValidator.Validity.FULLY_VALID), + arrayOf(CreditCardExpiration("12", "19"), 2099, true, CreditCardExpirationValidator.Validity.FULLY_VALID), + arrayOf(CreditCardExpiration("00", "20"), 2020, true, CreditCardExpirationValidator.Validity.INVALID_MONTH), + arrayOf(CreditCardExpiration("X", ""), 2020, true, CreditCardExpirationValidator.Validity.INVALID_MONTH), + arrayOf(CreditCardExpiration("1X", ""), 2020, true, CreditCardExpirationValidator.Validity.INVALID_MONTH), + arrayOf(CreditCardExpiration("123", ""), 2020, true, CreditCardExpirationValidator.Validity.INVALID_MONTH), + arrayOf(CreditCardExpiration("", "X"), 2020, true, CreditCardExpirationValidator.Validity.INVALID_YEAR), + arrayOf(CreditCardExpiration("", "2X"), 2020, true, CreditCardExpirationValidator.Validity.INVALID_YEAR), + arrayOf(CreditCardExpiration("", "202"), 2020, true, CreditCardExpirationValidator.Validity.INVALID_YEAR), + arrayOf(CreditCardExpiration("", "2020"), 2020, true, CreditCardExpirationValidator.Validity.INVALID_YEAR), + arrayOf(CreditCardExpiration("X", "X"), 2020, true, CreditCardExpirationValidator.Validity.INVALID_MONTH) + ) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardNumberValidatorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardNumberValidatorTest.kt new file mode 100644 index 0000000000..1884387035 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardNumberValidatorTest.kt @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card + +import android.app.Application +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(ParameterizedRobolectricTestRunner::class) +@Config(application = Application::class) +class CreditCardNumberValidatorTest( + private val creditCardNumber: String, + private val creditCardNumberFieldFocused: Boolean, + private val validity: CreditCardNumberValidator.Validity +) { + + @Test + fun getValidity() { + assertEquals(validity, CreditCardNumberValidator.getValidity(creditCardNumber, creditCardNumberFieldFocused)) + } + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "{index}: getValidity(..) = {0}, {1}, {2}") + fun data(): Iterable> = arrayListOf( + arrayOf("", false, CreditCardNumberValidator.Validity.POTENTIALLY_VALID), + arrayOf("4", false, CreditCardNumberValidator.Validity.POTENTIALLY_VALID), + arrayOf("42", false, CreditCardNumberValidator.Validity.POTENTIALLY_VALID), + arrayOf("42424242424", false, CreditCardNumberValidator.Validity.POTENTIALLY_VALID), + arrayOf("424242424242", false, CreditCardNumberValidator.Validity.FULLY_VALID), + arrayOf("424242424242424242", false, CreditCardNumberValidator.Validity.FULLY_VALID), + arrayOf("4242424242424", false, CreditCardNumberValidator.Validity.INVALID), + arrayOf("4242424242424", true, CreditCardNumberValidator.Validity.POTENTIALLY_VALID), + arrayOf("6200000000000004", false, CreditCardNumberValidator.Validity.FULLY_VALID), + arrayOf("6200000000000005", false, CreditCardNumberValidator.Validity.FULLY_VALID), + arrayOf("42424242424242424242", false, CreditCardNumberValidator.Validity.INVALID), + arrayOf("X", false, CreditCardNumberValidator.Validity.INVALID), + arrayOf("42X", false, CreditCardNumberValidator.Validity.INVALID), + arrayOf("424242424242X", false, CreditCardNumberValidator.Validity.INVALID) + ) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardTypeTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardTypeTest.kt new file mode 100644 index 0000000000..e1ca410ee7 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardTypeTest.kt @@ -0,0 +1,44 @@ +package org.thoughtcrime.securesms.components.settings.app.subscription.donate.card + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +class CreditCardTypeTest( + private val creditCardNumber: String, + private val creditCardType: CreditCardType +) { + + @Test + fun fromCardNumber() { + assertEquals(creditCardType, CreditCardType.fromCardNumber(cardNumber = creditCardNumber)) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{index}: fromCardNumber(..) = {0}, {1}") + fun data(): Iterable> = arrayListOf( + arrayOf("34", CreditCardType.AMERICAN_EXPRESS), + arrayOf("37", CreditCardType.AMERICAN_EXPRESS), + arrayOf("343452000000306", CreditCardType.AMERICAN_EXPRESS), + arrayOf("371449635398431", CreditCardType.AMERICAN_EXPRESS), + arrayOf("378282246310005", CreditCardType.AMERICAN_EXPRESS), + arrayOf("62", CreditCardType.UNIONPAY), + arrayOf("81", CreditCardType.UNIONPAY), + arrayOf("6200000000000004", CreditCardType.UNIONPAY), + arrayOf("", CreditCardType.OTHER), + arrayOf("X", CreditCardType.OTHER), + arrayOf("4", CreditCardType.OTHER), + arrayOf("4111111111111111", CreditCardType.OTHER), + arrayOf("4242424242424242", CreditCardType.OTHER), + arrayOf("5555555555554444", CreditCardType.OTHER), + arrayOf("5555555555554444", CreditCardType.OTHER), + arrayOf("2223003122003222", CreditCardType.OTHER), + arrayOf("6011111111111117", CreditCardType.OTHER), + arrayOf("3056930009020004", CreditCardType.OTHER), + arrayOf("3566002020360505", CreditCardType.OTHER), + ) + } +}