Replace monthly badge expires with cancellation dialogs.

This commit is contained in:
Cody Henthorne 2023-11-16 10:22:01 -05:00 committed by GitHub
parent 62bf5abd8d
commit df4bd1fa4a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 384 additions and 301 deletions

View file

@ -1,52 +0,0 @@
package org.thoughtcrime.securesms.badges.self.expired
import androidx.core.content.ContextCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.SplashImage
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.CommunicationActions
class CantProcessSubscriptionPaymentBottomSheetDialogFragment : DSLSettingsBottomSheetFragment() {
override fun bindAdapter(adapter: DSLSettingsAdapter) {
SplashImage.register(adapter)
adapter.submitList(getConfiguration().toMappingModelList())
}
private fun getConfiguration(): DSLConfiguration {
return configure {
customPref(SplashImage.Model(R.drawable.ic_card_process))
sectionHeaderPref(
title = DSLSettingsText.from(R.string.CantProcessSubscriptionPaymentBottomSheetDialogFragment__cant_process_subscription_payment, DSLSettingsText.CenterModifier)
)
textPref(
summary = DSLSettingsText.from(
requireContext().getString(R.string.CantProcessSubscriptionPaymentBottomSheetDialogFragment__were_having_trouble),
DSLSettingsText.LearnMoreModifier(ContextCompat.getColor(requireContext(), R.color.signal_accent_primary)) {
CommunicationActions.openBrowserLink(requireContext(), requireContext().getString(R.string.donation_decline_code_error_url))
},
DSLSettingsText.CenterModifier
)
)
primaryButton(
text = DSLSettingsText.from(android.R.string.ok)
) {
dismissAllowingStateLoss()
}
secondaryButtonNoOutline(
text = DSLSettingsText.from(R.string.CantProcessSubscriptionPaymentBottomSheetDialogFragment__dont_show_this_again)
) {
SignalStore.donationsValues().showCantProcessDialog = false
dismissAllowingStateLoss()
}
}
}
}

View file

