Implement checks for badge redemption progress for subscriptions.

This commit is contained in:
Alex Hart 2021-11-15 13:47:51 -04:00 committed by GitHub
parent 16ae2c870f
commit b0f43535c6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 166 additions and 16 deletions

View file

@ -241,8 +241,8 @@ class BoostFragment : DSLSettingsBottomSheetFragment(
if (throwable is DonationExceptions.TimedOutWaitingForTokenRedemption) {
Log.w(TAG, "Timed out while redeeming token", throwable, true)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__redemption_still_pending)
.setMessage(R.string.DonationsErrors__you_might_not_see_your_badge_right_away)
.setTitle(R.string.DonationsErrors__still_processing)
.setMessage(R.string.DonationsErrors__your_payment_is_still)
.setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
findNavController().popBackStack()

View file

@ -1,7 +1,11 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import android.text.SpannableStringBuilder
import android.text.method.LinkMovementMethod
import android.view.View
import android.widget.ProgressBar
import android.widget.TextView
import androidx.core.content.ContextCompat
import com.google.android.material.button.MaterialButton
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
@ -12,6 +16,8 @@ import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.visible
import java.util.Locale
/**
@ -23,7 +29,9 @@ object ActiveSubscriptionPreference {
class Model(
val subscription: Subscription,
val onAddBoostClick: () -> Unit,
val renewalTimestamp: Long = -1L
val renewalTimestamp: Long = -1L,
val redemptionState: ManageDonationsState.SubscriptionRedemptionState,
val onContactSupport: () -> Unit
) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return subscription.id == newItem.subscription.id
@ -32,7 +40,8 @@ object ActiveSubscriptionPreference {
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) &&
subscription == newItem.subscription &&
renewalTimestamp == newItem.renewalTimestamp
renewalTimestamp == newItem.renewalTimestamp &&
redemptionState == newItem.redemptionState
}
}
@ -43,6 +52,7 @@ object ActiveSubscriptionPreference {
val price: TextView = itemView.findViewById(R.id.my_support_price)
val expiry: TextView = itemView.findViewById(R.id.my_support_expiry)
val boost: MaterialButton = itemView.findViewById(R.id.my_support_boost)
val progress: ProgressBar = itemView.findViewById(R.id.my_support_progress)
override fun bind(model: Model) {
badge.setBadge(model.subscription.badge)
@ -56,7 +66,20 @@ object ActiveSubscriptionPreference {
FiatMoneyUtil.formatOptions()
)
)
expiry.movementMethod = LinkMovementMethod.getInstance()
when (model.redemptionState) {
ManageDonationsState.SubscriptionRedemptionState.NONE -> presentRenewalState(model)
ManageDonationsState.SubscriptionRedemptionState.IN_PROGRESS -> presentInProgressState()
ManageDonationsState.SubscriptionRedemptionState.FAILED -> presentFailureState(model)
}
boost.setOnClickListener {
model.onAddBoostClick()
}
}
private fun presentRenewalState(model: Model) {
expiry.text = context.getString(
R.string.MySupportPreference__renews_s,
DateUtils.formatDateWithYear(
@ -64,10 +87,27 @@ object ActiveSubscriptionPreference {
model.renewalTimestamp
)
)
badge.alpha = 1f
progress.visible = false
}
boost.setOnClickListener {
model.onAddBoostClick()
}
private fun presentInProgressState() {
expiry.text = context.getString(R.string.MySupportPreference__processing_transaction)
badge.alpha = 0.2f
progress.visible = true
}
private fun presentFailureState(model: Model) {
expiry.text = SpannableStringBuilder(context.getString(R.string.MySupportPreference__couldnt_add_badge))
.append(" ")
.append(
SpanUtil.clickable(
context.getString(R.string.MySupportPreference__please_contact_support),
ContextCompat.getColor(context, R.color.signal_accent_primary)
) { model.onContactSupport() }
)
badge.alpha = 0.2f
progress.visible = false
}
}

View file

@ -13,10 +13,12 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
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.SubscriptionsRepository
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.LifecycleDisposable
import java.util.concurrent.TimeUnit
@ -114,7 +116,12 @@ class ManageDonationsFragment : DSLSettingsFragment() {
onAddBoostClick = {
findNavController().navigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToBoosts())
},
renewalTimestamp = TimeUnit.SECONDS.toMillis(activeSubscription.activeSubscription.endOfCurrentPeriod)
renewalTimestamp = TimeUnit.SECONDS.toMillis(activeSubscription.activeSubscription.endOfCurrentPeriod),
redemptionState = state.subscriptionRedemptionState,
onContactSupport = {
requireActivity().finish()
requireActivity().startActivity(AppSettingsActivity.help(requireContext(), HelpFragment.DONATION_INDEX))
}
)
)
@ -132,6 +139,7 @@ class ManageDonationsFragment : DSLSettingsFragment() {
clickPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__manage_subscription),
icon = DSLSettingsIcon.from(R.drawable.ic_person_white_24dp),
isEnabled = state.subscriptionRedemptionState != ManageDonationsState.SubscriptionRedemptionState.IN_PROGRESS,
onClick = {
findNavController().navigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToSubscribeFragment())
}

View file

@ -7,11 +7,18 @@ import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
data class ManageDonationsState(
val featuredBadge: Badge? = null,
val transactionState: TransactionState = TransactionState.Init,
val availableSubscriptions: List<Subscription> = emptyList()
val availableSubscriptions: List<Subscription> = emptyList(),
val subscriptionRedemptionState: SubscriptionRedemptionState = SubscriptionRedemptionState.NONE
) {
sealed class TransactionState {
object Init : TransactionState()
object InTransaction : TransactionState()
class NotInTransaction(val activeSubscription: ActiveSubscription) : TransactionState()
}
enum class SubscriptionRedemptionState {
NONE,
IN_PROGRESS,
FAILED
}
}

View file

@ -12,6 +12,7 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.subscription.LevelUpdate
import org.thoughtcrime.securesms.util.livedata.Store
@ -44,6 +45,22 @@ class ManageDonationsViewModel(
val levelUpdateOperationEdges: Observable<Boolean> = LevelUpdate.isProcessing.distinctUntilChanged()
val activeSubscription: Single<ActiveSubscription> = subscriptionsRepository.getActiveSubscription()
disposables += SubscriptionRedemptionJobWatcher.watch().subscribeBy { jobStateOptional ->
store.update { manageDonationsState ->
manageDonationsState.copy(
subscriptionRedemptionState = jobStateOptional.transform { jobState ->
when (jobState) {
JobTracker.JobState.PENDING -> ManageDonationsState.SubscriptionRedemptionState.IN_PROGRESS
JobTracker.JobState.RUNNING -> ManageDonationsState.SubscriptionRedemptionState.IN_PROGRESS
JobTracker.JobState.SUCCESS -> ManageDonationsState.SubscriptionRedemptionState.NONE
JobTracker.JobState.FAILURE -> ManageDonationsState.SubscriptionRedemptionState.FAILED
JobTracker.JobState.IGNORED -> ManageDonationsState.SubscriptionRedemptionState.NONE
}
}.or(ManageDonationsState.SubscriptionRedemptionState.NONE)
)
}
}
disposables += levelUpdateOperationEdges.flatMapSingle { isProcessing ->
if (isProcessing) {
Single.just(ManageDonationsState.TransactionState.InTransaction)

View file

@ -0,0 +1,33 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import io.reactivex.rxjava3.core.Observable
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobs.DonationReceiptRedemptionJob
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.libsignal.util.guava.Optional
import java.util.concurrent.TimeUnit
/**
* Allows observer to poll for the status of the latest pending, running, or completed redemption job for subscriptions.
*/
object SubscriptionRedemptionJobWatcher {
fun watch(): Observable<Optional<JobTracker.JobState>> = Observable.interval(0, 5, TimeUnit.SECONDS).map {
val redemptionJobState: JobTracker.JobState? = ApplicationDependencies.getJobManager().getFirstMatchingJobState {
it.factoryKey == DonationReceiptRedemptionJob.KEY && it.parameters.queue == DonationReceiptRedemptionJob.SUBSCRIPTION_QUEUE
}
val receiptJobState: JobTracker.JobState? = ApplicationDependencies.getJobManager().getFirstMatchingJobState {
it.factoryKey == SubscriptionReceiptRequestResponseJob.KEY && it.parameters.queue == DonationReceiptRedemptionJob.SUBSCRIPTION_QUEUE
}
val jobState: JobTracker.JobState? = redemptionJobState ?: receiptJobState
if (jobState == null && SignalStore.donationsValues().getSubscriptionRedemptionFailed()) {
Optional.of(JobTracker.JobState.FAILURE)
} else {
Optional.fromNullable(jobState)
}
}.distinctUntilChanged()
}

View file

@ -275,12 +275,12 @@ class SubscribeFragment : DSLSettingsFragment(
if (throwable is DonationExceptions.TimedOutWaitingForTokenRedemption) {
Log.w(TAG, "Timeout occurred while redeeming token", throwable, true)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__redemption_still_pending)
.setMessage(R.string.DonationsErrors__you_might_not_see_your_badge_right_away)
.setTitle(R.string.DonationsErrors__still_processing)
.setMessage(R.string.DonationsErrors__your_payment)
.setPositiveButton(android.R.string.ok) { dialog, _ ->
dialog.dismiss()
requireActivity().finish()
requireActivity().startActivity(AppSettingsActivity.subscriptions(requireContext()))
requireActivity().startActivity(AppSettingsActivity.manageSubscriptions(requireContext()))
}
.show()
} else if (throwable is DonationExceptions.SetupFailed) {

View file

@ -14,6 +14,7 @@ import org.whispersystems.signalservice.internal.EmptyResponse;
import org.whispersystems.signalservice.internal.ServiceResponse;
import java.io.IOException;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
@ -23,6 +24,7 @@ import java.util.concurrent.TimeUnit;
public class DonationReceiptRedemptionJob extends BaseJob {
private static final String TAG = Log.tag(DonationReceiptRedemptionJob.class);
public static final String SUBSCRIPTION_QUEUE = "ReceiptRedemption";
public static final String KEY = "DonationReceiptRedemptionJob";
public static final String INPUT_RECEIPT_CREDENTIAL_PRESENTATION = "data.receipt.credential.presentation";
@ -31,7 +33,7 @@ public class DonationReceiptRedemptionJob extends BaseJob {
new Job.Parameters
.Builder()
.addConstraint(NetworkConstraint.KEY)
.setQueue("ReceiptRedemption")
.setQueue(SUBSCRIPTION_QUEUE)
.setMaxAttempts(Parameters.UNLIMITED)
.setMaxInstancesForQueue(1)
.setLifespan(TimeUnit.DAYS.toMillis(7))
@ -66,6 +68,9 @@ public class DonationReceiptRedemptionJob extends BaseJob {
@Override
public void onFailure() {
SubscriptionNotification.RedemptionFailed.INSTANCE.show(context);
if (isForSubscription()) {
SignalStore.donationsValues().markSubscriptionRedemptionFailed();
}
}
@Override
@ -103,6 +108,14 @@ public class DonationReceiptRedemptionJob extends BaseJob {
Log.w(TAG, "Encountered a retryable exception", response.getExecutionError().get(), true);
throw new RetryableException();
}
if (isForSubscription()) {
SignalStore.donationsValues().clearSubscriptionRedemptionFailed();
}
}
private boolean isForSubscription() {
return Objects.equals(getParameters().getQueue(), SUBSCRIPTION_QUEUE);
}
@Override

View file

@ -4,6 +4,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.VerificationFailedException;
@ -103,6 +104,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
@Override
public void onFailure() {
SubscriptionNotification.VerificationFailed.INSTANCE.show(context);
SignalStore.donationsValues().markSubscriptionRedemptionFailed();
}
@Override
@ -140,6 +142,10 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
if (response.getApplicationError().isPresent()) {
handleApplicationError(response);
if (response.getStatus() == 204) {
SignalStore.donationsValues().clearSubscriptionRedemptionFailed();
}
} else if (response.getResult().isPresent()) {
ReceiptCredential receiptCredential = getReceiptCredential(response.getResult().get());

View file

@ -30,6 +30,7 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
private const val KEY_LEVEL_OPERATION_PREFIX = "donation.level.operation."
private const val KEY_LEVEL_HISTORY = "donation.level.history"
private const val DISPLAY_BADGES_ON_PROFILE = "donation.display.badges.on.profile"
private const val SUBSCRIPTION_REDEMPTION_FAILED = "donation.subscription.redemption.failed"
}
override fun onFirstEverAppLaunch() = Unit
@ -197,4 +198,16 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
fun getDisplayBadgesOnProfile(): Boolean {
return getBoolean(DISPLAY_BADGES_ON_PROFILE, false)
}
fun getSubscriptionRedemptionFailed(): Boolean {
return getBoolean(SUBSCRIPTION_REDEMPTION_FAILED, false)
}
fun markSubscriptionRedemptionFailed() {
putBoolean(SUBSCRIPTION_REDEMPTION_FAILED, true)
}
fun clearSubscriptionRedemptionFailed() {
putBoolean(SUBSCRIPTION_REDEMPTION_FAILED, false)
}
}

View file

@ -25,6 +25,16 @@
app:layout_constraintTop_toTopOf="@id/my_support_title"
tools:src="@drawable/test_gradient" />
<ProgressBar
android:id="@+id/my_support_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/my_support_badge"
app:layout_constraintEnd_toEndOf="@id/my_support_badge"
app:layout_constraintStart_toStartOf="@id/my_support_badge"
app:layout_constraintTop_toTopOf="@id/my_support_badge" />
<TextView
android:id="@+id/my_support_title"
android:layout_width="0dp"
@ -75,10 +85,10 @@
<com.google.android.material.button.MaterialButton
android:id="@+id/my_support_boost"
android:background="@drawable/my_boost_gradient"
style="@style/Signal.Widget.Button.Large.Primary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/my_boost_gradient"
android:text="@string/MySupportPreference__add_a_signal_boost"
app:cornerRadius="0dp"
app:layout_constraintTop_toBottomOf="@id/my_support_heading_barrier" />

View file

@ -3986,6 +3986,9 @@
<string name="MySupportPreference__add_a_signal_boost">Add a Signal Boost</string>
<string name="MySupportPreference__s_per_month">%1$s/month</string>
<string name="MySupportPreference__renews_s">Renews %1$s</string>
<string name="MySupportPreference__processing_transaction">Processing transaction…</string>
<string name="MySupportPreference__couldnt_add_badge">Couldn\'t add badge.</string>
<string name="MySupportPreference__please_contact_support">Please contact support.</string>
<string name="ExpiredBadgeBottomSheetDialogFragment__your_badge_has_expired">Your Badge has Expired</string>
<string name="ExpiredBadgeBottomSheetDialogFragment__badge_expired">Badge expired</string>
@ -4007,10 +4010,10 @@
<string name="SubscribeFragment__processing_payment">Processing payment…</string>
<string name="DonationsErrors__payment_failed">Payment failed</string>
<string name="DonationsErrors__your_payment">Your payment couldn\'t be processed and you have not been charged. Please try again.</string>
<string name="DonationsErrors__redemption_still_pending">Redemption still pending</string>
<string name="DonationsErrors__still_processing">Still processing</string>
<string name="DonationsErrors__redemption_failed">Redemption failed</string>
<string name="DonationsErrors__please_contact_support">Please contact support</string>
<string name="DonationsErrors__you_might_not_see_your_badge_right_away">You may not see your badge right away, but we\'re working on it!</string>
<string name="DonationsErrors__your_payment_is_still">Your payment is still being processed. This can take a few minutes depending on your connection.</string>
<string name="DonationsErrors__google_pay_unavailable">Google Pay Unavailable</string>
<string name="DonationsErrors__you_have_to_set_up_google_pay_to_donate_in_app">You have to set up Google Pay to donate in-app.</string>
<string name="DonationsErrors__failed_to_cancel_subscription">Failed to cancel subscription</string>