mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-25 05:27:42 +00:00
Implement pending one-time donation error handling.
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.internal
|
||||
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Rows
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.donations.StripeDeclineCode
|
||||
import org.signal.donations.StripeFailureCode
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.PayPalDeclineCode
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
/**
|
||||
* Allows configuration of a PendingOneTimeDonation object to display different
|
||||
* states in the donation settings screen.
|
||||
*/
|
||||
class InternalPendingOneTimeDonationConfigurationFragment : ComposeFragment() {
|
||||
|
||||
private val viewModel: InternalPendingOneTimeDonationConfigurationViewModel by viewModels()
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state
|
||||
Content(
|
||||
state,
|
||||
onNavigationClick = {
|
||||
findNavController().popBackStack()
|
||||
},
|
||||
onAddError = {
|
||||
viewModel.state.value = viewModel.state.value.copy(error = PendingOneTimeDonation.Error())
|
||||
},
|
||||
onClearError = {
|
||||
viewModel.state.value = viewModel.state.value.copy(error = null)
|
||||
},
|
||||
onPaymentMethodTypeSelected = {
|
||||
viewModel.state.value = viewModel.state.value.copy(paymentMethodType = it, error = null)
|
||||
},
|
||||
onErrorTypeSelected = {
|
||||
viewModel.state.value = viewModel.state.value.copy(error = viewModel.state.value.error!!.copy(type = it))
|
||||
},
|
||||
onErrorCodeChanged = {
|
||||
viewModel.state.value = viewModel.state.value.copy(error = viewModel.state.value.error!!.copy(code = it))
|
||||
},
|
||||
onSave = {
|
||||
SignalStore.donationsValues().setPendingOneTimeDonation(viewModel.state.value)
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ContentPreview() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
Content(
|
||||
state = PendingOneTimeDonation.Builder().error(PendingOneTimeDonation.Error()).build(),
|
||||
onNavigationClick = {},
|
||||
onClearError = {},
|
||||
onAddError = {},
|
||||
onPaymentMethodTypeSelected = {},
|
||||
onErrorTypeSelected = {},
|
||||
onErrorCodeChanged = {},
|
||||
onSave = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun Content(
|
||||
state: PendingOneTimeDonation,
|
||||
onNavigationClick: () -> Unit,
|
||||
onAddError: () -> Unit,
|
||||
onClearError: () -> Unit,
|
||||
onPaymentMethodTypeSelected: (PendingOneTimeDonation.PaymentMethodType) -> Unit,
|
||||
onErrorTypeSelected: (PendingOneTimeDonation.Error.Type) -> Unit,
|
||||
onErrorCodeChanged: (String) -> Unit,
|
||||
onSave: () -> Unit
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = "One-time donation state",
|
||||
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24),
|
||||
navigationContentDescription = null,
|
||||
onNavigationClick = onNavigationClick
|
||||
) {
|
||||
val isCodedError = remember(state.error?.type) {
|
||||
state.error?.type in setOf(PendingOneTimeDonation.Error.Type.PROCESSOR_CODE, PendingOneTimeDonation.Error.Type.DECLINE_CODE, PendingOneTimeDonation.Error.Type.FAILURE_CODE)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
horizontalAlignment = CenterHorizontally,
|
||||
modifier = Modifier.padding(it)
|
||||
) {
|
||||
item {
|
||||
var expanded by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = {
|
||||
expanded = !expanded
|
||||
}
|
||||
) {
|
||||
TextField(
|
||||
value = state.paymentMethodType.name,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier.menuAnchor()
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
PendingOneTimeDonation.PaymentMethodType.values().forEach { item ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = item.name) },
|
||||
onClick = {
|
||||
onPaymentMethodTypeSelected(item)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.ToggleRow(
|
||||
checked = state.error != null,
|
||||
text = "Enable error",
|
||||
onCheckChanged = {
|
||||
if (it) {
|
||||
onAddError()
|
||||
} else {
|
||||
onClearError()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (state.error != null) {
|
||||
item {
|
||||
var expanded by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = {
|
||||
expanded = !expanded
|
||||
}
|
||||
) {
|
||||
TextField(
|
||||
value = state.error.type.name,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier.menuAnchor()
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
PendingOneTimeDonation.Error.Type.values().filterNot {
|
||||
state.paymentMethodType == PendingOneTimeDonation.PaymentMethodType.PAYPAL && it == PendingOneTimeDonation.Error.Type.FAILURE_CODE
|
||||
}.forEach { item ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = item.name) },
|
||||
onClick = {
|
||||
onErrorTypeSelected(item)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isCodedError) {
|
||||
item {
|
||||
var expanded by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = {
|
||||
expanded = !expanded
|
||||
}
|
||||
) {
|
||||
TextField(
|
||||
value = state.error.code,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier.menuAnchor()
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
when (state.error.type) {
|
||||
PendingOneTimeDonation.Error.Type.PROCESSOR_CODE -> {
|
||||
ProcessorErrorsDropdown(state.paymentMethodType, onErrorCodeChanged)
|
||||
}
|
||||
|
||||
PendingOneTimeDonation.Error.Type.DECLINE_CODE -> {
|
||||
DeclineCodeErrorsDropdown(state.paymentMethodType, onErrorCodeChanged)
|
||||
}
|
||||
|
||||
PendingOneTimeDonation.Error.Type.FAILURE_CODE -> {
|
||||
FailureCodeErrorsDropdown(onErrorCodeChanged)
|
||||
}
|
||||
|
||||
else -> error("This should never happen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Buttons.LargeTonal(
|
||||
enabled = state.badge != null,
|
||||
onClick = onSave
|
||||
) {
|
||||
Text(text = "Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.ProcessorErrorsDropdown(
|
||||
paymentMethodType: PendingOneTimeDonation.PaymentMethodType,
|
||||
onErrorCodeSelected: (String) -> Unit
|
||||
) {
|
||||
val values = when (paymentMethodType) {
|
||||
PendingOneTimeDonation.PaymentMethodType.PAYPAL -> arrayOf("2046", "2074")
|
||||
else -> arrayOf("currency_not_supported", "call_issuer")
|
||||
}
|
||||
|
||||
ValuesDropdown(values = values, onErrorCodeSelected = onErrorCodeSelected)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.DeclineCodeErrorsDropdown(
|
||||
paymentMethodType: PendingOneTimeDonation.PaymentMethodType,
|
||||
onErrorCodeSelected: (String) -> Unit
|
||||
) {
|
||||
val values = remember(paymentMethodType) {
|
||||
when (paymentMethodType) {
|
||||
PendingOneTimeDonation.PaymentMethodType.PAYPAL -> PayPalDeclineCode.KnownCode.values()
|
||||
else -> StripeDeclineCode.Code.values()
|
||||
}.map { it.name }.toTypedArray()
|
||||
}
|
||||
|
||||
ValuesDropdown(values = values, onErrorCodeSelected = onErrorCodeSelected)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.FailureCodeErrorsDropdown(
|
||||
onErrorCodeSelected: (String) -> Unit
|
||||
) {
|
||||
val values = remember {
|
||||
StripeFailureCode.Code.values().map { it.name }.toTypedArray()
|
||||
}
|
||||
|
||||
ValuesDropdown(values = values, onErrorCodeSelected = onErrorCodeSelected)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ValuesDropdown(values: Array<String>, onErrorCodeSelected: (String) -> Unit) {
|
||||
values.forEach { item ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = item) },
|
||||
onClick = {
|
||||
onErrorCodeSelected(item)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.internal
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatValue
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Fetches a badge for our pending donation, which requires downloading the donation config.
|
||||
*/
|
||||
class InternalPendingOneTimeDonationConfigurationViewModel : ViewModel() {
|
||||
|
||||
val state: MutableState<PendingOneTimeDonation> = mutableStateOf(
|
||||
PendingOneTimeDonation(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
amount = FiatMoney(BigDecimal.valueOf(20), Currency.getInstance("EUR")).toFiatValue()
|
||||
)
|
||||
)
|
||||
|
||||
val disposable: Disposable = Single
|
||||
.fromCallable {
|
||||
ApplicationDependencies.getDonationsService()
|
||||
.getDonationsConfiguration(Locale.getDefault())
|
||||
}
|
||||
.flatMap { it.flattenResult() }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { config ->
|
||||
val badge = Badges.fromServiceBadge(config.levels.values.first().badge)
|
||||
state.value = state.value.copy(badge = Badges.toDatabaseBadge(badge))
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
@@ -475,6 +475,22 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
)
|
||||
}
|
||||
|
||||
if (state.hasPendingOneTimeDonation) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Clear pending one-time donation."),
|
||||
onClick = {
|
||||
SignalStore.donationsValues().setPendingOneTimeDonation(null)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Set pending one-time donation."),
|
||||
onClick = {
|
||||
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToOneTimeDonationConfigurationFragment())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(DSLSettingsText.from("Release channel"))
|
||||
|
||||
@@ -22,5 +22,6 @@ data class InternalSettingsState(
|
||||
val disableStorageService: Boolean,
|
||||
val canClearOnboardingState: Boolean,
|
||||
val pnpInitialized: Boolean,
|
||||
val useConversationItemV2ForMedia: Boolean
|
||||
val useConversationItemV2ForMedia: Boolean,
|
||||
val hasPendingOneTimeDonation: Boolean
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components.settings.app.internal
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import org.signal.ringrtc.CallManager
|
||||
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob
|
||||
import org.thoughtcrime.securesms.keyvalue.InternalValues
|
||||
@@ -20,6 +21,14 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
repository.getEmojiVersionInfo { version ->
|
||||
store.update { it.copy(emojiVersion = version) }
|
||||
}
|
||||
|
||||
val pendingOneTimeDonation: Observable<Boolean> = SignalStore.donationsValues().observablePendingOneTimeDonation
|
||||
.distinctUntilChanged()
|
||||
.map { it.isPresent }
|
||||
|
||||
store.update(pendingOneTimeDonation) { pending, state ->
|
||||
state.copy(hasPendingOneTimeDonation = pending)
|
||||
}
|
||||
}
|
||||
|
||||
val state: LiveData<InternalSettingsState> = store.stateLiveData
|
||||
@@ -136,7 +145,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
disableStorageService = SignalStore.internalValues().storageServiceDisabled(),
|
||||
canClearOnboardingState = SignalStore.storyValues().hasDownloadedOnboardingStory && Stories.isFeatureEnabled(),
|
||||
pnpInitialized = SignalStore.misc().hasPniInitializedDevices(),
|
||||
useConversationItemV2ForMedia = SignalStore.internalValues().useConversationItemV2Media()
|
||||
useConversationItemV2ForMedia = SignalStore.internalValues().useConversationItemV2Media(),
|
||||
hasPendingOneTimeDonation = SignalStore.donationsValues().getPendingOneTimeDonation() != null
|
||||
)
|
||||
|
||||
fun onClearOnboardingState() {
|
||||
|
||||
@@ -68,7 +68,7 @@ object DonationSerializationHelper {
|
||||
)
|
||||
}
|
||||
|
||||
private fun FiatMoney.toFiatValue(): FiatValue {
|
||||
fun FiatMoney.toFiatValue(): FiatValue {
|
||||
return FiatValue(
|
||||
currencyCode = currency.currencyCode,
|
||||
amount = amount.toDecimalValue()
|
||||
|
||||
@@ -233,6 +233,7 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
|
||||
SignalStore.donationsValues().requireSubscriber()
|
||||
}.flatMap {
|
||||
Log.d(TAG, "Setting default payment method via Signal service...")
|
||||
// TODO [sepa] -- iDEAL has its own call
|
||||
Single.fromCallable {
|
||||
ApplicationDependencies
|
||||
.getDonationsService()
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.signal.core.util.StringUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.core.util.money.PlatformCurrencyUtil
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.isExpired
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeDonationRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
|
||||
@@ -218,7 +219,7 @@ class DonateToSignalViewModel(
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val isOneTimeDonationPending: Observable<Boolean> = SignalStore.donationsValues().observablePendingOneTimeDonation
|
||||
.map { it.isPresent }
|
||||
.map { pending -> pending.filter { !it.isExpired }.isPresent }
|
||||
.distinctUntilChanged()
|
||||
|
||||
oneTimeDonationDisposables += Observable
|
||||
|
||||
@@ -11,7 +11,7 @@ fun StripeFailureCode.mapToErrorStringResource(): Int {
|
||||
is StripeFailureCode.Known -> when (this.code) {
|
||||
StripeFailureCode.Code.REFER_TO_CUSTOMER -> R.string.StripeFailureCode__verify_your_bank_details_are_correct
|
||||
StripeFailureCode.Code.INSUFFICIENT_FUNDS -> R.string.StripeFailureCode__the_bank_account_provided
|
||||
StripeFailureCode.Code.DEBIT_DISPUTED -> R.string.StripeFailureCode__verify_your_bank_details_are_correct // TODO [sepa] -- verify
|
||||
StripeFailureCode.Code.DEBIT_DISPUTED -> R.string.StripeFailureCode__verify_your_bank_details_are_correct
|
||||
StripeFailureCode.Code.AUTHORIZATION_REVOKED -> R.string.StripeFailureCode__this_payment_was_revoked
|
||||
StripeFailureCode.Code.DEBIT_NOT_AUTHORIZED -> R.string.StripeFailureCode__this_payment_was_revoked
|
||||
StripeFailureCode.Code.ACCOUNT_CLOSED -> R.string.StripeFailureCode__the_bank_details_provided_could_not_be_processed
|
||||
|
||||
@@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.Do
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.help.HelpFragment
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -183,6 +184,9 @@ class ManageDonationsFragment :
|
||||
pendingOneTimeDonation = pendingOneTimeDonation,
|
||||
onPendingClick = {
|
||||
displayPendingDialog(it)
|
||||
},
|
||||
onErrorClick = {
|
||||
displayPendingOneTimeDonationErrorDialog(it)
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -340,6 +344,16 @@ class ManageDonationsFragment :
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun displayPendingOneTimeDonationErrorDialog(error: PendingOneTimeDonation.Error) {
|
||||
// TODO [sepa] -- actual dialog text?
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonationsErrors__error_processing_payment)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
SignalStore.donationsValues().setPendingOneTimeDonation(null)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun onMakeAMonthlyDonation() {
|
||||
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonateToSignalFragment(DonateToSignalType.MONTHLY))
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ object OneTimeDonationPreference {
|
||||
|
||||
class Model(
|
||||
val pendingOneTimeDonation: PendingOneTimeDonation,
|
||||
val onPendingClick: (FiatMoney) -> Unit
|
||||
val onPendingClick: (FiatMoney) -> Unit,
|
||||
val onErrorClick: (PendingOneTimeDonation.Error) -> Unit
|
||||
) : MappingModel<Model> {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean = true
|
||||
|
||||
@@ -55,15 +56,38 @@ object OneTimeDonationPreference {
|
||||
FiatMoneyUtil.format(context.resources, model.pendingOneTimeDonation.amount!!.toFiatMoney(), FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
|
||||
)
|
||||
|
||||
if (model.pendingOneTimeDonation.error != null) {
|
||||
presentErrorState(model, model.pendingOneTimeDonation.error)
|
||||
} else {
|
||||
presentPendingState(model)
|
||||
}
|
||||
}
|
||||
|
||||
private fun presentErrorState(model: Model, error: PendingOneTimeDonation.Error) {
|
||||
expiry.text = getErrorSubtitle(error)
|
||||
|
||||
itemView.setOnClickListener { model.onErrorClick(error) }
|
||||
|
||||
progress.visible = false
|
||||
}
|
||||
|
||||
private fun presentPendingState(model: Model) {
|
||||
expiry.text = getPendingSubtitle(model.pendingOneTimeDonation.paymentMethodType)
|
||||
|
||||
if (model.pendingOneTimeDonation.paymentMethodType == PendingOneTimeDonation.PaymentMethodType.SEPA_DEBIT) {
|
||||
itemView.setOnClickListener { model.onPendingClick(model.pendingOneTimeDonation.amount.toFiatMoney()) }
|
||||
itemView.setOnClickListener { model.onPendingClick(model.pendingOneTimeDonation.amount!!.toFiatMoney()) }
|
||||
}
|
||||
|
||||
progress.visible = model.pendingOneTimeDonation.paymentMethodType != PendingOneTimeDonation.PaymentMethodType.SEPA_DEBIT
|
||||
}
|
||||
|
||||
private fun getErrorSubtitle(error: PendingOneTimeDonation.Error): String {
|
||||
return when (error.type) {
|
||||
PendingOneTimeDonation.Error.Type.REDEMPTION -> context.getString(R.string.DonationsErrors__couldnt_add_badge)
|
||||
else -> context.getString(R.string.DonationsErrors__donation_failed)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPendingSubtitle(paymentMethodType: PendingOneTimeDonation.PaymentMethodType): String {
|
||||
return when (paymentMethodType) {
|
||||
PendingOneTimeDonation.PaymentMethodType.CARD -> context.getString(R.string.OneTimeDonationPreference__donation_processing)
|
||||
|
||||
@@ -7,6 +7,8 @@ import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.donations.StripeDeclineCode;
|
||||
import org.signal.donations.StripeFailureCode;
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException;
|
||||
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations;
|
||||
@@ -18,6 +20,7 @@ import org.signal.libsignal.zkgroup.receipts.ReceiptSerial;
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError;
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationCompletedQueue;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.jobmanager.JobManager;
|
||||
@@ -25,9 +28,11 @@ import org.thoughtcrime.securesms.jobmanager.JsonJobData;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription;
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels;
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse;
|
||||
import org.whispersystems.signalservice.internal.push.DonationProcessor;
|
||||
import org.whispersystems.signalservice.internal.push.exceptions.DonationReceiptCredentialError;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.SecureRandom;
|
||||
@@ -208,6 +213,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
||||
|
||||
if (!isCredentialValid(receiptCredential)) {
|
||||
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.badgeCredentialVerificationFailure(donationErrorSource));
|
||||
setPendingOneTimeDonationGenericRedemptionError(-1);
|
||||
throw new IOException("Could not validate receipt credential");
|
||||
}
|
||||
|
||||
@@ -232,6 +238,54 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
||||
SignalStore.donationsValues().setPendingOneTimeDonation(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the pending one-time donation error according to the status code.
|
||||
*/
|
||||
private void setPendingOneTimeDonationGenericRedemptionError(int statusCode) {
|
||||
SignalStore.donationsValues().setPendingOneTimeDonationError(
|
||||
new PendingOneTimeDonation.Error.Builder()
|
||||
.type(statusCode == 402
|
||||
? PendingOneTimeDonation.Error.Type.PAYMENT
|
||||
: PendingOneTimeDonation.Error.Type.REDEMPTION)
|
||||
.code(Integer.toString(statusCode))
|
||||
.build()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the pending one-time donation error according to the given charge failure.
|
||||
*/
|
||||
private void setPendingOneTimeDonationChargeFailureError(@NonNull ActiveSubscription.ChargeFailure chargeFailure) {
|
||||
final PendingOneTimeDonation.Error.Type type;
|
||||
final String code;
|
||||
|
||||
if (donationProcessor == DonationProcessor.PAYPAL) {
|
||||
code = chargeFailure.getCode();
|
||||
type = PendingOneTimeDonation.Error.Type.PROCESSOR_CODE;
|
||||
} else {
|
||||
StripeDeclineCode declineCode = StripeDeclineCode.Companion.getFromCode(chargeFailure.getOutcomeNetworkReason());
|
||||
StripeFailureCode failureCode = StripeFailureCode.Companion.getFromCode(chargeFailure.getCode());
|
||||
|
||||
if (failureCode.isKnown()) {
|
||||
code = failureCode.toString();
|
||||
type = PendingOneTimeDonation.Error.Type.FAILURE_CODE;
|
||||
} else if (declineCode.isKnown()) {
|
||||
code = declineCode.toString();
|
||||
type = PendingOneTimeDonation.Error.Type.DECLINE_CODE;
|
||||
} else {
|
||||
code = chargeFailure.getCode();
|
||||
type = PendingOneTimeDonation.Error.Type.PROCESSOR_CODE;
|
||||
}
|
||||
}
|
||||
|
||||
SignalStore.donationsValues().setPendingOneTimeDonationError(
|
||||
new PendingOneTimeDonation.Error.Builder()
|
||||
.type(type)
|
||||
.code(code)
|
||||
.build()
|
||||
);
|
||||
}
|
||||
|
||||
private void handleApplicationError(Context context, ServiceResponse<ReceiptCredentialResponse> response, @NonNull DonationErrorSource donationErrorSource) throws Exception {
|
||||
Throwable applicationException = response.getApplicationError().get();
|
||||
switch (response.getStatus()) {
|
||||
@@ -241,14 +295,23 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
||||
case 400:
|
||||
Log.w(TAG, "Receipt credential request failed to validate.", applicationException, true);
|
||||
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(donationErrorSource));
|
||||
setPendingOneTimeDonationGenericRedemptionError(response.getStatus());
|
||||
throw new Exception(applicationException);
|
||||
case 402:
|
||||
Log.w(TAG, "User payment failed.", applicationException, true);
|
||||
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericPaymentFailure(donationErrorSource));
|
||||
|
||||
if (applicationException instanceof DonationReceiptCredentialError) {
|
||||
setPendingOneTimeDonationChargeFailureError(((DonationReceiptCredentialError) applicationException).getChargeFailure());
|
||||
} else {
|
||||
setPendingOneTimeDonationGenericRedemptionError(response.getStatus());
|
||||
}
|
||||
|
||||
throw new Exception(applicationException);
|
||||
case 409:
|
||||
Log.w(TAG, "Receipt already redeemed with a different request credential.", response.getApplicationError().get(), true);
|
||||
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(donationErrorSource));
|
||||
setPendingOneTimeDonationGenericRedemptionError(response.getStatus());
|
||||
throw new Exception(applicationException);
|
||||
default:
|
||||
Log.w(TAG, "Encountered a server failure: " + response.getStatus(), applicationException, true);
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationCompletedQueue;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.jobmanager.JobManager;
|
||||
@@ -127,9 +128,9 @@ public class DonationReceiptRedemptionJob extends BaseJob {
|
||||
|
||||
private DonationReceiptRedemptionJob(long giftMessageId, boolean primary, @NonNull DonationErrorSource errorSource, long uiSessionKey, boolean isLongRunning, @NonNull Job.Parameters parameters) {
|
||||
super(parameters);
|
||||
this.giftMessageId = giftMessageId;
|
||||
this.makePrimary = primary;
|
||||
this.errorSource = errorSource;
|
||||
this.giftMessageId = giftMessageId;
|
||||
this.makePrimary = primary;
|
||||
this.errorSource = errorSource;
|
||||
this.uiSessionKey = uiSessionKey;
|
||||
this.isLongRunningDonationPaymentType = isLongRunning;
|
||||
}
|
||||
@@ -158,8 +159,6 @@ public class DonationReceiptRedemptionJob extends BaseJob {
|
||||
MultiDeviceSubscriptionSyncRequestJob.enqueue();
|
||||
} else if (giftMessageId != NO_ID) {
|
||||
SignalDatabase.messages().markGiftRedemptionFailed(giftMessageId);
|
||||
} else {
|
||||
SignalStore.donationsValues().setPendingOneTimeDonation(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,6 +206,16 @@ public class DonationReceiptRedemptionJob extends BaseJob {
|
||||
} else {
|
||||
Log.w(TAG, "Encountered a non-recoverable exception " + response.getStatus(), response.getApplicationError().get(), true);
|
||||
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(errorSource));
|
||||
|
||||
if (isForOneTimeDonation()) {
|
||||
SignalStore.donationsValues().setPendingOneTimeDonationError(
|
||||
new PendingOneTimeDonation.Error.Builder()
|
||||
.type(PendingOneTimeDonation.Error.Type.REDEMPTION)
|
||||
.code(Integer.toString(response.getStatus()))
|
||||
.build()
|
||||
);
|
||||
}
|
||||
|
||||
throw new IOException(response.getApplicationError().get());
|
||||
}
|
||||
} else if (response.getExecutionError().isPresent()) {
|
||||
|
||||
@@ -160,9 +160,13 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
|
||||
|
||||
private var _pendingOneTimeDonation: PendingOneTimeDonation? by protoValue(PENDING_ONE_TIME_DONATION, PendingOneTimeDonation.ADAPTER)
|
||||
private val pendingOneTimeDonationPublisher: Subject<Optional<PendingOneTimeDonation>> by lazy { BehaviorSubject.createDefault(Optional.ofNullable(_pendingOneTimeDonation)) }
|
||||
|
||||
/**
|
||||
* Returns a stream of PendingOneTimeDonation, filtering out expired donations that do not have an error attached to them.
|
||||
*/
|
||||
val observablePendingOneTimeDonation: Observable<Optional<PendingOneTimeDonation>> by lazy {
|
||||
pendingOneTimeDonationPublisher.map { optionalPendingOneTimeDonation ->
|
||||
optionalPendingOneTimeDonation.filter { !it.isExpired }
|
||||
optionalPendingOneTimeDonation.filter { (it.error != null) || !it.isExpired }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,11 +538,28 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
|
||||
}
|
||||
}
|
||||
|
||||
fun getPendingOneTimeDonation(): PendingOneTimeDonation? = _pendingOneTimeDonation.takeUnless { it?.isExpired == true }
|
||||
fun getPendingOneTimeDonation(): PendingOneTimeDonation? {
|
||||
return synchronized(this) {
|
||||
_pendingOneTimeDonation.takeUnless { it?.isExpired == true }
|
||||
}
|
||||
}
|
||||
|
||||
fun setPendingOneTimeDonation(pendingOneTimeDonation: PendingOneTimeDonation?) {
|
||||
this._pendingOneTimeDonation = pendingOneTimeDonation
|
||||
pendingOneTimeDonationPublisher.onNext(Optional.ofNullable(pendingOneTimeDonation))
|
||||
synchronized(this) {
|
||||
this._pendingOneTimeDonation = pendingOneTimeDonation
|
||||
pendingOneTimeDonationPublisher.onNext(Optional.ofNullable(pendingOneTimeDonation))
|
||||
}
|
||||
}
|
||||
|
||||
fun setPendingOneTimeDonationError(error: PendingOneTimeDonation.Error) {
|
||||
synchronized(this) {
|
||||
val pendingOneTimeDonation = getPendingOneTimeDonation()
|
||||
if (pendingOneTimeDonation != null) {
|
||||
setPendingOneTimeDonation(pendingOneTimeDonation.newBuilder().error(error).build())
|
||||
} else {
|
||||
Log.w(TAG, "PendingOneTimeDonation was null, ignoring error.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun consumePending3DSData(uiSessionKey: Long): Stripe3DSData? {
|
||||
|
||||
@@ -361,7 +361,7 @@ public final class FeatureFlags {
|
||||
|
||||
/** Internal testing extensions. */
|
||||
public static boolean internalUser() {
|
||||
return getBoolean(INTERNAL_USER, false) || Environment.IS_PNP;
|
||||
return getBoolean(INTERNAL_USER, false) || Environment.IS_PNP || Environment.IS_STAGING;
|
||||
}
|
||||
|
||||
/** Whether or not to use the UUID in verification codes. */
|
||||
|
||||
@@ -286,6 +286,19 @@ message FiatValue {
|
||||
}
|
||||
|
||||
message PendingOneTimeDonation {
|
||||
message Error {
|
||||
enum Type {
|
||||
PROCESSOR_CODE = 0; // Generic processor error (e.g. Stripe returned an error code)
|
||||
DECLINE_CODE = 1; // Stripe or PayPal decline Code
|
||||
FAILURE_CODE = 2; // Stripe bank transfer failure code
|
||||
REDEMPTION = 3; // Generic redemption error (status is HTTP code)
|
||||
PAYMENT = 4; // Generic payment error (status is HTTP code)
|
||||
}
|
||||
|
||||
Type type = 1;
|
||||
string code = 2;
|
||||
}
|
||||
|
||||
enum PaymentMethodType {
|
||||
CARD = 0;
|
||||
SEPA_DEBIT = 1;
|
||||
@@ -293,10 +306,11 @@ message PendingOneTimeDonation {
|
||||
IDEAL = 3;
|
||||
}
|
||||
|
||||
PaymentMethodType paymentMethodType = 1;
|
||||
FiatValue amount = 2;
|
||||
BadgeList.Badge badge = 3;
|
||||
int64 timestamp = 4;
|
||||
PaymentMethodType paymentMethodType = 1;
|
||||
FiatValue amount = 2;
|
||||
BadgeList.Badge badge = 3;
|
||||
int64 timestamp = 4;
|
||||
optional Error error = 5;
|
||||
}
|
||||
|
||||
message DonationCompletedQueue {
|
||||
|
||||
@@ -589,8 +589,16 @@
|
||||
<action
|
||||
android:id="@+id/action_internalSettingsFragment_to_internalConversationSpringboardFragment"
|
||||
app:destination="@id/internalConversationSpringboardFragment" />
|
||||
<action
|
||||
android:id="@+id/action_internalSettingsFragment_to_oneTimeDonationConfigurationFragment"
|
||||
app:destination="@id/oneTimeDonationConfigurationFragment" />
|
||||
</fragment>
|
||||
|
||||
<fragment
|
||||
android:id="@+id/oneTimeDonationConfigurationFragment"
|
||||
android:name="org.thoughtcrime.securesms.components.settings.app.internal.InternalPendingOneTimeDonationConfigurationFragment"
|
||||
android:label="one_time_donation_configuration_fragment" />
|
||||
|
||||
<fragment
|
||||
android:id="@+id/donorErrorConfigurationFragment"
|
||||
android:name="org.thoughtcrime.securesms.components.settings.app.internal.donor.InternalDonorErrorConfigurationFragment"
|
||||
|
||||
@@ -113,6 +113,7 @@ import org.whispersystems.signalservice.internal.contacts.entities.KeyBackupResp
|
||||
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse;
|
||||
import org.whispersystems.signalservice.internal.crypto.AttachmentDigest;
|
||||
import org.whispersystems.signalservice.internal.push.exceptions.DonationProcessorError;
|
||||
import org.whispersystems.signalservice.internal.push.exceptions.DonationReceiptCredentialError;
|
||||
import org.whispersystems.signalservice.internal.push.exceptions.ForbiddenException;
|
||||
import org.whispersystems.signalservice.internal.push.exceptions.GroupExistsException;
|
||||
import org.whispersystems.signalservice.internal.push.exceptions.GroupMismatchedDevicesException;
|
||||
@@ -1174,6 +1175,16 @@ public class PushServiceSocket {
|
||||
NO_HEADERS,
|
||||
(code, body) -> {
|
||||
if (code == 204) throw new NonSuccessfulResponseCodeException(204);
|
||||
if (code == 402) {
|
||||
DonationReceiptCredentialError donationReceiptCredentialError;
|
||||
try {
|
||||
donationReceiptCredentialError = JsonUtil.fromJson(body.string(), DonationReceiptCredentialError.class);
|
||||
} catch (IOException e) {
|
||||
throw new NonSuccessfulResponseCodeException(402);
|
||||
}
|
||||
|
||||
throw donationReceiptCredentialError;
|
||||
}
|
||||
});
|
||||
|
||||
ReceiptCredentialResponseJson responseJson = JsonUtil.fromJson(response, ReceiptCredentialResponseJson.class);
|
||||
@@ -2668,11 +2679,14 @@ public class PushServiceSocket {
|
||||
}
|
||||
|
||||
if (responseCode == 440) {
|
||||
DonationProcessorError exception;
|
||||
try {
|
||||
throw JsonUtil.fromJson(body.string(), DonationProcessorError.class);
|
||||
exception = JsonUtil.fromJson(body.string(), DonationProcessorError.class);
|
||||
} catch (IOException e) {
|
||||
throw new NonSuccessfulResponseCodeException(440);
|
||||
}
|
||||
|
||||
throw exception;
|
||||
} else {
|
||||
throw new NonSuccessfulResponseCodeException(responseCode);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.internal.push.exceptions
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription.ChargeFailure
|
||||
|
||||
/**
|
||||
* HTTP 402 Exception when trying to submit credentials for a donation with
|
||||
* a failed payment.
|
||||
*/
|
||||
class DonationReceiptCredentialError @JsonCreator constructor(
|
||||
val chargeFailure: ChargeFailure
|
||||
) : NonSuccessfulResponseCodeException(402) {
|
||||
override fun toString(): String {
|
||||
return """
|
||||
DonationReceiptCredentialError (402)
|
||||
Charge Failure: $chargeFailure
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user