@ -1,167 +0,0 @@
package org.thoughtcrime.securesms.badges.self.expired
import androidx.fragment.app.FragmentManager
import org.signal.core.util.DimensionUnit
import org.signal.core.util.logging.Log
import org.signal.donations.StripeDeclineCode
import org.signal.donations.StripeFailureCode
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.models.ExpiredBadge
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.mapToErrorStringResource
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.shouldRouteToGooglePay
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
/**
* Bottom sheet displaying a fading badge with a notice and action for becoming a subscriber again.
*/
class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
peekHeightPercentage = 1f
) {
override fun bindAdapter(adapter: DSLSettingsAdapter) {
ExpiredBadge.register(adapter)
adapter.submitList(getConfiguration().toMappingModelList())
}
private fun getConfiguration(): DSLConfiguration {
val args = ExpiredBadgeBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
val badge: Badge = args.badge
val cancellationReason = UnexpectedSubscriptionCancellation.fromStatus(args.cancelationReason)
val declineCode: StripeDeclineCode? = args.chargeFailure?.let { StripeDeclineCode.getFromCode(it) }
val failureCode: StripeFailureCode? = args.chargeFailure?.let { StripeFailureCode.getFromCode(it) }
val isLikelyASustainer = SignalStore.donationsValues().isLikelyASustainer()
val inactive = cancellationReason == UnexpectedSubscriptionCancellation.INACTIVE
Log.d(TAG, "Displaying Expired Badge Fragment with bundle: ${requireArguments()}", true)
return configure {
customPref(ExpiredBadge.Model(badge))
sectionHeaderPref(
DSLSettingsText.from(
if (badge.isBoost()) {
R.string.ExpiredBadgeBottomSheetDialogFragment__boost_badge_expired
} else {
R.string.ExpiredBadgeBottomSheetDialogFragment__monthly_donation_cancelled
},
DSLSettingsText.CenterModifier
)
)
space(DimensionUnit.DP.toPixels(4f).toInt())
noPadTextPref(
DSLSettingsText.from(
if (badge.isBoost()) {
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_boost_badge_has_expired_and)
} else if (declineCode != null) {
getString(
R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_canceled_s,
getString(declineCode.mapToErrorStringResource()),
badge.name
)
} else if (failureCode != null) {
getString(
R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_canceled_s,
getString(failureCode.mapToErrorStringResource()),
badge.name
)
} else if (inactive) {
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_automatically, badge.name)
} else {
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_canceled)
},
DSLSettingsText.CenterModifier
)
)
space(DimensionUnit.DP.toPixels(16f).toInt())
if (badge.isSubscription() && declineCode?.shouldRouteToGooglePay() == true) {
space(DimensionUnit.DP.toPixels(68f).toInt())
secondaryButtonNoOutline(
text = DSLSettingsText.from(R.string.ExpiredBadgeBottomSheetDialogFragment__go_to_google_pay),
onClick = {
CommunicationActions.openBrowserLink(requireContext(), getString(R.string.google_pay_url))
}
)
} else {
noPadTextPref(
DSLSettingsText.from(
if (badge.isBoost()) {
if (isLikelyASustainer) {
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_reactivate
} else {
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_keep
}
} else {
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can
},
DSLSettingsText.CenterModifier
)
)
space(DimensionUnit.DP.toPixels(92f).toInt())
}
primaryButton(
text = DSLSettingsText.from(
if (badge.isBoost()) {
if (isLikelyASustainer) {
R.string.ExpiredBadgeBottomSheetDialogFragment__add_a_boost
} else {
R.string.ExpiredBadgeBottomSheetDialogFragment__become_a_sustainer
}
} else {
R.string.ExpiredBadgeBottomSheetDialogFragment__renew_subscription
}
),
onClick = {
dismiss()
if (isLikelyASustainer) {
requireActivity().startActivity(AppSettingsActivity.boost(requireContext()))
} else {
requireActivity().startActivity(AppSettingsActivity.subscriptions(requireContext()))
}
}
)
secondaryButtonNoOutline(
text = DSLSettingsText.from(R.string.ExpiredBadgeBottomSheetDialogFragment__not_now),
onClick = {
dismiss()
}
)
}
}
companion object {
private val TAG = Log.tag(ExpiredBadgeBottomSheetDialogFragment::class.java)
@JvmStatic
fun show(
badge: Badge,
cancellationReason: UnexpectedSubscriptionCancellation?,
chargeFailure: ActiveSubscription.ChargeFailure?,
fragmentManager: FragmentManager
) {
val args = ExpiredBadgeBottomSheetDialogFragmentArgs.Builder(badge, cancellationReason?.status, chargeFailure?.code).build()
val fragment = ExpiredBadgeBottomSheetDialogFragment()
fragment.arguments = args.toBundle()
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}

View file

@ -0,0 +1,121 @@
package org.thoughtcrime.securesms.badges.self.expired
import androidx.fragment.app.FragmentManager
import org.signal.core.util.DimensionUnit
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.models.ExpiredBadge
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
/**
* Bottom sheet displaying a fading badge with a notice and action for becoming a subscriber again.
*/
class ExpiredOneTimeBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
peekHeightPercentage = 1f
) {
override fun bindAdapter(adapter: DSLSettingsAdapter) {
ExpiredBadge.register(adapter)
adapter.submitList(getConfiguration().toMappingModelList())
}
private fun getConfiguration(): DSLConfiguration {
val args = ExpiredOneTimeBadgeBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
val badge: Badge = args.badge
val isLikelyASustainer = SignalStore.donationsValues().isLikelyASustainer()
Log.d(TAG, "Displaying Expired Badge Fragment with bundle: ${requireArguments()}", true)
return configure {
customPref(ExpiredBadge.Model(badge))
sectionHeaderPref(
DSLSettingsText.from(
if (badge.isBoost()) {
R.string.ExpiredBadgeBottomSheetDialogFragment__boost_badge_expired
} else {
R.string.ExpiredBadgeBottomSheetDialogFragment__monthly_donation_cancelled
},
DSLSettingsText.CenterModifier
)
)
space(DimensionUnit.DP.toPixels(4f).toInt())
noPadTextPref(
DSLSettingsText.from(
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_boost_badge_has_expired_and),
DSLSettingsText.CenterModifier
)
)
space(DimensionUnit.DP.toPixels(16f).toInt())
noPadTextPref(
DSLSettingsText.from(
if (isLikelyASustainer) {
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_reactivate
} else {
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_keep
},
DSLSettingsText.CenterModifier
)
)
space(DimensionUnit.DP.toPixels(92f).toInt())
primaryButton(
text = DSLSettingsText.from(
if (isLikelyASustainer) {
R.string.ExpiredBadgeBottomSheetDialogFragment__add_a_boost
} else {
R.string.ExpiredBadgeBottomSheetDialogFragment__become_a_sustainer
}
),
onClick = {
dismiss()
if (isLikelyASustainer) {
requireActivity().startActivity(AppSettingsActivity.boost(requireContext()))
} else {
requireActivity().startActivity(AppSettingsActivity.subscriptions(requireContext()))
}
}
)
secondaryButtonNoOutline(
text = DSLSettingsText.from(R.string.ExpiredBadgeBottomSheetDialogFragment__not_now),
onClick = {
dismiss()
}
)
}
}
companion object {
private val TAG = Log.tag(ExpiredOneTimeBadgeBottomSheetDialogFragment::class.java)
@JvmStatic
fun show(
badge: Badge,
cancellationReason: UnexpectedSubscriptionCancellation?,
chargeFailure: ActiveSubscription.ChargeFailure?,
fragmentManager: FragmentManager
) {
val args = ExpiredOneTimeBadgeBottomSheetDialogFragmentArgs.Builder(badge, cancellationReason?.status, chargeFailure?.code).build()
val fragment = ExpiredOneTimeBadgeBottomSheetDialogFragment()
fragment.arguments = args.toBundle()
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}

View file

@ -0,0 +1,191 @@
package org.thoughtcrime.securesms.badges.self.expired
import android.content.res.Configuration
import android.net.Uri
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
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.fragment.app.FragmentManager
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Texts
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.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImage112
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.mapToErrorStringResource
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.ManageDonationsFragment
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SpanUtil
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
class MonthlyDonationCanceledBottomSheetDialogFragment : ComposeBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 1f
@Composable
override fun SheetContent() {
val chargeFailure: ActiveSubscription.ChargeFailure? = SignalStore.donationsValues().getUnexpectedSubscriptionCancelationChargeFailure()
val declineCode: StripeDeclineCode = StripeDeclineCode.getFromCode(chargeFailure?.outcomeNetworkReason)
val failureCode: StripeFailureCode = StripeFailureCode.getFromCode(chargeFailure?.code)
val errorMessage = if (declineCode.isKnown()) {
declineCode.mapToErrorStringResource()
} else if (failureCode.isKnown) {
failureCode.mapToErrorStringResource()
} else {
declineCode.mapToErrorStringResource()
}
MonthlyDonationCanceled(
badge = SignalStore.donationsValues().getExpiredBadge(),
errorMessageRes = errorMessage,
onRenewClicked = {
startActivity(AppSettingsActivity.subscriptions(requireContext()))
dismissAllowingStateLoss()
},
onNotNowClicked = {
SignalStore.donationsValues().showMonthlyDonationCanceledDialog = false
dismissAllowingStateLoss()
}
)
}
companion object {
@JvmStatic
fun show(fragmentManager: FragmentManager) {
val fragment = MonthlyDonationCanceledBottomSheetDialogFragment()
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}
@Preview(name = "Light Theme", group = "ShortName", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark Theme", group = "ShortName", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun MonthlyDonationCanceledPreview() {
SignalTheme {
Surface {
MonthlyDonationCanceled(
badge = Badge(
id = "",
category = Badge.Category.Donor,
name = "Signal Star",
description = "",
imageUrl = Uri.EMPTY,
imageDensity = "",
expirationTimestamp = 0L,
visible = true,
duration = 0L
),
errorMessageRes = R.string.StripeFailureCode__verify_your_bank_details_are_correct,
onRenewClicked = {},
onNotNowClicked = {}
)
}
}
}
@Composable
private fun MonthlyDonationCanceled(
badge: Badge?,
@StringRes errorMessageRes: Int,
onRenewClicked: () -> Unit,
onNotNowClicked: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 34.dp)
) {
BottomSheets.Handle()
if (badge != null) {
Box(modifier = Modifier.padding(top = 21.dp, bottom = 16.dp)) {
BadgeImage112(
badge = badge,
modifier = Modifier
.size(80.dp)
)
Image(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_error_circle_fill_24),
contentScale = ContentScale.Inside,
contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.error),
modifier = Modifier
.size(24.dp)
.align(Alignment.TopEnd)
.background(
color = SignalTheme.colors.colorSurface1,
shape = CircleShape
)
)
}
}
Text(
text = stringResource(id = R.string.MonthlyDonationCanceled__title),
style = MaterialTheme.typography.titleLarge.copy(textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurface),
modifier = Modifier.padding(bottom = 20.dp)
)
val context = LocalContext.current
val learnMore = stringResource(id = R.string.MonthlyDonationCanceled__learn_more)
val errorMessage = stringResource(id = errorMessageRes)
val fullString = stringResource(id = R.string.MonthlyDonationCanceled__message, errorMessage, learnMore)
val spanned = SpanUtil.urlSubsequence(fullString, learnMore, ManageDonationsFragment.DONATE_TROUBLESHOOTING_URL)
Texts.LinkifiedText(
textWithUrlSpans = spanned,
onUrlClick = { CommunicationActions.openBrowserLink(context, it) },
style = MaterialTheme.typography.bodyLarge.copy(textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant),
modifier = Modifier.padding(bottom = 36.dp)
)
Buttons.LargeTonal(
onClick = onRenewClicked,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = 16.dp)
) {
Text(text = stringResource(id = R.string.MonthlyDonationCanceled__renew_button))
}
TextButton(
onClick = onNotNowClicked,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = 56.dp)
) {
Text(text = stringResource(id = R.string.MonthlyDonationCanceled__not_now_button))
}
}
}

