diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/DonationCompletedBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/DonationCompletedBottomSheet.kt new file mode 100644 index 0000000000..dde096f8d5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/DonationCompletedBottomSheet.kt @@ -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) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/DonationCompletedRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/DonationCompletedRepository.kt new file mode 100644 index 0000000000..39ba3af12d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/DonationCompletedRepository.kt @@ -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 { + return Single + .fromCallable { donationsService.getDonationsConfiguration(Locale.getDefault()) } + .flatMap { it.flattenResult() } + .map { it.levels[donationCompleted.level.toInt()]!! } + .map { Badges.fromServiceBadge(it.badge) } + .subscribeOn(Schedulers.io()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/DonationCompletedViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/DonationCompletedViewModel.kt new file mode 100644 index 0000000000..156d5bd542 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/DonationCompletedViewModel.kt @@ -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(null) + private val internalToggleChecked = mutableStateOf(false) + private val internalToggleType = mutableStateOf(ToggleType.NONE) + + val badge: State = internalBadge + val isToggleChecked: State = internalToggleChecked + val toggleType: State = 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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 135768e9fb..8f82cb015c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -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 donationCompletedList = SignalStore.donationsValues().consumeDonationCompletionList(); + for (DonationCompletedQueue.DonationCompleted donationCompleted : donationCompletedList) { + DonationCompletedBottomSheet.show(getParentFragmentManager(), donationCompleted); + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java index eb3f9729dd..0205dcd7fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java @@ -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 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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java index d52a8b849d..3eca549dca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java @@ -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); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java index 5dff0373ce..4bc8d252d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java @@ -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); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt index 34d98a0ec7..963fc724de 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt @@ -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 { + 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() diff --git a/app/src/main/protowire/Database.proto b/app/src/main/protowire/Database.proto index 72a6d18124..36db7fa528 100644 --- a/app/src/main/protowire/Database.proto +++ b/app/src/main/protowire/Database.proto @@ -272,3 +272,11 @@ message ThreadMergeEvent { message SessionSwitchoverEvent { string e164 = 1; } + +message DonationCompletedQueue { + message DonationCompleted { + int64 level = 1; + } + + repeated DonationCompleted donationsCompleted = 1; +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b55047eb56..e6dc6e1ccc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5876,6 +5876,13 @@ Done + + Donation Complete + + Your bank transfer was received. You can choose to display this badge on your profile to show off your support. + + Done + Cancelling…