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
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
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> {

View file

@ -306,11 +306,13 @@ message PendingOneTimeDonation {
IDEAL = 3;
}
PaymentMethodType paymentMethodType = 1;
FiatValue amount = 2;
BadgeList.Badge badge = 3;
int64 timestamp = 4;
optional DonationErrorValue error = 5;
PaymentMethodType paymentMethodType = 1;
FiatValue amount = 2;
BadgeList.Badge badge = 3;
int64 timestamp = 4;
optional DonationErrorValue error = 5;
bool pendingVerification = 6;
bool checkedVerification = 7;
}
/**

View file

@ -11,7 +11,7 @@
android:insetTop="2dp"
android:insetBottom="2dp"
app:iconGravity="textStart"
app:iconSize="32dp"
app:iconSize="24dp"
app:iconTint="@null"
tools:icon="@drawable/credit_card"
tools:text="Primary button"

View file

@ -11,7 +11,7 @@
android:insetTop="2dp"
android:insetBottom="2dp"
app:iconGravity="textStart"
app:iconSize="32dp"
app:iconSize="24dp"
app:iconTint="@null"
tools:icon="@drawable/bank_transfer"
tools:text="Tonal button"

View file

@ -18,7 +18,5 @@
app:iconGravity="textStart"
app:iconPadding="0dp"
app:iconTint="@null"
app:backgroundTint="#EEEEEE"
app:strokeColor="@color/paypal_outline"
app:strokeWidth="1.5dp" />
app:backgroundTint="#f6c757" />
</FrameLayout>

View file

@ -4764,6 +4764,10 @@
<string name="ManageDonationsFragment__other_ways_to_give">Other ways to give</string>
<!-- Preference label to launch badge gifting -->
<string name="ManageDonationsFragment__donate_for_a_friend">Donate for a Friend</string>
<!-- Dialog title shown when a donation requires verifying/confirmation outside of the app and the user hasn't done that yet -->
<string name="ManageDonationsFragment__couldnt_confirm_donation">Couldn\'t confirm donation</string>
<!-- Dialog message shown when a donation requires verifying/confirmation outside of the app and the user hasn't done that yet -->
<string name="ManageDonationsFragment__your_one_time_s_donation_couldnt_be_confirmed">Your one-time %1$s donation couldn\'t be confirmed. Check your banking app to approve your iDEAL payment.</string>
<string name="Boost__enter_custom_amount">Enter Custom Amount</string>
<!-- Error label when the amount is smaller than what we can accept -->
@ -4863,6 +4867,8 @@
<string name="DonationsErrors__this_user_cant_receive_donations_until">This user can\'t receive donations until they upgrade Signal.</string>
<!-- Displayed as a dialog message when the user\'s profile could not be fetched, likely due to lack of internet -->
<string name="DonationsErrors__your_donation_could_not_be_sent">Your donation could not be sent because of a network error. Check your connection and try again.</string>
<!-- Displayed as a dialog message when the user encounters an error during an iDEAL donation -->
<string name="DonationsErrors__your_ideal_couldnt_be_processed">Your iDEAL donation couldn\'t be processed. Try another payment method or contact your bank for more information.</string>
<!-- Gift message view title -->
<string name="GiftMessageView__donation_on_behalf_of_s">Donation on behalf of %1$s</string>
@ -5843,6 +5849,8 @@
<string name="DonateToSignalFragment__your_payment_is_still_being_processed_monthly">Your payment is still being processed. This can take a few minutes depending on your connection. Please wait until this payment completes before updating your subscription.</string>
<!-- Dialog body when a user tries to donate while they already have a pending one time donation. -->
<string name="DonateToSignalFragment__your_payment_is_still_being_processed_onetime">Your payment is still being processed. This can take a few minutes depending on your connection. Please wait until this payment completes before making another donation.</string>
<!-- Dialog body when a user opens the manage donations main screen and they have a pending iDEAL donation -->
<string name="DonateToSignalFragment__your_ideal_payment_is_still_processing">Your iDEAL payment is still processing. Check your banking app to approve your payment before making another donation.</string>
<!-- Donation pill toggle monthly text -->
<string name="DonationPillToggle__monthly">Monthly</string>
@ -5910,7 +5918,7 @@
<!-- Subtitle learn more of screen displaying bank transfer mandate -->
<string name="BankTransferMandateFragment__learn_more">Learn more</string>
<!-- Button label to continue with transfer -->
<string name="BankTransferMandateFragment__continue">Continue</string>
<string name="BankTransferMandateFragment__agree">Agree</string>
<!-- Button label to read more of the bank mandate that is currently off screen -->
<string name="BankTransferMandateFragment__read_more">Read more</string>
<!-- Text displayed when mandate load fails -->
@ -5918,7 +5926,7 @@
<!-- BankTransferDetailsFragment -->
<!-- Subtext explaining how email is used. Placeholder is \'Learn more\' -->
<string name="BankTransferDetailsFragment__enter_your_bank_details">Enter your bank details and email address. Your email is used by Stripe to send you updates about your donation. %1$s</string>
<string name="BankTransferDetailsFragment__enter_your_bank_details">Enter your bank details and email. Stripe uses this email to send you updates about your donation. %1$s</string>
<!-- Subtext learn more link text -->
<string name="BankTransferDetailsFragment__learn_more">Learn more</string>
<!-- Text field label for name on bank account -->