View file

@ -144,19 +144,21 @@ class InternalDonorErrorConfigurationViewModel : ViewModel() {
private fun handleSubscriptionExpiration(state: InternalDonorErrorConfigurationState) {
SignalStore.donationsValues().setExpiredBadge(state.selectedBadge)
SignalStore.donationsValues().clearUserManuallyCancelled()
handleSubscriptionPaymentFailure(state)
}
private fun handleSubscriptionPaymentFailure(state: InternalDonorErrorConfigurationState) {
SignalStore.donationsValues().unexpectedSubscriptionCancelationReason = state.selectedUnexpectedSubscriptionCancellation?.status
SignalStore.donationsValues().unexpectedSubscriptionCancelationTimestamp = System.currentTimeMillis()
SignalStore.donationsValues().showMonthlyDonationCanceledDialog = true
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationChargeFailure(
state.selectedStripeDeclineCode?.let {
ActiveSubscription.ChargeFailure(
it.code,
"Test Charge Failure",
"Test Network Status",
"Test Network Reason",
it.code,
"Test"
)
}

View file

@ -31,17 +31,17 @@ fun StripeFailureCode.mapToErrorStringResource(): Int {
fun StripeDeclineCode.mapToErrorStringResource(): Int {
return when (this) {
is StripeDeclineCode.Known -> when (this.code) {
StripeDeclineCode.Code.APPROVE_WITH_ID -> R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again
StripeDeclineCode.Code.CALL_ISSUER -> R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again_if_the_problem
StripeDeclineCode.Code.APPROVE_WITH_ID -> R.string.DeclineCode__verify_your_card_details_are_correct_and_try_again
StripeDeclineCode.Code.CALL_ISSUER -> R.string.DeclineCode__verify_your_card_details_are_correct_and_try_again_if_the_problem_continues
StripeDeclineCode.Code.CARD_NOT_SUPPORTED -> R.string.DeclineCode__your_card_does_not_support_this_type_of_purchase
StripeDeclineCode.Code.EXPIRED_CARD -> R.string.DeclineCode__your_card_has_expired
StripeDeclineCode.Code.INCORRECT_NUMBER -> R.string.DeclineCode__your_card_number_is_incorrect
StripeDeclineCode.Code.INCORRECT_CVC -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect
StripeDeclineCode.Code.EXPIRED_CARD -> R.string.DeclineCode__your_card_has_expired_verify_your_card_details
StripeDeclineCode.Code.INCORRECT_NUMBER -> R.string.DeclineCode__your_card_number_is_incorrect_verify_your_card_details
StripeDeclineCode.Code.INCORRECT_CVC -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect_verify_your_card_details
StripeDeclineCode.Code.INSUFFICIENT_FUNDS -> R.string.DeclineCode__your_card_does_not_have_sufficient_funds
StripeDeclineCode.Code.INVALID_CVC -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect
StripeDeclineCode.Code.INVALID_EXPIRY_MONTH -> R.string.DeclineCode__the_expiration_month
StripeDeclineCode.Code.INVALID_EXPIRY_YEAR -> R.string.DeclineCode__the_expiration_year
StripeDeclineCode.Code.INVALID_NUMBER -> R.string.DeclineCode__your_card_number_is_incorrect
StripeDeclineCode.Code.INVALID_CVC -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect_verify_your_card_details
StripeDeclineCode.Code.INVALID_EXPIRY_MONTH -> R.string.DeclineCode__the_expiration_month_on_your_card_is_incorrect
StripeDeclineCode.Code.INVALID_EXPIRY_YEAR -> R.string.DeclineCode__the_expiration_year_on_your_card_is_incorrect
StripeDeclineCode.Code.INVALID_NUMBER -> R.string.DeclineCode__your_card_number_is_incorrect_verify_your_card_details
StripeDeclineCode.Code.ISSUER_NOT_AVAILABLE -> R.string.DeclineCode__try_completing_the_payment_again
StripeDeclineCode.Code.PROCESSING_ERROR -> R.string.DeclineCode__try_again
StripeDeclineCode.Code.REENTER_TRANSACTION -> R.string.DeclineCode__try_again
@ -50,26 +50,3 @@ fun StripeDeclineCode.mapToErrorStringResource(): Int {
else -> R.string.DeclineCode__try_another_payment_method_or_contact_your_bank
}
}
fun StripeDeclineCode.shouldRouteToGooglePay(): Boolean {
return when (this) {
is StripeDeclineCode.Known -> when (this.code) {
StripeDeclineCode.Code.APPROVE_WITH_ID -> true
StripeDeclineCode.Code.CALL_ISSUER -> true
StripeDeclineCode.Code.CARD_NOT_SUPPORTED -> false
StripeDeclineCode.Code.EXPIRED_CARD -> true
StripeDeclineCode.Code.INCORRECT_NUMBER -> true
StripeDeclineCode.Code.INCORRECT_CVC -> true
StripeDeclineCode.Code.INSUFFICIENT_FUNDS -> false
StripeDeclineCode.Code.INVALID_CVC -> true
StripeDeclineCode.Code.INVALID_EXPIRY_MONTH -> true
StripeDeclineCode.Code.INVALID_EXPIRY_YEAR -> true
StripeDeclineCode.Code.INVALID_NUMBER -> true
StripeDeclineCode.Code.ISSUER_NOT_AVAILABLE -> false
StripeDeclineCode.Code.PROCESSING_ERROR -> false
StripeDeclineCode.Code.REENTER_TRANSACTION -> false
else -> false
}
else -> false
}
}

View file

@ -55,7 +55,7 @@ class ManageDonationsFragment :
companion object {
private val alertedIdealDonations = mutableSetOf<Long>()
private const val DONATE_TROUBLESHOOTING_URL = "https://support.signal.org/hc/articles/360031949872#fix"
const val DONATE_TROUBLESHOOTING_URL = "https://support.signal.org/hc/articles/360031949872#fix"
}
private val supportTechSummary: CharSequence by lazy {

View file

@ -27,7 +27,7 @@ data class ManageDonationsState(
private fun getStateFromActiveSubscription(activeSubscription: ActiveSubscription): RedemptionState? {
return when {
activeSubscription.isFailedPayment -> RedemptionState.FAILED
activeSubscription.isFailedPayment && !activeSubscription.isPastDue -> RedemptionState.FAILED
activeSubscription.isPendingBankTransfer -> RedemptionState.IS_PENDING_BANK_TRANSFER
activeSubscription.isInProgress -> RedemptionState.IN_PROGRESS
else -> null

View file

@ -90,8 +90,8 @@ import org.thoughtcrime.securesms.MuteDialog;
import org.thoughtcrime.securesms.NewConversationActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.badges.models.Badge;
import org.thoughtcrime.securesms.badges.self.expired.CantProcessSubscriptionPaymentBottomSheetDialogFragment;
import org.thoughtcrime.securesms.badges.self.expired.ExpiredBadgeBottomSheetDialogFragment;
import org.thoughtcrime.securesms.badges.self.expired.MonthlyDonationCanceledBottomSheetDialogFragment;
import org.thoughtcrime.securesms.badges.self.expired.ExpiredOneTimeBadgeBottomSheetDialogFragment;
import org.thoughtcrime.securesms.components.Material3SearchToolbar;
import org.thoughtcrime.securesms.components.RatingManager;
import org.thoughtcrime.securesms.components.SignalProgressDialog;
@ -167,7 +167,6 @@ import org.thoughtcrime.securesms.stories.tabs.ConversationListTab;
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.BottomSheetUtil;
import org.thoughtcrime.securesms.util.CachedInflater;
import org.thoughtcrime.securesms.util.ConversationUtil;
import org.thoughtcrime.securesms.util.PlayStoreUtil;
@ -491,7 +490,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
Badge expiredBadge = SignalStore.donationsValues().getExpiredBadge();
String subscriptionCancellationReason = SignalStore.donationsValues().getUnexpectedSubscriptionCancelationReason();
UnexpectedSubscriptionCancellation unexpectedSubscriptionCancellation = UnexpectedSubscriptionCancellation.fromStatus(subscriptionCancellationReason);
boolean isDisplayingSubscriptionFailure = false;
long subscriptionFailureTimestamp = SignalStore.donationsValues().getUnexpectedSubscriptionCancelationTimestamp();
long subscriptionFailureWatermark = SignalStore.donationsValues().getUnexpectedSubscriptionCancelationWatermark();
boolean isWatermarkPriorToTimestamp = subscriptionFailureWatermark < subscriptionFailureTimestamp;
@ -502,9 +500,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
isWatermarkPriorToTimestamp)
{
Log.w(TAG, "Displaying bottom sheet for unexpected cancellation: " + unexpectedSubscriptionCancellation, true);
new CantProcessSubscriptionPaymentBottomSheetDialogFragment().show(getChildFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG);
MonthlyDonationCanceledBottomSheetDialogFragment.show(getChildFragmentManager());
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationWatermark(subscriptionFailureTimestamp);
isDisplayingSubscriptionFailure = true;
} else if (unexpectedSubscriptionCancellation != null && SignalStore.donationsValues().isUserManuallyCancelled()) {
Log.w(TAG, "Unexpected cancellation detected but not displaying dialog because user manually cancelled their subscription: " + unexpectedSubscriptionCancellation, true);
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationWatermark(subscriptionFailureTimestamp);
@ -513,17 +510,16 @@ public class ConversationListFragment extends MainFragment implements ActionMode
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationWatermark(subscriptionFailureTimestamp);
}
if (expiredBadge != null && !isDisplayingSubscriptionFailure) {
if (expiredBadge != null && expiredBadge.isBoost()) {
SignalStore.donationsValues().setExpiredBadge(null);
if (expiredBadge.isBoost() || !SignalStore.donationsValues().isUserManuallyCancelled()) {
Log.w(TAG, "Displaying bottom sheet for an expired badge", true);
ExpiredBadgeBottomSheetDialogFragment.show(
expiredBadge,
unexpectedSubscriptionCancellation,
SignalStore.donationsValues().getUnexpectedSubscriptionCancelationChargeFailure(),
getParentFragmentManager());
}
Log.w(TAG, "Displaying bottom sheet for an expired badge", true);
ExpiredOneTimeBadgeBottomSheetDialogFragment.show(
expiredBadge,
unexpectedSubscriptionCancellation,
SignalStore.donationsValues().getUnexpectedSubscriptionCancelationChargeFailure(),
getParentFragmentManager()
);
}
}

View file

@ -72,7 +72,6 @@ public class DonationReceiptRedemptionJob extends BaseJob {
.addConstraint(NetworkConstraint.KEY)
.setQueue(SUBSCRIPTION_QUEUE + (isLongRunningDonationPaymentType ? LONG_RUNNING_QUEUE_SUFFIX : ""))
.setMaxAttempts(Parameters.UNLIMITED)
.setMaxInstancesForQueue(1)
.setLifespan(TimeUnit.DAYS.toMillis(1))
.build());
}

View file

@ -109,19 +109,6 @@ public class SubscriptionKeepAliveJob extends BaseJob {
return;
}
if (activeSubscription.isFailedPayment()) {
Log.i(TAG, "User has a subscription with a failed payment. Marking the payment failure. Status message: " + activeSubscription.getActiveSubscription().getStatus(), true);
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationChargeFailure(activeSubscription.getChargeFailure());
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationReason(activeSubscription.getActiveSubscription().getStatus());
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationTimestamp(activeSubscription.getActiveSubscription().getEndOfCurrentPeriod());
return;
}
if (!activeSubscription.getActiveSubscription().isActive()) {
Log.i(TAG, "User has an inactive subscription. Status message: " + activeSubscription.getActiveSubscription().getStatus() + " Exiting.", true);
return;
}
DonationRedemptionJobStatus status = DonationRedemptionJobWatcher.getSubscriptionRedemptionJobStatus();
if (status != DonationRedemptionJobStatus.None.INSTANCE && status != DonationRedemptionJobStatus.FailedSubscription.INSTANCE) {
Log.i(TAG, "Already trying to redeem donation, current status: " + status.getClass().getSimpleName(), true);

View file

@ -15,6 +15,7 @@ import org.signal.libsignal.zkgroup.receipts.ReceiptCredential;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequestContext;
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationsConfigurationExtensionsKt;
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError;
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource;
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.PayPalDeclineCode;
@ -30,11 +31,13 @@ import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.subscription.Subscriber;
import org.signal.core.util.Base64;
import org.whispersystems.signalservice.api.services.DonationsService;
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription;
import org.whispersystems.signalservice.api.subscriptions.SubscriberId;
import org.whispersystems.signalservice.internal.ServiceResponse;
import java.io.IOException;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
import okio.ByteString;
@ -72,7 +75,6 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
.Builder()
.addConstraint(NetworkConstraint.KEY)
.setQueue("ReceiptRedemption")
.setMaxInstancesForQueue(1)
.setLifespan(terminalDonation.isLongRunningPaymentMethod ? TimeUnit.DAYS.toMillis(30) : TimeUnit.DAYS.toMillis(1))
.setMaxAttempts(Parameters.UNLIMITED)
.build(),
@ -174,12 +176,11 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
}
if (isForKeepAlive) {
Log.w(TAG, "Subscription payment failure in active subscription response (status = " + subscription.getStatus() + ").", true);
onPaymentFailure(subscription.getStatus(), subscription.getProcessor(), chargeFailure, subscription.getEndOfCurrentPeriod(), true);
throw new Exception("Active subscription hit a payment failure: " + subscription.getStatus());
Log.w(TAG, "Subscription payment failure in active subscription response (status = " + subscription.getStatus() + "). Payment could still be retried by processor.", true);
throw new Exception("Payment renewal is in retry state, let keep-alive job restart process");
} else {
Log.w(TAG, "New subscription has hit a payment failure. (status = " + subscription.getStatus() + ").", true);
onPaymentFailure(subscription.getStatus(), subscription.getProcessor(), chargeFailure, subscription.getEndOfCurrentPeriod(), false);
onPaymentFailure(subscription, chargeFailure, false);
throw new Exception("New subscription has hit a payment failure: " + subscription.getStatus());
}
} else if (!subscription.isActive()) {
@ -189,16 +190,19 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
if (!isForKeepAlive) {
Log.w(TAG, "Initial subscription payment failed, treating as a permanent failure.");
onPaymentFailure(subscription.getStatus(), subscription.getProcessor(), chargeFailure, subscription.getEndOfCurrentPeriod(), false);
onPaymentFailure(subscription, chargeFailure, false);
throw new Exception("New subscription has hit a payment failure.");
}
}
if (isForKeepAlive && subscription.isCanceled()) {
Log.w(TAG, "Permanent payment failure in renewing subscription. (status = " + subscription.getStatus() + ").", true);
onPaymentFailure(subscription, chargeFailure, true);
throw new Exception();
}
Log.w(TAG, "Subscription is not yet active. Status: " + subscription.getStatus(), true);
throw new RetryableException();
} else if (subscription.isCanceled()) {
Log.w(TAG, "Subscription is marked as cancelled, but it's possible that the user cancelled and then later tried to resubscribe. Scheduling a retry.", true);
throw new RetryableException();
} else {
Log.i(TAG, "Subscription is valid, proceeding with request for ReceiptCredentialResponse", true);
long storedEndOfPeriod = SignalStore.donationsValues().getLastEndOfPeriod();
@ -353,15 +357,21 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
* 1. In the case of a keep-alive event, we want to book-keep the error to show the user on a subsequent launch, and we want to sync our failure state to
* linked devices.
*/
private void onPaymentFailure(@NonNull String status, @NonNull ActiveSubscription.Processor processor, @Nullable ActiveSubscription.ChargeFailure chargeFailure, long timestamp, boolean isForKeepAlive) {
private void onPaymentFailure(@NonNull ActiveSubscription.Subscription subscription, @Nullable ActiveSubscription.ChargeFailure chargeFailure, boolean isForKeepAlive) {
SignalStore.donationsValues().setShouldCancelSubscriptionBeforeNextSubscribeAttempt(true);
if (isForKeepAlive) {
Log.d(TAG, "Is for a keep-alive and we have a status. Setting UnexpectedSubscriptionCancelation state...", true);
Log.d(TAG, "Subscription canceled during keep-alive. Setting UnexpectedSubscriptionCancelation state...", true);
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationChargeFailure(chargeFailure);
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationReason(status);
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationTimestamp(timestamp);
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationReason(subscription.getStatus());
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationTimestamp(subscription.getEndOfCurrentPeriod());
SignalStore.donationsValues().setShowMonthlyDonationCanceledDialog(true);
ApplicationDependencies.getDonationsService().getDonationsConfiguration(Locale.getDefault()).getResult().ifPresent(config -> {
SignalStore.donationsValues().setExpiredBadge(DonationsConfigurationExtensionsKt.getBadge(config, subscription.getLevel()));
});
MultiDeviceSubscriptionSyncRequestJob.enqueue();
} else if (chargeFailure != null && processor == ActiveSubscription.Processor.STRIPE) {
} else if (chargeFailure != null && subscription.getProcessor() == ActiveSubscription.Processor.STRIPE) {
Log.d(TAG, "Stripe charge failure detected: " + chargeFailure, true);
StripeDeclineCode declineCode = StripeDeclineCode.Companion.getFromCode(chargeFailure.getOutcomeNetworkReason());
@ -399,7 +409,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);
onPaymentFailedError(paymentSetupError);
} else if (chargeFailure != null && processor == ActiveSubscription.Processor.BRAINTREE) {
} else if (chargeFailure != null && subscription.getProcessor() == ActiveSubscription.Processor.BRAINTREE) {
Log.d(TAG, "PayPal charge failure detected: " + chargeFailure, true);

View file

@ -387,7 +387,7 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
var unexpectedSubscriptionCancelationWatermark: Long by longValue(SUBSCRIPTION_CANCELATION_WATERMARK, 0L)
@get:JvmName("showCantProcessDialog")
var showCantProcessDialog: Boolean by booleanValue(SHOW_CANT_PROCESS_DIALOG, true)
var showMonthlyDonationCanceledDialog: Boolean by booleanValue(SHOW_CANT_PROCESS_DIALOG, true)
/**
* Denotes that the previous attempt to subscribe failed in some way. Either an

View file

@ -28,7 +28,7 @@
<dialog
android:id="@+id/expiredBadgeDialog"
android:name="org.thoughtcrime.securesms.badges.self.expired.ExpiredBadgeBottomSheetDialogFragment"
android:name="org.thoughtcrime.securesms.badges.self.expired.ExpiredOneTimeBadgeBottomSheetDialogFragment"
android:label="dialog_expired_badge">
<argument

View file

@ -6443,5 +6443,16 @@
<!-- Step 2 of bottom sheet shown after tapping "Turn on" from the megaphone to re-enable full screen notifications for incoming call notifications, it indicates the name of the setting that needs to be re-enabled -->
<string name="GrantFullScreenIntentPermission_bottomsheet_step2">2. %1$s Allow full screen notifications</string>
<!-- Bottom sheet dialog shown when a monthly donation fails to renew, title for dialog -->
<string name="MonthlyDonationCanceled__title">Monthly donation canceled</string>
<!-- Bottom sheet dialog shown when a monthly donation fails to renew, body for dialog. First placeholder is a payment related error message. Second placeholder is 'learn more' -->
<string name="MonthlyDonationCanceled__message">Your recurring monthly donation was canceled. %1$s\n\nYour badge will no longer be visible on your profile. %2$s</string>
<!-- Bottom sheet dialog shown when a monthly donation fails to renew, learn more used in placeholder for body for dialog. -->
<string name="MonthlyDonationCanceled__learn_more">Learn more</string>
<!-- Bottom sheet dialog shown when a monthly donation fails to renew, primary button to renew subscription with new data -->
<string name="MonthlyDonationCanceled__renew_button">Renew subscription</string>
<!-- Bottom sheet dialog shown when a monthly donation fails to renew, second button to dismiss the dialog entirely -->
<string name="MonthlyDonationCanceled__not_now_button">Not now</string>
<!-- EOF -->
</resources>

View file

@ -136,7 +136,11 @@ public final class ActiveSubscription {
}
public boolean isInProgress() {
return activeSubscription != null && !isActive() && !activeSubscription.isFailedPayment();
return activeSubscription != null && !isActive() && (!activeSubscription.isFailedPayment() || activeSubscription.isPastDue());
}
public boolean isPastDue() {
return activeSubscription != null && activeSubscription.isPastDue();
}
public boolean isFailedPayment() {
@ -249,6 +253,10 @@ public final class ActiveSubscription {
return Status.isPaymentFailed(getStatus());
}
public boolean isPastDue() {
return Status.getStatus(getStatus()) == Status.PAST_DUE;
}
public boolean isCanceled() {
return Status.getStatus(getStatus()) == Status.CANCELED;
}