Implement bank transfer completed sheet.

This commit is contained in:
Alex Hart
2023-10-11 10:18:15 -04:00
committed by Cody Henthorne
parent 89199b81ab
commit bf37c09ba0
10 changed files with 494 additions and 58 deletions

View File

@@ -0,0 +1,204 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.completed
import android.content.DialogInterface
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeRepository
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImage112
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationCompletedQueue
import org.thoughtcrime.securesms.util.viewModel
/**
* Bottom Sheet displayed when the app notices that a long-running donation has
* completed.
*/
class DonationCompletedBottomSheet : ComposeBottomSheetDialogFragment() {
companion object {
private const val ARG_DONATION_COMPLETED = "arg.donation.completed"
@JvmStatic
fun show(fragmentManager: FragmentManager, donationCompleted: DonationCompletedQueue.DonationCompleted) {
DonationCompletedBottomSheet().apply {
arguments = bundleOf(
ARG_DONATION_COMPLETED to donationCompleted.encode()
)
show(fragmentManager, null)
}
}
}
private val donationCompleted: DonationCompletedQueue.DonationCompleted by lazy(LazyThreadSafetyMode.NONE) {
DonationCompletedQueue.DonationCompleted.ADAPTER.decode(requireArguments().getByteArray(ARG_DONATION_COMPLETED)!!)
}
private val viewModel: DonationCompletedViewModel by viewModel {
DonationCompletedViewModel(donationCompleted, badgeRepository = BadgeRepository(requireContext()))
}
@Composable
override fun SheetContent() {
val badge by viewModel.badge
val isToggleChecked by viewModel.isToggleChecked
val toggleType by viewModel.toggleType
DonationCompletedSheetContent(
badge = badge,
isToggleChecked = isToggleChecked,
toggleType = toggleType,
onCheckChanged = viewModel::onToggleCheckChanged,
onDoneClick = { dismissAllowingStateLoss() }
)
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
viewModel.commitToggleState()
}
}
@Preview
@Composable
private fun DonationCompletedSheetContentPreview() {
SignalTheme {
Surface {
DonationCompletedSheetContent(
badge = null,
isToggleChecked = false,
toggleType = DonationCompletedViewModel.ToggleType.NONE,
onCheckChanged = {},
onDoneClick = {}
)
}
}
}
@Composable
private fun DonationCompletedSheetContent(
badge: Badge?,
isToggleChecked: Boolean,
toggleType: DonationCompletedViewModel.ToggleType,
onCheckChanged: (Boolean) -> Unit,
onDoneClick: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
BottomSheets.Handle()
BadgeImage112(
badge = badge,
modifier = Modifier
.padding(top = 21.dp, bottom = 16.dp)
.size(80.dp)
)
Text(
text = stringResource(id = R.string.DonationCompletedBottomSheet__donation_complete),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 45.dp)
)
Text(
text = stringResource(id = R.string.DonationCompleteBottomSheet__your_bank_transfer_was_received),
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, bottom = 24.dp)
.padding(horizontal = 45.dp)
)
if (toggleType == DonationCompletedViewModel.ToggleType.NONE) {
CircularProgressIndicator()
} else {
DonationToggleRow(
checked = isToggleChecked,
text = stringResource(id = toggleType.copyId),
onCheckChanged = onCheckChanged
)
}
Buttons.LargeTonal(
onClick = onDoneClick,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(top = 48.dp, bottom = 56.dp)
) {
Text(
text = stringResource(id = R.string.DonationPendingBottomSheet__done)
)
}
}
}
@Composable
private fun DonationToggleRow(
checked: Boolean,
text: String,
onCheckChanged: (Boolean) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
shape = RoundedCornerShape(12.dp)
)
.padding(horizontal = 16.dp)
) {
Text(
text = text,
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
.padding(vertical = 16.dp)
)
Switch(
checked = checked,
onCheckedChange = onCheckChanged,
modifier = Modifier.align(Alignment.CenterVertically)
)
}
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.completed
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationCompletedQueue
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.whispersystems.signalservice.api.services.DonationsService
import java.util.Locale
class DonationCompletedRepository(
private val donationsService: DonationsService = ApplicationDependencies.getDonationsService()
) {
fun getBadge(donationCompleted: DonationCompletedQueue.DonationCompleted): Single<Badge> {
return Single
.fromCallable { donationsService.getDonationsConfiguration(Locale.getDefault()) }
.flatMap { it.flattenResult() }
.map { it.levels[donationCompleted.level.toInt()]!! }
.map { Badges.fromServiceBadge(it.badge) }
.subscribeOn(Schedulers.io())
}
}

