Donation error sheet wiring and UI.

This commit is contained in:
Alex Hart 2023-10-25 10:48:38 -04:00 committed by Cody Henthorne
parent e12d467627
commit 079400f89e
33 changed files with 1015 additions and 369 deletions

View file

@ -24,7 +24,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.DonationCompletedDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.TerminalDonationDelegate
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.events.ReminderUpdateEvent
import org.thoughtcrime.securesms.keyvalue.SignalStore
@ -51,7 +51,7 @@ class AppSettingsFragment : DSLSettingsFragment(
private lateinit var reminderView: Stub<ReminderView>
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycle.addObserver(DonationCompletedDelegate(childFragmentManager, viewLifecycleOwner))
viewLifecycleOwner.lifecycle.addObserver(TerminalDonationDelegate(childFragmentManager, viewLifecycleOwner))
super.onViewCreated(view, savedInstanceState)
reminderView = ViewUtil.findStubById(view, R.id.reminder_stub)

View file

@ -5,7 +5,6 @@
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
@ -30,11 +29,11 @@ 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.components.settings.app.internal.donor.DonationErrorValueCodeSelector
import org.thoughtcrime.securesms.components.settings.app.internal.donor.DonationErrorValueTypeSelector
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
import org.thoughtcrime.securesms.keyvalue.SignalStore
@ -55,7 +54,7 @@ class InternalPendingOneTimeDonationConfigurationFragment : ComposeFragment() {
findNavController().popBackStack()
},
onAddError = {
viewModel.state.value = viewModel.state.value.copy(error = PendingOneTimeDonation.Error())
viewModel.state.value = viewModel.state.value.copy(error = DonationErrorValue())
},
onClearError = {
viewModel.state.value = viewModel.state.value.copy(error = null)
@ -83,7 +82,7 @@ private fun ContentPreview() {
SignalTheme {
Surface {
Content(
state = PendingOneTimeDonation.Builder().error(PendingOneTimeDonation.Error()).build(),
state = PendingOneTimeDonation.Builder().error(DonationErrorValue()).build(),
onNavigationClick = {},
onClearError = {},
onAddError = {},
@ -104,7 +103,7 @@ private fun Content(
onAddError: () -> Unit,
onClearError: () -> Unit,
onPaymentMethodTypeSelected: (PendingOneTimeDonation.PaymentMethodType) -> Unit,
onErrorTypeSelected: (PendingOneTimeDonation.Error.Type) -> Unit,
onErrorTypeSelected: (DonationErrorValue.Type) -> Unit,
onErrorCodeChanged: (String) -> Unit,
onSave: () -> Unit
) {
@ -114,10 +113,6 @@ private fun Content(
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)
@ -174,85 +169,20 @@ private fun Content(
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
}
)
}
}
}
DonationErrorValueTypeSelector(
selectedPaymentMethodType = state.paymentMethodType,
selectedErrorType = state.error.type,
onErrorTypeSelected = onErrorTypeSelected
)
}
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 {
DonationErrorValueCodeSelector(
selectedPaymentMethodType = state.paymentMethodType,
selectedErrorType = state.error.type,
selectedErrorCode = state.error.code,
onErrorCodeSelected = onErrorCodeChanged
)
}
}
@ -267,54 +197,3 @@ private fun Content(
}
}
}
@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)
}
)
}
}

View file

@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.database.LogDatabase
import org.thoughtcrime.securesms.database.MegaphoneDatabase
import org.thoughtcrime.securesms.database.OneTimePreKeyTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob
@ -491,6 +492,13 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
)
}
clickPref(
title = DSLSettingsText.from("Enqueue terminal donation"),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToTerminalDonationConfigurationFragment())
}
)
dividerPref()
sectionHeaderPref(DSLSettingsText.from("Release channel"))
@ -757,7 +765,12 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
private fun enqueueSubscriptionRedemption() {
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(-1L, false).enqueue()
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(
-1L,
TerminalDonationQueue.TerminalDonation(
level = 1000
)
).enqueue()
}
private fun enqueueSubscriptionKeepAlive() {

View file

@ -0,0 +1,148 @@
/*
* 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.defaultMinSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.fragment.findNavController
import org.signal.core.ui.Buttons
import org.signal.core.ui.Rows
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.components.settings.app.internal.donor.DonationErrorValueCodeSelector
import org.thoughtcrime.securesms.components.settings.app.internal.donor.DonationErrorValueTypeSelector
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* Configuration fragment for [TerminalDonationQueue.TerminalDonation]
*/
class InternalTerminalDonationConfigurationFragment : ComposeFragment() {
@Composable
override fun FragmentContent() {
InternalTerminalDonationConfigurationContent(
onAddClick = {
SignalStore.donationsValues().appendToTerminalDonationQueue(it)
findNavController().popBackStack()
}
)
}
}
@Preview
@Composable
private fun InternalTerminalDonationConfigurationContentPreview() {
SignalTheme {
Surface {
InternalTerminalDonationConfigurationContent(
onAddClick = {}
)
}
}
}
@Composable
private fun InternalTerminalDonationConfigurationContent(
onAddClick: (TerminalDonationQueue.TerminalDonation) -> Unit
) {
val terminalDonationState: MutableState<TerminalDonationQueue.TerminalDonation> = remember {
mutableStateOf(
TerminalDonationQueue.TerminalDonation(
level = 1000L,
isLongRunningPaymentMethod = true
)
)
}
val paymentMethodType = remember(terminalDonationState.value.isLongRunningPaymentMethod) {
if (terminalDonationState.value.isLongRunningPaymentMethod) PendingOneTimeDonation.PaymentMethodType.SEPA_DEBIT else PendingOneTimeDonation.PaymentMethodType.CARD
}
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
Rows.ToggleRow(
checked = terminalDonationState.value.isLongRunningPaymentMethod,
text = "Long-running payment method",
onCheckChanged = {
terminalDonationState.value = terminalDonationState.value.copy(isLongRunningPaymentMethod = it)
}
)
}
item {
Rows.ToggleRow(
checked = terminalDonationState.value.error != null,
text = "Enable error",
onCheckChanged = {
val error = if (it) {
DonationErrorValue()
} else {
null
}
terminalDonationState.value = terminalDonationState.value.copy(error = error)
}
)
}
val error = terminalDonationState.value.error
if (error != null) {
item {
DonationErrorValueTypeSelector(
selectedPaymentMethodType = paymentMethodType,
selectedErrorType = error.type,
onErrorTypeSelected = {
terminalDonationState.value = terminalDonationState.value.copy(
error = error.copy(
type = it,
code = ""
)
)
}
)
}
item {
DonationErrorValueCodeSelector(
selectedPaymentMethodType = paymentMethodType,
selectedErrorType = error.type,
selectedErrorCode = error.code,
onErrorCodeSelected = {
terminalDonationState.value = terminalDonationState.value.copy(
error = error.copy(
code = it
)
)
}
)
}
}
item {
Buttons.LargeTonal(
onClick = { onAddClick(terminalDonationState.value) },
modifier = Modifier.defaultMinSize(minWidth = 220.dp)
) {
Text(text = "Confirm")
}
}
}
}

View file

