Flesh out iDEAL sad path UX and address UI polish feedback.

This commit is contained in:
Cody Henthorne
2023-11-07 11:04:36 -05:00
committed by GitHub
parent cfe5ea3f9b
commit 7f2b6a874e
19 changed files with 305 additions and 122 deletions

View File

@@ -320,6 +320,8 @@ class DonateToSignalFragment :
val message = if (state.donateToSignalType == DonateToSignalType.ONE_TIME) {
if (state.oneTimeDonationState.isOneTimeDonationLongRunning) {
R.string.DonateToSignalFragment__bank_transfers_usually_take_1_business_day_to_process_onetime
} else if (state.oneTimeDonationState.isNonVerifiedIdeal) {
R.string.DonateToSignalFragment__your_ideal_payment_is_still_processing
} else {
R.string.DonateToSignalFragment__your_payment_is_still_being_processed_onetime
}

View File

@@ -4,6 +4,7 @@ import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
import org.thoughtcrime.securesms.database.model.isLongRunning
import org.thoughtcrime.securesms.database.model.isPending
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -94,10 +95,13 @@ data class DonateToSignalState(
val isCustomAmountFocused: Boolean = false,
val donationStage: DonationStage = DonationStage.INIT,
val selectableCurrencyCodes: List<String> = emptyList(),
val isOneTimeDonationPending: Boolean = SignalStore.donationsValues().getPendingOneTimeDonation().isPending(),
val isOneTimeDonationLongRunning: Boolean = SignalStore.donationsValues().getPendingOneTimeDonation().isLongRunning(),
private val pendingOneTimeDonation: PendingOneTimeDonation? = null,
private val minimumDonationAmounts: Map<Currency, FiatMoney> = emptyMap()
) {
val isOneTimeDonationPending: Boolean = pendingOneTimeDonation.isPending()
val isOneTimeDonationLongRunning: Boolean = pendingOneTimeDonation.isLongRunning()
val isNonVerifiedIdeal = pendingOneTimeDonation?.pendingVerification == true
val minimumDonationAmountOfSelectedCurrency: FiatMoney = minimumDonationAmounts[selectedCurrency] ?: FiatMoney(BigDecimal.ZERO, selectedCurrency)
private val isCustomAmountTooSmall: Boolean = if (isCustomAmountFocused) customAmount.amount < minimumDonationAmountOfSelectedCurrency.amount else false
private val isCustomAmountZero: Boolean = customAmount.amount == BigDecimal.ZERO

View File

@@ -13,14 +13,16 @@ import org.signal.core.util.StringUtil
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.core.util.money.PlatformCurrencyUtil
import org.signal.core.util.orNull
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.DonationRedemptionJobStatus
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.DonationRedemptionJobWatcher
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
import org.thoughtcrime.securesms.database.model.isExpired
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.subscription.LevelUpdate
@@ -34,6 +36,7 @@ import java.math.BigDecimal
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.Currency
import java.util.Optional
/**
* Contains the logic to manage the UI of the unified donations screen.
@@ -208,24 +211,31 @@ class DonateToSignalViewModel(
}
private fun initializeOneTimeDonationState(oneTimeDonationRepository: OneTimeDonationRepository) {
val isOneTimeDonationInProgress: Observable<Boolean> = DonationRedemptionJobWatcher.watchOneTimeRedemption().map {
it.map { jobState ->
when (jobState) {
JobTracker.JobState.PENDING -> true
JobTracker.JobState.RUNNING -> true
else -> false
}
}.orElse(false)
val oneTimeDonationFromJob: Observable<Optional<PendingOneTimeDonation>> = DonationRedemptionJobWatcher.watchOneTimeRedemption().map {
when (it) {
is DonationRedemptionJobStatus.PendingExternalVerification -> Optional.ofNullable(it.pendingOneTimeDonation)
DonationRedemptionJobStatus.PendingReceiptRedemption,
DonationRedemptionJobStatus.PendingReceiptRequest,
DonationRedemptionJobStatus.FailedSubscription,
DonationRedemptionJobStatus.None -> Optional.empty()
}
}.distinctUntilChanged()
val isOneTimeDonationPending: Observable<Boolean> = SignalStore.donationsValues().observablePendingOneTimeDonation
.map { pending -> pending.filter { !it.isExpired }.isPresent }
val oneTimeDonationFromStore: Observable<Optional<PendingOneTimeDonation>> = SignalStore.donationsValues().observablePendingOneTimeDonation
.map { pending -> pending.filter { !it.isExpired } }
.distinctUntilChanged()
oneTimeDonationDisposables += Observable
.combineLatest(isOneTimeDonationInProgress, isOneTimeDonationPending) { a, b -> a || b }
.subscribe { hasPendingOneTimeDonation ->
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(isOneTimeDonationPending = hasPendingOneTimeDonation)) }
.combineLatest(oneTimeDonationFromJob, oneTimeDonationFromStore) { job, store ->
if (store.isPresent) {
store
} else {
job
}
}
.subscribe { pendingOneTimeDonation: Optional<PendingOneTimeDonation> ->
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(pendingOneTimeDonation = pendingOneTimeDonation.orNull())) }
}
oneTimeDonationDisposables += oneTimeDonationRepository.getBoostBadge().subscribeBy(
@@ -296,13 +306,14 @@ class DonateToSignalViewModel(
private fun monitorLevelUpdateProcessing() {
val isTransactionJobInProgress: Observable<Boolean> = DonationRedemptionJobWatcher.watchSubscriptionRedemption().map {
it.map { jobState ->
when (jobState) {
JobTracker.JobState.PENDING -> true
JobTracker.JobState.RUNNING -> true
else -> false
}
}.orElse(false)
when (it) {
is DonationRedemptionJobStatus.PendingExternalVerification,
DonationRedemptionJobStatus.PendingReceiptRedemption,
DonationRedemptionJobStatus.PendingReceiptRequest -> true
DonationRedemptionJobStatus.FailedSubscription,
DonationRedemptionJobStatus.None -> false
}
}
monthlyDonationDisposables += Observable

View File

@@ -65,22 +65,24 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
presentTitleAndSubtitle(requireContext(), args.request)
space(66.dp)
space(16.dp)
if (state.loading) {
space(16.dp)
customPref(IndeterminateLoadingCircle)
space(16.dp)
return@configure
}
state.gatewayOrderStrategy.orderedGateways.forEachIndexed { index, gateway ->
val isFirst = index == 0
space(16.dp)
when (gateway) {
GatewayResponse.Gateway.GOOGLE_PAY -> renderGooglePayButton(state, isFirst)
GatewayResponse.Gateway.PAYPAL -> renderPayPalButton(state, isFirst)
GatewayResponse.Gateway.CREDIT_CARD -> renderCreditCardButton(state, isFirst)
GatewayResponse.Gateway.SEPA_DEBIT -> renderSEPADebitButton(state, isFirst)
GatewayResponse.Gateway.IDEAL -> renderIDEALButton(state, isFirst)
GatewayResponse.Gateway.GOOGLE_PAY -> renderGooglePayButton(state)
GatewayResponse.Gateway.PAYPAL -> renderPayPalButton(state)
GatewayResponse.Gateway.CREDIT_CARD -> renderCreditCardButton(state)
GatewayResponse.Gateway.SEPA_DEBIT -> renderSEPADebitButton(state)
GatewayResponse.Gateway.IDEAL -> renderIDEALButton(state)
}
}
@@ -88,12 +90,8 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
}
}
private fun DSLConfiguration.renderGooglePayButton(state: GatewaySelectorState, isFirstButton: Boolean) {
private fun DSLConfiguration.renderGooglePayButton(state: GatewaySelectorState) {
if (state.isGooglePayAvailable) {
if (!isFirstButton) {
space(8.dp)
}
customPref(
GooglePayButton.Model(
isEnabled = true,
@@ -107,12 +105,8 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
}
}
private fun DSLConfiguration.renderPayPalButton(state: GatewaySelectorState, isFirstButton: Boolean) {
private fun DSLConfiguration.renderPayPalButton(state: GatewaySelectorState) {
if (state.isPayPalAvailable) {
if (!isFirstButton) {
space(8.dp)
}
customPref(
PayPalButton.Model(
onClick = {
@@ -126,12 +120,8 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
}
}
private fun DSLConfiguration.renderCreditCardButton(state: GatewaySelectorState, isFirstButton: Boolean) {
private fun DSLConfiguration.renderCreditCardButton(state: GatewaySelectorState) {
if (state.isCreditCardAvailable) {
if (!isFirstButton) {
space(8.dp)
}
primaryButton(
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__credit_or_debit_card),
icon = DSLSettingsIcon.from(R.drawable.credit_card, R.color.signal_colorOnCustom),
@@ -144,12 +134,8 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
}
}
private fun DSLConfiguration.renderSEPADebitButton(state: GatewaySelectorState, isFirstButton: Boolean) {
private fun DSLConfiguration.renderSEPADebitButton(state: GatewaySelectorState) {
if (state.isSEPADebitAvailable) {
if (!isFirstButton) {
space(8.dp)
}
tonalButton(
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__bank_transfer),
icon = DSLSettingsIcon.from(R.drawable.bank_transfer),
@@ -162,12 +148,8 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
}
}
private fun DSLConfiguration.renderIDEALButton(state: GatewaySelectorState, isFirstButton: Boolean) {
private fun DSLConfiguration.renderIDEALButton(state: GatewaySelectorState) {
if (state.isIDEALAvailable) {
if (!isFirstButton) {
space(8.dp)
}
tonalButton(
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__ideal),
icon = DSLSettingsIcon.from(R.drawable.logo_ideal, NO_TINT),

View File

@@ -5,23 +5,29 @@ import android.content.DialogInterface
import android.content.Intent
import android.graphics.Bitmap
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import androidx.activity.ComponentDialog
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.setFragmentResult
import androidx.navigation.fragment.navArgs
import com.google.android.material.button.MaterialButton
import org.signal.donations.PaymentSourceType
import org.signal.donations.StripeIntentAccessor
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationWebViewOnBackPressedCallback
import org.thoughtcrime.securesms.databinding.DonationWebviewFragmentBinding
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.visible
/**
@@ -49,7 +55,7 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen)
}
@SuppressLint("SetJavaScriptEnabled")
@SuppressLint("SetJavaScriptEnabled", "SetTextI18n")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
dialog!!.window!!.setFlags(
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
@@ -69,6 +75,19 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
binding.webView
)
)
if (FeatureFlags.internalUser() && args.stripe3DSData.paymentSourceType == PaymentSourceType.Stripe.IDEAL) {
val openApp = MaterialButton(requireContext()).apply {
text = "Open App"
layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
}
setOnClickListener {
handleLaunchExternal(Intent(Intent.ACTION_VIEW, args.uri))
}
}
binding.root.addView(openApp)
}
}
override fun onDismiss(dialog: DialogInterface) {

View File

@@ -274,6 +274,7 @@ private fun BankTransferDetailsContent(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp)
.defaultMinSize(minHeight = 78.dp)
.onFocusChanged { onIBANFocusChanged(it.hasFocus) }
.focusRequester(focusRequester)
)
@@ -293,9 +294,11 @@ private fun BankTransferDetailsContent(
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
supportingText = {},
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
.padding(top = 16.dp)
.defaultMinSize(minHeight = 78.dp)
)
}
@@ -313,16 +316,20 @@ private fun BankTransferDetailsContent(
keyboardActions = KeyboardActions(
onDone = { onDonateClick() }
),
supportingText = {},
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
.padding(top = 16.dp)
.defaultMinSize(minHeight = 78.dp)
)
}
item {
Box(
contentAlignment = Center,
modifier = Modifier.fillMaxWidth()
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
) {
TextButton(
onClick = { setDisplayFindAccountInfoSheet(true) }
@@ -338,7 +345,7 @@ private fun BankTransferDetailsContent(
onClick = onDonateClick,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = 16.dp)
.padding(vertical = 16.dp)
) {
Text(text = donateLabel)
}

View File

@@ -38,6 +38,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
@@ -45,6 +46,7 @@ import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.fragment.findNavController
@@ -192,14 +194,16 @@ fun BankTransferScreen(
item {
Image(
painter = painterResource(id = R.drawable.bank_transfer),
contentScale = ContentScale.Inside,
contentScale = ContentScale.FillBounds,
contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface),
modifier = Modifier
.size(72.dp)
.background(
SignalTheme.colors.colorSurface2,
CircleShape
)
.padding(18.dp)
)
}
@@ -221,7 +225,8 @@ fun BankTransferScreen(
onLearnMoreClick()
},
style = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
),
modifier = Modifier
.padding(bottom = 12.dp)
@@ -262,7 +267,7 @@ fun BankTransferScreen(
.padding(top = 16.dp, bottom = 16.dp)
.defaultMinSize(minWidth = 220.dp)
) {
Text(text = if (listState.canScrollForward) stringResource(id = R.string.BankTransferMandateFragment__read_more) else stringResource(id = R.string.BankTransferMandateFragment__continue))
Text(text = if (listState.canScrollForward) stringResource(id = R.string.BankTransferMandateFragment__read_more) else stringResource(id = R.string.BankTransferMandateFragment__agree))
}
}
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
/**
* Represent the status of a donation as represented in the job system.
*/
sealed interface DonationRedemptionJobStatus {
/**
* No pending/running jobs for a donation type.
*/
object None : DonationRedemptionJobStatus
/**
* Donation is pending external user verification (e.g., iDEAL).
*
* For one-time, pending donation data is provided via the job data as it is not in the store yet.
*/
class PendingExternalVerification(val pendingOneTimeDonation: PendingOneTimeDonation? = null) : DonationRedemptionJobStatus
/**
* Donation is at the receipt request status.
*
* For one-time donations, pending donation data available via the store.
*/
object PendingReceiptRequest : DonationRedemptionJobStatus
/**
* Donation is at the receipt redemption status.
*
* For one-time donations, pending donation data available via the store.
*/
object PendingReceiptRedemption : DonationRedemptionJobStatus
/**
* Representation of a failed subscription job chain derived from no pending/running jobs and
* a failure state in the store.
*/
object FailedSubscription : DonationRedemptionJobStatus
}

View File

@@ -1,14 +1,14 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import io.reactivex.rxjava3.core.Observable
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec
import org.thoughtcrime.securesms.jobs.BoostReceiptRequestResponseJob
import org.thoughtcrime.securesms.jobs.DonationReceiptRedemptionJob
import org.thoughtcrime.securesms.jobs.ExternalLaunchDonationJob
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import java.util.Optional
import java.util.concurrent.TimeUnit
/**
@@ -21,22 +21,23 @@ object DonationRedemptionJobWatcher {
ONE_TIME
}
fun watchSubscriptionRedemption(): Observable<Optional<JobTracker.JobState>> = watch(RedemptionType.SUBSCRIPTION)
fun watchSubscriptionRedemption(): Observable<DonationRedemptionJobStatus> = watch(RedemptionType.SUBSCRIPTION)
fun watchOneTimeRedemption(): Observable<Optional<JobTracker.JobState>> = watch(RedemptionType.ONE_TIME)
fun watchOneTimeRedemption(): Observable<DonationRedemptionJobStatus> = watch(RedemptionType.ONE_TIME)
private fun watch(redemptionType: RedemptionType): Observable<Optional<JobTracker.JobState>> = Observable.interval(0, 5, TimeUnit.SECONDS).map {
private fun watch(redemptionType: RedemptionType): Observable<DonationRedemptionJobStatus> = Observable.interval(0, 5, TimeUnit.SECONDS).map {
val queue = when (redemptionType) {
RedemptionType.SUBSCRIPTION -> DonationReceiptRedemptionJob.SUBSCRIPTION_QUEUE
RedemptionType.ONE_TIME -> DonationReceiptRedemptionJob.ONE_TIME_QUEUE
}
val externalLaunchJobState: JobTracker.JobState? = ApplicationDependencies.getJobManager().getFirstMatchingJobState {
it.factoryKey == ExternalLaunchDonationJob.KEY && it.parameters.queue?.startsWith(queue) == true
}
val donationJobSpecs = ApplicationDependencies
.getJobManager()
.find { it.queueKey?.startsWith(queue) == true }
.sortedBy { it.createTime }
val redemptionJobState: JobTracker.JobState? = ApplicationDependencies.getJobManager().getFirstMatchingJobState {
it.factoryKey == DonationReceiptRedemptionJob.KEY && it.parameters.queue?.startsWith(queue) == true
val externalLaunchJobSpec: JobSpec? = donationJobSpecs.firstOrNull {
it.factoryKey == ExternalLaunchDonationJob.KEY
}
val receiptRequestJobKey = when (redemptionType) {
@@ -44,16 +45,48 @@ object DonationRedemptionJobWatcher {
RedemptionType.ONE_TIME -> BoostReceiptRequestResponseJob.KEY
}
val receiptJobState: JobTracker.JobState? = ApplicationDependencies.getJobManager().getFirstMatchingJobState {
it.factoryKey == receiptRequestJobKey && it.parameters.queue?.startsWith(queue) == true
val receiptJobSpec: JobSpec? = donationJobSpecs.firstOrNull {
it.factoryKey == receiptRequestJobKey
}
val jobState: JobTracker.JobState? = externalLaunchJobState ?: redemptionJobState ?: receiptJobState
val redemptionJobSpec: JobSpec? = donationJobSpecs.firstOrNull {
it.factoryKey == DonationReceiptRedemptionJob.KEY
}
if (redemptionType == RedemptionType.SUBSCRIPTION && jobState == null && SignalStore.donationsValues().getSubscriptionRedemptionFailed()) {
Optional.of(JobTracker.JobState.FAILURE)
val jobSpec: JobSpec? = externalLaunchJobSpec ?: redemptionJobSpec ?: receiptJobSpec
if (redemptionType == RedemptionType.SUBSCRIPTION && jobSpec == null && SignalStore.donationsValues().getSubscriptionRedemptionFailed()) {
DonationRedemptionJobStatus.FailedSubscription
} else {
Optional.ofNullable(jobState)
jobSpec?.toDonationRedemptionStatus() ?: DonationRedemptionJobStatus.None
}
}.distinctUntilChanged()
private fun JobSpec.toDonationRedemptionStatus(): DonationRedemptionJobStatus {
return when (factoryKey) {
ExternalLaunchDonationJob.KEY -> {
val stripe3DSData = ExternalLaunchDonationJob.Factory.parseSerializedData(serializedData!!)
DonationRedemptionJobStatus.PendingExternalVerification(
pendingOneTimeDonation = DonationSerializationHelper.createPendingOneTimeDonationProto(
badge = stripe3DSData.gatewayRequest.badge,
paymentSourceType = stripe3DSData.paymentSourceType,
amount = stripe3DSData.gatewayRequest.fiat
).copy(
timestamp = createTime,
pendingVerification = true,
checkedVerification = runAttempt > 0
)
)
}
SubscriptionReceiptRequestResponseJob.KEY,
BoostReceiptRequestResponseJob.KEY -> DonationRedemptionJobStatus.PendingReceiptRequest
DonationReceiptRedemptionJob.KEY -> DonationRedemptionJobStatus.PendingReceiptRedemption
else -> {
DonationRedemptionJobStatus.None
}
}
}
}

View File

@@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
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.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.TerminalDonationDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
@@ -27,6 +28,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.models.Ne
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -51,6 +53,10 @@ class ManageDonationsFragment :
),
ExpiredGiftSheet.Callback {
companion object {
private val alertedIdealDonations = mutableSetOf<Long>()
}
private val supportTechSummary: CharSequence by lazy {
SpannableStringBuilder(SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurfaceVariant), requireContext().getString(R.string.DonateToSignalFragment__private_messaging)))
.append(" ")
@@ -92,6 +98,21 @@ class ManageDonationsFragment :
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
if (state.pendingOneTimeDonation?.pendingVerification == true &&
state.pendingOneTimeDonation.checkedVerification &&
!alertedIdealDonations.contains(state.pendingOneTimeDonation.timestamp)
) {
alertedIdealDonations += state.pendingOneTimeDonation.timestamp
val amount = FiatMoneyUtil.format(resources, state.pendingOneTimeDonation.amount!!.toFiatMoney(), FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.ManageDonationsFragment__couldnt_confirm_donation)
.setMessage(getString(R.string.ManageDonationsFragment__your_one_time_s_donation_couldnt_be_confirmed, amount))
.setPositiveButton(android.R.string.ok, null)
.show()
}
}
}
@@ -149,7 +170,7 @@ class ManageDonationsFragment :
} else {
customPref(IndeterminateLoadingCircle)
}
} else if (state.hasOneTimeBadge) {
} else if (state.hasOneTimeBadge || state.pendingOneTimeDonation != null) {
presentActiveOneTimeDonorSettings(state)
} else {
presentNotADonorSettings(state.hasReceipts)
@@ -186,7 +207,7 @@ class ManageDonationsFragment :
displayPendingDialog(it)
},
onErrorClick = {
displayPendingOneTimeDonationErrorDialog(it)
displayPendingOneTimeDonationErrorDialog(it, pendingOneTimeDonation.paymentMethodType == PendingOneTimeDonation.PaymentMethodType.IDEAL)
}
)
)
@@ -344,7 +365,7 @@ class ManageDonationsFragment :
.show()
}
private fun displayPendingOneTimeDonationErrorDialog(error: DonationErrorValue) {
private fun displayPendingOneTimeDonationErrorDialog(error: DonationErrorValue, isIdeal: Boolean) {
when (error.type) {
DonationErrorValue.Type.REDEMPTION -> {
MaterialAlertDialogBuilder(requireContext())
@@ -363,9 +384,15 @@ class ManageDonationsFragment :
.show()
}
else -> {
val message = if (isIdeal) {
R.string.DonationsErrors__your_ideal_couldnt_be_processed
} else {
R.string.DonationsErrors__try_another_payment_method
}
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__error_processing_payment)
.setMessage(R.string.DonationsErrors__try_another_payment_method)
.setMessage(message)
.setNegativeButton(R.string.DonationsErrors__learn_more) { _, _ ->
CommunicationActions.openBrowserLink(requireContext(), getString(R.string.donate_url))
}

View File

@@ -14,13 +14,13 @@ import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.subscription.LevelUpdate
import org.thoughtcrime.securesms.util.InternetConnectionObserver
import org.thoughtcrime.securesms.util.livedata.Store
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import java.util.Optional
class ManageDonationsViewModel(
private val subscriptionsRepository: MonthlyDonationRepository
@@ -76,16 +76,26 @@ class ManageDonationsViewModel(
store.update { it.copy(hasReceipts = hasReceipts) }
}
disposables += DonationRedemptionJobWatcher.watchSubscriptionRedemption().subscribeBy { jobStateOptional ->
disposables += DonationRedemptionJobWatcher.watchSubscriptionRedemption().subscribeBy { redemptionStatus ->
store.update { manageDonationsState ->
manageDonationsState.copy(
subscriptionRedemptionState = jobStateOptional.map(this::mapJobStateToRedemptionState).orElse(ManageDonationsState.RedemptionState.NONE)
subscriptionRedemptionState = mapStatusToRedemptionState(redemptionStatus)
)
}
}
disposables += SignalStore.donationsValues()
.observablePendingOneTimeDonation
disposables += Observable.combineLatest(
SignalStore.donationsValues().observablePendingOneTimeDonation,
DonationRedemptionJobWatcher.watchOneTimeRedemption()
) { pendingFromStore, pendingFromJob ->
if (pendingFromStore.isPresent) {
pendingFromStore
} else if (pendingFromJob is DonationRedemptionJobStatus.PendingExternalVerification) {
Optional.ofNullable(pendingFromJob.pendingOneTimeDonation)
} else {
Optional.empty()
}
}
.distinctUntilChanged()
.subscribeBy { pending ->
store.update { it.copy(pendingOneTimeDonation = pending.orNull()) }
@@ -122,13 +132,14 @@ class ManageDonationsViewModel(
)
}
private fun mapJobStateToRedemptionState(jobState: JobTracker.JobState): ManageDonationsState.RedemptionState {
return when (jobState) {
JobTracker.JobState.PENDING -> ManageDonationsState.RedemptionState.IN_PROGRESS
JobTracker.JobState.RUNNING -> ManageDonationsState.RedemptionState.IN_PROGRESS
JobTracker.JobState.SUCCESS -> ManageDonationsState.RedemptionState.NONE
JobTracker.JobState.FAILURE -> ManageDonationsState.RedemptionState.FAILED
JobTracker.JobState.IGNORED -> ManageDonationsState.RedemptionState.NONE
private fun mapStatusToRedemptionState(status: DonationRedemptionJobStatus): ManageDonationsState.RedemptionState {
return when (status) {
DonationRedemptionJobStatus.FailedSubscription -> ManageDonationsState.RedemptionState.FAILED
DonationRedemptionJobStatus.None -> ManageDonationsState.RedemptionState.NONE
is DonationRedemptionJobStatus.PendingExternalVerification,
DonationRedemptionJobStatus.PendingReceiptRedemption,
DonationRedemptionJobStatus.PendingReceiptRequest -> ManageDonationsState.RedemptionState.IN_PROGRESS
}
}

View File

@@ -25,7 +25,7 @@ abstract class ComposeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetD
SignalTheme(
isDarkMode = isDarkTheme()
) {
Surface(shape = RoundedCornerShape(18.dp, 18.dp)) {
Surface(shape = RoundedCornerShape(18.dp, 18.dp), color = SignalTheme.colors.colorSurface1) {
SheetContent()
}
}

View File

@@ -237,22 +237,12 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
receiptCredentialPresentation.serialize())
.putBlobAsString(DonationReceiptRedemptionJob.INPUT_TERMINAL_DONATION, terminalDonation.encode())
.serialize());
enqueueDonationComplete();
} else {
Log.w(TAG, "Encountered a retryable exception: " + response.getStatus(), response.getExecutionError().orElse(null), true);
throw new RetryableException();
}
}
private void enqueueDonationComplete() {
if (donationErrorSource != DonationErrorSource.GIFT) {
return;
}
SignalStore.donationsValues().setPendingOneTimeDonation(null);
}
/**
* Sets the pending one-time donation error according to the status code.
*/

View File

@@ -15,6 +15,9 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.DonationS
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSData
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError.Companion.toDonationErrorValue
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
@@ -40,6 +43,8 @@ class ExternalLaunchDonationJob private constructor(
parameters: Parameters
) : BaseJob(parameters), StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper {
private var donationError: DonationError? = null
companion object {
const val KEY = "ExternalLaunchDonationJob"
@@ -96,6 +101,15 @@ class ExternalLaunchDonationJob private constructor(
jobChain.after(checkJob).enqueue()
}
private fun createDonationError(stripe3DSData: Stripe3DSData, throwable: Throwable): DonationError {
val source = when (stripe3DSData.gatewayRequest.donateToSignalType) {
DonateToSignalType.ONE_TIME -> DonationErrorSource.ONE_TIME
DonateToSignalType.MONTHLY -> DonationErrorSource.MONTHLY
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
}
return DonationError.PaymentSetupError.GenericError(source, throwable)
}
}
private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, ApplicationDependencies.getOkHttpClient())
@@ -106,7 +120,19 @@ class ExternalLaunchDonationJob private constructor(
override fun getFactoryKey(): String = KEY
override fun onFailure() = Unit
override fun onFailure() {
if (donationError != null) {
SignalStore.donationsValues().setPendingOneTimeDonation(
DonationSerializationHelper.createPendingOneTimeDonationProto(
stripe3DSData.gatewayRequest.badge,
stripe3DSData.paymentSourceType,
stripe3DSData.gatewayRequest.fiat
).copy(
error = donationError?.toDonationErrorValue()
)
)
}
}
override fun onRun() {
when (stripe3DSData.stripeIntentAccessor.objectType) {
@@ -207,11 +233,18 @@ class ExternalLaunchDonationJob private constructor(
StripeIntentStatus.CANCELED -> {
Log.i(TAG, "Stripe Intent is cancelled, we cannot proceed.", true)
throw Exception("User cancelled payment.")
donationError = createDonationError(stripe3DSData, Exception("User cancelled payment."))
throw donationError!!
}
StripeIntentStatus.REQUIRES_PAYMENT_METHOD -> {
Log.i(TAG, "Stripe Intent payment failed, we cannot proceed.", true)
donationError = createDonationError(stripe3DSData, Exception("payment failed"))
throw donationError!!
}
else -> {
Log.i(TAG, "Stripe Intent is still processing, retry later.", true)
Log.i(TAG, "Stripe Intent is still processing, retry later. $stripeIntentStatus", true)
throw RetryException()
}
}
@@ -260,10 +293,16 @@ class ExternalLaunchDonationJob private constructor(
error("Unexpected null value for serialized data")
}
val stripe3DSData = Stripe3DSData.fromProtoBytes(serializedData, -1L)
val stripe3DSData = parseSerializedData(serializedData)
return ExternalLaunchDonationJob(stripe3DSData, parameters)
}
companion object {
fun parseSerializedData(serializedData: ByteArray): Stripe3DSData {
return Stripe3DSData.fromProtoBytes(serializedData, -1L)
}
}
}
override fun fetchPaymentIntent(price: FiatMoney, level: Long, sourceType: PaymentSourceType.Stripe): Single<StripeIntentAccessor> {