View File

@@ -0,0 +1,111 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.completed
import android.annotation.SuppressLint
import androidx.annotation.StringRes
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeRepository
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationCompletedQueue
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
class DonationCompletedViewModel(
donationCompleted: DonationCompletedQueue.DonationCompleted,
repository: DonationCompletedRepository = DonationCompletedRepository(),
private val badgeRepository: BadgeRepository
) : ViewModel() {
companion object {
private val TAG = Log.tag(DonationCompletedViewModel::class.java)
}
private val disposables = CompositeDisposable()
private val internalBadge = mutableStateOf<Badge?>(null)
private val internalToggleChecked = mutableStateOf(false)
private val internalToggleType = mutableStateOf(ToggleType.NONE)
val badge: State<Badge?> = internalBadge
val isToggleChecked: State<Boolean> = internalToggleChecked
val toggleType: State<ToggleType> = internalToggleType
init {
disposables += repository.getBadge(donationCompleted)
.map { badge ->
val hasOtherBadges = Recipient.self().badges.filterNot { it.id == badge.id }.isNotEmpty()
val isDisplayingBadges = SignalStore.donationsValues().getDisplayBadgesOnProfile()
val toggleType = when {
hasOtherBadges && isDisplayingBadges -> ToggleType.MAKE_FEATURED_BADGE
else -> ToggleType.DISPLAY_ON_PROFILE
}
badge to toggleType
}
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy(
onSuccess = { (badge, toggleType) ->
internalBadge.value = badge
internalToggleType.value = toggleType
}
)
}
fun onToggleCheckChanged(isChecked: Boolean) {
internalToggleChecked.value = isChecked
}
/**
* Note that the intention here is that these are able to complete outside of the scope of the ViewModel's lifecycle.
*/
@SuppressLint("CheckResult")
fun commitToggleState() {
when (toggleType.value) {
ToggleType.NONE -> Unit
ToggleType.MAKE_FEATURED_BADGE -> {
badgeRepository.setVisibilityForAllBadges(isToggleChecked.value).subscribeBy(
onError = {
Log.w(TAG, "Failure while updating badge visibility", it)
}
)
}
ToggleType.DISPLAY_ON_PROFILE -> {
val badge = this.badge.value
if (badge == null) {
Log.w(TAG, "No badge!")
return
}
badgeRepository.setFeaturedBadge(badge).subscribeBy(
onError = {
Log.w(TAG, "Failure while updating featured badge", it)
}
)
}
}
}
override fun onCleared() {
disposables.clear()
}
enum class ToggleType(@StringRes val copyId: Int) {
NONE(-1),
MAKE_FEATURED_BADGE(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__make_featured_badge),
DISPLAY_ON_PROFILE(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__display_on_profile)
}
}

View File

@@ -111,6 +111,7 @@ import org.thoughtcrime.securesms.components.reminder.ServiceOutageReminder;
import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder;
import org.thoughtcrime.securesms.components.reminder.UsernameOutOfSyncReminder;
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment;
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.DonationCompletedBottomSheet;
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation;
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
@@ -133,6 +134,7 @@ import org.thoughtcrime.securesms.database.MessageTable.MarkedMessageInfo;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.ThreadTable;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationCompletedQueue;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.events.ReminderUpdateEvent;
import org.thoughtcrime.securesms.exporter.flow.SmsExportDialogs;
@@ -521,6 +523,11 @@ public class ConversationListFragment extends MainFragment implements ActionMode
SignalStore.donationsValues().getUnexpectedSubscriptionCancelationChargeFailure(),
getParentFragmentManager());
}
} else {
List<DonationCompletedQueue.DonationCompleted> donationCompletedList = SignalStore.donationsValues().consumeDonationCompletionList();
for (DonationCompletedQueue.DonationCompleted donationCompleted : donationCompletedList) {
DonationCompletedBottomSheet.show(getParentFragmentManager(), donationCompleted);
}
}
}

