diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt index 0e4b0cc4ae..39d6ae5bb1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt @@ -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 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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalPendingOneTimeDonationConfigurationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalPendingOneTimeDonationConfigurationFragment.kt index 7d0f69976d..0aef6a262e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalPendingOneTimeDonationConfigurationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalPendingOneTimeDonationConfigurationFragment.kt @@ -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, onErrorCodeSelected: (String) -> Unit) { - values.forEach { item -> - DropdownMenuItem( - text = { Text(text = item) }, - onClick = { - onErrorCodeSelected(item) - } - ) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index 3d1286c1d8..d644cd08e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -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() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalTerminalDonationConfigurationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalTerminalDonationConfigurationFragment.kt new file mode 100644 index 0000000000..943d490088 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalTerminalDonationConfigurationFragment.kt @@ -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 = 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") + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/InternalDonationErrorValues.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/InternalDonationErrorValues.kt new file mode 100644 index 0000000000..7d1c42b120 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/InternalDonationErrorValues.kt @@ -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, onErrorCodeSelected: (String) -> Unit) { + values.forEach { item -> + DropdownMenuItem( + text = { Text(text = item) }, + onClick = { + onErrorCodeSelected(item) + } + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/BadgeImage.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/BadgeImage.kt index db8d860886..c090ec401b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/BadgeImage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/BadgeImage.kt @@ -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 = { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/MonthlyDonationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/MonthlyDonationRepository.kt index 74c857dfba..33a85b39c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/MonthlyDonationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/MonthlyDonationRepository.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeDonationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeDonationRepository.kt index ab6252543a..cbf02eea93 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeDonationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeDonationRepository.kt @@ -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 -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt index 4ae83b81f8..a06df8f543 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt @@ -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 { + fun getStatusAndPaymentMethodId( + stripeIntentAccessor: StripeIntentAccessor, + paymentMethodId: String? + ): Single { 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/DonationCompletedBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/TerminalDonationBottomSheet.kt similarity index 55% rename from app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/DonationCompletedBottomSheet.kt rename to app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/TerminalDonationBottomSheet.kt index dde096f8d5..ea87275f84 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/DonationCompletedBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/TerminalDonationBottomSheet.kt @@ -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( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/DonationCompletedDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/TerminalDonationDelegate.kt similarity index 78% rename from app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/DonationCompletedDelegate.kt rename to app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/TerminalDonationDelegate.kt index 42b05f8ff0..5ba2e04e68 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/DonationCompletedDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/TerminalDonationDelegate.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/DonationCompletedRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/TerminalDonationRepository.kt similarity index 81% rename from app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/DonationCompletedRepository.kt rename to app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/TerminalDonationRepository.kt index 39ba3af12d..86b48c33dc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/DonationCompletedRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/TerminalDonationRepository.kt @@ -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 { + fun getBadge(terminalDonation: TerminalDonationQueue.TerminalDonation): Single { 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()) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/DonationCompletedViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/TerminalDonationViewModel.kt similarity index 92% rename from app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/DonationCompletedViewModel.kt rename to app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/TerminalDonationViewModel.kt index 156d5bd542..db49bab35f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/DonationCompletedViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/TerminalDonationViewModel.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt index aeed2cad95..1737697db0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt @@ -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) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt index d788131b2e..82078c8361 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt index dc78bf24d1..fb8cfed528 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt @@ -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() }, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsFragment.kt index d04a28cb96..71f665b4a5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsFragment.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt index 6c47b6baca..4120e114e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt index a9fbfcb005..bff30c285f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt @@ -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() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/OneTimeDonationPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/OneTimeDonationPreference.kt index 004889c24b..45dcafd9eb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/OneTimeDonationPreference.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/OneTimeDonationPreference.kt @@ -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 { 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) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index f7c969788d..106a7a35e4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -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()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java index ef174e025c..be4b2e4476 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java @@ -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 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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java index b437202a72..9a0b9944ca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java @@ -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 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); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ExternalLaunchDonationJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/ExternalLaunchDonationJob.kt index f0999fb174..0017511031 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ExternalLaunchDonationJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ExternalLaunchDonationJob.kt @@ -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 { override fun create(parameters: Parameters, serializedData: ByteArray?): ExternalLaunchDonationJob { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionKeepAliveJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionKeepAliveJob.java index 1103df58d4..13bfac1af3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionKeepAliveJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionKeepAliveJob.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java index c072cc9fba..2e3d86dd31 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java @@ -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. *

@@ -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); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt index d1b92a92db..20d04aeb25 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt @@ -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 { + fun consumeTerminalDonations(): List { 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) { diff --git a/app/src/main/protowire/Database.proto b/app/src/main/protowire/Database.proto index 50507aacbc..e2b2bffcb3 100644 --- a/app/src/main/protowire/Database.proto +++ b/app/src/main/protowire/Database.proto @@ -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; } /** diff --git a/app/src/main/res/drawable/symbol_error_circle_fill_24.xml b/app/src/main/res/drawable/symbol_error_circle_fill_24.xml new file mode 100644 index 0000000000..be9acc804d --- /dev/null +++ b/app/src/main/res/drawable/symbol_error_circle_fill_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/navigation/app_settings.xml b/app/src/main/res/navigation/app_settings.xml index 62bf222df5..ae43f0d79f 100644 --- a/app/src/main/res/navigation/app_settings.xml +++ b/app/src/main/res/navigation/app_settings.xml @@ -592,8 +592,16 @@ + + + https://support.signal.org/hc/articles/5389476324250 https://support.signal.org/hc/articles/5538911756954 https://support.signal.org/hc/articles/360031949872#pending + https://support.signal.org/hc/articles/360031949872#donate Yes No @@ -4829,6 +4830,10 @@ Processing payment… Error processing payment + + Try another payment method or contact your bank for more information. + + Learn more Error processing payment. %1$s Your payment couldn\'t be processed and you have not been charged. Please try again. @@ -5954,6 +5959,15 @@ Done + + Donation couldn\'t be processed + + We\'re having trouble processing your bank transfer. You have not been charged. Try another payment method or contact your bank for more information. + + Try again + + Not now + Donation Complete diff --git a/donations/lib/src/main/java/org/signal/donations/StripeDeclineCode.kt b/donations/lib/src/main/java/org/signal/donations/StripeDeclineCode.kt index 7d44acbfbc..986cd7881c 100644 --- a/donations/lib/src/main/java/org/signal/donations/StripeDeclineCode.kt +++ b/donations/lib/src/main/java/org/signal/donations/StripeDeclineCode.kt @@ -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 diff --git a/donations/lib/src/main/java/org/signal/donations/StripeFailureCode.kt b/donations/lib/src/main/java/org/signal/donations/StripeFailureCode.kt index 4fd5d326b6..28837eb59f 100644 --- a/donations/lib/src/main/java/org/signal/donations/StripeFailureCode.kt +++ b/donations/lib/src/main/java/org/signal/donations/StripeFailureCode.kt @@ -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) {