mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-28 04:34:21 +01:00
Credit card validator implementations and spec tests.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
|
||||
117
app/src/main/res/layout/credit_card_fragment.xml
Normal file
117
app/src/main/res/layout/credit_card_fragment.xml
Normal file
@@ -0,0 +1,117 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<org.thoughtcrime.securesms.util.views.DarkOverflowToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/signal_m3_toolbar_height"
|
||||
android:background="@null"
|
||||
android:minHeight="@dimen/signal_m3_toolbar_height"
|
||||
android:theme="?attr/settingsToolbarStyle"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:navigationContentDescription="@string/DSLSettingsToolbar__navigate_up"
|
||||
app:navigationIcon="@drawable/ic_arrow_left_24"
|
||||
app:titleTextAppearance="@style/Signal.Text.TitleLarge" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:paddingHorizontal="32dp"
|
||||
android:textAppearance="@style/Signal.Text.TitleLarge"
|
||||
app:layout_constraintTop_toBottomOf="@id/toolbar"
|
||||
tools:text="Donation amount: $20" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/description"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center"
|
||||
android:paddingHorizontal="32dp"
|
||||
android:text="@string/CreditCardFragment__enter_your_card_information_below"
|
||||
android:textColor="@color/signal_colorOnSurfaceVariant"
|
||||
app:layout_constraintTop_toBottomOf="@id/title" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/card_number_wrapper"
|
||||
style="@style/Widget.Signal.TextInputLayout.FilledBox.ContactNameEditor"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="36dp"
|
||||
android:hint="@string/CreditCardFragment__card_number"
|
||||
app:boxStrokeColor="@color/signal_colorPrimary"
|
||||
app:errorEnabled="true"
|
||||
app:hintTextColor="@color/signal_colorPrimary"
|
||||
app:layout_constraintTop_toBottomOf="@id/description">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/card_number"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number"
|
||||
android:maxLength="19"
|
||||
android:maxLines="1" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/card_expiry_wrapper"
|
||||
style="@style/Widget.Signal.TextInputLayout.FilledBox.ContactNameEditor"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:hint="@string/CreditCardFragment__mm_yy"
|
||||
android:paddingEnd="18dp"
|
||||
app:boxStrokeColor="@color/signal_colorPrimary"
|
||||
app:errorEnabled="true"
|
||||
app:hintTextColor="@color/signal_colorPrimary"
|
||||
app:layout_constraintEnd_toStartOf="@id/card_cvv_wrapper"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/card_number_wrapper">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/card_expiry"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="datetime|date"
|
||||
android:maxLength="5"
|
||||
android:maxLines="1"
|
||||
android:nextFocusDown="@id/card_cvv" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/card_cvv_wrapper"
|
||||
style="@style/Widget.Signal.TextInputLayout.FilledBox.ContactNameEditor"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:hint="@string/CreditCardFragment__cvv"
|
||||
android:paddingStart="18dp"
|
||||
app:boxStrokeColor="@color/signal_colorPrimary"
|
||||
app:errorEnabled="true"
|
||||
app:hintTextColor="@color/signal_colorPrimary"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/card_expiry_wrapper"
|
||||
app:layout_constraintTop_toBottomOf="@id/card_number_wrapper">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/card_cvv"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:imeOptions="actionDone"
|
||||
android:inputType="number"
|
||||
android:maxLength="4"
|
||||
android:maxLines="1" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -36,6 +36,9 @@
|
||||
<action
|
||||
android:id="@+id/action_donateToSignalFragment_to_subscribeLearnMoreBottomSheetDialog"
|
||||
app:destination="@id/subscribeLearnMoreBottomSheetDialog" />
|
||||
<action
|
||||
android:id="@+id/action_donateToSignalFragment_to_creditCardFragment"
|
||||
app:destination="@id/creditCardFragment" />
|
||||
|
||||
</fragment>
|
||||
|
||||
@@ -107,4 +110,16 @@
|
||||
android:label="subscribe_learn_more_bottom_sheet_dialog"
|
||||
tools:layout="@layout/subscribe_learn_more_bottom_sheet_dialog_fragment" />
|
||||
|
||||
<fragment
|
||||
android:id="@+id/creditCardFragment"
|
||||
android:name="org.thoughtcrime.securesms.components.settings.app.subscription.donate.card.CreditCardFragment"
|
||||
android:label="credit_card_fragment"
|
||||
tools:layout="@layout/credit_card_fragment">
|
||||
|
||||
<argument
|
||||
android:name="request"
|
||||
app:argType="org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest"
|
||||
app:nullable="false" />
|
||||
</fragment>
|
||||
|
||||
</navigation>
|
||||
@@ -125,6 +125,33 @@
|
||||
<string name="BlockedUsersActivity__do_you_want_to_unblock_s">Do you want to unblock \"%1$s\"?</string>
|
||||
<string name="BlockedUsersActivity__unblock">Unblock</string>
|
||||
|
||||
<!-- CreditCardFragment -->
|
||||
<!-- Title of fragment detailing the donation amount, displayed above the credit card text fields -->
|
||||
<string name="CreditCardFragment__donation_amount_s">Donation amount: %1$s</string>
|
||||
<!-- Explanation of how to fill in the form, displayed above the credit card text fields -->
|
||||
<string name="CreditCardFragment__enter_your_card_information_below">Enter your card information below</string>
|
||||
<!-- Displayed as a hint in the card number text field -->
|
||||
<string name="CreditCardFragment__card_number">Card number</string>
|
||||
<!-- Displayed as a hint in the card expiry text field -->
|
||||
<string name="CreditCardFragment__mm_yy">MM/YY</string>
|
||||
<!-- Displayed as a hint in the card cvv text field -->
|
||||
<string name="CreditCardFragment__cvv">CVV</string>
|
||||
<!-- Error displayed under the card number text field when there is an invalid card number entered -->
|
||||
<string name="CreditCardFragment__invalid_card_number">Invalid card number</string>
|
||||
<!-- Error displayed under the card expiry text field when the card is expired -->
|
||||
<string name="CreditCardFragment__card_has_expired">Card has expired</string>
|
||||
<!-- Error displayed under the card cvv text field when the cvv is too short -->
|
||||
<string name="CreditCardFragment__code_is_too_short">Code is too short</string>
|
||||
<!-- Error displayed under the card cvv text field when the cvv is too long -->
|
||||
<string name="CreditCardFragment__code_is_too_long">Code is too long</string>
|
||||
<!-- Error displayed under the card cvv text field when the cvv is invalid -->
|
||||
<string name="CreditCardFragment__invalid_code">Invalid code</string>
|
||||
<!-- Error displayed under the card expiry text field when the expiry month is invalid -->
|
||||
<string name="CreditCardFragment__invalid_month">Invalid month</string>
|
||||
<!-- Error displayed under the card expiry text field when the expiry is missing the year -->
|
||||
<string name="CreditCardFragment__year_required">Year required</string>
|
||||
<!-- Error displayed under the card expiry text field when the expiry year is invalid -->
|
||||
<string name="CreditCardFragment__invalid_year">Invalid year</string>
|
||||
|
||||
<!-- BlockUnblockDialog -->
|
||||
<string name="BlockUnblockDialog_block_and_leave_s">Block and leave %1$s?</string>
|
||||
@@ -5502,6 +5529,8 @@
|
||||
<string name="GatewaySelectorBottomSheet__donate_s_to_signal">Donate %1$s to Signal</string>
|
||||
<!-- Sheet summary when giving a one-time donation -->
|
||||
<string name="GatewaySelectorBottomSheet__get_a_s_badge_for_d_days">Get a %1$s badge for %2$d days</string>
|
||||
<!-- Button label for paying with a credit card -->
|
||||
<string name="GatewaySelectorBottomSheet__credit_or_debit_card">Credit or debit card</string>
|
||||
|
||||
<!-- StripePaymentInProgressFragment -->
|
||||
<string name="StripePaymentInProgressFragment__cancelling">Cancelling…</string>
|
||||
|
||||
Reference in New Issue
Block a user