View File

@@ -17,11 +17,13 @@ import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse;
import org.signal.libsignal.zkgroup.receipts.ReceiptSerial;
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError;
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource;
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationCompletedQueue;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.JsonJobData;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobmanager.JsonJobData;
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels;
import org.whispersystems.signalservice.internal.ServiceResponse;
@@ -59,8 +61,8 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
private final String paymentIntentId;
private final long badgeLevel;
private final DonationProcessor donationProcessor;
private final long uiSessionKey;
private final boolean isLongRunning;
private final long uiSessionKey;
private final boolean isLongRunningDonationPaymentType;
private static String resolveQueue(DonationErrorSource donationErrorSource, boolean isLongRunning) {
String baseQueue = donationErrorSource == DonationErrorSource.BOOST ? BOOST_QUEUE : GIFT_QUEUE;
@@ -96,7 +98,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
boolean isLongRunning)
{
BoostReceiptRequestResponseJob requestReceiptJob = createJob(paymentIntentId, DonationErrorSource.BOOST, Long.parseLong(SubscriptionLevels.BOOST_LEVEL), donationProcessor, uiSessionKey, isLongRunning);
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForBoost(uiSessionKey);
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForBoost(uiSessionKey, isLongRunning);
RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forBoost();
MultiDeviceProfileContentUpdateJob multiDeviceProfileContentUpdateJob = new MultiDeviceProfileContentUpdateJob();
@@ -131,16 +133,16 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
long badgeLevel,
@NonNull DonationProcessor donationProcessor,
long uiSessionKey,
boolean isLongRunning)
boolean isLongRunningDonationPaymentType)
{
super(parameters);
this.requestContext = requestContext;
this.paymentIntentId = paymentIntentId;
this.donationErrorSource = donationErrorSource;
this.badgeLevel = badgeLevel;
this.donationProcessor = donationProcessor;
this.uiSessionKey = uiSessionKey;
this.isLongRunning = isLongRunning;
this.requestContext = requestContext;
this.paymentIntentId = paymentIntentId;
this.donationErrorSource = donationErrorSource;
this.badgeLevel = badgeLevel;
this.donationProcessor = donationProcessor;
this.uiSessionKey = uiSessionKey;
this.isLongRunningDonationPaymentType = isLongRunningDonationPaymentType;
}
@Override
@@ -150,7 +152,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
.putLong(DATA_BADGE_LEVEL, badgeLevel)
.putString(DATA_DONATION_PROCESSOR, donationProcessor.getCode())
.putLong(DATA_UI_SESSION_KEY, uiSessionKey)
.putBoolean(DATA_IS_LONG_RUNNING, isLongRunning);
.putBoolean(DATA_IS_LONG_RUNNING, isLongRunningDonationPaymentType);
if (requestContext != null) {
builder.putBlobAsString(DATA_REQUEST_BYTES, requestContext.serialize());
@@ -170,7 +172,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
@Override
public long getNextRunAttemptBackoff(int pastAttemptCount, @NonNull Exception exception) {
if (isLongRunning) {
if (isLongRunningDonationPaymentType) {
return TimeUnit.DAYS.toMillis(1);
} else {
return super.getNextRunAttemptBackoff(pastAttemptCount, exception);
@@ -214,12 +216,24 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
setOutputData(new JsonJobData.Builder().putBlobAsString(DonationReceiptRedemptionJob.INPUT_RECEIPT_CREDENTIAL_PRESENTATION,
receiptCredentialPresentation.serialize())
.serialize());
enqueueDonationComplete(receiptCredentialPresentation.getReceiptLevel());
} else {
Log.w(TAG, "Encountered a retryable exception: " + response.getStatus(), response.getExecutionError().orElse(null), true);
throw new RetryableException();
}
}
private void enqueueDonationComplete(long receiptLevel) {
if (donationErrorSource != DonationErrorSource.GIFT || !isLongRunningDonationPaymentType) {
return;
}
SignalStore.donationsValues().appendToDonationCompletionList(
new DonationCompletedQueue.DonationCompleted.Builder().level(receiptLevel).build()
);
}
private void handleApplicationError(Context context, ServiceResponse<ReceiptCredentialResponse> response, @NonNull DonationErrorSource donationErrorSource) throws Exception {
Throwable applicationException = response.getApplicationError().get();
switch (response.getStatus()) {
@@ -304,22 +318,22 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
public @NonNull BoostReceiptRequestResponseJob create(@NonNull Parameters parameters, @Nullable byte[] serializedData) {
JsonJobData data = JsonJobData.deserialize(serializedData);
String paymentIntentId = data.getString(DATA_PAYMENT_INTENT_ID);
DonationErrorSource donationErrorSource = DonationErrorSource.deserialize(data.getStringOrDefault(DATA_ERROR_SOURCE, DonationErrorSource.BOOST.serialize()));
long badgeLevel = data.getLongOrDefault(DATA_BADGE_LEVEL, Long.parseLong(SubscriptionLevels.BOOST_LEVEL));
String rawDonationProcessor = data.getStringOrDefault(DATA_DONATION_PROCESSOR, DonationProcessor.STRIPE.getCode());
DonationProcessor donationProcessor = DonationProcessor.fromCode(rawDonationProcessor);
long uiSessionKey = data.getLongOrDefault(DATA_UI_SESSION_KEY, -1L);
boolean isLongRunning = data.getBooleanOrDefault(DATA_IS_LONG_RUNNING, false);
String paymentIntentId = data.getString(DATA_PAYMENT_INTENT_ID);
DonationErrorSource donationErrorSource = DonationErrorSource.deserialize(data.getStringOrDefault(DATA_ERROR_SOURCE, DonationErrorSource.BOOST.serialize()));
long badgeLevel = data.getLongOrDefault(DATA_BADGE_LEVEL, Long.parseLong(SubscriptionLevels.BOOST_LEVEL));
String rawDonationProcessor = data.getStringOrDefault(DATA_DONATION_PROCESSOR, DonationProcessor.STRIPE.getCode());
DonationProcessor donationProcessor = DonationProcessor.fromCode(rawDonationProcessor);
long uiSessionKey = data.getLongOrDefault(DATA_UI_SESSION_KEY, -1L);
boolean isLongRunningDonationPaymentType = data.getBooleanOrDefault(DATA_IS_LONG_RUNNING, false);
try {
if (data.hasString(DATA_REQUEST_BYTES)) {
byte[] blob = data.getStringAsBlob(DATA_REQUEST_BYTES);
ReceiptCredentialRequestContext requestContext = new ReceiptCredentialRequestContext(blob);
return new BoostReceiptRequestResponseJob(parameters, requestContext, paymentIntentId, donationErrorSource, badgeLevel, donationProcessor, uiSessionKey, isLongRunning);
return new BoostReceiptRequestResponseJob(parameters, requestContext, paymentIntentId, donationErrorSource, badgeLevel, donationProcessor, uiSessionKey, isLongRunningDonationPaymentType);
} else {
return new BoostReceiptRequestResponseJob(parameters, null, paymentIntentId, donationErrorSource, badgeLevel, donationProcessor, uiSessionKey, isLongRunning);
return new BoostReceiptRequestResponseJob(parameters, null, paymentIntentId, donationErrorSource, badgeLevel, donationProcessor, uiSessionKey, isLongRunningDonationPaymentType);
}
} catch (InvalidInputException e) {
throw new IllegalStateException(e);

View File

@@ -12,6 +12,7 @@ import org.thoughtcrime.securesms.database.MessageTable;
import org.thoughtcrime.securesms.database.NoSuchMessageException;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationCompletedQueue;
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.Job;
@@ -44,18 +45,21 @@ public class DonationReceiptRedemptionJob extends BaseJob {
public static final String DATA_GIFT_MESSAGE_ID = "data.gift.message.id";
public static final String DATA_PRIMARY = "data.primary";
public static final String DATA_UI_SESSION_KEY = "data.ui.session.key";
public static final String DATA_IS_LONG_RUNNING = "data.is.long.running";
private final long giftMessageId;
private final boolean makePrimary;
private final DonationErrorSource errorSource;
private final long uiSessionKey;
private final long uiSessionKey;
private final boolean isLongRunningDonationPaymentType;
public static DonationReceiptRedemptionJob createJobForSubscription(@NonNull DonationErrorSource errorSource, long uiSessionKey) {
public static DonationReceiptRedemptionJob createJobForSubscription(@NonNull DonationErrorSource errorSource, long uiSessionKey, boolean isLongRunningDonationPaymentType) {
return new DonationReceiptRedemptionJob(
NO_ID,
false,
errorSource,
uiSessionKey,
isLongRunningDonationPaymentType,
new Job.Parameters
.Builder()
.addConstraint(NetworkConstraint.KEY)
@@ -66,12 +70,13 @@ public class DonationReceiptRedemptionJob extends BaseJob {
.build());
}
public static DonationReceiptRedemptionJob createJobForBoost(long uiSessionKey) {
public static DonationReceiptRedemptionJob createJobForBoost(long uiSessionKey, boolean isLongRunningDonationPaymentType) {
return new DonationReceiptRedemptionJob(
NO_ID,
false,
DonationErrorSource.BOOST,
uiSessionKey,
isLongRunningDonationPaymentType,
new Job.Parameters
.Builder()
.addConstraint(NetworkConstraint.KEY)
@@ -82,7 +87,7 @@ public class DonationReceiptRedemptionJob extends BaseJob {
}
public static JobManager.Chain createJobChainForKeepAlive() {
DonationReceiptRedemptionJob redemptionJob = createJobForSubscription(DonationErrorSource.KEEP_ALIVE, -1L);
DonationReceiptRedemptionJob redemptionJob = createJobForSubscription(DonationErrorSource.KEEP_ALIVE, -1L, false);
RefreshOwnProfileJob refreshOwnProfileJob = new RefreshOwnProfileJob();
MultiDeviceProfileContentUpdateJob multiDeviceProfileContentUpdateJob = new MultiDeviceProfileContentUpdateJob();
@@ -98,6 +103,7 @@ public class DonationReceiptRedemptionJob extends BaseJob {
primary,
DonationErrorSource.GIFT_REDEMPTION,
-1L,
false,
new Job.Parameters
.Builder()
.addConstraint(NetworkConstraint.KEY)
@@ -115,12 +121,13 @@ public class DonationReceiptRedemptionJob extends BaseJob {
.then(multiDeviceProfileContentUpdateJob);
}
private DonationReceiptRedemptionJob(long giftMessageId, boolean primary, @NonNull DonationErrorSource errorSource, long uiSessionKey, @NonNull Job.Parameters parameters) {
private DonationReceiptRedemptionJob(long giftMessageId, boolean primary, @NonNull DonationErrorSource errorSource, long uiSessionKey, boolean isLongRunning, @NonNull Job.Parameters parameters) {
super(parameters);
this.giftMessageId = giftMessageId;
this.makePrimary = primary;
this.errorSource = errorSource;
this.uiSessionKey = uiSessionKey;
this.uiSessionKey = uiSessionKey;
this.isLongRunningDonationPaymentType = isLongRunning;
}
@Override
@@ -130,6 +137,7 @@ public class DonationReceiptRedemptionJob extends BaseJob {
.putLong(DATA_GIFT_MESSAGE_ID, giftMessageId)
.putBoolean(DATA_PRIMARY, makePrimary)
.putLong(DATA_UI_SESSION_KEY, uiSessionKey)
.putBoolean(DATA_IS_LONG_RUNNING, isLongRunningDonationPaymentType)
.serialize();
}
@@ -201,6 +209,7 @@ public class DonationReceiptRedemptionJob extends BaseJob {
}
Log.i(TAG, "Successfully redeemed token with response code " + response.getStatus() + "... isForSubscription: " + isForSubscription(), true);
enqueueDonationComplete(presentation.getReceiptLevel());
if (isForSubscription()) {
Log.d(TAG, "Clearing subscription failure", true);
@@ -278,6 +287,22 @@ public class DonationReceiptRedemptionJob extends BaseJob {
return Objects.equals(getParameters().getQueue(), SUBSCRIPTION_QUEUE);
}
private void enqueueDonationComplete(long receiptLevel) {
if (errorSource == DonationErrorSource.GIFT || errorSource == DonationErrorSource.GIFT_REDEMPTION) {
Log.i(TAG, "Skipping donation complete sheet for GIFT related redemption.");
return;
}
if (uiSessionKey == -1L || !isLongRunningDonationPaymentType) {
Log.i(TAG, "Skipping donation complete sheet for state " + uiSessionKey + ", " + isLongRunningDonationPaymentType);
return;
}
SignalStore.donationsValues().appendToDonationCompletionList(
new DonationCompletedQueue.DonationCompleted.Builder().level(receiptLevel).build()
);
}
@Override
protected boolean onShouldRetry(@NonNull Exception e) {
return e instanceof RetryableException;
@@ -291,13 +316,14 @@ public class DonationReceiptRedemptionJob extends BaseJob {
public @NonNull DonationReceiptRedemptionJob create(@NonNull Parameters parameters, @Nullable byte[] serializedData) {
JsonJobData data = JsonJobData.deserialize(serializedData);
String serializedErrorSource = data.getStringOrDefault(DATA_ERROR_SOURCE, DonationErrorSource.UNKNOWN.serialize());
long messageId = data.getLongOrDefault(DATA_GIFT_MESSAGE_ID, NO_ID);
boolean primary = data.getBooleanOrDefault(DATA_PRIMARY, false);
DonationErrorSource errorSource = DonationErrorSource.deserialize(serializedErrorSource);
long uiSessionKey = data.getLongOrDefault(DATA_UI_SESSION_KEY, -1L);
String serializedErrorSource = data.getStringOrDefault(DATA_ERROR_SOURCE, DonationErrorSource.UNKNOWN.serialize());
long messageId = data.getLongOrDefault(DATA_GIFT_MESSAGE_ID, NO_ID);
boolean primary = data.getBooleanOrDefault(DATA_PRIMARY, false);
DonationErrorSource errorSource = DonationErrorSource.deserialize(serializedErrorSource);
long uiSessionKey = data.getLongOrDefault(DATA_UI_SESSION_KEY, -1L);
boolean isLongRunningDonationPaymentType = data.getBooleanOrDefault(DATA_IS_LONG_RUNNING, false);
return new DonationReceiptRedemptionJob(messageId, primary, errorSource, uiSessionKey, parameters);
return new DonationReceiptRedemptionJob(messageId, primary, errorSource, uiSessionKey, isLongRunningDonationPaymentType, parameters);
}
}
}

View File

@@ -54,34 +54,34 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
private final SubscriberId subscriberId;
private final boolean isForKeepAlive;
private final long uiSessionKey;
private final boolean isLongRunning;
private final long uiSessionKey;
private final boolean isLongRunningDonationPaymentType;
private static SubscriptionReceiptRequestResponseJob createJob(SubscriberId subscriberId, boolean isForKeepAlive, long uiSessionKey, boolean isLongRunning) {
private static SubscriptionReceiptRequestResponseJob createJob(SubscriberId subscriberId, boolean isForKeepAlive, long uiSessionKey, boolean isLongRunningDonationPaymentType) {
return new SubscriptionReceiptRequestResponseJob(
new Parameters
.Builder()
.addConstraint(NetworkConstraint.KEY)
.setQueue("ReceiptRedemption")
.setMaxInstancesForQueue(1)
.setLifespan(isLongRunning ? TimeUnit.DAYS.toMillis(14) : TimeUnit.DAYS.toMillis(1))
.setLifespan(isLongRunningDonationPaymentType ? TimeUnit.DAYS.toMillis(14) : TimeUnit.DAYS.toMillis(1))
.setMaxAttempts(Parameters.UNLIMITED)
.build(),
subscriberId,
isForKeepAlive,
uiSessionKey,
isLongRunning
isLongRunningDonationPaymentType
);
}
public static JobManager.Chain createSubscriptionContinuationJobChain(long uiSessionKey, boolean isLongRunning) {
return createSubscriptionContinuationJobChain(false, uiSessionKey, isLongRunning);
public static JobManager.Chain createSubscriptionContinuationJobChain(long uiSessionKey, boolean isLongRunningDonationPaymentType) {
return createSubscriptionContinuationJobChain(false, uiSessionKey, isLongRunningDonationPaymentType);
}
public static JobManager.Chain createSubscriptionContinuationJobChain(boolean isForKeepAlive, long uiSessionKey, boolean isLongRunning) {
public static JobManager.Chain createSubscriptionContinuationJobChain(boolean isForKeepAlive, long uiSessionKey, boolean isLongRunningDonationPaymentType) {
Subscriber subscriber = SignalStore.donationsValues().requireSubscriber();
SubscriptionReceiptRequestResponseJob requestReceiptJob = createJob(subscriber.getSubscriberId(), isForKeepAlive, uiSessionKey, isLongRunning);
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForSubscription(requestReceiptJob.getErrorSource(), uiSessionKey);
SubscriptionReceiptRequestResponseJob requestReceiptJob = createJob(subscriber.getSubscriberId(), isForKeepAlive, uiSessionKey, isLongRunningDonationPaymentType);
DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForSubscription(requestReceiptJob.getErrorSource(), uiSessionKey, isLongRunningDonationPaymentType);
RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forSubscription();
MultiDeviceProfileContentUpdateJob multiDeviceProfileContentUpdateJob = new MultiDeviceProfileContentUpdateJob();
@@ -96,13 +96,13 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
@NonNull SubscriberId subscriberId,
boolean isForKeepAlive,
long uiSessionKey,
boolean isLongRunning)
boolean isLongRunningDonationPaymentType)
{
super(parameters);
this.subscriberId = subscriberId;
this.isForKeepAlive = isForKeepAlive;
this.uiSessionKey = uiSessionKey;
this.isLongRunning = isLongRunning;
this.subscriberId = subscriberId;
this.isForKeepAlive = isForKeepAlive;
this.uiSessionKey = uiSessionKey;
this.isLongRunningDonationPaymentType = isLongRunningDonationPaymentType;
}
@Override
@@ -110,7 +110,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
JsonJobData.Builder builder = new JsonJobData.Builder().putBlobAsString(DATA_SUBSCRIBER_ID, subscriberId.getBytes())
.putBoolean(DATA_IS_FOR_KEEP_ALIVE, isForKeepAlive)
.putLong(DATA_UI_SESSION_KEY, uiSessionKey)
.putBoolean(DATA_IS_LONG_RUNNING, isLongRunning);
.putBoolean(DATA_IS_LONG_RUNNING, isLongRunningDonationPaymentType);
return builder.serialize();
}
@@ -437,12 +437,12 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
public @NonNull SubscriptionReceiptRequestResponseJob create(@NonNull Parameters parameters, @Nullable byte[] serializedData) {
JsonJobData data = JsonJobData.deserialize(serializedData);
SubscriberId subscriberId = SubscriberId.fromBytes(data.getStringAsBlob(DATA_SUBSCRIBER_ID));
boolean isForKeepAlive = data.getBooleanOrDefault(DATA_IS_FOR_KEEP_ALIVE, false);
String requestString = data.getStringOrDefault(DATA_REQUEST_BYTES, null);
byte[] requestContextBytes = requestString != null ? Base64.decodeOrThrow(requestString) : null;
long uiSessionKey = data.getLongOrDefault(DATA_UI_SESSION_KEY, -1L);
boolean isLongRunning = data.getBooleanOrDefault(DATA_IS_LONG_RUNNING, false);
SubscriberId subscriberId = SubscriberId.fromBytes(data.getStringAsBlob(DATA_SUBSCRIBER_ID));
boolean isForKeepAlive = data.getBooleanOrDefault(DATA_IS_FOR_KEEP_ALIVE, false);
String requestString = data.getStringOrDefault(DATA_REQUEST_BYTES, null);
byte[] requestContextBytes = requestString != null ? Base64.decodeOrThrow(requestString) : null;
long uiSessionKey = data.getLongOrDefault(DATA_UI_SESSION_KEY, -1L);
boolean isLongRunningDonationPaymentType = data.getBooleanOrDefault(DATA_IS_LONG_RUNNING, false);
ReceiptCredentialRequestContext requestContext;
if (requestContextBytes != null && SignalStore.donationsValues().getSubscriptionRequestCredential() == null) {
@@ -455,7 +455,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob {
}
}
return new SubscriptionReceiptRequestResponseJob(parameters, subscriberId, isForKeepAlive, uiSessionKey, isLongRunning);
return new SubscriptionReceiptRequestResponseJob(parameters, subscriberId, isForKeepAlive, uiSessionKey, isLongRunningDonationPaymentType);
}
}
}

View File

@@ -15,6 +15,7 @@ import org.signal.libsignal.zkgroup.receipts.ReceiptSerial
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.model.databaseprotos.BadgeList
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationCompletedQueue
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
@@ -108,6 +109,12 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
* awaiting the background task.
*/
private const val IS_GOOGLE_PAY_READY = "subscription.is.google.pay.ready"
/**
* Appended to whenever we complete a donation redemption (or gift send) for a bank transfer.
* Popped from whenever we enter the conversation list.
*/
private const val DONATION_COMPLETE_QUEUE = "donation.complete.queue"
}
override fun onFirstEverAppLaunch() = Unit
@@ -470,6 +477,30 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign
var subscriptionEndOfPeriodRedemptionStarted by longValue(SUBSCRIPTION_EOP_STARTED_TO_REDEEM, 0L)
var subscriptionEndOfPeriodRedeemed by longValue(SUBSCRIPTION_EOP_REDEEMED, 0L)
fun appendToDonationCompletionList(donationCompleted: DonationCompletedQueue.DonationCompleted) {
synchronized(this) {
val pendingBytes = getBlob(DONATION_COMPLETE_QUEUE, null)
val queue: DonationCompletedQueue = pendingBytes?.let { DonationCompletedQueue.ADAPTER.decode(pendingBytes) } ?: DonationCompletedQueue()
val newQueue: DonationCompletedQueue = queue.copy(donationsCompleted = queue.donationsCompleted + donationCompleted)
putBlob(DONATION_COMPLETE_QUEUE, newQueue.encode())
}
}
fun consumeDonationCompletionList(): List<DonationCompletedQueue.DonationCompleted> {
synchronized(this) {
val pendingBytes = getBlob(DONATION_COMPLETE_QUEUE, null)
if (pendingBytes == null) {
return emptyList()
} else {
val queue: DonationCompletedQueue = DonationCompletedQueue.ADAPTER.decode(pendingBytes)
remove(DONATION_COMPLETE_QUEUE)
return queue.donationsCompleted
}
}
}
private fun generateRequestCredential(): ReceiptCredentialRequestContext {
Log.d(TAG, "Generating request credentials context for token redemption...", true)
val secureRandom = SecureRandom()

View File

@@ -272,3 +272,11 @@ message ThreadMergeEvent {
message SessionSwitchoverEvent {
string e164 = 1;
}
message DonationCompletedQueue {
message DonationCompleted {
int64 level = 1;
}
repeated DonationCompleted donationsCompleted = 1;
}

View File

@@ -5876,6 +5876,13 @@
<!-- Confirmation button for donation pending sheet displayed after donating via a bank transfer. -->
<string name="DonationPendingBottomSheet__done">Done</string>
<!-- Title of 'Donation Complete' sheet displayed after a bank transfer completes and the badge is redeemed -->
<string name="DonationCompletedBottomSheet__donation_complete">Donation Complete</string>
<!-- Text block of 'Donation Complete' sheet displayed after a bank transfer completes and the badge is redeemed -->
<string name="DonationCompleteBottomSheet__your_bank_transfer_was_received">Your bank transfer was received. You can choose to display this badge on your profile to show off your support.</string>
<!-- Button text of 'Donation Complete' sheet displayed after a bank transfer completes and the badge is redeemed to dismiss sheet -->
<string name="DonationCompleteBottomSheet__done">Done</string>
<!-- StripePaymentInProgressFragment -->
<string name="StripePaymentInProgressFragment__cancelling">Cancelling…</string>