@ -0,0 +1,181 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.internal.donor
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
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.Modifier
import org.signal.donations.StripeDeclineCode
import org.signal.donations.StripeFailureCode
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.PayPalDeclineCode
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
/**
* Displays a dropdown widget for selecting an error type.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DonationErrorValueTypeSelector(
selectedPaymentMethodType: PendingOneTimeDonation.PaymentMethodType,
selectedErrorType: DonationErrorValue.Type,
onErrorTypeSelected: (DonationErrorValue.Type) -> Unit
) {
var expanded by remember {
mutableStateOf(false)
}
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
expanded = !expanded
}
) {
TextField(
value = selectedErrorType.name,
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.menuAnchor()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DonationErrorValue.Type.values().filterNot {
selectedPaymentMethodType == PendingOneTimeDonation.PaymentMethodType.PAYPAL && it == DonationErrorValue.Type.FAILURE_CODE
}.forEach { item ->
DropdownMenuItem(
text = { Text(text = item.name) },
onClick = {
onErrorTypeSelected(item)
expanded = false
}
)
}
}
}
}
/**
* Displays a dropdown widget for selecting an error code, if the corresponding type
* allows for such things.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DonationErrorValueCodeSelector(
selectedPaymentMethodType: PendingOneTimeDonation.PaymentMethodType,
selectedErrorType: DonationErrorValue.Type,
selectedErrorCode: String,
onErrorCodeSelected: (String) -> Unit
) {
val isCodedError = remember(selectedErrorType) {
selectedErrorType in setOf(DonationErrorValue.Type.PROCESSOR_CODE, DonationErrorValue.Type.DECLINE_CODE, DonationErrorValue.Type.FAILURE_CODE)
}
var expanded by remember {
mutableStateOf(false)
}
if (isCodedError) {
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
expanded = !expanded
}
) {
TextField(
value = selectedErrorCode,
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.menuAnchor()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
when (selectedErrorType) {
DonationErrorValue.Type.PROCESSOR_CODE -> {
ProcessorErrorsDropdown(selectedPaymentMethodType, onErrorCodeSelected)
}
DonationErrorValue.Type.DECLINE_CODE -> {
DeclineCodeErrorsDropdown(selectedPaymentMethodType, onErrorCodeSelected)
}
DonationErrorValue.Type.FAILURE_CODE -> {
FailureCodeErrorsDropdown(onErrorCodeSelected)
}
else -> error("This should never happen")
}
}
}
}
}
@Composable
private fun 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 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 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)
}
)
}
}

View file

@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -30,7 +31,7 @@ fun BadgeImage112(
modifier: Modifier = Modifier
) {
if (LocalInspectionMode.current) {
Box(modifier = modifier.background(color = Color.Red))
Box(modifier = modifier.background(color = Color.Black, shape = CircleShape))
} else {
AndroidView(
factory = {

View file

@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.ga
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.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob
@ -190,7 +191,12 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
val countDownLatch = CountDownLatch(1)
var finalJobState: JobTracker.JobState? = null
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(uiSessionKey, isLongRunning).enqueue { _, jobState ->
val terminalDonation = TerminalDonationQueue.TerminalDonation(
level = gatewayRequest.level,
isLongRunningPaymentMethod = isLongRunning
)
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(uiSessionKey, terminalDonation).enqueue { _, jobState ->
if (jobState.isComplete) {
finalJobState = jobState
countDownLatch.countDown()

View file

@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Do
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobs.BoostReceiptRequestResponseJob
@ -137,12 +138,17 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
)
)
val terminalDonation = TerminalDonationQueue.TerminalDonation(
level = gatewayRequest.level,
isLongRunningPaymentMethod = isLongRunning
)
val countDownLatch = CountDownLatch(1)
var finalJobState: JobTracker.JobState? = null
val chain = if (isBoost) {
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntentId, donationProcessor, gatewayRequest.uiSessionKey, isLongRunning)
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntentId, donationProcessor, gatewayRequest.uiSessionKey, terminalDonation)
} else {
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntentId, gatewayRequest.recipientId, gatewayRequest.additionalMessage, gatewayRequest.level, donationProcessor, gatewayRequest.uiSessionKey, isLongRunning)
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntentId, gatewayRequest.recipientId, gatewayRequest.additionalMessage, gatewayRequest.level, donationProcessor, gatewayRequest.uiSessionKey, terminalDonation)
}
chain.enqueue { _, jobState ->

View file

@ -202,10 +202,13 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
* that we are successful and proceed as normal. If the payment didn't actually succeed, then we
* expect an error later in the chain to inform us of this.
*/
fun getStatusAndPaymentMethodId(stripeIntentAccessor: StripeIntentAccessor): Single<StatusAndPaymentMethodId> {
fun getStatusAndPaymentMethodId(
stripeIntentAccessor: StripeIntentAccessor,
paymentMethodId: String?
): Single<StatusAndPaymentMethodId> {
return Single.fromCallable {
when (stripeIntentAccessor.objectType) {
StripeIntentAccessor.ObjectType.NONE -> StatusAndPaymentMethodId(stripeIntentAccessor.intentId, StripeIntentStatus.SUCCEEDED, null)
StripeIntentAccessor.ObjectType.NONE -> StatusAndPaymentMethodId(stripeIntentAccessor.intentId, StripeIntentStatus.SUCCEEDED, paymentMethodId)
StripeIntentAccessor.ObjectType.PAYMENT_INTENT -> stripeApi.getPaymentIntent(stripeIntentAccessor).let {
if (it.status == null) {
Log.d(TAG, "Returned payment intent had a null status.", true)
@ -230,7 +233,6 @@ 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 {
if (paymentSourceType == PaymentSourceType.Stripe.IDEAL) {
ApplicationDependencies

View file

@ -6,23 +6,29 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.completed
import android.content.DialogInterface
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@ -35,26 +41,27 @@ import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeRepository
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImage112
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationCompletedQueue
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
import org.thoughtcrime.securesms.util.viewModel
/**
* Bottom Sheet displayed when the app notices that a long-running donation has
* completed.
*/
class DonationCompletedBottomSheet : ComposeBottomSheetDialogFragment() {
class TerminalDonationBottomSheet : ComposeBottomSheetDialogFragment() {
companion object {
private const val ARG_DONATION_COMPLETED = "arg.donation.completed"
@JvmStatic
fun show(fragmentManager: FragmentManager, donationCompleted: DonationCompletedQueue.DonationCompleted) {
DonationCompletedBottomSheet().apply {
fun show(fragmentManager: FragmentManager, terminalDonation: TerminalDonationQueue.TerminalDonation) {
TerminalDonationBottomSheet().apply {
arguments = bundleOf(
ARG_DONATION_COMPLETED to donationCompleted.encode()
ARG_DONATION_COMPLETED to terminalDonation.encode()
)
show(fragmentManager, null)
@ -62,16 +69,42 @@ class DonationCompletedBottomSheet : ComposeBottomSheetDialogFragment() {
}
}
private val donationCompleted: DonationCompletedQueue.DonationCompleted by lazy(LazyThreadSafetyMode.NONE) {
DonationCompletedQueue.DonationCompleted.ADAPTER.decode(requireArguments().getByteArray(ARG_DONATION_COMPLETED)!!)
override val peekHeightPercentage: Float = 1f
private val terminalDonation: TerminalDonationQueue.TerminalDonation by lazy(LazyThreadSafetyMode.NONE) {
TerminalDonationQueue.TerminalDonation.ADAPTER.decode(requireArguments().getByteArray(ARG_DONATION_COMPLETED)!!)
}
private val viewModel: DonationCompletedViewModel by viewModel {
DonationCompletedViewModel(donationCompleted, badgeRepository = BadgeRepository(requireContext()))
private val viewModel: TerminalDonationViewModel by viewModel {
TerminalDonationViewModel(terminalDonation, badgeRepository = BadgeRepository(requireContext()))
}
@Composable
override fun SheetContent() {
if (terminalDonation.error != null) {
PaymentFailureBottomSheet()
} else {
CompletedSheet()
}
}
@Composable
private fun PaymentFailureBottomSheet() {
val badge by viewModel.badge
DonationPaymentFailureBottomSheet(
badge = badge,
onTryAgainClick = {
startActivity(AppSettingsActivity.manageSubscriptions(requireContext()))
},
onNotNowClick = {
dismissAllowingStateLoss()
}
)
}
@Composable
private fun CompletedSheet() {
val badge by viewModel.badge
val isToggleChecked by viewModel.isToggleChecked
val toggleType by viewModel.toggleType
@ -92,6 +125,103 @@ class DonationCompletedBottomSheet : ComposeBottomSheetDialogFragment() {
}
}
@Preview
@Composable
private fun DonationPaymentFailureBottomSheet() {
SignalTheme {
Surface {
DonationPaymentFailureBottomSheet(
badge = null,
onTryAgainClick = {},
onNotNowClick = {}
)
}
}
}
@Composable
private fun DonationPaymentFailureBottomSheet(
badge: Badge?,
onTryAgainClick: () -> Unit,
onNotNowClick: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
BottomSheets.Handle()
Box(
modifier = Modifier
.padding(top = 21.dp, bottom = 16.dp)
) {
BadgeImage112(
badge = badge,
modifier = Modifier
.size(80.dp)
)
Box(
modifier = Modifier
.size(24.dp)
.padding(2.dp)
.background(
color = MaterialTheme.colorScheme.background,
shape = CircleShape
)
.align(Alignment.TopEnd)
)
Icon(
painter = painterResource(id = R.drawable.symbol_error_circle_fill_24),
tint = MaterialTheme.colorScheme.error,
contentDescription = null,
modifier = Modifier.align(Alignment.TopEnd)
)
}
Text(
text = stringResource(id = R.string.DonationErrorBottomSheet__donation_couldnt_be_processed),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 45.dp)
)
Text(
text = stringResource(id = R.string.DonationErrorBottomSheet__were_having_trouble),
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, bottom = 24.dp)
.padding(horizontal = 45.dp)
)
Buttons.LargeTonal(
onClick = onTryAgainClick,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(top = 32.dp, bottom = 16.dp)
) {
Text(
text = stringResource(id = R.string.DonationErrorBottomSheet__try_again)
)
}
TextButton(
onClick = onNotNowClick,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = 56.dp)
) {
Text(
text = stringResource(id = R.string.DonationErrorBottomSheet__not_now)
)
}
}
}
@Preview
@Composable
private fun DonationCompletedSheetContentPreview() {
@ -100,7 +230,7 @@ private fun DonationCompletedSheetContentPreview() {
DonationCompletedSheetContent(
badge = null,
isToggleChecked = false,
toggleType = DonationCompletedViewModel.ToggleType.NONE,
toggleType = TerminalDonationViewModel.ToggleType.NONE,
onCheckChanged = {},
onDoneClick = {}
)
@ -112,7 +242,7 @@ private fun DonationCompletedSheetContentPreview() {
private fun DonationCompletedSheetContent(
badge: Badge?,
isToggleChecked: Boolean,
toggleType: DonationCompletedViewModel.ToggleType,
toggleType: TerminalDonationViewModel.ToggleType,
onCheckChanged: (Boolean) -> Unit,
onDoneClick: () -> Unit
) {
@ -147,7 +277,7 @@ private fun DonationCompletedSheetContent(
.padding(horizontal = 45.dp)
)
if (toggleType == DonationCompletedViewModel.ToggleType.NONE) {
if (toggleType == TerminalDonationViewModel.ToggleType.NONE) {
CircularProgressIndicator()
} else {
DonationToggleRow(

View file

@ -12,13 +12,14 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragmentArgs
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* Handles displaying the "Thank You" or "Donation completed" sheet when the user navigates to an appropriate screen.
* These sheets are one-shot.
*/
class DonationCompletedDelegate(
class TerminalDonationDelegate(
private val fragmentManager: FragmentManager,
private val lifecycleOwner: LifecycleOwner
) : DefaultLifecycleObserver {
@ -27,13 +28,13 @@ class DonationCompletedDelegate(
bindTo(lifecycleOwner)
}
private val badgeRepository = DonationCompletedRepository()
private val badgeRepository = TerminalDonationRepository()
override fun onResume(owner: LifecycleOwner) {
val donations = SignalStore.donationsValues().consumeDonationCompletionList()
val donations = SignalStore.donationsValues().consumeTerminalDonations()
for (donation in donations) {
if (donation.isLongRunningPaymentMethod) {
DonationCompletedBottomSheet.show(fragmentManager, donation)
if (donation.isLongRunningPaymentMethod && (donation.error == null || donation.error.type != DonationErrorValue.Type.REDEMPTION)) {
TerminalDonationBottomSheet.show(fragmentManager, donation)
} else {
lifecycleDisposable += badgeRepository.getBadge(donation).observeOn(AndroidSchedulers.mainThread()).subscribe { badge ->
val args = ThanksForYourSupportBottomSheetDialogFragmentArgs.Builder(badge).build().toBundle()

View file

@ -9,19 +9,19 @@ import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationCompletedQueue
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.whispersystems.signalservice.api.services.DonationsService
import java.util.Locale
class DonationCompletedRepository(
class TerminalDonationRepository(
private val donationsService: DonationsService = ApplicationDependencies.getDonationsService()
) {
fun getBadge(donationCompleted: DonationCompletedQueue.DonationCompleted): Single<Badge> {
fun getBadge(terminalDonation: TerminalDonationQueue.TerminalDonation): Single<Badge> {
return Single
.fromCallable { donationsService.getDonationsConfiguration(Locale.getDefault()) }
.flatMap { it.flattenResult() }
.map { it.levels[donationCompleted.level.toInt()]!! }
.map { it.levels[terminalDonation.level.toInt()]!! }
.map { Badges.fromServiceBadge(it.badge) }
.subscribeOn(Schedulers.io())
}

View file

@ -18,18 +18,18 @@ import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeRepository
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationCompletedQueue
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
class DonationCompletedViewModel(
donationCompleted: DonationCompletedQueue.DonationCompleted,
repository: DonationCompletedRepository = DonationCompletedRepository(),
class TerminalDonationViewModel(
donationCompleted: TerminalDonationQueue.TerminalDonation,
repository: TerminalDonationRepository = TerminalDonationRepository(),
private val badgeRepository: BadgeRepository
) : ViewModel() {
companion object {
private val TAG = Log.tag(DonationCompletedViewModel::class.java)
private val TAG = Log.tag(TerminalDonationViewModel::class.java)
}
private val disposables = CompositeDisposable()

View file

@ -134,7 +134,7 @@ class DonationCheckoutDelegate(
if (result.action == DonationProcessorAction.CANCEL_SUBSCRIPTION) {
Snackbar.make(fragment.requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG).show()
} else {
SignalStore.donationsValues().removeDonationComplete(result.request.level)
SignalStore.donationsValues().removeTerminalDonation(result.request.level)
callback.onPaymentComplete(result.request)
}
}

View file

@ -165,7 +165,7 @@ class StripePaymentInProgressViewModel(
paymentSourceProvider.paymentSourceType.code
)
)
.flatMap { secure3DSResult -> stripeRepository.getStatusAndPaymentMethodId(secure3DSResult) }
.flatMap { secure3DSResult -> stripeRepository.getStatusAndPaymentMethodId(secure3DSResult, secure3DSAction.paymentMethodId) }
}
.flatMapCompletable { stripeRepository.setDefaultPaymentMethod(it.paymentMethod!!, it.intentId, paymentSourceProvider.paymentSourceType) }
.onErrorResumeNext {
@ -214,17 +214,18 @@ class StripePaymentInProgressViewModel(
disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) ->
stripeRepository.confirmPayment(paymentSource, paymentIntent, request.recipientId)
.flatMap {
nextActionHandler.handle(
it,
Stripe3DSData(
it.stripeIntentAccessor,
request,
paymentSourceProvider.paymentSourceType.code
.flatMap { action ->
nextActionHandler
.handle(
action,
Stripe3DSData(
action.stripeIntentAccessor,
request,
paymentSourceProvider.paymentSourceType.code
)
)
)
.flatMap { stripeRepository.getStatusAndPaymentMethodId(it, action.paymentMethodId) }
}
.flatMap { stripeRepository.getStatusAndPaymentMethodId(it) }
.flatMapCompletable {
oneTimeDonationRepository.waitForOneTimeRedemption(
gatewayRequest = request,

View file

@ -226,7 +226,7 @@ private fun BankTransferDetailsContent(
val fullString = stringResource(id = R.string.BankTransferDetailsFragment__enter_your_bank_details, learnMore)
Texts.LinkifiedText(
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_url)), // TODO [sepa] -- final URL
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_faq_url)),
onUrlClick = {
onLearnMoreClick()
},

View file

@ -204,7 +204,7 @@ private fun IdealTransferDetailsContent(
val fullString = stringResource(id = R.string.IdealTransferDetailsFragment__enter_your_bank, learnMore)
Texts.LinkifiedText(
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_url)), // TODO [sepa] -- final URL
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_faq_url)),
onUrlClick = {
onLearnMoreClick()
},
@ -257,7 +257,11 @@ private fun IdealTransferDetailsContent(
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { onDonateClick() }
onDone = {
if (state.canProceed()) {
onDonateClick()
}
}
),
modifier = Modifier
.fillMaxWidth()

View file

@ -10,6 +10,7 @@ import org.signal.donations.StripeDeclineCode
import org.signal.donations.StripeError
import org.signal.donations.StripeFailureCode
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
sealed class DonationError(val source: DonationErrorSource, cause: Throwable) : Exception(cause) {
@ -147,7 +148,44 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
}
@JvmStatic
fun routeBackgroundError(context: Context, uiSessionKey: Long, error: DonationError) {
fun DonationError.toDonationErrorValue(): DonationErrorValue {
return when (this) {
is PaymentSetupError.GenericError -> DonationErrorValue(
type = DonationErrorValue.Type.PAYMENT,
code = ""
)
is PaymentSetupError.StripeCodedError -> DonationErrorValue(
type = DonationErrorValue.Type.PROCESSOR_CODE,
code = this.errorCode
)
is PaymentSetupError.StripeDeclinedError -> DonationErrorValue(
type = DonationErrorValue.Type.DECLINE_CODE,
code = this.declineCode.rawCode
)
is PaymentSetupError.StripeFailureCodeError -> DonationErrorValue(
type = DonationErrorValue.Type.FAILURE_CODE,
code = this.failureCode.rawCode
)
is PaymentSetupError.PayPalCodedError -> DonationErrorValue(
type = DonationErrorValue.Type.PROCESSOR_CODE,
code = this.errorCode.toString()
)
is PaymentSetupError.PayPalDeclinedError -> DonationErrorValue(
type = DonationErrorValue.Type.DECLINE_CODE,
code = this.code.code.toString()
)
else -> error("Don't know how to convert error $this")
}
}
@JvmStatic
@JvmOverloads
fun routeBackgroundError(
context: Context,
uiSessionKey: Long,
error: DonationError,
suppressNotification: Boolean = true
) {
if (error.source == DonationErrorSource.GIFT_REDEMPTION) {
routeDonationError(context, error)
return
@ -159,6 +197,9 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
Log.i(TAG, "Routing background donation error to uiSessionKey $uiSessionKey dialog", error)
subject.onNext(error)
}
suppressNotification -> {
Log.i(TAG, "Suppressing notification for error.", error)
}
else -> {
Log.i(TAG, "Routing background donation error to uiSessionKey $uiSessionKey notification", error)
DonationErrorNotifications.displayErrorNotification(context, error)

View file

@ -21,12 +21,12 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.DonationCompletedDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.TerminalDonationDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
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.database.model.databaseprotos.DonationErrorValue
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
@ -68,7 +68,7 @@ class ManageDonationsFragment :
)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycle.addObserver(DonationCompletedDelegate(childFragmentManager, viewLifecycleOwner))
viewLifecycleOwner.lifecycle.addObserver(TerminalDonationDelegate(childFragmentManager, viewLifecycleOwner))
super.onViewCreated(view, savedInstanceState)
}
@ -344,14 +344,38 @@ 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)
private fun displayPendingOneTimeDonationErrorDialog(error: DonationErrorValue) {
when (error.type) {
DonationErrorValue.Type.REDEMPTION -> {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__couldnt_add_badge)
.setMessage(R.string.DonationsErrors__your_badge_could_not)
.setNegativeButton(R.string.DonationsErrors__learn_more) { _, _ ->
CommunicationActions.openBrowserLink(requireContext(), getString(R.string.donate_url))
}
.setPositiveButton(R.string.Subscription__contact_support) { _, _ ->
requireActivity().finish()
startActivity(AppSettingsActivity.help(requireContext(), HelpFragment.DONATION_INDEX))
}
.setOnDismissListener {
SignalStore.donationsValues().setPendingOneTimeDonation(null)
}
.show()
}
.show()
else -> {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__error_processing_payment)
.setMessage(R.string.DonationsErrors__try_another_payment_method)
.setNegativeButton(R.string.DonationsErrors__learn_more) { _, _ ->
CommunicationActions.openBrowserLink(requireContext(), getString(R.string.donate_url))
}
.setPositiveButton(android.R.string.ok, null)
.setOnDismissListener {
SignalStore.donationsValues().setPendingOneTimeDonation(null)
}
.show()
}
}
}
override fun onMakeAMonthlyDonation() {

View file

@ -12,6 +12,7 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
import org.thoughtcrime.securesms.databinding.MySupportPreferenceBinding
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
@ -33,7 +34,7 @@ object OneTimeDonationPreference {
class Model(
val pendingOneTimeDonation: PendingOneTimeDonation,
val onPendingClick: (FiatMoney) -> Unit,
val onErrorClick: (PendingOneTimeDonation.Error) -> Unit
val onErrorClick: (DonationErrorValue) -> Unit
) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean = true
@ -63,7 +64,7 @@ object OneTimeDonationPreference {
}
}
private fun presentErrorState(model: Model, error: PendingOneTimeDonation.Error) {
private fun presentErrorState(model: Model, error: DonationErrorValue) {
expiry.text = getErrorSubtitle(error)
itemView.setOnClickListener { model.onErrorClick(error) }
@ -81,9 +82,9 @@ object OneTimeDonationPreference {
progress.visible = model.pendingOneTimeDonation.paymentMethodType != PendingOneTimeDonation.PaymentMethodType.SEPA_DEBIT
}
private fun getErrorSubtitle(error: PendingOneTimeDonation.Error): String {
private fun getErrorSubtitle(error: DonationErrorValue): String {
return when (error.type) {
PendingOneTimeDonation.Error.Type.REDEMPTION -> context.getString(R.string.DonationsErrors__couldnt_add_badge)
DonationErrorValue.Type.REDEMPTION -> context.getString(R.string.DonationsErrors__couldnt_add_badge)
else -> context.getString(R.string.DonationsErrors__donation_failed)
}
}

View file

@ -111,8 +111,7 @@ import org.thoughtcrime.securesms.components.reminder.ServiceOutageReminder;
import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder;
import org.thoughtcrime.securesms.components.reminder.UsernameOutOfSyncReminder;
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment;
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.DonationCompletedBottomSheet;
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.DonationCompletedDelegate;
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.TerminalDonationDelegate;
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation;
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
@ -135,7 +134,6 @@ import org.thoughtcrime.securesms.database.MessageTable.MarkedMessageInfo;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.ThreadTable;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationCompletedQueue;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.events.ReminderUpdateEvent;
import org.thoughtcrime.securesms.exporter.flow.SmsExportDialogs;
@ -279,7 +277,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
getViewLifecycleOwner().getLifecycle().addObserver(new DonationCompletedDelegate(getParentFragmentManager(), getViewLifecycleOwner()));
getViewLifecycleOwner().getLifecycle().addObserver(new TerminalDonationDelegate(getParentFragmentManager(), getViewLifecycleOwner()));
lifecycleDisposable = new LifecycleDisposable();
lifecycleDisposable.bindTo(getViewLifecycleOwner());

View file

@ -19,8 +19,8 @@ import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;
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.database.model.databaseprotos.DonationErrorValue;
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.JobManager;
@ -38,6 +38,8 @@ import java.io.IOException;
import java.security.SecureRandom;
import java.util.concurrent.TimeUnit;
import okio.ByteString;
/**
* Job responsible for submitting ReceiptCredentialRequest objects to the server until
* we get a response.
@ -58,16 +60,17 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
private static final String DATA_BADGE_LEVEL = "data.badge.level";
private static final String DATA_DONATION_PROCESSOR = "data.donation.processor";
private static final String DATA_UI_SESSION_KEY = "data.ui.session.key";
private static final String DATA_IS_LONG_RUNNING = "data.is.long.running";
private static final String DATA_TERMINAL_DONATION = "data.terminal.donation";
private ReceiptCredentialRequestContext requestContext;
private TerminalDonationQueue.TerminalDonation terminalDonation;
private ReceiptCredentialRequestContext requestContext;
private final DonationErrorSource donationErrorSource;
private final String paymentIntentId;
private final long badgeLevel;
private final DonationProcessor donationProcessor;
private final long uiSessionKey;
private final boolean isLongRunningDonationPaymentType;
private final long uiSessionKey;
private static String resolveQueue(DonationErrorSource donationErrorSource, boolean isLongRunning) {
String baseQueue = donationErrorSource == DonationErrorSource.ONE_TIME ? BOOST_QUEUE : GIFT_QUEUE;
@ -78,13 +81,19 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
return isLongRunning ? TimeUnit.DAYS.toMillis(14) : TimeUnit.DAYS.toMillis(1);
}
private static BoostReceiptRequestResponseJob createJob(String paymentIntentId, DonationErrorSource donationErrorSource, long badgeLevel, DonationProcessor donationProcessor, long uiSessionKey, boolean isLongRunning) {
private static BoostReceiptRequestResponseJob createJob(@NonNull String paymentIntentId,
@NonNull DonationErrorSource donationErrorSource,
long badgeLevel,
@NonNull DonationProcessor donationProcessor,
long uiSessionKey,
@NonNull TerminalDonationQueue.TerminalDonation terminalDonation)
{
return new BoostReceiptRequestResponseJob(
new Parameters
.Builder()
.addConstraint(NetworkConstraint.KEY)
.setQueue(resolveQueue(donationErrorSource, isLongRunning))
.setLifespan(resolveLifespan(isLongRunning))
.setQueue(resolveQueue(donationErrorSource, terminalDonation.isLongRunningPaymentMethod))
.setLifespan(resolveLifespan(terminalDonation.isLongRunningPaymentMethod))
.setMaxAttempts(Parameters.UNLIMITED)
.build(),
null,
@ -93,17 +102,17 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
badgeLevel,
donationProcessor,
uiSessionKey,
isLongRunning
terminalDonation
);
}
public static JobManager.Chain createJobChainForBoost(@NonNull String paymentIntentId,
@NonNull DonationProcessor donationProcessor,
long uiSessionKey,
boolean isLongRunning)
@NonNull TerminalDonationQueue.TerminalDonation terminalDonation)
{
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntentId, DonationErrorSource.ONE_TIME, Long.parseLong(SubscriptionLevels.BOOST_LEVEL), donationProcessor, uiSessionKey, isLongRunning);
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForBoost(uiSessionKey, isLongRunning);
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntentId, DonationErrorSource.ONE_TIME, Long.parseLong(SubscriptionLevels.BOOST_LEVEL), donationProcessor, uiSessionKey, terminalDonation);
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForBoost(uiSessionKey, terminalDonation.isLongRunningPaymentMethod);
RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forBoost();
MultiDeviceProfileContentUpdateJob multiDeviceProfileContentUpdateJob = new MultiDeviceProfileContentUpdateJob();
@ -120,9 +129,9 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
long badgeLevel,
@NonNull DonationProcessor donationProcessor,
long uiSessionKey,
boolean isLongRunning)
@NonNull TerminalDonationQueue.TerminalDonation terminalDonation)
{
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntentId, DonationErrorSource.GIFT, badgeLevel, donationProcessor, uiSessionKey, isLongRunning);
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntentId, DonationErrorSource.GIFT, badgeLevel, donationProcessor, uiSessionKey, terminalDonation);
GiftSendJob giftSendJob = new GiftSendJob(recipientId, additionalMessage);
@ -138,16 +147,16 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
long badgeLevel,
@NonNull DonationProcessor donationProcessor,
long uiSessionKey,
boolean isLongRunningDonationPaymentType)
@NonNull TerminalDonationQueue.TerminalDonation terminalDonation)
{
super(parameters);
this.requestContext = requestContext;
this.paymentIntentId = paymentIntentId;
this.donationErrorSource = donationErrorSource;
this.badgeLevel = badgeLevel;
this.donationProcessor = donationProcessor;
this.uiSessionKey = uiSessionKey;
this.isLongRunningDonationPaymentType = isLongRunningDonationPaymentType;
this.requestContext = requestContext;
this.paymentIntentId = paymentIntentId;
this.donationErrorSource = donationErrorSource;
this.badgeLevel = badgeLevel;
this.donationProcessor = donationProcessor;
this.uiSessionKey = uiSessionKey;
this.terminalDonation = terminalDonation;
}
@Override
@ -157,7 +166,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
.putLong(DATA_BADGE_LEVEL, badgeLevel)
.putString(DATA_DONATION_PROCESSOR, donationProcessor.getCode())
.putLong(DATA_UI_SESSION_KEY, uiSessionKey)
.putBoolean(DATA_IS_LONG_RUNNING, isLongRunningDonationPaymentType);
.putBlobAsString(DATA_TERMINAL_DONATION, terminalDonation.encode());
if (requestContext != null) {
builder.putBlobAsString(DATA_REQUEST_BYTES, requestContext.serialize());
@ -173,11 +182,16 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
@Override
public void onFailure() {
if (terminalDonation.error != null) {
SignalStore.donationsValues().appendToTerminalDonationQueue(terminalDonation);
} else {
Log.w(TAG, "Job is in terminal state without an error on TerminalDonation.");
}
}
@Override
public long getNextRunAttemptBackoff(int pastAttemptCount, @NonNull Exception exception) {
if (isLongRunningDonationPaymentType) {
if (terminalDonation.isLongRunningPaymentMethod) {
return TimeUnit.DAYS.toMillis(1);
} else {
return super.getNextRunAttemptBackoff(pastAttemptCount, exception);
@ -221,6 +235,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
ReceiptCredentialPresentation receiptCredentialPresentation = getReceiptCredentialPresentation(receiptCredential);
setOutputData(new JsonJobData.Builder().putBlobAsString(DonationReceiptRedemptionJob.INPUT_RECEIPT_CREDENTIAL_PRESENTATION,
receiptCredentialPresentation.serialize())
.putBlobAsString(DonationReceiptRedemptionJob.INPUT_TERMINAL_DONATION, terminalDonation.encode())
.serialize());
enqueueDonationComplete();
@ -242,48 +257,60 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
* Sets the pending one-time donation error according to the status code.
*/
private void setPendingOneTimeDonationGenericRedemptionError(int statusCode) {
DonationErrorValue donationErrorValue = new DonationErrorValue.Builder()
.type(statusCode == 402
? DonationErrorValue.Type.PAYMENT
: DonationErrorValue.Type.REDEMPTION)
.code(Integer.toString(statusCode))
.build();
SignalStore.donationsValues().setPendingOneTimeDonationError(
new PendingOneTimeDonation.Error.Builder()
.type(statusCode == 402
? PendingOneTimeDonation.Error.Type.PAYMENT
: PendingOneTimeDonation.Error.Type.REDEMPTION)
.code(Integer.toString(statusCode))
.build()
donationErrorValue
);
terminalDonation = terminalDonation.newBuilder()
.error(donationErrorValue)
.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;
final DonationErrorValue.Type type;
final String code;
if (donationProcessor == DonationProcessor.PAYPAL) {
code = chargeFailure.getCode();
type = PendingOneTimeDonation.Error.Type.PROCESSOR_CODE;
type = DonationErrorValue.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;
type = DonationErrorValue.Type.FAILURE_CODE;
} else if (declineCode.isKnown()) {
code = declineCode.toString();
type = PendingOneTimeDonation.Error.Type.DECLINE_CODE;
type = DonationErrorValue.Type.DECLINE_CODE;
} else {
code = chargeFailure.getCode();
type = PendingOneTimeDonation.Error.Type.PROCESSOR_CODE;
type = DonationErrorValue.Type.PROCESSOR_CODE;
}
}
DonationErrorValue donationErrorValue = new DonationErrorValue.Builder()
.type(type)
.code(code)
.build();
SignalStore.donationsValues().setPendingOneTimeDonationError(
new PendingOneTimeDonation.Error.Builder()
.type(type)
.code(code)
.build()
donationErrorValue
);
terminalDonation = terminalDonation.newBuilder()
.error(donationErrorValue)
.build();
}
private void handleApplicationError(Context context, ServiceResponse<ReceiptCredentialResponse> response, @NonNull DonationErrorSource donationErrorSource) throws Exception {
@ -299,7 +326,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
throw new Exception(applicationException);
case 402:
Log.w(TAG, "User payment failed.", applicationException, true);
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericPaymentFailure(donationErrorSource));
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericPaymentFailure(donationErrorSource), terminalDonation.isLongRunningPaymentMethod);
if (applicationException instanceof DonationReceiptCredentialError) {
setPendingOneTimeDonationChargeFailureError(((DonationReceiptCredentialError) applicationException).getChargeFailure());
@ -379,22 +406,40 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
public @NonNull BoostReceiptRequestResponseJob create(@NonNull Parameters parameters, @Nullable byte[] serializedData) {
JsonJobData data = JsonJobData.deserialize(serializedData);
String paymentIntentId = data.getString(DATA_PAYMENT_INTENT_ID);
DonationErrorSource donationErrorSource = DonationErrorSource.deserialize(data.getStringOrDefault(DATA_ERROR_SOURCE, DonationErrorSource.ONE_TIME.serialize()));
long badgeLevel = data.getLongOrDefault(DATA_BADGE_LEVEL, Long.parseLong(SubscriptionLevels.BOOST_LEVEL));
String rawDonationProcessor = data.getStringOrDefault(DATA_DONATION_PROCESSOR, DonationProcessor.STRIPE.getCode());
DonationProcessor donationProcessor = DonationProcessor.fromCode(rawDonationProcessor);
long uiSessionKey = data.getLongOrDefault(DATA_UI_SESSION_KEY, -1L);
boolean isLongRunningDonationPaymentType = data.getBooleanOrDefault(DATA_IS_LONG_RUNNING, false);
String paymentIntentId = data.getString(DATA_PAYMENT_INTENT_ID);
DonationErrorSource donationErrorSource = DonationErrorSource.deserialize(data.getStringOrDefault(DATA_ERROR_SOURCE, DonationErrorSource.ONE_TIME.serialize()));
long badgeLevel = data.getLongOrDefault(DATA_BADGE_LEVEL, Long.parseLong(SubscriptionLevels.BOOST_LEVEL));
String rawDonationProcessor = data.getStringOrDefault(DATA_DONATION_PROCESSOR, DonationProcessor.STRIPE.getCode());
DonationProcessor donationProcessor = DonationProcessor.fromCode(rawDonationProcessor);
long uiSessionKey = data.getLongOrDefault(DATA_UI_SESSION_KEY, -1L);
byte[] rawTerminalDonation = data.getStringAsBlob(DATA_TERMINAL_DONATION);
TerminalDonationQueue.TerminalDonation terminalDonation = null;
if (rawTerminalDonation != null) {
try {
terminalDonation = TerminalDonationQueue.TerminalDonation.ADAPTER.decode(rawTerminalDonation);
} catch (IOException e) {
Log.e(TAG, "Failed to parse terminal donation. Generating a default.");
}
}
if (terminalDonation == null) {
terminalDonation = new TerminalDonationQueue.TerminalDonation(
-1,
false,
null,
ByteString.EMPTY
);
}
try {
if (data.hasString(DATA_REQUEST_BYTES)) {
byte[] blob = data.getStringAsBlob(DATA_REQUEST_BYTES);
ReceiptCredentialRequestContext requestContext = new ReceiptCredentialRequestContext(blob);
return new BoostReceiptRequestResponseJob(parameters, requestContext, paymentIntentId, donationErrorSource, badgeLevel, donationProcessor, uiSessionKey, isLongRunningDonationPaymentType);
return new BoostReceiptRequestResponseJob(parameters, requestContext, paymentIntentId, donationErrorSource, badgeLevel, donationProcessor, uiSessionKey, terminalDonation);
} else {
return new BoostReceiptRequestResponseJob(parameters, null, paymentIntentId, donationErrorSource, badgeLevel, donationProcessor, uiSessionKey, isLongRunningDonationPaymentType);
return new BoostReceiptRequestResponseJob(parameters, null, paymentIntentId, donationErrorSource, badgeLevel, donationProcessor, uiSessionKey, terminalDonation);
}
} catch (InvalidInputException e) {
throw new IllegalStateException(e);

View file

@ -12,9 +12,9 @@ import org.thoughtcrime.securesms.database.MessageTable;
import org.thoughtcrime.securesms.database.NoSuchMessageException;
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.DonationErrorValue;
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge;
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation;
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.JobManager;
@ -30,6 +30,8 @@ import java.util.Collections;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import okio.ByteString;
/**
* Job to redeem a verified donation receipt. It is up to the Job prior in the chain to specify a valid
* presentation object via setOutputData. This is expected to be the byte[] blob of a ReceiptCredentialPresentation object.
@ -45,18 +47,19 @@ public class DonationReceiptRedemptionJob extends BaseJob {
private static final String LONG_RUNNING_QUEUE_SUFFIX = "__LONG_RUNNING";
public static final String INPUT_RECEIPT_CREDENTIAL_PRESENTATION = "data.receipt.credential.presentation";
public static final String INPUT_TERMINAL_DONATION = "data.terminal.donation";
public static final String INPUT_KEEP_ALIVE_409 = "data.keep.alive.409";
public static final String DATA_ERROR_SOURCE = "data.error.source";
public static final String DATA_GIFT_MESSAGE_ID = "data.gift.message.id";
public static final String DATA_PRIMARY = "data.primary";
public static final String DATA_UI_SESSION_KEY = "data.ui.session.key";
public static final String DATA_IS_LONG_RUNNING = "data.is.long.running";
private final long giftMessageId;
private final boolean makePrimary;
private final DonationErrorSource errorSource;
private final long uiSessionKey;
private final boolean isLongRunningDonationPaymentType;
private final long uiSessionKey;
private TerminalDonationQueue.TerminalDonation terminalDonation;
public static DonationReceiptRedemptionJob createJobForSubscription(@NonNull DonationErrorSource errorSource, long uiSessionKey, boolean isLongRunningDonationPaymentType) {
return new DonationReceiptRedemptionJob(
@ -64,7 +67,6 @@ public class DonationReceiptRedemptionJob extends BaseJob {
false,
errorSource,
uiSessionKey,
isLongRunningDonationPaymentType,
new Job.Parameters
.Builder()
.addConstraint(NetworkConstraint.KEY)
@ -81,7 +83,6 @@ public class DonationReceiptRedemptionJob extends BaseJob {
false,
DonationErrorSource.ONE_TIME,
uiSessionKey,
isLongRunningDonationPaymentType,
new Job.Parameters
.Builder()
.addConstraint(NetworkConstraint.KEY)
@ -108,7 +109,6 @@ public class DonationReceiptRedemptionJob extends BaseJob {
primary,
DonationErrorSource.GIFT_REDEMPTION,
-1L,
false,
new Job.Parameters
.Builder()
.addConstraint(NetworkConstraint.KEY)
@ -126,13 +126,12 @@ public class DonationReceiptRedemptionJob extends BaseJob {
.then(multiDeviceProfileContentUpdateJob);
}
private DonationReceiptRedemptionJob(long giftMessageId, boolean primary, @NonNull DonationErrorSource errorSource, long uiSessionKey, boolean isLongRunning, @NonNull Job.Parameters parameters) {
private DonationReceiptRedemptionJob(long giftMessageId, boolean primary, @NonNull DonationErrorSource errorSource, long uiSessionKey, @NonNull Job.Parameters parameters) {
super(parameters);
this.giftMessageId = giftMessageId;
this.makePrimary = primary;
this.errorSource = errorSource;
this.uiSessionKey = uiSessionKey;
this.isLongRunningDonationPaymentType = isLongRunning;
}
@Override
@ -142,7 +141,6 @@ public class DonationReceiptRedemptionJob extends BaseJob {
.putLong(DATA_GIFT_MESSAGE_ID, giftMessageId)
.putBoolean(DATA_PRIMARY, makePrimary)
.putLong(DATA_UI_SESSION_KEY, uiSessionKey)
.putBoolean(DATA_IS_LONG_RUNNING, isLongRunningDonationPaymentType)
.serialize();
}
@ -160,6 +158,10 @@ public class DonationReceiptRedemptionJob extends BaseJob {
} else if (giftMessageId != NO_ID) {
SignalDatabase.messages().markGiftRedemptionFailed(giftMessageId);
}
if (terminalDonation != null) {
SignalStore.donationsValues().appendToTerminalDonationQueue(terminalDonation);
}
}
@Override
@ -181,7 +183,9 @@ public class DonationReceiptRedemptionJob extends BaseJob {
}
private void doRun() throws Exception {
boolean isKeepAlive409 = getInputData() != null && JsonJobData.deserialize(getInputData()).getBooleanOrDefault(INPUT_KEEP_ALIVE_409, false);
JsonJobData inputData = getInputData() != null ? JsonJobData.deserialize(getInputData()) : null;
boolean isKeepAlive409 = inputData != null && inputData.getBooleanOrDefault(INPUT_KEEP_ALIVE_409, false);
if (isKeepAlive409) {
Log.d(TAG, "Keep-Alive redemption job hit a 409. Exiting.", true);
return;
@ -193,6 +197,17 @@ public class DonationReceiptRedemptionJob extends BaseJob {
return;
}
byte[] rawTerminalDonation = inputData != null ? inputData.getStringAsBlob(INPUT_TERMINAL_DONATION) : null;
if (rawTerminalDonation != null) {
Log.d(TAG, "Retrieved terminal donation information from input data.");
terminalDonation = TerminalDonationQueue.TerminalDonation.ADAPTER.decode(rawTerminalDonation);
} else {
Log.d(TAG, "Input data does not contain terminal donation data. Creating one with sane defaults.");
terminalDonation = new TerminalDonationQueue.TerminalDonation.Builder()
.level(presentation.getReceiptLevel())
.build();
}
Log.d(TAG, "Attempting to redeem token... isForSubscription: " + isForSubscription(), true);
ServiceResponse<EmptyResponse> response = ApplicationDependencies.getDonationsService()
.redeemReceipt(presentation,
@ -208,12 +223,18 @@ public class DonationReceiptRedemptionJob extends BaseJob {
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(errorSource));
if (isForOneTimeDonation()) {
DonationErrorValue donationErrorValue = new DonationErrorValue.Builder()
.type(DonationErrorValue.Type.REDEMPTION)
.code(Integer.toString(response.getStatus()))
.build();
SignalStore.donationsValues().setPendingOneTimeDonationError(
new PendingOneTimeDonation.Error.Builder()
.type(PendingOneTimeDonation.Error.Type.REDEMPTION)
.code(Integer.toString(response.getStatus()))
.build()
donationErrorValue
);
terminalDonation = terminalDonation.newBuilder()
.error(donationErrorValue)
.build();
}
throw new IOException(response.getApplicationError().get());
@ -224,7 +245,7 @@ public class DonationReceiptRedemptionJob extends BaseJob {
}
Log.i(TAG, "Successfully redeemed token with response code " + response.getStatus() + "... isForSubscription: " + isForSubscription(), true);
enqueueDonationComplete(presentation.getReceiptLevel());
enqueueDonationComplete();
if (isForSubscription()) {
Log.d(TAG, "Clearing subscription failure", true);
@ -310,7 +331,7 @@ public class DonationReceiptRedemptionJob extends BaseJob {
return Objects.requireNonNull(getParameters().getQueue()).startsWith(ONE_TIME_QUEUE) && giftMessageId == NO_ID;
}
private void enqueueDonationComplete(long receiptLevel) {
private void enqueueDonationComplete() {
if (errorSource == DonationErrorSource.GIFT || errorSource == DonationErrorSource.GIFT_REDEMPTION) {
Log.i(TAG, "Skipping donation complete sheet for GIFT related redemption.");
return;
@ -321,12 +342,7 @@ public class DonationReceiptRedemptionJob extends BaseJob {
return;
}
SignalStore.donationsValues().appendToDonationCompletionList(
new DonationCompletedQueue.DonationCompleted.Builder()
.isLongRunningPaymentMethod(isLongRunningDonationPaymentType)
.level(receiptLevel)
.build()
);
SignalStore.donationsValues().appendToTerminalDonationQueue(terminalDonation);
}
@Override
@ -347,9 +363,8 @@ public class DonationReceiptRedemptionJob extends BaseJob {
boolean primary = data.getBooleanOrDefault(DATA_PRIMARY, false);
DonationErrorSource errorSource = DonationErrorSource.deserialize(serializedErrorSource);
long uiSessionKey = data.getLongOrDefault(DATA_UI_SESSION_KEY, -1L);
boolean isLongRunningDonationPaymentType = data.getBooleanOrDefault(DATA_IS_LONG_RUNNING, false);
return new DonationReceiptRedemptionJob(messageId, primary, errorSource, uiSessionKey, isLongRunningDonationPaymentType, parameters);
return new DonationReceiptRedemptionJob(messageId, primary, errorSource, uiSessionKey, parameters);
}
}
}

View file

@ -17,6 +17,8 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.Do
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSData
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
@ -27,7 +29,6 @@ import org.thoughtcrime.securesms.subscription.LevelUpdate
import org.thoughtcrime.securesms.util.Environment
import org.whispersystems.signalservice.internal.ServiceResponse
import org.whispersystems.signalservice.internal.push.DonationProcessor
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
@ -53,12 +54,20 @@ class ExternalLaunchDonationJob private constructor(
stripe3DSData.stripeIntentAccessor.intentId,
DonationProcessor.STRIPE,
-1L,
stripe3DSData.paymentSourceType == PaymentSourceType.Stripe.SEPADebit
TerminalDonationQueue.TerminalDonation(
level = stripe3DSData.gatewayRequest.level,
isLongRunningPaymentMethod = stripe3DSData.paymentSourceType == PaymentSourceType.Stripe.SEPADebit
)
)
DonateToSignalType.MONTHLY -> SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(
-1L,
stripe3DSData.paymentSourceType.isBankTransfer
TerminalDonationQueue.TerminalDonation(
level = stripe3DSData.gatewayRequest.level,
isLongRunningPaymentMethod = stripe3DSData.paymentSourceType.isBankTransfer
)
)
DonateToSignalType.GIFT -> BoostReceiptRequestResponseJob.createJobChainForGift(
stripe3DSData.stripeIntentAccessor.intentId,
stripe3DSData.gatewayRequest.recipientId,
@ -66,7 +75,10 @@ class ExternalLaunchDonationJob private constructor(
stripe3DSData.gatewayRequest.level,
DonationProcessor.STRIPE,
-1L,
false
TerminalDonationQueue.TerminalDonation(
level = stripe3DSData.gatewayRequest.level,
isLongRunningPaymentMethod = stripe3DSData.paymentSourceType == PaymentSourceType.Stripe.SEPADebit
)
)
}
@ -100,6 +112,7 @@ class ExternalLaunchDonationJob private constructor(
Log.w(TAG, "NONE type does not require confirmation. Failing Permanently.")
throw Exception()
}
StripeIntentAccessor.ObjectType.PAYMENT_INTENT -> runForPaymentIntent()
StripeIntentAccessor.ObjectType.SETUP_INTENT -> runForSetupIntent()
}
@ -189,10 +202,12 @@ class ExternalLaunchDonationJob private constructor(
null, StripeIntentStatus.SUCCEEDED -> {
Log.i(TAG, "Stripe Intent is in the SUCCEEDED state, we can proceed.", true)
}
StripeIntentStatus.CANCELED -> {
Log.i(TAG, "Stripe Intent is cancelled, we cannot proceed.", true)
throw Exception("User cancelled payment.")
}
else -> {
Log.i(TAG, "Stripe Intent is still processing, retry later.", true)
throw RetryException()
@ -209,20 +224,33 @@ class ExternalLaunchDonationJob private constructor(
} else if (serviceResponse.applicationError.isPresent) {
Log.w(TAG, "An application error was present. ${serviceResponse.status}", serviceResponse.applicationError.get(), true)
doOnApplicationError()
SignalStore.donationsValues().appendToTerminalDonationQueue(
TerminalDonationQueue.TerminalDonation(
level = stripe3DSData.gatewayRequest.level,
isLongRunningPaymentMethod = stripe3DSData.gatewayRequest.donateToSignalType == DonateToSignalType.MONTHLY && stripe3DSData.paymentSourceType.isBankTransfer ||
stripe3DSData.paymentSourceType == PaymentSourceType.Stripe.SEPADebit,
error = DonationErrorValue(
DonationErrorValue.Type.PAYMENT,
code = serviceResponse.status.toString()
)
)
)
throw serviceResponse.applicationError.get()
} else if (serviceResponse.executionError.isPresent) {
Log.w(TAG, "An execution error was present. ${serviceResponse.status}", serviceResponse.executionError.get(), true)
throw serviceResponse.executionError.get()
throw RetryException(serviceResponse.executionError.get())
}
error("Should never get here.")
}
override fun onShouldRetry(e: Exception): Boolean {
return e is RetryException || e is IOException
return e is RetryException
}
class RetryException : Exception()
class RetryException(cause: Throwable? = null) : Exception(cause)
class Factory : Job.Factory<ExternalLaunchDonationJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): ExternalLaunchDonationJob {

View file

@ -5,6 +5,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
@ -19,6 +20,8 @@ import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import okio.ByteString;
/**
* Job that, once there is a valid local subscriber id, should be run every 3 days
* to ensure that a user's subscription does not lapse.
@ -132,13 +135,19 @@ public class SubscriptionKeepAliveJob extends BaseJob {
MultiDeviceSubscriptionSyncRequestJob.enqueue();
}
boolean isLongRunning = Objects.equals(activeSubscription.getActiveSubscription().getPaymentMethod(), ActiveSubscription.PAYMENT_METHOD_SEPA_DEBIT);
TerminalDonationQueue.TerminalDonation terminalDonation = new TerminalDonationQueue.TerminalDonation(
activeSubscription.getActiveSubscription().getLevel(),
Objects.equals(activeSubscription.getActiveSubscription().getPaymentMethod(), ActiveSubscription.PAYMENT_METHOD_SEPA_DEBIT),
null,
ByteString.EMPTY
);
if (endOfCurrentPeriod > SignalStore.donationsValues().getSubscriptionEndOfPeriodConversionStarted()) {
Log.i(TAG, "Subscription end of period is after the conversion end of period. Storing it, generating a credential, and enqueuing the continuation job chain.", true);
SignalStore.donationsValues().setSubscriptionEndOfPeriodConversionStarted(endOfCurrentPeriod);
SignalStore.donationsValues().refreshSubscriptionRequestCredential();
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(true, -1L, isLongRunning).enqueue();
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(true, -1L, terminalDonation).enqueue();
} else if (endOfCurrentPeriod > SignalStore.donationsValues().getSubscriptionEndOfPeriodRedemptionStarted()) {
if (SignalStore.donationsValues().getSubscriptionRequestCredential() == null) {
Log.i(TAG, "We have not started a redemption, but do not have a request credential. Possible that the subscription changed.", true);
@ -146,7 +155,7 @@ public class SubscriptionKeepAliveJob extends BaseJob {
}
Log.i(TAG, "We have a request credential and have not yet turned it into a redeemable token.", true);
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(true, -1L, isLongRunning).enqueue();
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(true, -1L, terminalDonation).enqueue();
} else if (endOfCurrentPeriod > SignalStore.donationsValues().getSubscriptionEndOfPeriodRedeemed()) {
if (SignalStore.donationsValues().getSubscriptionReceiptCredential() == null) {
Log.i(TAG, "We have successfully started redemption but have no stored token. Possible that the subscription changed.", true);

View file

@ -20,6 +20,8 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Do
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.PayPalDeclineCode;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord;
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue;
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.JsonJobData;
import org.thoughtcrime.securesms.jobmanager.Job;
@ -35,6 +37,8 @@ import org.whispersystems.signalservice.internal.ServiceResponse;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
import okio.ByteString;
/**
* Job responsible for submitting ReceiptCredentialRequest objects to the server until
* we get a response.
@ -49,40 +53,44 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
private static final String DATA_SUBSCRIBER_ID = "data.subscriber.id";
private static final String DATA_IS_FOR_KEEP_ALIVE = "data.is.for.keep.alive";
private static final String DATA_UI_SESSION_KEY = "data.ui.session.key";
private static final String DATA_IS_LONG_RUNNING = "data.is.long.running";
private static final String DATA_TERMINAL_DONATION = "data.terminal.donation";
public static final Object MUTEX = new Object();
private final SubscriberId subscriberId;
private final boolean isForKeepAlive;
private final long uiSessionKey;
private final boolean isLongRunningDonationPaymentType;
private final SubscriberId subscriberId;
private final boolean isForKeepAlive;
private final long uiSessionKey;
private TerminalDonationQueue.TerminalDonation terminalDonation;
private static SubscriptionReceiptRequestResponseJob createJob(SubscriberId subscriberId, boolean isForKeepAlive, long uiSessionKey, boolean isLongRunningDonationPaymentType) {
private static SubscriptionReceiptRequestResponseJob createJob(@NonNull SubscriberId subscriberId,
boolean isForKeepAlive,
long uiSessionKey,
@NonNull TerminalDonationQueue.TerminalDonation terminalDonation)
{
return new SubscriptionReceiptRequestResponseJob(
new Parameters
.Builder()
.addConstraint(NetworkConstraint.KEY)
.setQueue("ReceiptRedemption")
.setMaxInstancesForQueue(1)
.setLifespan(isLongRunningDonationPaymentType ? TimeUnit.DAYS.toMillis(14) : TimeUnit.DAYS.toMillis(1))
.setLifespan(terminalDonation.isLongRunningPaymentMethod ? TimeUnit.DAYS.toMillis(14) : TimeUnit.DAYS.toMillis(1))
.setMaxAttempts(Parameters.UNLIMITED)
.build(),
subscriberId,
isForKeepAlive,
uiSessionKey,
isLongRunningDonationPaymentType
terminalDonation
);
}
public static JobManager.Chain createSubscriptionContinuationJobChain(long uiSessionKey, boolean isLongRunningDonationPaymentType) {
return createSubscriptionContinuationJobChain(false, uiSessionKey, isLongRunningDonationPaymentType);
public static JobManager.Chain createSubscriptionContinuationJobChain(long uiSessionKey, @NonNull TerminalDonationQueue.TerminalDonation terminalDonation) {
return createSubscriptionContinuationJobChain(false, uiSessionKey, terminalDonation);
}
public static JobManager.Chain createSubscriptionContinuationJobChain(boolean isForKeepAlive, long uiSessionKey, boolean isLongRunningDonationPaymentType) {
public static JobManager.Chain createSubscriptionContinuationJobChain(boolean isForKeepAlive, long uiSessionKey, @NonNull TerminalDonationQueue.TerminalDonation terminalDonation) {
Subscriber subscriber = SignalStore.donationsValues().requireSubscriber();
SubscriptionReceiptRequestResponseJob requestReceiptJob = createJob(subscriber.getSubscriberId(), isForKeepAlive, uiSessionKey, isLongRunningDonationPaymentType);
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForSubscription(requestReceiptJob.getErrorSource(), uiSessionKey, isLongRunningDonationPaymentType);
SubscriptionReceiptRequestResponseJob requestReceiptJob = createJob(subscriber.getSubscriberId(), isForKeepAlive, uiSessionKey, terminalDonation);
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForSubscription(requestReceiptJob.getErrorSource(), uiSessionKey, terminalDonation.isLongRunningPaymentMethod);
RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forSubscription();
MultiDeviceProfileContentUpdateJob multiDeviceProfileContentUpdateJob = new MultiDeviceProfileContentUpdateJob();
@ -97,13 +105,13 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
@NonNull SubscriberId subscriberId,
boolean isForKeepAlive,
long uiSessionKey,
boolean isLongRunningDonationPaymentType)
@NonNull TerminalDonationQueue.TerminalDonation terminalDonation)
{
super(parameters);
this.subscriberId = subscriberId;
this.isForKeepAlive = isForKeepAlive;
this.uiSessionKey = uiSessionKey;
this.isLongRunningDonationPaymentType = isLongRunningDonationPaymentType;
this.subscriberId = subscriberId;
this.isForKeepAlive = isForKeepAlive;
this.uiSessionKey = uiSessionKey;
this.terminalDonation = terminalDonation;
}
@Override
@ -111,7 +119,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
JsonJobData.Builder builder = new JsonJobData.Builder().putBlobAsString(DATA_SUBSCRIBER_ID, subscriberId.getBytes())
.putBoolean(DATA_IS_FOR_KEEP_ALIVE, isForKeepAlive)
.putLong(DATA_UI_SESSION_KEY, uiSessionKey)
.putBoolean(DATA_IS_LONG_RUNNING, isLongRunningDonationPaymentType);
.putBlobAsString(DATA_TERMINAL_DONATION, terminalDonation.encode());
return builder.serialize();
}
@ -123,6 +131,11 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
@Override
public void onFailure() {
if (terminalDonation.error != null) {
SignalStore.donationsValues().appendToTerminalDonationQueue(terminalDonation);
} else {
Log.w(TAG, "Job is in terminal state without an error on TerminalDonation.");
}
}
@Override
@ -132,6 +145,15 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
}
}
@Override
public long getNextRunAttemptBackoff(int pastAttemptCount, @NonNull Exception exception) {
if (terminalDonation.isLongRunningPaymentMethod) {
return TimeUnit.DAYS.toMillis(1);
} else {
return super.getNextRunAttemptBackoff(pastAttemptCount, exception);
}
}
private void doRun() throws Exception {
ReceiptCredentialRequestContext requestContext = SignalStore.donationsValues().getSubscriptionRequestCredential();
ActiveSubscription activeSubscription = getLatestSubscriptionInformation();
@ -202,7 +224,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
ReceiptCredential receiptCredential = getReceiptCredential(requestContext, response.getResult().get());
if (!isCredentialValid(subscription, receiptCredential)) {
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
onGenericRedemptionError();
throw new IOException("Could not validate receipt credential");
}
@ -214,6 +236,11 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
SignalStore.donationsValues().clearSubscriptionRequestCredential();
SignalStore.donationsValues().setSubscriptionReceiptCredential(receiptCredentialPresentation);
SignalStore.donationsValues().setSubscriptionEndOfPeriodRedemptionStarted(subscription.getEndOfCurrentPeriod());
setOutputData(new JsonJobData.Builder()
.putBlobAsString(DonationReceiptRedemptionJob.INPUT_TERMINAL_DONATION, terminalDonation.encode())
.build()
.serialize());
} else {
Log.w(TAG, "Encountered a retryable exception: " + response.getStatus(), response.getExecutionError().orElse(null), true);
throw new RetryableException();
@ -228,7 +255,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
return activeSubscription.getResult().get();
} else if (activeSubscription.getApplicationError().isPresent()) {
Log.w(TAG, "Unrecoverable error getting the user's current subscription. Failing.", activeSubscription.getApplicationError().get(), true);
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
onGenericRedemptionError();
throw new IOException(activeSubscription.getApplicationError().get());
} else {
throw new RetryableException();
@ -265,18 +292,18 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
throw new RetryableException();
case 400:
Log.w(TAG, "Receipt credential request failed to validate.", response.getApplicationError().get(), true);
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
onGenericRedemptionError();
throw new Exception(response.getApplicationError().get());
case 402:
Log.w(TAG, "Payment looks like a failure but may be retried.", response.getApplicationError().get(), true);
throw new RetryableException();
case 403:
Log.w(TAG, "SubscriberId password mismatch or account auth was present.", response.getApplicationError().get(), true);
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
onGenericRedemptionError();
throw new Exception(response.getApplicationError().get());
case 404:
Log.w(TAG, "SubscriberId not found or misformed.", response.getApplicationError().get(), true);
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
onGenericRedemptionError();
throw new Exception(response.getApplicationError().get());
case 409:
onAlreadyRedeemed(response);
@ -287,6 +314,35 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
}
}
private void onGenericRedemptionError() {
terminalDonation = terminalDonation.newBuilder()
.error(new DonationErrorValue(
DonationErrorValue.Type.REDEMPTION,
"",
ByteString.EMPTY
))
.build();
DonationError.routeBackgroundError(
context,
uiSessionKey,
DonationError.genericBadgeRedemptionFailure(getErrorSource())
);
}
private void onPaymentFailedError(DonationError.PaymentSetupError paymentFailure) {
terminalDonation = terminalDonation.newBuilder()
.error(DonationError.toDonationErrorValue(paymentFailure))
.build();
DonationError.routeBackgroundError(
context,
uiSessionKey,
paymentFailure,
terminalDonation.isLongRunningPaymentMethod
);
}
/**
* Handles state updates and error routing for a payment failure.
* <p>
@ -342,7 +398,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
}
Log.w(TAG, "Not for a keep-alive and we have a charge failure. Routing a payment setup error...", true);
DonationError.routeBackgroundError(context, uiSessionKey, paymentSetupError);
onPaymentFailedError(paymentSetupError);
} else if (chargeFailure != null && processor == ActiveSubscription.Processor.BRAINTREE) {
Log.d(TAG, "PayPal charge failure detected: " + chargeFailure, true);
@ -380,10 +436,10 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
}
Log.w(TAG, "Not for a keep-alive and we have a charge failure. Routing a payment setup error...", true);
DonationError.routeBackgroundError(context, uiSessionKey, paymentSetupError);
onPaymentFailedError(paymentSetupError);
} else {
Log.d(TAG, "Not for a keep-alive and we have a failure status. Routing a payment setup error...", true);
DonationError.routeBackgroundError(context, uiSessionKey, new DonationError.PaymentSetupError.GenericError(
onPaymentFailedError(new DonationError.PaymentSetupError.GenericError(
getErrorSource(),
new Exception("Got a failure status from the subscription object.")
));
@ -399,7 +455,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
setOutputData(new JsonJobData.Builder().putBoolean(DonationReceiptRedemptionJob.INPUT_KEEP_ALIVE_409, true).serialize());
} else {
Log.w(TAG, "Latest paid receipt on subscription already redeemed with a different request credential.", response.getApplicationError().get(), true);
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(getErrorSource()));
onGenericRedemptionError();
throw new Exception(response.getApplicationError().get());
}
}
@ -446,12 +502,12 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
public @NonNull SubscriptionReceiptRequestResponseJob create(@NonNull Parameters parameters, @Nullable byte[] serializedData) {
JsonJobData data = JsonJobData.deserialize(serializedData);
SubscriberId subscriberId = SubscriberId.fromBytes(data.getStringAsBlob(DATA_SUBSCRIBER_ID));
boolean isForKeepAlive = data.getBooleanOrDefault(DATA_IS_FOR_KEEP_ALIVE, false);
String requestString = data.getStringOrDefault(DATA_REQUEST_BYTES, null);
byte[] requestContextBytes = requestString != null ? Base64.decodeOrThrow(requestString) : null;
long uiSessionKey = data.getLongOrDefault(DATA_UI_SESSION_KEY, -1L);
boolean isLongRunningDonationPaymentType = data.getBooleanOrDefault(DATA_IS_LONG_RUNNING, false);
SubscriberId subscriberId = SubscriberId.fromBytes(data.getStringAsBlob(DATA_SUBSCRIBER_ID));
boolean isForKeepAlive = data.getBooleanOrDefault(DATA_IS_FOR_KEEP_ALIVE, false);
String requestString = data.getStringOrDefault(DATA_REQUEST_BYTES, null);
byte[] requestContextBytes = requestString != null ? Base64.decodeOrThrow(requestString) : null;
long uiSessionKey = data.getLongOrDefault(DATA_UI_SESSION_KEY, -1L);
byte[] rawTerminalDonation = data.getStringAsBlob(DATA_TERMINAL_DONATION);
ReceiptCredentialRequestContext requestContext;
if (requestContextBytes != null && SignalStore.donationsValues().getSubscriptionRequestCredential() == null) {
@ -464,7 +520,25 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
}
}
return new SubscriptionReceiptRequestResponseJob(parameters, subscriberId, isForKeepAlive, uiSessionKey, isLongRunningDonationPaymentType);
TerminalDonationQueue.TerminalDonation terminalDonation = null;
if (rawTerminalDonation != null) {
try {
terminalDonation = TerminalDonationQueue.TerminalDonation.ADAPTER.decode(rawTerminalDonation);
} catch (IOException e) {
Log.e(TAG, "Failed to parse terminal donation. Generating a default.");
}
}
if (terminalDonation == null) {
terminalDonation = new TerminalDonationQueue.TerminalDonation(
-1,
false,
null,
ByteString.EMPTY
);
}
return new SubscriptionReceiptRequestResponseJob(parameters, subscriberId, isForKeepAlive, uiSessionKey, terminalDonation);
}
}
}

View file

@ -17,8 +17,9 @@ import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.isExpired
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSData
import org.thoughtcrime.securesms.database.model.databaseprotos.BadgeList
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationCompletedQueue
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
@ -505,35 +506,35 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
var subscriptionEndOfPeriodRedemptionStarted by longValue(SUBSCRIPTION_EOP_STARTED_TO_REDEEM, 0L)
var subscriptionEndOfPeriodRedeemed by longValue(SUBSCRIPTION_EOP_REDEEMED, 0L)
fun appendToDonationCompletionList(donationCompleted: DonationCompletedQueue.DonationCompleted) {
fun appendToTerminalDonationQueue(terminalDonation: TerminalDonationQueue.TerminalDonation) {
synchronized(this) {
val pendingBytes = getBlob(DONATION_COMPLETE_QUEUE, null)
val queue: DonationCompletedQueue = pendingBytes?.let { DonationCompletedQueue.ADAPTER.decode(pendingBytes) } ?: DonationCompletedQueue()
val newQueue: DonationCompletedQueue = queue.copy(donationsCompleted = queue.donationsCompleted + donationCompleted)
val queue: TerminalDonationQueue = pendingBytes?.let { TerminalDonationQueue.ADAPTER.decode(pendingBytes) } ?: TerminalDonationQueue()
val newQueue: TerminalDonationQueue = queue.copy(terminalDonations = queue.terminalDonations + terminalDonation)
putBlob(DONATION_COMPLETE_QUEUE, newQueue.encode())
}
}
fun consumeDonationCompletionList(): List<DonationCompletedQueue.DonationCompleted> {
fun consumeTerminalDonations(): List<TerminalDonationQueue.TerminalDonation> {
synchronized(this) {
val pendingBytes = getBlob(DONATION_COMPLETE_QUEUE, null)
if (pendingBytes == null) {
return emptyList()
} else {
val queue: DonationCompletedQueue = DonationCompletedQueue.ADAPTER.decode(pendingBytes)
val queue: TerminalDonationQueue = TerminalDonationQueue.ADAPTER.decode(pendingBytes)
remove(DONATION_COMPLETE_QUEUE)
return queue.donationsCompleted
return queue.terminalDonations
}
}
}
fun removeDonationComplete(level: Long) {
fun removeTerminalDonation(level: Long) {
synchronized(this) {
val donationCompletionList = consumeDonationCompletionList()
val donationCompletionList = consumeTerminalDonations()
donationCompletionList.filterNot { it.level == level }.forEach {
appendToDonationCompletionList(it)
appendToTerminalDonationQueue(it)
}
}
}
@ -551,7 +552,7 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
}
}
fun setPendingOneTimeDonationError(error: PendingOneTimeDonation.Error) {
fun setPendingOneTimeDonationError(error: DonationErrorValue) {
synchronized(this) {
val pendingOneTimeDonation = getPendingOneTimeDonation()
if (pendingOneTimeDonation != null) {

View file

@ -285,20 +285,20 @@ message FiatValue {
uint64 timestamp = 3;
}
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;
message DonationErrorValue {
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;
}
message PendingOneTimeDonation {
enum PaymentMethodType {
CARD = 0;
SEPA_DEBIT = 1;
@ -306,20 +306,27 @@ message PendingOneTimeDonation {
IDEAL = 3;
}
PaymentMethodType paymentMethodType = 1;
FiatValue amount = 2;
BadgeList.Badge badge = 3;
int64 timestamp = 4;
optional Error error = 5;
PaymentMethodType paymentMethodType = 1;
FiatValue amount = 2;
BadgeList.Badge badge = 3;
int64 timestamp = 4;
optional DonationErrorValue error = 5;
}
message DonationCompletedQueue {
message DonationCompleted {
int64 level = 1;
bool isLongRunningPaymentMethod = 2;
/**
* Contains the data necessary to show the corresponding terminal sheet
* for a given donation. Note that the word "terminal" here is used in
* the same way that it is used in Rx, where we simply mean that, regardless
* of outcome, a donation has completed processing.
*/
message TerminalDonationQueue {
message TerminalDonation {
int64 level = 1;
bool isLongRunningPaymentMethod = 2;
optional DonationErrorValue error = 3;
}
repeated DonationCompleted donationsCompleted = 1;
repeated TerminalDonation terminalDonations = 1;
}
/**

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M1.5 12C1.5 6.2 6.2 1.5 12 1.5S22.5 6.2 22.5 12 17.8 22.5 12 22.5 1.5 17.8 1.5 12ZM12 6.5c-0.7 0-1.24 0.6-1.2 1.29l0.43 5.5C11.26 13.69 11.6 14 12 14s0.74-0.31 0.77-0.71l0.42-5.5C13.24 7.09 12.7 6.5 12 6.5Zm0 8.75c-0.69 0-1.25 0.56-1.25 1.25s0.56 1.25 1.25 1.25 1.25-0.56 1.25-1.25-0.56-1.25-1.25-1.25Z"/>
</vector>

View file

@ -592,8 +592,16 @@
<action
android:id="@+id/action_internalSettingsFragment_to_oneTimeDonationConfigurationFragment"
app:destination="@id/oneTimeDonationConfigurationFragment" />
<action
android:id="@+id/action_internalSettingsFragment_to_terminalDonationConfigurationFragment"
app:destination="@id/terminalDonationConfigurationFragment" />
</fragment>
<fragment
android:id="@+id/terminalDonationConfigurationFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.internal.InternalTerminalDonationConfigurationFragment"
android:label="terminal_donation_configuration_fragment" />
<fragment
android:id="@+id/oneTimeDonationConfigurationFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.internal.InternalPendingOneTimeDonationConfigurationFragment"

View file

@ -16,6 +16,7 @@
<string name="username_support_url" translatable="false">https://support.signal.org/hc/articles/5389476324250</string>
<string name="export_account_data_url" translatable="false">https://support.signal.org/hc/articles/5538911756954</string>
<string name="pending_transfer_url" translatable="false">https://support.signal.org/hc/articles/360031949872#pending</string>
<string name="donate_faq_url" translatable="false">https://support.signal.org/hc/articles/360031949872#donate</string>
<string name="yes">Yes</string>
<string name="no">No</string>
@ -4829,6 +4830,10 @@
<string name="SubscribeFragment__processing_payment">Processing payment…</string>
<!-- Displayed in notification when user payment fails to process on Stripe -->
<string name="DonationsErrors__error_processing_payment">Error processing payment</string>
<!-- Displayed on manage donations screen as a dialog message when payment method failed -->
<string name="DonationsErrors__try_another_payment_method">Try another payment method or contact your bank for more information.</string>
<!-- Displayed on manage donations screen error dialogs as an action label -->
<string name="DonationsErrors__learn_more">Learn more</string>
<!-- Displayed on "My Support" screen when user subscription payment method failed. -->
<string name="DonationsErrors__error_processing_payment_s">Error processing payment. %1$s</string>
<string name="DonationsErrors__your_payment">Your payment couldn\'t be processed and you have not been charged. Please try again.</string>
@ -5954,6 +5959,15 @@
<!-- Confirmation button for donation pending sheet displayed after donating via a bank transfer. -->
<string name="DonationPendingBottomSheet__done">Done</string>
<!-- Title of donation error sheet displayed after making a bank transfer that fails -->
<string name="DonationErrorBottomSheet__donation_couldnt_be_processed">Donation couldn\'t be processed</string>
<!-- Text block of donation error sheet displayed after making a bank transfer that fails -->
<string name="DonationErrorBottomSheet__were_having_trouble">We\'re having trouble processing your bank transfer. You have not been charged. Try another payment method or contact your bank for more information.</string>
<!-- Button label for retry button of donation error sheet displayed after making a bank transfer that fails -->
<string name="DonationErrorBottomSheet__try_again">Try again</string>
<!-- Button label for not now button of donation error sheet displayed after making a bank transfer that fails -->
<string name="DonationErrorBottomSheet__not_now">Not now</string>
<!-- Title of \'Donation Complete\' sheet displayed after a bank transfer completes and the badge is redeemed -->
<string name="DonationCompletedBottomSheet__donation_complete">Donation Complete</string>
<!-- Text block of \'Donation Complete\' sheet displayed after a bank transfer completes and the badge is redeemed -->

View file

@ -3,10 +3,10 @@ package org.signal.donations
/**
* Stripe Payment Processor decline codes
*/
sealed class StripeDeclineCode {
sealed class StripeDeclineCode(val rawCode: String) {
data class Known(val code: Code) : StripeDeclineCode()
data class Unknown(val code: String) : StripeDeclineCode()
data class Known(val code: Code) : StripeDeclineCode(code.code)
data class Unknown(val code: String) : StripeDeclineCode(code)
fun isKnown(): Boolean = this is Known

View file

@ -9,9 +9,9 @@ package org.signal.donations
* Bank Transfer failure codes, as detailed here:
* https://stripe.com/docs/payments/sepa-debit#failed-payments
*/
sealed interface StripeFailureCode {
data class Known(val code: Code) : StripeFailureCode
data class Unknown(val code: String) : StripeFailureCode
sealed class StripeFailureCode(val rawCode: String) {
data class Known(val code: Code) : StripeFailureCode(code.code)
data class Unknown(val code: String) : StripeFailureCode(code)
val isKnown get() = this is Known
enum class Code(val code: String) {