mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-27 20:24:32 +01:00
Rewrite in-app-payment flows to prepare for backups support.
This commit is contained in:
committed by
Cody Henthorne
parent
b36b00a11c
commit
d719edf104
@@ -56,11 +56,12 @@ import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob;
|
||||
import org.thoughtcrime.securesms.jobs.CheckServiceReachabilityJob;
|
||||
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob;
|
||||
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob;
|
||||
import org.thoughtcrime.securesms.jobs.ExternalLaunchDonationJob;
|
||||
import org.thoughtcrime.securesms.jobs.FcmRefreshJob;
|
||||
import org.thoughtcrime.securesms.jobs.FontDownloaderJob;
|
||||
import org.thoughtcrime.securesms.jobs.GroupRingCleanupJob;
|
||||
import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob;
|
||||
import org.thoughtcrime.securesms.jobs.InAppPaymentAuthCheckJob;
|
||||
import org.thoughtcrime.securesms.jobs.InAppPaymentKeepAliveJob;
|
||||
import org.thoughtcrime.securesms.jobs.LinkedDeviceInactiveCheckJob;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
|
||||
import org.thoughtcrime.securesms.jobs.PnpInitializeDevicesJob;
|
||||
@@ -70,7 +71,6 @@ import org.thoughtcrime.securesms.jobs.RefreshSvrCredentialsJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob;
|
||||
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob;
|
||||
import org.thoughtcrime.securesms.jobs.SubscriptionKeepAliveJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.KeepMessagesDuration;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
|
||||
@@ -236,8 +236,8 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
ApplicationDependencies.getFrameRateTracker().start();
|
||||
ApplicationDependencies.getMegaphoneRepository().onAppForegrounded();
|
||||
ApplicationDependencies.getDeadlockDetector().start();
|
||||
SubscriptionKeepAliveJob.enqueueAndTrackTimeIfNecessary();
|
||||
ExternalLaunchDonationJob.enqueueIfNecessary();
|
||||
InAppPaymentKeepAliveJob.enqueueAndTrackTimeIfNecessary();
|
||||
ApplicationDependencies.getJobManager().add(new InAppPaymentAuthCheckJob());
|
||||
FcmFetchManager.onForeground(this);
|
||||
startAnrDetector();
|
||||
|
||||
|
||||
@@ -11,9 +11,11 @@ import org.thoughtcrime.securesms.backup.v2.database.restoreSelfFromBackup
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.AccountData
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.recipients
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
|
||||
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
|
||||
@@ -21,7 +23,6 @@ import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberD
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.subscription.Subscriber
|
||||
import org.thoughtcrime.securesms.util.ProfileUtil
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
|
||||
@@ -38,7 +39,7 @@ object AccountDataProcessor {
|
||||
val self = Recipient.self().fresh()
|
||||
val record = recipients.getRecordForSync(self.id)
|
||||
|
||||
val subscriber: Subscriber? = SignalStore.donationsValues().getSubscriber()
|
||||
val subscriber: InAppPaymentSubscriberRecord? = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
|
||||
emitter.emit(
|
||||
Frame(
|
||||
@@ -47,7 +48,7 @@ object AccountDataProcessor {
|
||||
givenName = self.profileName.givenName,
|
||||
familyName = self.profileName.familyName,
|
||||
avatarUrlPath = self.profileAvatar ?: "",
|
||||
subscriptionManuallyCancelled = SignalStore.donationsValues().isUserManuallyCancelled(),
|
||||
subscriptionManuallyCancelled = InAppPaymentsRepository.isUserManuallyCancelled(InAppPaymentSubscriberRecord.Type.DONATION),
|
||||
username = self.username.getOrNull(),
|
||||
subscriberId = subscriber?.subscriberId?.bytes?.toByteString() ?: defaultAccountRecord.subscriberId,
|
||||
subscriberCurrencyCode = subscriber?.currencyCode ?: defaultAccountRecord.subscriberCurrencyCode,
|
||||
@@ -101,15 +102,23 @@ object AccountDataProcessor {
|
||||
SignalStore.storyValues().userHasSeenGroupStoryEducationSheet = settings.hasSeenGroupStoryEducationSheet
|
||||
SignalStore.storyValues().viewedReceiptsEnabled = settings.storyViewReceiptsEnabled ?: settings.readReceipts
|
||||
|
||||
if (accountData.subscriptionManuallyCancelled) {
|
||||
SignalStore.donationsValues().updateLocalStateForManualCancellation()
|
||||
} else {
|
||||
SignalStore.donationsValues().clearUserManuallyCancelled()
|
||||
if (accountData.subscriberId.size > 0) {
|
||||
val remoteSubscriberId = SubscriberId.fromBytes(accountData.subscriberId.toByteArray())
|
||||
val localSubscriber = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
|
||||
val subscriber = InAppPaymentSubscriberRecord(
|
||||
remoteSubscriberId,
|
||||
accountData.subscriberCurrencyCode,
|
||||
InAppPaymentSubscriberRecord.Type.DONATION,
|
||||
localSubscriber?.requiresCancel ?: false,
|
||||
InAppPaymentsRepository.getLatestPaymentMethodType(InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
)
|
||||
|
||||
InAppPaymentsRepository.setSubscriber(subscriber)
|
||||
}
|
||||
|
||||
if (accountData.subscriberId.size > 0) {
|
||||
val subscriber = Subscriber(SubscriberId.fromBytes(accountData.subscriberId.toByteArray()), accountData.subscriberCurrencyCode)
|
||||
SignalStore.donationsValues().setSubscriber(subscriber)
|
||||
if (accountData.subscriptionManuallyCancelled) {
|
||||
SignalStore.donationsValues().updateLocalStateForManualCancellation(InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
}
|
||||
|
||||
if (accountData.avatarUrlPath.isNotEmpty()) {
|
||||
|
||||
@@ -35,8 +35,8 @@ import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.databinding.PaypalButtonBinding
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
|
||||
@@ -44,9 +44,9 @@ import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
@Composable
|
||||
fun MessageBackupsCheckoutSheet(
|
||||
messageBackupTier: MessageBackupTier,
|
||||
availablePaymentGateways: List<GatewayResponse.Gateway>,
|
||||
availablePaymentMethods: List<InAppPaymentData.PaymentMethodType>,
|
||||
onDismissRequest: () -> Unit,
|
||||
onPaymentGatewaySelected: (GatewayResponse.Gateway) -> Unit
|
||||
onPaymentMethodSelected: (InAppPaymentData.PaymentMethodType) -> Unit
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismissRequest,
|
||||
@@ -55,8 +55,8 @@ fun MessageBackupsCheckoutSheet(
|
||||
) {
|
||||
SheetContent(
|
||||
messageBackupTier = messageBackupTier,
|
||||
availablePaymentGateways = availablePaymentGateways,
|
||||
onPaymentGatewaySelected = onPaymentGatewaySelected
|
||||
availablePaymentGateways = availablePaymentMethods,
|
||||
onPaymentGatewaySelected = onPaymentMethodSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -64,8 +64,8 @@ fun MessageBackupsCheckoutSheet(
|
||||
@Composable
|
||||
private fun SheetContent(
|
||||
messageBackupTier: MessageBackupTier,
|
||||
availablePaymentGateways: List<GatewayResponse.Gateway>,
|
||||
onPaymentGatewaySelected: (GatewayResponse.Gateway) -> Unit
|
||||
availablePaymentGateways: List<InAppPaymentData.PaymentMethodType>,
|
||||
onPaymentGatewaySelected: (InAppPaymentData.PaymentMethodType) -> Unit
|
||||
) {
|
||||
val resources = LocalContext.current.resources
|
||||
val backupTypeDetails = remember(messageBackupTier) {
|
||||
@@ -101,25 +101,27 @@ private fun SheetContent(
|
||||
) {
|
||||
availablePaymentGateways.forEach {
|
||||
when (it) {
|
||||
GatewayResponse.Gateway.GOOGLE_PAY -> GooglePayButton {
|
||||
onPaymentGatewaySelected(GatewayResponse.Gateway.GOOGLE_PAY)
|
||||
InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> GooglePayButton {
|
||||
onPaymentGatewaySelected(InAppPaymentData.PaymentMethodType.GOOGLE_PAY)
|
||||
}
|
||||
|
||||
GatewayResponse.Gateway.PAYPAL -> PayPalButton {
|
||||
onPaymentGatewaySelected(GatewayResponse.Gateway.PAYPAL)
|
||||
InAppPaymentData.PaymentMethodType.PAYPAL -> PayPalButton {
|
||||
onPaymentGatewaySelected(InAppPaymentData.PaymentMethodType.PAYPAL)
|
||||
}
|
||||
|
||||
GatewayResponse.Gateway.CREDIT_CARD -> CreditOrDebitCardButton {
|
||||
onPaymentGatewaySelected(GatewayResponse.Gateway.CREDIT_CARD)
|
||||
InAppPaymentData.PaymentMethodType.CARD -> CreditOrDebitCardButton {
|
||||
onPaymentGatewaySelected(InAppPaymentData.PaymentMethodType.CARD)
|
||||
}
|
||||
|
||||
GatewayResponse.Gateway.SEPA_DEBIT -> SepaButton {
|
||||
onPaymentGatewaySelected(GatewayResponse.Gateway.SEPA_DEBIT)
|
||||
InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> SepaButton {
|
||||
onPaymentGatewaySelected(InAppPaymentData.PaymentMethodType.SEPA_DEBIT)
|
||||
}
|
||||
|
||||
GatewayResponse.Gateway.IDEAL -> IdealButton {
|
||||
onPaymentGatewaySelected(GatewayResponse.Gateway.IDEAL)
|
||||
InAppPaymentData.PaymentMethodType.IDEAL -> IdealButton {
|
||||
onPaymentGatewaySelected(InAppPaymentData.PaymentMethodType.IDEAL)
|
||||
}
|
||||
|
||||
InAppPaymentData.PaymentMethodType.UNKNOWN -> error("Unsupported payment method type $it")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,7 +223,7 @@ private fun CreditOrDebitCardButton(
|
||||
@Preview
|
||||
@Composable
|
||||
private fun MessageBackupsCheckoutSheetPreview() {
|
||||
val availablePaymentGateways = GatewayResponse.Gateway.values().toList()
|
||||
val availablePaymentGateways = InAppPaymentData.PaymentMethodType.values().toList() - InAppPaymentData.PaymentMethodType.UNKNOWN
|
||||
|
||||
Previews.Preview {
|
||||
Column(
|
||||
|
||||
@@ -104,10 +104,10 @@ class MessageBackupsFlowActivity : PassphraseRequiredActivity() {
|
||||
dialog(route = MessageBackupsScreen.CHECKOUT_SHEET.name) {
|
||||
MessageBackupsCheckoutSheet(
|
||||
messageBackupTier = state.selectedMessageBackupTier!!,
|
||||
availablePaymentGateways = state.availablePaymentGateways,
|
||||
availablePaymentMethods = state.availablePaymentMethods,
|
||||
onDismissRequest = navController::popOrFinish,
|
||||
onPaymentGatewaySelected = {
|
||||
viewModel.onPaymentGatewayUpdated(it)
|
||||
onPaymentMethodSelected = {
|
||||
viewModel.onPaymentMethodUpdated(it)
|
||||
MessageBackupsScreen.CHECKOUT_SHEET.next()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
|
||||
|
||||
@@ -14,8 +14,8 @@ data class MessageBackupsFlowState(
|
||||
val selectedMessageBackupTier: MessageBackupTier? = null,
|
||||
val currentMessageBackupTier: MessageBackupTier? = null,
|
||||
val availableBackupTiers: List<MessageBackupTier> = emptyList(),
|
||||
val selectedPaymentGateway: GatewayResponse.Gateway? = null,
|
||||
val availablePaymentGateways: List<GatewayResponse.Gateway> = emptyList(),
|
||||
val selectedPaymentMethod: InAppPaymentData.PaymentMethodType? = null,
|
||||
val availablePaymentMethods: List<InAppPaymentData.PaymentMethodType> = emptyList(),
|
||||
val pin: String = "",
|
||||
val pinKeyboardType: PinKeyboardType = SignalStore.pinValues().keyboardType
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
|
||||
import org.thoughtcrime.securesms.lock.v2.SvrConstants
|
||||
@@ -51,8 +51,8 @@ class MessageBackupsFlowViewModel : ViewModel() {
|
||||
internalState.value = state.value.copy(pinKeyboardType = pinKeyboardType)
|
||||
}
|
||||
|
||||
fun onPaymentGatewayUpdated(gateway: GatewayResponse.Gateway) {
|
||||
internalState.value = state.value.copy(selectedPaymentGateway = gateway)
|
||||
fun onPaymentMethodUpdated(paymentMethod: InAppPaymentData.PaymentMethodType) {
|
||||
internalState.value = state.value.copy(selectedPaymentMethod = paymentMethod)
|
||||
}
|
||||
|
||||
fun onMessageBackupTierUpdated(messageBackupTier: MessageBackupTier) {
|
||||
|
||||
@@ -23,16 +23,13 @@ import org.thoughtcrime.securesms.components.emoji.MediaKeyboard
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationCheckoutDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorAction
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.RecipientPreference
|
||||
import org.thoughtcrime.securesms.components.settings.models.TextInput
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPage
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPagerViewModel
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
|
||||
@@ -41,6 +38,7 @@ import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.Debouncer
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import java.util.Optional
|
||||
|
||||
/**
|
||||
* Allows the user to confirm details about a gift, add a message, and finally make a payment.
|
||||
@@ -85,7 +83,11 @@ class GiftFlowConfirmationFragment :
|
||||
|
||||
keyboardPagerViewModel.setOnlyPage(KeyboardPage.EMOJI)
|
||||
|
||||
donationCheckoutDelegate = DonationCheckoutDelegate(this, this, viewModel.uiSessionKey, DonationErrorSource.GIFT)
|
||||
donationCheckoutDelegate = DonationCheckoutDelegate(
|
||||
this,
|
||||
this,
|
||||
viewModel.state.mapOptional { Optional.ofNullable(it.inAppPaymentId) }.distinctUntilChanged()
|
||||
)
|
||||
|
||||
processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext())
|
||||
.setView(R.layout.processing_payment_dialog)
|
||||
@@ -104,23 +106,13 @@ class GiftFlowConfirmationFragment :
|
||||
|
||||
val continueButton = requireView().findViewById<MaterialButton>(R.id.continue_button)
|
||||
continueButton.setOnClickListener {
|
||||
findNavController().safeNavigate(
|
||||
GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToGatewaySelectorBottomSheet(
|
||||
with(viewModel.snapshot) {
|
||||
GatewayRequest(
|
||||
uiSessionKey = viewModel.uiSessionKey,
|
||||
donateToSignalType = DonateToSignalType.GIFT,
|
||||
badge = giftBadge!!,
|
||||
label = getString(R.string.preferences__one_time),
|
||||
price = giftPrices[currency]!!.amount,
|
||||
currencyCode = currency.currencyCode,
|
||||
level = giftLevel!!,
|
||||
recipientId = recipient!!.id,
|
||||
additionalMessage = additionalMessage?.toString()
|
||||
)
|
||||
}
|
||||
lifecycleDisposable += viewModel.insertInAppPayment(requireContext()).subscribe { inAppPayment ->
|
||||
findNavController().safeNavigate(
|
||||
GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToGatewaySelectorBottomSheet(
|
||||
inAppPayment
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val textInput = requireView().findViewById<FrameLayout>(R.id.text_input)
|
||||
@@ -253,27 +245,27 @@ class GiftFlowConfirmationFragment :
|
||||
}
|
||||
}
|
||||
|
||||
override fun navigateToStripePaymentInProgress(gatewayRequest: GatewayRequest) {
|
||||
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToStripePaymentInProgressFragment(DonationProcessorAction.PROCESS_NEW_DONATION, gatewayRequest))
|
||||
override fun navigateToStripePaymentInProgress(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToStripePaymentInProgressFragment(DonationProcessorAction.PROCESS_NEW_DONATION, inAppPayment, inAppPayment.type))
|
||||
}
|
||||
|
||||
override fun navigateToPayPalPaymentInProgress(gatewayRequest: GatewayRequest) {
|
||||
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToPaypalPaymentInProgressFragment(DonationProcessorAction.PROCESS_NEW_DONATION, gatewayRequest))
|
||||
override fun navigateToPayPalPaymentInProgress(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToPaypalPaymentInProgressFragment(DonationProcessorAction.PROCESS_NEW_DONATION, inAppPayment, inAppPayment.type))
|
||||
}
|
||||
|
||||
override fun navigateToCreditCardForm(gatewayRequest: GatewayRequest) {
|
||||
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToCreditCardFragment(gatewayRequest))
|
||||
override fun navigateToCreditCardForm(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToCreditCardFragment(inAppPayment))
|
||||
}
|
||||
|
||||
override fun navigateToIdealDetailsFragment(gatewayRequest: GatewayRequest) {
|
||||
override fun navigateToIdealDetailsFragment(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
error("Unsupported operation")
|
||||
}
|
||||
|
||||
override fun navigateToBankTransferMandate(gatewayResponse: GatewayResponse) {
|
||||
override fun navigateToBankTransferMandate(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
error("Unsupported operation")
|
||||
}
|
||||
|
||||
override fun onPaymentComplete(gatewayRequest: GatewayRequest) {
|
||||
override fun onPaymentComplete(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
val mainActivityIntent = MainActivity.clearTop(requireContext())
|
||||
|
||||
lifecycleDisposable += ConversationIntents
|
||||
@@ -291,5 +283,5 @@ class GiftFlowConfirmationFragment :
|
||||
|
||||
override fun onUserLaunchedAnExternalApplication() = Unit
|
||||
|
||||
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) = error("Unsupported operation")
|
||||
override fun navigateToDonationPending(inAppPayment: InAppPaymentTable.InAppPayment) = error("Unsupported operation")
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts.flow
|
||||
|
||||
import android.content.Context
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatValue
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.getGiftBadgeAmounts
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.getGiftBadges
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
|
||||
import java.util.Currency
|
||||
@@ -21,6 +29,25 @@ class GiftFlowRepository {
|
||||
private val TAG = Log.tag(GiftFlowRepository::class.java)
|
||||
}
|
||||
|
||||
fun insertInAppPayment(context: Context, giftSnapshot: GiftFlowState): Single<InAppPaymentTable.InAppPayment> {
|
||||
return Single.fromCallable {
|
||||
SignalDatabase.inAppPayments.insert(
|
||||
type = InAppPaymentTable.Type.ONE_TIME_GIFT,
|
||||
state = InAppPaymentTable.State.CREATED,
|
||||
subscriberId = null,
|
||||
endOfPeriod = null,
|
||||
inAppPaymentData = InAppPaymentData(
|
||||
badge = Badges.toDatabaseBadge(giftSnapshot.giftBadge!!),
|
||||
label = context.getString(R.string.preferences__one_time),
|
||||
amount = giftSnapshot.giftPrices[giftSnapshot.currency]!!.toFiatValue(),
|
||||
level = giftSnapshot.giftLevel!!,
|
||||
recipientId = giftSnapshot.recipient!!.id.serialize(),
|
||||
additionalMessage = giftSnapshot.additionalMessage?.toString()
|
||||
)
|
||||
)
|
||||
}.flatMap { InAppPaymentsRepository.requireInAppPayment(it) }.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun getGiftBadge(): Single<Pair<Int, Badge>> {
|
||||
return Single
|
||||
.fromCallable {
|
||||
|
||||
@@ -15,6 +15,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.models.Ne
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
|
||||
import org.thoughtcrime.securesms.components.settings.models.SplashImage
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
@@ -91,7 +92,7 @@ class GiftFlowStartFragment : DSLSettingsFragment(
|
||||
selectedCurrency = state.currency,
|
||||
isEnabled = state.stage == GiftFlowState.Stage.READY,
|
||||
onClick = {
|
||||
val action = GiftFlowStartFragmentDirections.actionGiftFlowStartFragmentToSetCurrencyFragment(true, viewModel.getSupportedCurrencyCodes().toTypedArray())
|
||||
val action = GiftFlowStartFragmentDirections.actionGiftFlowStartFragmentToSetCurrencyFragment(InAppPaymentTable.Type.ONE_TIME_GIFT, viewModel.getSupportedCurrencyCodes().toTypedArray())
|
||||
findNavController().safeNavigate(action)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.badges.gifts.flow
|
||||
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import java.util.Currency
|
||||
|
||||
@@ -9,6 +10,7 @@ import java.util.Currency
|
||||
* State maintained by the GiftFlowViewModel
|
||||
*/
|
||||
data class GiftFlowState(
|
||||
val inAppPaymentId: InAppPaymentTable.InAppPaymentId? = null,
|
||||
val currency: Currency,
|
||||
val giftLevel: Long? = null,
|
||||
val giftBadge: Badge? = null,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts.flow
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
@@ -14,6 +17,7 @@ import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.InternetConnectionObserver
|
||||
@@ -39,7 +43,6 @@ class GiftFlowViewModel(
|
||||
val state: Flowable<GiftFlowState> = store.stateFlowable
|
||||
val events: Observable<DonationEvent> = eventPublisher
|
||||
val snapshot: GiftFlowState get() = store.state
|
||||
val uiSessionKey: Long = System.currentTimeMillis()
|
||||
|
||||
init {
|
||||
refresh()
|
||||
@@ -101,6 +104,15 @@ class GiftFlowViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
fun insertInAppPayment(context: Context): Single<InAppPaymentTable.InAppPayment> {
|
||||
val giftSnapshot = snapshot
|
||||
return giftFlowRepository.insertInAppPayment(context, giftSnapshot)
|
||||
.doOnSuccess { inAppPayment ->
|
||||
store.update { it.copy(inAppPaymentId = inAppPayment.id) }
|
||||
}
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
@@ -10,14 +10,19 @@ import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.badges.BadgeRepository
|
||||
import org.thoughtcrime.securesms.badges.gifts.viewgift.ViewGiftRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError.BadgeRedemptionError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.jobmanager.JobTracker
|
||||
import org.thoughtcrime.securesms.jobs.DonationReceiptRedemptionJob
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver.MessageObserver
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.InAppPaymentRedemptionJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.requireGiftBadge
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ViewReceivedGiftViewModel(
|
||||
@@ -98,44 +103,22 @@ class ViewReceivedGiftViewModel(
|
||||
}
|
||||
|
||||
private fun awaitRedemptionCompletion(setAsPrimary: Boolean): Completable {
|
||||
return Completable.create {
|
||||
Log.i(TAG, "Enqueuing gift redemption and awaiting result...", true)
|
||||
|
||||
var finalJobState: JobTracker.JobState? = null
|
||||
val countDownLatch = CountDownLatch(1)
|
||||
|
||||
DonationReceiptRedemptionJob.createJobChainForGift(messageId, setAsPrimary).enqueue { _, state ->
|
||||
if (state.isComplete) {
|
||||
finalJobState = state
|
||||
countDownLatch.countDown()
|
||||
return Completable.create { emitter ->
|
||||
val messageObserver = MessageObserver { messageId ->
|
||||
val message = SignalDatabase.messages.getMessageRecord(messageId.id)
|
||||
when (message.requireGiftBadge().redemptionState) {
|
||||
GiftBadge.RedemptionState.REDEEMED -> emitter.onComplete()
|
||||
GiftBadge.RedemptionState.FAILED -> emitter.onError(DonationError.genericBadgeRedemptionFailure(DonationErrorSource.GIFT_REDEMPTION))
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (countDownLatch.await(10, TimeUnit.SECONDS)) {
|
||||
when (finalJobState) {
|
||||
JobTracker.JobState.SUCCESS -> {
|
||||
Log.d(TAG, "Gift redemption job chain succeeded.", true)
|
||||
it.onComplete()
|
||||
}
|
||||
JobTracker.JobState.FAILURE -> {
|
||||
Log.d(TAG, "Gift redemption job chain failed permanently.", true)
|
||||
it.onError(DonationError.genericBadgeRedemptionFailure(DonationErrorSource.GIFT_REDEMPTION))
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Gift redemption job chain ignored due to in-progress jobs.", true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.GIFT_REDEMPTION))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Timeout awaiting for gift token redemption and profile refresh", true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.GIFT_REDEMPTION))
|
||||
}
|
||||
} catch (e: InterruptedException) {
|
||||
Log.w(TAG, "Interrupted awaiting for gift token redemption and profile refresh", true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.GIFT_REDEMPTION))
|
||||
ApplicationDependencies.getJobManager().add(InAppPaymentRedemptionJob.create(MessageId(messageId), setAsPrimary))
|
||||
ApplicationDependencies.getDatabaseObserver().registerMessageUpdateObserver(messageObserver)
|
||||
emitter.setCancellable {
|
||||
ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageObserver)
|
||||
}
|
||||
}
|
||||
}.timeout(10, TimeUnit.SECONDS, Completable.error(BadgeRedemptionError.TimeoutWaitingForTokenError(DonationErrorSource.GIFT_REDEMPTION)))
|
||||
}
|
||||
|
||||
class Factory(
|
||||
|
||||
@@ -16,6 +16,8 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
@@ -27,47 +29,68 @@ import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.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.Texts
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.donations.StripeDeclineCode
|
||||
import org.signal.donations.StripeFailureCode
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImage112
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.mapToErrorStringResource
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.ManageDonationsFragment
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
|
||||
class MonthlyDonationCanceledBottomSheetDialogFragment : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
companion object {
|
||||
|
||||
private const val ARG_IN_APP_PAYMENT_ID = "arg.inAppPaymentId"
|
||||
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
fun show(fragmentManager: FragmentManager, inAppPaymentId: InAppPaymentTable.InAppPaymentId? = null) {
|
||||
val fragment = MonthlyDonationCanceledBottomSheetDialogFragment()
|
||||
fragment.arguments = bundleOf(
|
||||
ARG_IN_APP_PAYMENT_ID to inAppPaymentId
|
||||
)
|
||||
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
override val peekHeightPercentage: Float = 1f
|
||||
|
||||
private val viewModel by viewModel {
|
||||
MonthlyDonationCanceledViewModel(arguments?.getParcelableCompat(ARG_IN_APP_PAYMENT_ID, InAppPaymentTable.InAppPaymentId::class.java))
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
val chargeFailure: ActiveSubscription.ChargeFailure? = SignalStore.donationsValues().getUnexpectedSubscriptionCancelationChargeFailure()
|
||||
val declineCode: StripeDeclineCode = StripeDeclineCode.getFromCode(chargeFailure?.outcomeNetworkReason)
|
||||
val failureCode: StripeFailureCode = StripeFailureCode.getFromCode(chargeFailure?.code)
|
||||
val state by viewModel.state
|
||||
|
||||
val errorMessage = if (declineCode.isKnown()) {
|
||||
declineCode.mapToErrorStringResource()
|
||||
} else if (failureCode.isKnown) {
|
||||
failureCode.mapToErrorStringResource()
|
||||
} else {
|
||||
declineCode.mapToErrorStringResource()
|
||||
if (state.loadState == MonthlyDonationCanceledState.LoadState.LOADING) {
|
||||
return
|
||||
}
|
||||
|
||||
if (state.loadState == MonthlyDonationCanceledState.LoadState.FAILED) {
|
||||
LaunchedEffect(Unit) {
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
MonthlyDonationCanceled(
|
||||
badge = SignalStore.donationsValues().getExpiredBadge(),
|
||||
errorMessageRes = errorMessage,
|
||||
badge = state.badge,
|
||||
errorMessageRes = state.errorMessage,
|
||||
onRenewClicked = {
|
||||
startActivity(AppSettingsActivity.subscriptions(requireContext()))
|
||||
dismissAllowingStateLoss()
|
||||
@@ -78,15 +101,6 @@ class MonthlyDonationCanceledBottomSheetDialogFragment : ComposeBottomSheetDialo
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun show(fragmentManager: FragmentManager) {
|
||||
val fragment = MonthlyDonationCanceledBottomSheetDialogFragment()
|
||||
|
||||
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Light Theme", group = "ShortName", uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.badges.self.expired
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
|
||||
data class MonthlyDonationCanceledState(
|
||||
val loadState: LoadState = LoadState.LOADING,
|
||||
val badge: Badge? = null,
|
||||
@StringRes val errorMessage: Int = -1
|
||||
) {
|
||||
enum class LoadState {
|
||||
LOADING,
|
||||
READY,
|
||||
FAILED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.badges.self.expired
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.donations.StripeDeclineCode
|
||||
import org.signal.donations.StripeFailureCode
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toActiveSubscriptionChargeFailure
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.mapToErrorStringResource
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription.ChargeFailure
|
||||
|
||||
class MonthlyDonationCanceledViewModel(
|
||||
inAppPaymentId: InAppPaymentTable.InAppPaymentId?
|
||||
) : ViewModel() {
|
||||
private val internalState = mutableStateOf(MonthlyDonationCanceledState())
|
||||
val state: State<MonthlyDonationCanceledState> = internalState
|
||||
|
||||
init {
|
||||
if (inAppPaymentId != null) {
|
||||
initializeFromInAppPaymentId(inAppPaymentId)
|
||||
} else {
|
||||
initializeFromSignalStore()
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializeFromInAppPaymentId(inAppPaymentId: InAppPaymentTable.InAppPaymentId) {
|
||||
viewModelScope.launch {
|
||||
val inAppPayment = withContext(Dispatchers.IO) {
|
||||
SignalDatabase.inAppPayments.getById(inAppPaymentId)
|
||||
}
|
||||
|
||||
if (inAppPayment != null) {
|
||||
internalState.value = MonthlyDonationCanceledState(
|
||||
loadState = MonthlyDonationCanceledState.LoadState.READY,
|
||||
badge = Badges.fromDatabaseBadge(inAppPayment.data.badge!!),
|
||||
errorMessage = getErrorMessage(inAppPayment.data.cancellation?.chargeFailure?.toActiveSubscriptionChargeFailure())
|
||||
)
|
||||
} else {
|
||||
internalState.value = internalState.value.copy(loadState = MonthlyDonationCanceledState.LoadState.FAILED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializeFromSignalStore() {
|
||||
internalState.value = MonthlyDonationCanceledState(
|
||||
loadState = MonthlyDonationCanceledState.LoadState.READY,
|
||||
badge = SignalStore.donationsValues().getExpiredBadge(),
|
||||
errorMessage = getErrorMessage(SignalStore.donationsValues().getUnexpectedSubscriptionCancelationChargeFailure())
|
||||
)
|
||||
}
|
||||
|
||||
@StringRes
|
||||
private fun getErrorMessage(chargeFailure: ChargeFailure?): Int {
|
||||
val declineCode: StripeDeclineCode = StripeDeclineCode.getFromCode(chargeFailure?.outcomeNetworkReason)
|
||||
val failureCode: StripeFailureCode = StripeFailureCode.getFromCode(chargeFailure?.code)
|
||||
|
||||
return if (declineCode.isKnown()) {
|
||||
declineCode.mapToErrorStringResource()
|
||||
} else if (failureCode.isKnown) {
|
||||
failureCode.mapToErrorStringResource()
|
||||
} else {
|
||||
declineCode.mapToErrorStringResource()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
@@ -21,7 +21,7 @@ class BecomeASustainerFragment : DSLSettingsBottomSheetFragment() {
|
||||
|
||||
private val viewModel: BecomeASustainerViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
BecomeASustainerViewModel.Factory(MonthlyDonationRepository(ApplicationDependencies.getDonationsService()))
|
||||
BecomeASustainerViewModel.Factory(RecurringInAppPaymentRepository(ApplicationDependencies.getDonationsService()))
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ 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.components.settings.app.subscription.MonthlyDonationRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
class BecomeASustainerViewModel(subscriptionsRepository: MonthlyDonationRepository) : ViewModel() {
|
||||
class BecomeASustainerViewModel(subscriptionsRepository: RecurringInAppPaymentRepository) : ViewModel() {
|
||||
|
||||
private val store = Store(BecomeASustainerState())
|
||||
|
||||
@@ -37,7 +37,7 @@ class BecomeASustainerViewModel(subscriptionsRepository: MonthlyDonationReposito
|
||||
private val TAG = Log.tag(BecomeASustainerViewModel::class.java)
|
||||
}
|
||||
|
||||
class Factory(private val subscriptionsRepository: MonthlyDonationRepository) : ViewModelProvider.Factory {
|
||||
class Factory(private val subscriptionsRepository: RecurringInAppPaymentRepository) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(BecomeASustainerViewModel(subscriptionsRepository))!!
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import org.thoughtcrime.securesms.badges.view.ViewBadgeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
@@ -31,7 +31,7 @@ class BadgesOverviewFragment : DSLSettingsFragment(
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
private val viewModel: BadgesOverviewViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
BadgesOverviewViewModel.Factory(BadgeRepository(requireContext()), MonthlyDonationRepository(ApplicationDependencies.getDonationsService()))
|
||||
BadgesOverviewViewModel.Factory(BadgeRepository(requireContext()), RecurringInAppPaymentRepository(ApplicationDependencies.getDonationsService()))
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.badges.BadgeRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.InternetConnectionObserver
|
||||
@@ -23,7 +24,7 @@ private val TAG = Log.tag(BadgesOverviewViewModel::class.java)
|
||||
|
||||
class BadgesOverviewViewModel(
|
||||
private val badgeRepository: BadgeRepository,
|
||||
private val subscriptionsRepository: MonthlyDonationRepository
|
||||
private val subscriptionsRepository: RecurringInAppPaymentRepository
|
||||
) : ViewModel() {
|
||||
private val store = Store(BadgesOverviewState())
|
||||
private val eventSubject = PublishSubject.create<BadgesOverviewEvent>()
|
||||
@@ -50,7 +51,7 @@ class BadgesOverviewViewModel(
|
||||
}
|
||||
|
||||
disposables += Single.zip(
|
||||
subscriptionsRepository.getActiveSubscription(),
|
||||
subscriptionsRepository.getActiveSubscription(InAppPaymentSubscriberRecord.Type.DONATION),
|
||||
subscriptionsRepository.getSubscriptions()
|
||||
) { active, all ->
|
||||
if (!active.isActive && active.activeSubscription?.willCancelAtPeriodEnd() == true) {
|
||||
@@ -89,7 +90,7 @@ class BadgesOverviewViewModel(
|
||||
|
||||
class Factory(
|
||||
private val badgeRepository: BadgeRepository,
|
||||
private val subscriptionsRepository: MonthlyDonationRepository
|
||||
private val subscriptionsRepository: RecurringInAppPaymentRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(BadgesOverviewViewModel(badgeRepository, subscriptionsRepository)))
|
||||
|
||||
@@ -13,7 +13,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.EditNotificationProfileScheduleFragmentArgs
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.help.HelpFragment
|
||||
import org.thoughtcrime.securesms.keyvalue.SettingsValues
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -55,8 +55,8 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent {
|
||||
StartLocation.PROXY -> AppSettingsFragmentDirections.actionDirectToEditProxyFragment()
|
||||
StartLocation.NOTIFICATIONS -> AppSettingsFragmentDirections.actionDirectToNotificationsSettingsFragment()
|
||||
StartLocation.CHANGE_NUMBER -> AppSettingsFragmentDirections.actionDirectToChangeNumberFragment()
|
||||
StartLocation.SUBSCRIPTIONS -> AppSettingsFragmentDirections.actionDirectToDonateToSignal(DonateToSignalType.MONTHLY)
|
||||
StartLocation.BOOST -> AppSettingsFragmentDirections.actionDirectToDonateToSignal(DonateToSignalType.ONE_TIME)
|
||||
StartLocation.SUBSCRIPTIONS -> AppSettingsFragmentDirections.actionDirectToDonateToSignal(InAppPaymentTable.Type.RECURRING_DONATION)
|
||||
StartLocation.BOOST -> AppSettingsFragmentDirections.actionDirectToDonateToSignal(InAppPaymentTable.Type.ONE_TIME_DONATION)
|
||||
StartLocation.MANAGE_SUBSCRIPTIONS -> AppSettingsFragmentDirections.actionDirectToManageDonations()
|
||||
StartLocation.NOTIFICATION_PROFILES -> AppSettingsFragmentDirections.actionDirectToNotificationProfiles()
|
||||
StartLocation.CREATE_NOTIFICATION_PROFILE -> AppSettingsFragmentDirections.actionDirectToCreateNotificationProfiles()
|
||||
|
||||
@@ -25,8 +25,10 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.TerminalDonationDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.events.ReminderUpdateEvent
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
|
||||
@@ -289,7 +291,8 @@ class AppSettingsFragment : DSLSettingsFragment(
|
||||
}
|
||||
|
||||
private fun copySubscriberIdToClipboard(): Boolean {
|
||||
val subscriber = SignalStore.donationsValues().getSubscriber()
|
||||
// TODO [alex] -- db access on main thread!
|
||||
val subscriber = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
return if (subscriber == null) {
|
||||
false
|
||||
} else {
|
||||
|
||||
@@ -6,8 +6,9 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
|
||||
import org.thoughtcrime.securesms.conversationlist.model.UnreadPaymentsLiveData
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
@@ -15,7 +16,7 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
class AppSettingsViewModel(
|
||||
monthlyDonationRepository: MonthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService())
|
||||
recurringInAppPaymentRepository: RecurringInAppPaymentRepository = RecurringInAppPaymentRepository(ApplicationDependencies.getDonationsService())
|
||||
) : ViewModel() {
|
||||
|
||||
private val store = Store(
|
||||
@@ -39,7 +40,7 @@ class AppSettingsViewModel(
|
||||
store.update(unreadPaymentsLiveData) { payments, state -> state.copy(unreadPaymentsCount = payments.map { it.unreadCount }.orElse(0)) }
|
||||
store.update(selfLiveData) { self, state -> state.copy(self = self) }
|
||||
|
||||
disposables += monthlyDonationRepository.getActiveSubscription().subscribeBy(
|
||||
disposables += recurringInAppPaymentRepository.getActiveSubscription(InAppPaymentSubscriberRecord.Type.DONATION).subscribeBy(
|
||||
onSuccess = { activeSubscription ->
|
||||
store.update { state ->
|
||||
state.copy(allowUserToGoToDonationManagementScreen = activeSubscription.isActive || InAppDonations.hasAtLeastOnePaymentMethodAvailable())
|
||||
|
||||
@@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.privacy.advanced.AdvancedPrivacySettingsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.database.JobDatabase
|
||||
import org.thoughtcrime.securesms.database.LocalMetricsDatabase
|
||||
@@ -33,11 +34,12 @@ import org.thoughtcrime.securesms.database.LogDatabase
|
||||
import org.thoughtcrime.securesms.database.MegaphoneDatabase
|
||||
import org.thoughtcrime.securesms.database.OneTimePreKeyTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.JobTracker
|
||||
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob
|
||||
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob
|
||||
import org.thoughtcrime.securesms.jobs.InAppPaymentKeepAliveJob
|
||||
import org.thoughtcrime.securesms.jobs.PnpInitializeDevicesJob
|
||||
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
|
||||
import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob
|
||||
@@ -45,8 +47,6 @@ import org.thoughtcrime.securesms.jobs.RemoteConfigRefreshJob
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob
|
||||
import org.thoughtcrime.securesms.jobs.RotateProfileKeyJob
|
||||
import org.thoughtcrime.securesms.jobs.StorageForcePushJob
|
||||
import org.thoughtcrime.securesms.jobs.SubscriptionKeepAliveJob
|
||||
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.megaphone.MegaphoneRepository
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphones
|
||||
@@ -63,7 +63,7 @@ import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.max
|
||||
import kotlin.random.Random
|
||||
import kotlin.random.nextInt
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__internal_preferences) {
|
||||
@@ -538,7 +538,8 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
|
||||
dividerPref()
|
||||
|
||||
if (SignalStore.donationsValues().getSubscriber() != null) {
|
||||
// TODO [alex] -- db access on main thread!
|
||||
if (InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION) != null) {
|
||||
sectionHeaderPref(DSLSettingsText.from("Badges"))
|
||||
|
||||
clickPref(
|
||||
@@ -571,7 +572,6 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
Toast.makeText(context, "Cleared", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
}
|
||||
|
||||
@@ -938,16 +938,11 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
|
||||
private fun enqueueSubscriptionRedemption() {
|
||||
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(
|
||||
-1L,
|
||||
TerminalDonationQueue.TerminalDonation(
|
||||
level = 1000
|
||||
)
|
||||
).enqueue()
|
||||
viewModel.enqueueSubscriptionRedemption()
|
||||
}
|
||||
|
||||
private fun enqueueSubscriptionKeepAlive() {
|
||||
SubscriptionKeepAliveJob.enqueueAndTrackTime(System.currentTimeMillis())
|
||||
InAppPaymentKeepAliveJob.enqueueAndTrackTime(System.currentTimeMillis().milliseconds)
|
||||
}
|
||||
|
||||
private fun clearCdsHistory() {
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components.settings.app.internal
|
||||
import android.content.Context
|
||||
import org.json.JSONObject
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord
|
||||
@@ -13,6 +14,7 @@ import org.thoughtcrime.securesms.emoji.EmojiFiles
|
||||
import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob
|
||||
import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob
|
||||
import org.thoughtcrime.securesms.jobs.FetchRemoteMegaphoneImageJob
|
||||
import org.thoughtcrime.securesms.jobs.InAppPaymentRecurringContextJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.notifications.v2.ConversationId
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
@@ -30,6 +32,15 @@ class InternalSettingsRepository(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun enqueueSubscriptionRedemption() {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val latest = SignalDatabase.inAppPayments.getByLatestEndOfPeriod(InAppPaymentTable.Type.RECURRING_DONATION)
|
||||
if (latest != null) {
|
||||
InAppPaymentRecurringContextJob.createJobChain(latest).enqueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addSampleReleaseNote() {
|
||||
SignalExecutors.UNBOUNDED.execute {
|
||||
ApplicationDependencies.getJobManager().runSynchronously(CreateReleaseChannelJob.create(), 5000)
|
||||
|
||||
@@ -136,6 +136,10 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
repository.addRemoteMegaphone(RemoteMegaphoneRecord.ActionId.DONATE_FOR_FRIEND)
|
||||
}
|
||||
|
||||
fun enqueueSubscriptionRedemption() {
|
||||
repository.enqueueSubscriptionRedemption()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
store.update { getState().copy(emojiVersion = it.emojiVersion) }
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Un
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.getBoostBadges
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.getGiftBadges
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.getSubscriptionLevels
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
@@ -101,7 +101,7 @@ class InternalDonorErrorConfigurationViewModel : ViewModel() {
|
||||
fun save(): Completable {
|
||||
val snapshot = store.state
|
||||
val saveState = Completable.fromAction {
|
||||
synchronized(SubscriptionReceiptRequestResponseJob.MUTEX) {
|
||||
synchronized(InAppPaymentSubscriberRecord.Type.DONATION) {
|
||||
when {
|
||||
snapshot.selectedBadge?.isGift() == true -> handleGiftExpiration(snapshot)
|
||||
snapshot.selectedBadge?.isBoost() == true -> handleBoostExpiration(snapshot)
|
||||
@@ -116,7 +116,7 @@ class InternalDonorErrorConfigurationViewModel : ViewModel() {
|
||||
|
||||
fun clearErrorState(): Completable {
|
||||
return Completable.fromAction {
|
||||
synchronized(SubscriptionReceiptRequestResponseJob.MUTEX) {
|
||||
synchronized(InAppPaymentSubscriberRecord.Type.DONATION) {
|
||||
SignalStore.donationsValues().setExpiredBadge(null)
|
||||
SignalStore.donationsValues().setExpiredGiftBadge(null)
|
||||
SignalStore.donationsValues().unexpectedSubscriptionCancelationReason = null
|
||||
|
||||
@@ -30,9 +30,9 @@ import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Texts
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
@@ -47,7 +47,7 @@ class DonationPendingBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
DonationPendingBottomSheetContent(
|
||||
badge = args.request.badge,
|
||||
badge = Badges.fromDatabaseBadge(args.inAppPayment.data.badge!!),
|
||||
onDoneClick = this::onDoneClick
|
||||
)
|
||||
}
|
||||
@@ -59,7 +59,7 @@ class DonationPendingBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||
override fun onDismiss(dialog: DialogInterface) {
|
||||
super.onDismiss(dialog)
|
||||
|
||||
if (args.request.donateToSignalType == DonateToSignalType.ONE_TIME) {
|
||||
if (!args.inAppPayment.type.recurring) {
|
||||
findNavController().popBackStack()
|
||||
} else {
|
||||
requireActivity().finish()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
@@ -24,21 +24,23 @@ object InAppDonations {
|
||||
return isCreditCardAvailable() || isPayPalAvailable() || isGooglePayAvailable() || isSEPADebitAvailable() || isIDEALAvailable()
|
||||
}
|
||||
|
||||
fun isPaymentSourceAvailable(paymentSourceType: PaymentSourceType, donateToSignalType: DonateToSignalType): Boolean {
|
||||
fun isPaymentSourceAvailable(paymentSourceType: PaymentSourceType, inAppPaymentType: InAppPaymentTable.Type): Boolean {
|
||||
return when (paymentSourceType) {
|
||||
PaymentSourceType.PayPal -> isPayPalAvailableForDonateToSignalType(donateToSignalType)
|
||||
PaymentSourceType.PayPal -> isPayPalAvailableForDonateToSignalType(inAppPaymentType)
|
||||
PaymentSourceType.Stripe.CreditCard -> isCreditCardAvailable()
|
||||
PaymentSourceType.Stripe.GooglePay -> isGooglePayAvailable()
|
||||
PaymentSourceType.Stripe.SEPADebit -> isSEPADebitAvailableForDonateToSignalType(donateToSignalType)
|
||||
PaymentSourceType.Stripe.IDEAL -> isIDEALAvailbleForDonateToSignalType(donateToSignalType)
|
||||
PaymentSourceType.Stripe.SEPADebit -> isSEPADebitAvailableForDonateToSignalType(inAppPaymentType)
|
||||
PaymentSourceType.Stripe.IDEAL -> isIDEALAvailbleForDonateToSignalType(inAppPaymentType)
|
||||
PaymentSourceType.Unknown -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isPayPalAvailableForDonateToSignalType(donateToSignalType: DonateToSignalType): Boolean {
|
||||
return when (donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME, DonateToSignalType.GIFT -> FeatureFlags.paypalOneTimeDonations()
|
||||
DonateToSignalType.MONTHLY -> FeatureFlags.paypalRecurringDonations()
|
||||
private fun isPayPalAvailableForDonateToSignalType(inAppPaymentType: InAppPaymentTable.Type): Boolean {
|
||||
return when (inAppPaymentType) {
|
||||
InAppPaymentTable.Type.UNKNOWN -> error("Unsupported type UNKNOWN")
|
||||
InAppPaymentTable.Type.ONE_TIME_DONATION, InAppPaymentTable.Type.ONE_TIME_GIFT -> FeatureFlags.paypalOneTimeDonations()
|
||||
InAppPaymentTable.Type.RECURRING_DONATION -> FeatureFlags.paypalRecurringDonations()
|
||||
InAppPaymentTable.Type.RECURRING_BACKUP -> FeatureFlags.messageBackups() && FeatureFlags.paypalRecurringDonations()
|
||||
} && !LocaleFeatureFlags.isPayPalDisabled()
|
||||
}
|
||||
|
||||
@@ -81,15 +83,15 @@ object InAppDonations {
|
||||
* Whether the user is in a region which supports SEPA Debit transfers, based off local phone number
|
||||
* and donation type.
|
||||
*/
|
||||
fun isSEPADebitAvailableForDonateToSignalType(donateToSignalType: DonateToSignalType): Boolean {
|
||||
return donateToSignalType != DonateToSignalType.GIFT && isSEPADebitAvailable()
|
||||
fun isSEPADebitAvailableForDonateToSignalType(inAppPaymentType: InAppPaymentTable.Type): Boolean {
|
||||
return inAppPaymentType != InAppPaymentTable.Type.ONE_TIME_GIFT && isSEPADebitAvailable()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user is in a region which suports IDEAL transfers, based off local phone number and
|
||||
* donation type
|
||||
*/
|
||||
fun isIDEALAvailbleForDonateToSignalType(donateToSignalType: DonateToSignalType): Boolean {
|
||||
return donateToSignalType != DonateToSignalType.GIFT && isIDEALAvailable()
|
||||
fun isIDEALAvailbleForDonateToSignalType(inAppPaymentType: InAppPaymentTable.Type): Boolean {
|
||||
return inAppPaymentType != InAppPaymentTable.Type.ONE_TIME_GIFT && isIDEALAvailable()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.squareup.wire.get
|
||||
import io.reactivex.rxjava3.core.BackpressureStrategy
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.processors.PublishProcessor
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.signal.donations.StripeDeclineCode
|
||||
import org.signal.donations.StripeFailureCode
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequestContext
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptSerial
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.toDonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.DonationRedemptionJobStatus
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.DonationRedemptionJobWatcher
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.NonVerifiedMonthlyDonation
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver.InAppPaymentObserver
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import org.whispersystems.signalservice.internal.push.DonationProcessor
|
||||
import org.whispersystems.signalservice.internal.push.exceptions.DonationProcessorError
|
||||
import java.security.SecureRandom
|
||||
import java.util.Currency
|
||||
import java.util.Optional
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
/**
|
||||
* Unifies legacy access and new access to in app payment data.
|
||||
*/
|
||||
object InAppPaymentsRepository {
|
||||
|
||||
private const val JOB_PREFIX = "InAppPayments__"
|
||||
private val TAG = Log.tag(InAppPaymentsRepository::class.java)
|
||||
|
||||
private val temporaryErrorProcessor = PublishProcessor.create<Pair<InAppPaymentTable.InAppPaymentId, Throwable>>()
|
||||
|
||||
/**
|
||||
* Wraps an in-app-payment update in a completable.
|
||||
*/
|
||||
fun updateInAppPayment(inAppPayment: InAppPaymentTable.InAppPayment): Completable {
|
||||
return Completable.fromAction {
|
||||
SignalDatabase.inAppPayments.update(inAppPayment)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
/**
|
||||
* Common logic for handling errors coming from the Rx chains that handle payments. These errors
|
||||
* are analyzed and then either written to the database or dispatched to the temporary error processor.
|
||||
*/
|
||||
fun handlePipelineError(
|
||||
inAppPaymentId: InAppPaymentTable.InAppPaymentId,
|
||||
donationErrorSource: DonationErrorSource,
|
||||
paymentSourceType: PaymentSourceType,
|
||||
error: Throwable
|
||||
) {
|
||||
if (error is InAppPaymentError) {
|
||||
setErrorIfNotPresent(inAppPaymentId, error.inAppPaymentDataError)
|
||||
return
|
||||
}
|
||||
|
||||
val donationError: DonationError = when (error) {
|
||||
is DonationError -> error
|
||||
is DonationProcessorError -> error.toDonationError(donationErrorSource, paymentSourceType)
|
||||
else -> DonationError.genericBadgeRedemptionFailure(donationErrorSource)
|
||||
}
|
||||
|
||||
val inAppPaymentError = InAppPaymentError.fromDonationError(donationError)?.inAppPaymentDataError
|
||||
if (inAppPaymentError != null) {
|
||||
Log.w(TAG, "Detected a terminal error.")
|
||||
setErrorIfNotPresent(inAppPaymentId, inAppPaymentError).subscribe()
|
||||
} else {
|
||||
Log.w(TAG, "Detected a temporary error.")
|
||||
temporaryErrorProcessor.onNext(inAppPaymentId to donationError)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe a stream of "temporary errors". These are situations in which either the user cancelled out, opened an external application,
|
||||
* or needs to wait a longer time period than 10s for the completion of their payment.
|
||||
*/
|
||||
fun observeTemporaryErrors(inAppPaymentId: InAppPaymentTable.InAppPaymentId): Flowable<Pair<InAppPaymentTable.InAppPaymentId, Throwable>> {
|
||||
return temporaryErrorProcessor.filter { (id, _) -> id == inAppPaymentId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the given error to the database, if and only if there is not already an error set.
|
||||
*/
|
||||
private fun setErrorIfNotPresent(inAppPaymentId: InAppPaymentTable.InAppPaymentId, error: InAppPaymentData.Error?): Completable {
|
||||
return Completable.fromAction {
|
||||
val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
|
||||
if (inAppPayment.data.error == null) {
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment.copy(
|
||||
notified = false,
|
||||
state = InAppPaymentTable.State.END,
|
||||
data = inAppPayment.data.copy(error = error)
|
||||
)
|
||||
)
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Single that can give a snapshot of the given payment, and will throw if it is not found.
|
||||
*/
|
||||
fun requireInAppPayment(inAppPaymentId: InAppPaymentTable.InAppPaymentId): Single<InAppPaymentTable.InAppPayment> {
|
||||
return Single.fromCallable {
|
||||
SignalDatabase.inAppPayments.getById(inAppPaymentId) ?: throw Exception("Not found.")
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Flowable source of InAppPayments that emits whenever the payment with the given id is updated. This
|
||||
* flowable is primed with the current state.
|
||||
*/
|
||||
fun observeUpdates(inAppPaymentId: InAppPaymentTable.InAppPaymentId): Flowable<InAppPaymentTable.InAppPayment> {
|
||||
return Flowable.create({ emitter ->
|
||||
val observer = InAppPaymentObserver {
|
||||
if (it.id == inAppPaymentId) {
|
||||
emitter.onNext(it)
|
||||
}
|
||||
}
|
||||
|
||||
ApplicationDependencies.getDatabaseObserver().registerInAppPaymentObserver(observer)
|
||||
emitter.setCancellable {
|
||||
ApplicationDependencies.getDatabaseObserver().unregisterObserver(observer)
|
||||
}
|
||||
|
||||
SignalDatabase.inAppPayments.getById(inAppPaymentId)?.also {
|
||||
observer.onInAppPaymentChanged(it)
|
||||
}
|
||||
}, BackpressureStrategy.LATEST)
|
||||
}
|
||||
|
||||
/**
|
||||
* For one-time:
|
||||
* - Each job chain is serialized with respect to the in-app-payment ID
|
||||
*
|
||||
* For recurring:
|
||||
* - Each job chain is serialized with respect to the in-app-payment type
|
||||
*/
|
||||
fun resolveJobQueueKey(inAppPayment: InAppPaymentTable.InAppPayment): String {
|
||||
return when (inAppPayment.type) {
|
||||
InAppPaymentTable.Type.UNKNOWN -> error("Unsupported type UNKNOWN.")
|
||||
InAppPaymentTable.Type.ONE_TIME_GIFT, InAppPaymentTable.Type.ONE_TIME_DONATION -> "$JOB_PREFIX${inAppPayment.id.serialize()}"
|
||||
InAppPaymentTable.Type.RECURRING_DONATION, InAppPaymentTable.Type.RECURRING_BACKUP -> "$JOB_PREFIX${inAppPayment.type.code}"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a duration to utilize for jobs tied to different payment methods. For long running bank transfers, we need to
|
||||
* allow extra time for completion.
|
||||
*/
|
||||
fun resolveContextJobLifespan(inAppPayment: InAppPaymentTable.InAppPayment): Duration {
|
||||
return when (inAppPayment.data.paymentMethodType) {
|
||||
InAppPaymentData.PaymentMethodType.SEPA_DEBIT, InAppPaymentData.PaymentMethodType.IDEAL -> 30.days
|
||||
else -> 1.days
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the object to utilize as a mutex for recurring subscriptions.
|
||||
*/
|
||||
fun resolveMutex(inAppPaymentId: InAppPaymentTable.InAppPaymentId): Any {
|
||||
val payment = SignalDatabase.inAppPayments.getById(inAppPaymentId) ?: error("Not found")
|
||||
|
||||
return payment.type.requireSubscriberType()
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a payment type into a request code for grabbing a Google Pay token.
|
||||
*/
|
||||
fun getGooglePayRequestCode(inAppPaymentType: InAppPaymentTable.Type): Int {
|
||||
return when (inAppPaymentType) {
|
||||
InAppPaymentTable.Type.UNKNOWN -> error("Unsupported type UNKNOWN")
|
||||
InAppPaymentTable.Type.ONE_TIME_GIFT -> 16143
|
||||
InAppPaymentTable.Type.ONE_TIME_DONATION -> 16141
|
||||
InAppPaymentTable.Type.RECURRING_DONATION -> 16142
|
||||
InAppPaymentTable.Type.RECURRING_BACKUP -> 16144
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an error source to a persistable type. For types that don't map,
|
||||
* UNKNOWN is returned.
|
||||
*/
|
||||
fun DonationErrorSource.toInAppPaymentType(): InAppPaymentTable.Type {
|
||||
return when (this) {
|
||||
DonationErrorSource.ONE_TIME -> InAppPaymentTable.Type.ONE_TIME_DONATION
|
||||
DonationErrorSource.MONTHLY -> InAppPaymentTable.Type.RECURRING_DONATION
|
||||
DonationErrorSource.GIFT -> InAppPaymentTable.Type.ONE_TIME_GIFT
|
||||
DonationErrorSource.GIFT_REDEMPTION -> InAppPaymentTable.Type.UNKNOWN
|
||||
DonationErrorSource.KEEP_ALIVE -> InAppPaymentTable.Type.UNKNOWN
|
||||
DonationErrorSource.UNKNOWN -> InAppPaymentTable.Type.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the structured payment source type into a type we can write to the database.
|
||||
*/
|
||||
fun PaymentSourceType.toPaymentMethodType(): InAppPaymentData.PaymentMethodType {
|
||||
return when (this) {
|
||||
PaymentSourceType.PayPal -> InAppPaymentData.PaymentMethodType.PAYPAL
|
||||
PaymentSourceType.Stripe.CreditCard -> InAppPaymentData.PaymentMethodType.CARD
|
||||
PaymentSourceType.Stripe.GooglePay -> InAppPaymentData.PaymentMethodType.GOOGLE_PAY
|
||||
PaymentSourceType.Stripe.IDEAL -> InAppPaymentData.PaymentMethodType.IDEAL
|
||||
PaymentSourceType.Stripe.SEPADebit -> InAppPaymentData.PaymentMethodType.SEPA_DEBIT
|
||||
PaymentSourceType.Unknown -> InAppPaymentData.PaymentMethodType.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the database payment method type to the structured sealed type
|
||||
*/
|
||||
fun InAppPaymentData.PaymentMethodType.toPaymentSourceType(): PaymentSourceType {
|
||||
return when (this) {
|
||||
InAppPaymentData.PaymentMethodType.PAYPAL -> PaymentSourceType.PayPal
|
||||
InAppPaymentData.PaymentMethodType.CARD -> PaymentSourceType.Stripe.CreditCard
|
||||
InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> PaymentSourceType.Stripe.GooglePay
|
||||
InAppPaymentData.PaymentMethodType.IDEAL -> PaymentSourceType.Stripe.IDEAL
|
||||
InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> PaymentSourceType.Stripe.SEPADebit
|
||||
InAppPaymentData.PaymentMethodType.UNKNOWN -> PaymentSourceType.Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts network ChargeFailure objects into the form we can persist in the database.
|
||||
*/
|
||||
fun ActiveSubscription.ChargeFailure.toInAppPaymentDataChargeFailure(): InAppPaymentData.ChargeFailure {
|
||||
return InAppPaymentData.ChargeFailure(
|
||||
code = this.code ?: "",
|
||||
message = this.message ?: "",
|
||||
outcomeNetworkStatus = outcomeNetworkStatus ?: "",
|
||||
outcomeNetworkReason = outcomeNetworkReason ?: "",
|
||||
outcomeType = outcomeType ?: ""
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts our database persistable ChargeFailure objects into the form we expect from the network.
|
||||
*/
|
||||
fun InAppPaymentData.ChargeFailure.toActiveSubscriptionChargeFailure(): ActiveSubscription.ChargeFailure {
|
||||
return ActiveSubscription.ChargeFailure(
|
||||
code,
|
||||
message,
|
||||
outcomeNetworkStatus,
|
||||
outcomeNetworkReason,
|
||||
outcomeType
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the latest payment method type, biasing the result towards what is available in the database and falling
|
||||
* back on information in SignalStore. This information is utilized in some error presentation as well as in subscription
|
||||
* updates.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun getLatestPaymentMethodType(subscriberType: InAppPaymentSubscriberRecord.Type): InAppPaymentData.PaymentMethodType {
|
||||
val paymentMethodType = getSubscriber(subscriberType)?.paymentMethodType ?: InAppPaymentData.PaymentMethodType.UNKNOWN
|
||||
return if (paymentMethodType != InAppPaymentData.PaymentMethodType.UNKNOWN) {
|
||||
paymentMethodType
|
||||
} else if (subscriberType == InAppPaymentSubscriberRecord.Type.DONATION) {
|
||||
SignalStore.donationsValues().getSubscriptionPaymentSourceType().toPaymentMethodType()
|
||||
} else {
|
||||
return InAppPaymentData.PaymentMethodType.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the latest subscription was manually cancelled by the user. We bias towards what the database tells us and
|
||||
* fall back on the SignalStore value (which is deprecated and will be removed in a future release)
|
||||
*/
|
||||
@JvmStatic
|
||||
@WorkerThread
|
||||
fun isUserManuallyCancelled(subscriberType: InAppPaymentSubscriberRecord.Type): Boolean {
|
||||
val subscriber = getSubscriber(subscriberType) ?: return false
|
||||
val latestSubscription = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(subscriber.type.inAppPaymentType)
|
||||
|
||||
return if (latestSubscription == null) {
|
||||
SignalStore.donationsValues().isUserManuallyCancelled()
|
||||
} else {
|
||||
latestSubscription.data.cancellation?.reason == InAppPaymentData.Cancellation.Reason.MANUAL
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether we should force a cancellation before our next subscription attempt. This is to help clean up
|
||||
* bad state in some edge cases.
|
||||
*/
|
||||
@JvmStatic
|
||||
@WorkerThread
|
||||
fun setShouldCancelSubscriptionBeforeNextSubscribeAttempt(subscriber: InAppPaymentSubscriberRecord, shouldCancel: Boolean) {
|
||||
if (subscriber.type == InAppPaymentSubscriberRecord.Type.DONATION) {
|
||||
SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt = shouldCancel
|
||||
}
|
||||
|
||||
SignalDatabase.inAppPaymentSubscribers.setRequiresCancel(
|
||||
subscriberId = subscriber.subscriberId,
|
||||
requiresCancel = shouldCancel
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves whether or not we should force a cancel before next subscribe attempt for in app payments of the given
|
||||
* type. This method will first check the database, and then fall back on the deprecated SignalStore value.
|
||||
*/
|
||||
@JvmStatic
|
||||
@WorkerThread
|
||||
fun getShouldCancelSubscriptionBeforeNextSubscribeAttempt(subscriberType: InAppPaymentSubscriberRecord.Type): Boolean {
|
||||
val latestSubscriber = getSubscriber(subscriberType)
|
||||
|
||||
return latestSubscriber?.requiresCancel ?: if (subscriberType == InAppPaymentSubscriberRecord.Type.DONATION) {
|
||||
SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grabs a subscriber based off the type and currency
|
||||
*/
|
||||
@JvmStatic
|
||||
@Suppress("DEPRECATION")
|
||||
@SuppressLint("DiscouragedApi")
|
||||
@WorkerThread
|
||||
fun getSubscriber(currency: Currency, type: InAppPaymentSubscriberRecord.Type): InAppPaymentSubscriberRecord? {
|
||||
val subscriber = SignalDatabase.inAppPaymentSubscribers.getByCurrencyCode(currency.currencyCode, type)
|
||||
|
||||
return if (subscriber == null && type == InAppPaymentSubscriberRecord.Type.DONATION) {
|
||||
SignalStore.donationsValues().getSubscriber(currency)
|
||||
} else {
|
||||
subscriber
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grabs the "active" subscriber according to the selected currency in the value store.
|
||||
*/
|
||||
@JvmStatic
|
||||
@WorkerThread
|
||||
fun getSubscriber(type: InAppPaymentSubscriberRecord.Type): InAppPaymentSubscriberRecord? {
|
||||
val currency = SignalStore.donationsValues().getSubscriptionCurrency(type)
|
||||
|
||||
return getSubscriber(currency, type)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a non-null subscriber for the given type, or throws.
|
||||
*/
|
||||
@JvmStatic
|
||||
@WorkerThread
|
||||
fun requireSubscriber(type: InAppPaymentSubscriberRecord.Type): InAppPaymentSubscriberRecord {
|
||||
return requireNotNull(getSubscriber(type))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the subscriber, writing them to the database.
|
||||
*/
|
||||
@JvmStatic
|
||||
@WorkerThread
|
||||
fun setSubscriber(subscriber: InAppPaymentSubscriberRecord) {
|
||||
SignalDatabase.inAppPaymentSubscribers.insertOrReplace(subscriber)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether or not a pending donation exists either in the database or via the legacy job watcher.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun hasPendingDonation(): Boolean {
|
||||
return SignalDatabase.inAppPayments.hasPendingDonation() || DonationRedemptionJobWatcher.hasPendingRedemptionJob()
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a stream of status updates for donations of the given type. Only One-time donations and recurring donations are currently supported.
|
||||
*/
|
||||
fun observeInAppPaymentRedemption(type: InAppPaymentTable.Type): Observable<DonationRedemptionJobStatus> {
|
||||
val jobStatusObservable: Observable<DonationRedemptionJobStatus> = when (type) {
|
||||
InAppPaymentTable.Type.UNKNOWN -> Observable.empty()
|
||||
InAppPaymentTable.Type.ONE_TIME_GIFT -> Observable.empty()
|
||||
InAppPaymentTable.Type.ONE_TIME_DONATION -> DonationRedemptionJobWatcher.watchOneTimeRedemption()
|
||||
InAppPaymentTable.Type.RECURRING_DONATION -> DonationRedemptionJobWatcher.watchSubscriptionRedemption()
|
||||
InAppPaymentTable.Type.RECURRING_BACKUP -> Observable.empty()
|
||||
}
|
||||
|
||||
val fromDatabase: Observable<DonationRedemptionJobStatus> = Observable.create { emitter ->
|
||||
val observer = InAppPaymentObserver {
|
||||
val latestInAppPayment = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(type)
|
||||
|
||||
emitter.onNext(Optional.ofNullable(latestInAppPayment))
|
||||
}
|
||||
|
||||
ApplicationDependencies.getDatabaseObserver().registerInAppPaymentObserver(observer)
|
||||
emitter.setCancellable { ApplicationDependencies.getDatabaseObserver().unregisterObserver(observer) }
|
||||
}.switchMap { inAppPaymentOptional ->
|
||||
val inAppPayment = inAppPaymentOptional.getOrNull() ?: return@switchMap jobStatusObservable
|
||||
|
||||
val value = when (inAppPayment.state) {
|
||||
InAppPaymentTable.State.CREATED -> error("This should have been filtered out.")
|
||||
InAppPaymentTable.State.WAITING_FOR_AUTHORIZATION -> {
|
||||
DonationRedemptionJobStatus.PendingExternalVerification(
|
||||
pendingOneTimeDonation = inAppPayment.toPendingOneTimeDonation(),
|
||||
nonVerifiedMonthlyDonation = inAppPayment.toNonVerifiedMonthlyDonation()
|
||||
)
|
||||
}
|
||||
InAppPaymentTable.State.PENDING -> {
|
||||
if (inAppPayment.data.redemption?.stage == InAppPaymentData.RedemptionState.Stage.REDEMPTION_STARTED) {
|
||||
DonationRedemptionJobStatus.PendingReceiptRedemption
|
||||
} else {
|
||||
DonationRedemptionJobStatus.PendingReceiptRequest
|
||||
}
|
||||
}
|
||||
InAppPaymentTable.State.END -> {
|
||||
if (type.recurring && inAppPayment.data.error != null) {
|
||||
DonationRedemptionJobStatus.FailedSubscription
|
||||
} else {
|
||||
DonationRedemptionJobStatus.None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Observable.just(value)
|
||||
}
|
||||
|
||||
return fromDatabase
|
||||
.switchMap {
|
||||
if (it == DonationRedemptionJobStatus.None) {
|
||||
jobStatusObservable
|
||||
} else {
|
||||
Observable.just(it)
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
private fun InAppPaymentTable.InAppPayment.toPendingOneTimeDonation(): PendingOneTimeDonation? {
|
||||
if (type.recurring) {
|
||||
return null
|
||||
}
|
||||
|
||||
return PendingOneTimeDonation(
|
||||
paymentMethodType = when (data.paymentMethodType) {
|
||||
InAppPaymentData.PaymentMethodType.UNKNOWN -> PendingOneTimeDonation.PaymentMethodType.CARD
|
||||
InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> PendingOneTimeDonation.PaymentMethodType.CARD
|
||||
InAppPaymentData.PaymentMethodType.CARD -> PendingOneTimeDonation.PaymentMethodType.CARD
|
||||
InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> PendingOneTimeDonation.PaymentMethodType.SEPA_DEBIT
|
||||
InAppPaymentData.PaymentMethodType.IDEAL -> PendingOneTimeDonation.PaymentMethodType.IDEAL
|
||||
InAppPaymentData.PaymentMethodType.PAYPAL -> PendingOneTimeDonation.PaymentMethodType.PAYPAL
|
||||
},
|
||||
amount = data.amount!!,
|
||||
badge = data.badge!!,
|
||||
timestamp = insertedAt.inWholeMilliseconds,
|
||||
error = null,
|
||||
pendingVerification = true,
|
||||
checkedVerification = data.waitForAuth!!.checkedVerification
|
||||
)
|
||||
}
|
||||
|
||||
private fun InAppPaymentTable.InAppPayment.toNonVerifiedMonthlyDonation(): NonVerifiedMonthlyDonation? {
|
||||
if (!type.recurring) {
|
||||
return null
|
||||
}
|
||||
|
||||
return NonVerifiedMonthlyDonation(
|
||||
timestamp = insertedAt.inWholeMilliseconds,
|
||||
price = data.amount!!.toFiatMoney(),
|
||||
level = data.level.toInt(),
|
||||
checkedVerification = data.waitForAuth!!.checkedVerification
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new request credential that can be used to retrieve a presentation that can be submitted to get a badge or backup.
|
||||
*/
|
||||
fun generateRequestCredential(): ReceiptCredentialRequestContext {
|
||||
Log.d(TAG, "Generating request credentials context for token redemption...", true)
|
||||
val secureRandom = SecureRandom()
|
||||
val randomBytes = Util.getSecretBytes(ReceiptSerial.SIZE)
|
||||
|
||||
return try {
|
||||
val receiptSerial = ReceiptSerial(randomBytes)
|
||||
val operations = ApplicationDependencies.getClientZkReceiptOperations()
|
||||
operations.createReceiptCredentialRequestContext(secureRandom, receiptSerial)
|
||||
} catch (e: InvalidInputException) {
|
||||
Log.e(TAG, "Failed to create credential.", e)
|
||||
throw AssertionError(e)
|
||||
} catch (e: VerificationFailedException) {
|
||||
Log.e(TAG, "Failed to create credential.", e)
|
||||
throw AssertionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Common logic for building failures based off payment failure state.
|
||||
*/
|
||||
fun buildPaymentFailure(inAppPayment: InAppPaymentTable.InAppPayment, chargeFailure: ActiveSubscription.ChargeFailure?): InAppPaymentData.Error {
|
||||
val builder = InAppPaymentData.Error.Builder()
|
||||
|
||||
if (chargeFailure == null) {
|
||||
builder.type = InAppPaymentData.Error.Type.PAYMENT_PROCESSING
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
val donationProcessor = inAppPayment.data.paymentMethodType.toDonationProcessor()
|
||||
if (donationProcessor == DonationProcessor.PAYPAL) {
|
||||
builder.type = InAppPaymentData.Error.Type.PAYPAL_CODED_ERROR
|
||||
builder.data_ = chargeFailure.code
|
||||
} else {
|
||||
val declineCode = StripeDeclineCode.getFromCode(chargeFailure.outcomeNetworkReason)
|
||||
val failureCode = StripeFailureCode.getFromCode(chargeFailure.code)
|
||||
|
||||
if (failureCode.isKnown) {
|
||||
builder.type = InAppPaymentData.Error.Type.STRIPE_FAILURE
|
||||
builder.data_ = failureCode.toString()
|
||||
} else if (declineCode.isKnown()) {
|
||||
builder.type = InAppPaymentData.Error.Type.STRIPE_DECLINED_ERROR
|
||||
builder.data_ = declineCode.toString()
|
||||
} else {
|
||||
builder.type = InAppPaymentData.Error.Type.STRIPE_CODED_ERROR
|
||||
builder.data_ = chargeFailure.code
|
||||
}
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a payment method type into the processor that manages it, either Stripe or PayPal.
|
||||
*/
|
||||
fun InAppPaymentData.PaymentMethodType.toDonationProcessor(): DonationProcessor {
|
||||
return when (this) {
|
||||
InAppPaymentData.PaymentMethodType.UNKNOWN -> DonationProcessor.STRIPE
|
||||
InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> DonationProcessor.STRIPE
|
||||
InAppPaymentData.PaymentMethodType.CARD -> DonationProcessor.STRIPE
|
||||
InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> DonationProcessor.STRIPE
|
||||
InAppPaymentData.PaymentMethodType.IDEAL -> DonationProcessor.STRIPE
|
||||
InAppPaymentData.PaymentMethodType.PAYPAL -> DonationProcessor.PAYPAL
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,31 +7,29 @@ import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError.BadgeRedemptionError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.JobTracker
|
||||
import org.thoughtcrime.securesms.jobs.BoostReceiptRequestResponseJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.jobs.InAppPaymentOneTimeContextJob
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.services.DonationsService
|
||||
import org.whispersystems.signalservice.internal.push.DonationProcessor
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class OneTimeDonationRepository(private val donationsService: DonationsService) {
|
||||
class OneTimeInAppPaymentRepository(private val donationsService: DonationsService) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(OneTimeDonationRepository::class.java)
|
||||
private val TAG = Log.tag(OneTimeInAppPaymentRepository::class.java)
|
||||
|
||||
fun <T : Any> handleCreatePaymentIntentError(throwable: Throwable, badgeRecipient: RecipientId, paymentSourceType: PaymentSourceType): Single<T> {
|
||||
return if (throwable is DonationError) {
|
||||
@@ -50,7 +48,7 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
|
||||
|
||||
if (recipient.isSelf) {
|
||||
Log.d(TAG, "Cannot send a gift to self.", true)
|
||||
throw DonationError.GiftRecipientVerificationError.SelectedRecipientDoesNotSupportGifts
|
||||
throw DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid
|
||||
}
|
||||
|
||||
if (recipient.isGroup || recipient.isDistributionList || recipient.registered != RecipientTable.RegisteredState.REGISTERED) {
|
||||
@@ -93,20 +91,25 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
|
||||
}
|
||||
|
||||
fun waitForOneTimeRedemption(
|
||||
gatewayRequest: GatewayRequest,
|
||||
inAppPayment: InAppPaymentTable.InAppPayment,
|
||||
paymentIntentId: String,
|
||||
donationProcessor: DonationProcessor,
|
||||
paymentSourceType: PaymentSourceType
|
||||
): Completable {
|
||||
val isLongRunning = paymentSourceType == PaymentSourceType.Stripe.SEPADebit
|
||||
val isBoost = gatewayRequest.recipientId == Recipient.self().id
|
||||
val isBoost = inAppPayment.data.recipientId?.let { RecipientId.from(it) } == Recipient.self().id
|
||||
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.ONE_TIME else DonationErrorSource.GIFT
|
||||
|
||||
val waitOnRedemption = Completable.create {
|
||||
val timeoutError: DonationError = if (isLongRunning) {
|
||||
BadgeRedemptionError.DonationPending(donationErrorSource, inAppPayment)
|
||||
} else {
|
||||
BadgeRedemptionError.TimeoutWaitingForTokenError(donationErrorSource)
|
||||
}
|
||||
|
||||
return Single.fromCallable {
|
||||
val donationReceiptRecord = if (isBoost) {
|
||||
DonationReceiptRecord.createForBoost(gatewayRequest.fiat)
|
||||
DonationReceiptRecord.createForBoost(inAppPayment.data.amount!!.toFiatMoney())
|
||||
} else {
|
||||
DonationReceiptRecord.createForGift(gatewayRequest.fiat)
|
||||
DonationReceiptRecord.createForGift(inAppPayment.data.amount!!.toFiatMoney())
|
||||
}
|
||||
|
||||
val donationTypeLabel = donationReceiptRecord.type.code.replaceFirstChar { c -> if (c.isLowerCase()) c.titlecase(Locale.US) else c.toString() }
|
||||
@@ -114,66 +117,30 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
|
||||
Log.d(TAG, "Confirmed payment intent. Recording $donationTypeLabel receipt and submitting badge reimbursement job chain.", true)
|
||||
SignalDatabase.donationReceipts.addReceipt(donationReceiptRecord)
|
||||
|
||||
SignalStore.donationsValues().setPendingOneTimeDonation(
|
||||
DonationSerializationHelper.createPendingOneTimeDonationProto(
|
||||
gatewayRequest.badge,
|
||||
paymentSourceType,
|
||||
gatewayRequest.fiat
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment = inAppPayment.copy(
|
||||
data = inAppPayment.data.copy(
|
||||
redemption = InAppPaymentData.RedemptionState(
|
||||
stage = InAppPaymentData.RedemptionState.Stage.INIT,
|
||||
paymentIntentId = paymentIntentId
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val terminalDonation = TerminalDonationQueue.TerminalDonation(
|
||||
level = gatewayRequest.level,
|
||||
isLongRunningPaymentMethod = isLongRunning
|
||||
)
|
||||
|
||||
val countDownLatch = CountDownLatch(1)
|
||||
var finalJobState: JobTracker.JobState? = null
|
||||
val chain = if (isBoost) {
|
||||
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntentId, donationProcessor, gatewayRequest.uiSessionKey, terminalDonation)
|
||||
} else {
|
||||
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntentId, gatewayRequest.recipientId, gatewayRequest.additionalMessage, gatewayRequest.level, donationProcessor, gatewayRequest.uiSessionKey, terminalDonation)
|
||||
InAppPaymentOneTimeContextJob.createJobChain(inAppPayment).enqueue()
|
||||
inAppPayment.id
|
||||
}.flatMap { inAppPaymentId ->
|
||||
Log.d(TAG, "Awaiting completion of redemption chain for up to 10 seconds.", true)
|
||||
InAppPaymentsRepository.observeUpdates(inAppPaymentId).filter {
|
||||
it.state == InAppPaymentTable.State.END
|
||||
}.take(1).firstOrError().timeout(10, TimeUnit.SECONDS, Single.error(timeoutError))
|
||||
}.map {
|
||||
if (it.data.error != null) {
|
||||
Log.d(TAG, "Failure during redemption chain.", true)
|
||||
throw DonationError.genericBadgeRedemptionFailure(DonationErrorSource.MONTHLY)
|
||||
}
|
||||
|
||||
chain.enqueue { _, jobState ->
|
||||
if (jobState.isComplete) {
|
||||
finalJobState = jobState
|
||||
countDownLatch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
val timeoutError: DonationError = if (isLongRunning) {
|
||||
DonationError.donationPending(donationErrorSource, gatewayRequest)
|
||||
} else {
|
||||
DonationError.timeoutWaitingForToken(donationErrorSource)
|
||||
}
|
||||
|
||||
try {
|
||||
if (countDownLatch.await(10, TimeUnit.SECONDS)) {
|
||||
when (finalJobState) {
|
||||
JobTracker.JobState.SUCCESS -> {
|
||||
Log.d(TAG, "$donationTypeLabel request response job chain succeeded.", true)
|
||||
it.onComplete()
|
||||
}
|
||||
JobTracker.JobState.FAILURE -> {
|
||||
Log.d(TAG, "$donationTypeLabel request response job chain failed permanently.", true)
|
||||
it.onError(DonationError.genericBadgeRedemptionFailure(donationErrorSource))
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "$donationTypeLabel request response job chain ignored due to in-progress jobs.", true)
|
||||
it.onError(timeoutError)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "$donationTypeLabel job chain timed out waiting for job completion.", true)
|
||||
it.onError(timeoutError)
|
||||
}
|
||||
} catch (e: InterruptedException) {
|
||||
Log.d(TAG, "$donationTypeLabel job chain interrupted", e, true)
|
||||
it.onError(timeoutError)
|
||||
}
|
||||
}
|
||||
|
||||
return waitOnRedemption
|
||||
it
|
||||
}.ignoreElement()
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal.PayPalConfirmationResult
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.services.DonationsService
|
||||
import org.whispersystems.signalservice.api.subscriptions.PayPalConfirmPaymentIntentResponse
|
||||
@@ -29,7 +31,7 @@ class PayPalRepository(private val donationsService: DonationsService) {
|
||||
private val TAG = Log.tag(PayPalRepository::class.java)
|
||||
}
|
||||
|
||||
private val monthlyDonationRepository = MonthlyDonationRepository(donationsService)
|
||||
private val recurringInAppPaymentRepository = RecurringInAppPaymentRepository(donationsService)
|
||||
|
||||
fun createOneTimePaymentIntent(
|
||||
amount: FiatMoney,
|
||||
@@ -48,7 +50,7 @@ class PayPalRepository(private val donationsService: DonationsService) {
|
||||
)
|
||||
}
|
||||
.flatMap { it.flattenResult() }
|
||||
.onErrorResumeNext { OneTimeDonationRepository.handleCreatePaymentIntentError(it, badgeRecipient, PaymentSourceType.PayPal) }
|
||||
.onErrorResumeNext { OneTimeInAppPaymentRepository.handleCreatePaymentIntentError(it, badgeRecipient, PaymentSourceType.PayPal) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
@@ -76,34 +78,42 @@ class PayPalRepository(private val donationsService: DonationsService) {
|
||||
* it means that the PaymentMethod is already tied to a Stripe account. We can retry in this
|
||||
* situation by simply deleting the old subscriber id on the service and replacing it.
|
||||
*/
|
||||
fun createPaymentMethod(retryOn409: Boolean = true): Single<PayPalCreatePaymentMethodResponse> {
|
||||
fun createPaymentMethod(subscriberType: InAppPaymentSubscriberRecord.Type, retryOn409: Boolean = true): Single<PayPalCreatePaymentMethodResponse> {
|
||||
return Single.fromCallable {
|
||||
donationsService.createPayPalPaymentMethod(
|
||||
Locale.getDefault(),
|
||||
SignalStore.donationsValues().requireSubscriber().subscriberId,
|
||||
InAppPaymentsRepository.requireSubscriber(subscriberType).subscriberId,
|
||||
MONTHLY_RETURN_URL,
|
||||
CANCEL_URL
|
||||
)
|
||||
}.flatMap { serviceResponse ->
|
||||
if (retryOn409 && serviceResponse.status == 409) {
|
||||
monthlyDonationRepository.rotateSubscriberId().andThen(createPaymentMethod(retryOn409 = false))
|
||||
recurringInAppPaymentRepository.rotateSubscriberId(subscriberType).andThen(createPaymentMethod(subscriberType, retryOn409 = false))
|
||||
} else {
|
||||
serviceResponse.flattenResult()
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun setDefaultPaymentMethod(paymentMethodId: String): Completable {
|
||||
return Single.fromCallable {
|
||||
Log.d(TAG, "Setting default payment method...", true)
|
||||
donationsService.setDefaultPayPalPaymentMethod(
|
||||
SignalStore.donationsValues().requireSubscriber().subscriberId,
|
||||
paymentMethodId
|
||||
)
|
||||
}.flatMap { it.flattenResult() }.ignoreElement().doOnComplete {
|
||||
Log.d(TAG, "Set default payment method.", true)
|
||||
Log.d(TAG, "Storing the subscription payment source type locally.", true)
|
||||
SignalStore.donationsValues().setSubscriptionPaymentSourceType(PaymentSourceType.PayPal)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
fun setDefaultPaymentMethod(subscriberType: InAppPaymentSubscriberRecord.Type, paymentMethodId: String): Completable {
|
||||
return Single
|
||||
.fromCallable { InAppPaymentsRepository.requireSubscriber(subscriberType) }
|
||||
.flatMapCompletable { subscriberRecord ->
|
||||
Single.fromCallable {
|
||||
Log.d(TAG, "Setting default payment method...", true)
|
||||
donationsService.setDefaultPayPalPaymentMethod(
|
||||
subscriberRecord.subscriberId,
|
||||
paymentMethodId
|
||||
)
|
||||
}.flatMap { it.flattenResult() }.ignoreElement().doOnComplete {
|
||||
Log.d(TAG, "Set default payment method.", true)
|
||||
Log.d(TAG, "Storing the subscription payment source type locally.", true)
|
||||
|
||||
SignalDatabase.inAppPaymentSubscribers.setPaymentMethod(
|
||||
subscriberRecord.subscriberId,
|
||||
InAppPaymentData.PaymentMethodType.PAYPAL
|
||||
)
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,23 +4,24 @@ import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError.BadgeRedemptionError
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.JobTracker
|
||||
import org.thoughtcrime.securesms.jobs.InAppPaymentKeepAliveJob
|
||||
import org.thoughtcrime.securesms.jobs.InAppPaymentRecurringContextJob
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob
|
||||
import org.thoughtcrime.securesms.jobs.SubscriptionKeepAliveJob
|
||||
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.subscription.LevelUpdate
|
||||
import org.thoughtcrime.securesms.subscription.LevelUpdateOperation
|
||||
import org.thoughtcrime.securesms.subscription.Subscriber
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.whispersystems.signalservice.api.services.DonationsService
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
@@ -29,26 +30,24 @@ import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
import org.whispersystems.signalservice.internal.EmptyResponse
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Repository which can query for the user's active subscription as well as a list of available subscriptions,
|
||||
* in the currency indicated.
|
||||
*/
|
||||
class MonthlyDonationRepository(private val donationsService: DonationsService) {
|
||||
class RecurringInAppPaymentRepository(private val donationsService: DonationsService) {
|
||||
|
||||
private val TAG = Log.tag(MonthlyDonationRepository::class.java)
|
||||
|
||||
fun getActiveSubscription(): Single<ActiveSubscription> {
|
||||
val localSubscription = SignalStore.donationsValues().getSubscriber()
|
||||
fun getActiveSubscription(type: InAppPaymentSubscriberRecord.Type): Single<ActiveSubscription> {
|
||||
val localSubscription = InAppPaymentsRepository.getSubscriber(type)
|
||||
return if (localSubscription != null) {
|
||||
Single.fromCallable { donationsService.getSubscription(localSubscription.subscriberId) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.flatMap(ServiceResponse<ActiveSubscription>::flattenResult)
|
||||
.doOnSuccess { activeSubscription ->
|
||||
if (activeSubscription.isActive && activeSubscription.activeSubscription.endOfCurrentPeriod > SignalStore.donationsValues().getLastEndOfPeriod()) {
|
||||
SubscriptionKeepAliveJob.enqueueAndTrackTime(System.currentTimeMillis())
|
||||
InAppPaymentKeepAliveJob.enqueueAndTrackTime(System.currentTimeMillis().milliseconds)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -85,23 +84,23 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
|
||||
* Since PayPal and Stripe can't interoperate, we need to be able to rotate the subscriber ID
|
||||
* in case of failures.
|
||||
*/
|
||||
fun rotateSubscriberId(): Completable {
|
||||
fun rotateSubscriberId(subscriberType: InAppPaymentSubscriberRecord.Type): Completable {
|
||||
Log.d(TAG, "Rotating SubscriberId due to alternate payment processor...", true)
|
||||
val cancelCompletable: Completable = if (SignalStore.donationsValues().getSubscriber() != null) {
|
||||
cancelActiveSubscription().andThen(updateLocalSubscriptionStateAndScheduleDataSync())
|
||||
val cancelCompletable: Completable = if (InAppPaymentsRepository.getSubscriber(subscriberType) != null) {
|
||||
cancelActiveSubscription(subscriberType).andThen(updateLocalSubscriptionStateAndScheduleDataSync(subscriberType))
|
||||
} else {
|
||||
Completable.complete()
|
||||
}
|
||||
|
||||
return cancelCompletable.andThen(ensureSubscriberId(isRotation = true))
|
||||
return cancelCompletable.andThen(ensureSubscriberId(subscriberType, isRotation = true))
|
||||
}
|
||||
|
||||
fun ensureSubscriberId(isRotation: Boolean = false): Completable {
|
||||
fun ensureSubscriberId(subscriberType: InAppPaymentSubscriberRecord.Type, isRotation: Boolean = false): Completable {
|
||||
Log.d(TAG, "Ensuring SubscriberId exists on Signal service {isRotation?$isRotation}...", true)
|
||||
val subscriberId: SubscriberId = if (isRotation) {
|
||||
SubscriberId.generate()
|
||||
} else {
|
||||
SignalStore.donationsValues().getSubscriber()?.subscriberId ?: SubscriberId.generate()
|
||||
InAppPaymentsRepository.getSubscriber(subscriberType)?.subscriberId ?: SubscriberId.generate()
|
||||
}
|
||||
|
||||
return Single
|
||||
@@ -113,20 +112,27 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
|
||||
.doOnComplete {
|
||||
Log.d(TAG, "Successfully set SubscriberId exists on Signal service.", true)
|
||||
|
||||
SignalStore
|
||||
.donationsValues()
|
||||
.setSubscriber(Subscriber(subscriberId, SignalStore.donationsValues().getSubscriptionCurrency().currencyCode))
|
||||
InAppPaymentsRepository.setSubscriber(
|
||||
InAppPaymentSubscriberRecord(
|
||||
subscriberId = subscriberId,
|
||||
currencyCode = SignalStore.donationsValues().getSubscriptionCurrency(subscriberType).currencyCode,
|
||||
type = subscriberType,
|
||||
requiresCancel = false,
|
||||
paymentMethodType = InAppPaymentData.PaymentMethodType.UNKNOWN
|
||||
)
|
||||
)
|
||||
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelActiveSubscription(): Completable {
|
||||
fun cancelActiveSubscription(subscriberType: InAppPaymentSubscriberRecord.Type): Completable {
|
||||
Log.d(TAG, "Canceling active subscription...", true)
|
||||
val localSubscriber = SignalStore.donationsValues().requireSubscriber()
|
||||
return Single
|
||||
.fromCallable {
|
||||
val localSubscriber = InAppPaymentsRepository.requireSubscriber(subscriberType)
|
||||
|
||||
donationsService.cancelSubscription(localSubscriber.subscriberId)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
@@ -135,12 +141,12 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
|
||||
.doOnComplete { Log.d(TAG, "Cancelled active subscription.", true) }
|
||||
}
|
||||
|
||||
fun cancelActiveSubscriptionIfNecessary(): Completable {
|
||||
return Single.just(SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt).flatMapCompletable {
|
||||
fun cancelActiveSubscriptionIfNecessary(subscriberType: InAppPaymentSubscriberRecord.Type): Completable {
|
||||
return Single.fromCallable { InAppPaymentsRepository.getShouldCancelSubscriptionBeforeNextSubscribeAttempt(subscriberType) }.flatMapCompletable {
|
||||
if (it) {
|
||||
Log.d(TAG, "Cancelling active subscription...", true)
|
||||
cancelActiveSubscription().doOnComplete {
|
||||
SignalStore.donationsValues().updateLocalStateForManualCancellation()
|
||||
cancelActiveSubscription(subscriberType).doOnComplete {
|
||||
SignalStore.donationsValues().updateLocalStateForManualCancellation(subscriberType)
|
||||
MultiDeviceSubscriptionSyncRequestJob.enqueue()
|
||||
}
|
||||
} else {
|
||||
@@ -149,13 +155,37 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubscriptionLevel(gatewayRequest: GatewayRequest, isLongRunning: Boolean): Completable {
|
||||
val subscriptionLevel = gatewayRequest.level.toString()
|
||||
val uiSessionKey = gatewayRequest.uiSessionKey
|
||||
fun getPaymentSourceTypeOfLatestSubscription(subscriberType: InAppPaymentSubscriberRecord.Type): Single<PaymentSourceType> {
|
||||
return Single.fromCallable {
|
||||
InAppPaymentsRepository.getLatestPaymentMethodType(subscriberType).toPaymentSourceType()
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubscriptionLevel(inAppPayment: InAppPaymentTable.InAppPayment, paymentSourceType: PaymentSourceType): Completable {
|
||||
val subscriptionLevel = inAppPayment.data.level.toString()
|
||||
val isLongRunning = paymentSourceType.isBankTransfer
|
||||
val subscriberType = inAppPayment.type.requireSubscriberType()
|
||||
val errorSource = subscriberType.inAppPaymentType.toErrorSource()
|
||||
|
||||
return getOrCreateLevelUpdateOperation(subscriptionLevel)
|
||||
.flatMapCompletable { levelUpdateOperation ->
|
||||
val subscriber = SignalStore.donationsValues().requireSubscriber()
|
||||
val subscriber = InAppPaymentsRepository.requireSubscriber(subscriberType)
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment = inAppPayment.copy(
|
||||
subscriberId = subscriber.subscriberId,
|
||||
data = inAppPayment.data.copy(
|
||||
redemption = InAppPaymentData.RedemptionState(
|
||||
stage = InAppPaymentData.RedemptionState.Stage.INIT
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val timeoutError = if (isLongRunning) {
|
||||
BadgeRedemptionError.DonationPending(errorSource, inAppPayment)
|
||||
} else {
|
||||
BadgeRedemptionError.TimeoutWaitingForTokenError(errorSource)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Attempting to set user subscription level to $subscriptionLevel", true)
|
||||
Single
|
||||
@@ -165,13 +195,13 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
|
||||
subscriptionLevel,
|
||||
subscriber.currencyCode,
|
||||
levelUpdateOperation.idempotencyKey.serialize(),
|
||||
SubscriptionReceiptRequestResponseJob.MUTEX
|
||||
subscriberType
|
||||
)
|
||||
}
|
||||
.flatMapCompletable {
|
||||
if (it.status == 200 || it.status == 204) {
|
||||
Log.d(TAG, "Successfully set user subscription to level $subscriptionLevel with response code ${it.status}", true)
|
||||
SignalStore.donationsValues().updateLocalStateForLocalSubscribe()
|
||||
SignalStore.donationsValues().updateLocalStateForLocalSubscribe(subscriberType)
|
||||
syncAccountRecord().subscribe()
|
||||
LevelUpdate.updateProcessingState(false)
|
||||
Completable.complete()
|
||||
@@ -186,54 +216,24 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
|
||||
LevelUpdate.updateProcessingState(false)
|
||||
it.flattenResult().ignoreElement()
|
||||
}
|
||||
}.andThen {
|
||||
Log.d(TAG, "Enqueuing request response job chain.", true)
|
||||
val countDownLatch = CountDownLatch(1)
|
||||
var finalJobState: JobTracker.JobState? = null
|
||||
|
||||
val terminalDonation = TerminalDonationQueue.TerminalDonation(
|
||||
level = gatewayRequest.level,
|
||||
isLongRunningPaymentMethod = isLongRunning
|
||||
)
|
||||
|
||||
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(uiSessionKey, terminalDonation).enqueue { _, jobState ->
|
||||
if (jobState.isComplete) {
|
||||
finalJobState = jobState
|
||||
countDownLatch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
val timeoutError: DonationError = if (isLongRunning) {
|
||||
DonationError.donationPending(DonationErrorSource.MONTHLY, gatewayRequest)
|
||||
} else {
|
||||
DonationError.timeoutWaitingForToken(DonationErrorSource.MONTHLY)
|
||||
}
|
||||
|
||||
try {
|
||||
if (countDownLatch.await(10, TimeUnit.SECONDS)) {
|
||||
when (finalJobState) {
|
||||
JobTracker.JobState.SUCCESS -> {
|
||||
Log.d(TAG, "Subscription request response job chain succeeded.", true)
|
||||
it.onComplete()
|
||||
}
|
||||
JobTracker.JobState.FAILURE -> {
|
||||
Log.d(TAG, "Subscription request response job chain failed permanently.", true)
|
||||
it.onError(DonationError.genericBadgeRedemptionFailure(DonationErrorSource.MONTHLY))
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "Subscription request response job chain ignored due to in-progress jobs.", true)
|
||||
it.onError(timeoutError)
|
||||
}
|
||||
}.andThen(
|
||||
Single.fromCallable {
|
||||
Log.d(TAG, "Enqueuing request response job chain.", true)
|
||||
val freshPayment = SignalDatabase.inAppPayments.getById(inAppPayment.id)!!
|
||||
InAppPaymentRecurringContextJob.createJobChain(freshPayment).enqueue()
|
||||
}.flatMap {
|
||||
Log.d(TAG, "Awaiting completion of redemption chain for up to 10 seconds.", true)
|
||||
InAppPaymentsRepository.observeUpdates(inAppPayment.id).filter {
|
||||
it.state == InAppPaymentTable.State.END
|
||||
}.take(1).map {
|
||||
if (it.data.error != null) {
|
||||
Log.d(TAG, "Failure during redemption chain.", true)
|
||||
throw DonationError.genericBadgeRedemptionFailure(errorSource)
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Subscription request response job timed out.", true)
|
||||
it.onError(timeoutError)
|
||||
}
|
||||
} catch (e: InterruptedException) {
|
||||
Log.w(TAG, "Subscription request response interrupted.", e, true)
|
||||
it.onError(timeoutError)
|
||||
}
|
||||
}
|
||||
it
|
||||
}.firstOrError()
|
||||
}.timeout(10, TimeUnit.SECONDS, Single.error(timeoutError)).ignoreElement()
|
||||
)
|
||||
}.doOnError {
|
||||
LevelUpdate.updateProcessingState(false)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
@@ -244,6 +244,8 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(RecurringInAppPaymentRepository::class.java)
|
||||
|
||||
fun getOrCreateLevelUpdateOperation(tag: String, subscriptionLevel: String): LevelUpdateOperation {
|
||||
Log.d(tag, "Retrieving level update operation for $subscriptionLevel")
|
||||
val levelUpdateOperation = SignalStore.donationsValues().getLevelOperation(subscriptionLevel)
|
||||
@@ -269,10 +271,10 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
|
||||
* Update local state information and schedule a storage sync for the change. This method
|
||||
* assumes you've already properly called the DELETE method for the stored ID on the server.
|
||||
*/
|
||||
private fun updateLocalSubscriptionStateAndScheduleDataSync(): Completable {
|
||||
private fun updateLocalSubscriptionStateAndScheduleDataSync(subscriberType: InAppPaymentSubscriberRecord.Type): Completable {
|
||||
return Completable.fromAction {
|
||||
Log.d(TAG, "Marking subscription cancelled...", true)
|
||||
SignalStore.donationsValues().updateLocalStateForManualCancellation()
|
||||
SignalStore.donationsValues().updateLocalStateForManualCancellation(subscriberType)
|
||||
MultiDeviceSubscriptionSyncRequestJob.enqueue()
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
@@ -13,11 +13,13 @@ import org.signal.donations.PaymentSourceType
|
||||
import org.signal.donations.StripeApi
|
||||
import org.signal.donations.StripeIntentAccessor
|
||||
import org.signal.donations.json.StripeIntentStatus
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentMethodType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError.OneTimeDonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
@@ -43,11 +45,14 @@ import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
* 1. Create a PaymentMethod vai the Stripe API, utilizing the token from Google Pay
|
||||
* 1. Confirm the PaymentIntent via the Stripe API
|
||||
*/
|
||||
class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper {
|
||||
class StripeRepository(
|
||||
activity: Activity,
|
||||
private val subscriberType: InAppPaymentSubscriberRecord.Type = InAppPaymentSubscriberRecord.Type.DONATION
|
||||
) : StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper {
|
||||
|
||||
private val googlePayApi = GooglePayApi(activity, StripeApi.Gateway(Environment.Donations.STRIPE_CONFIGURATION), Environment.Donations.GOOGLE_PAY_CONFIGURATION)
|
||||
private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, ApplicationDependencies.getOkHttpClient())
|
||||
private val monthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService())
|
||||
private val recurringInAppPaymentRepository = RecurringInAppPaymentRepository(ApplicationDependencies.getDonationsService())
|
||||
|
||||
fun isGooglePayAvailable(): Completable {
|
||||
return googlePayApi.queryIsReadyToPay()
|
||||
@@ -96,7 +101,7 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
|
||||
|
||||
return stripeApi.createPaymentIntent(price, badgeLevel, paymentSourceType)
|
||||
.onErrorResumeNext {
|
||||
OneTimeDonationRepository.handleCreatePaymentIntentError(it, badgeRecipient, paymentSourceType)
|
||||
OneTimeInAppPaymentRepository.handleCreatePaymentIntentError(it, badgeRecipient, paymentSourceType)
|
||||
}
|
||||
.flatMap { result ->
|
||||
val recipient = Recipient.resolved(badgeRecipient)
|
||||
@@ -104,9 +109,9 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
|
||||
|
||||
Log.d(TAG, "Created payment intent for $price.", true)
|
||||
when (result) {
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Single.error(DonationError.oneTimeDonationAmountTooSmall(errorSource))
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Single.error(DonationError.oneTimeDonationAmountTooLarge(errorSource))
|
||||
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Single.error(DonationError.invalidCurrencyForOneTimeDonation(errorSource))
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooSmall -> Single.error(OneTimeDonationError.AmountTooSmallError(errorSource))
|
||||
is StripeApi.CreatePaymentIntentResult.AmountIsTooLarge -> Single.error(OneTimeDonationError.AmountTooLargeError(errorSource))
|
||||
is StripeApi.CreatePaymentIntentResult.CurrencyIsNotSupported -> Single.error(OneTimeDonationError.InvalidCurrencyError(errorSource))
|
||||
is StripeApi.CreatePaymentIntentResult.Success -> Single.just(result.paymentIntent)
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
@@ -165,7 +170,7 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
|
||||
* situation by simply deleting the old subscriber id on the service and replacing it.
|
||||
*/
|
||||
private fun createPaymentMethod(paymentSourceType: PaymentSourceType.Stripe, retryOn409: Boolean = true): Single<StripeClientSecret> {
|
||||
return Single.fromCallable { SignalStore.donationsValues().requireSubscriber() }
|
||||
return Single.fromCallable { InAppPaymentsRepository.requireSubscriber(subscriberType) }
|
||||
.flatMap {
|
||||
Single.fromCallable {
|
||||
ApplicationDependencies
|
||||
@@ -175,7 +180,7 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
|
||||
}
|
||||
.flatMap { serviceResponse ->
|
||||
if (retryOn409 && serviceResponse.status == 409) {
|
||||
monthlyDonationRepository.rotateSubscriberId().andThen(createPaymentMethod(paymentSourceType, retryOn409 = false))
|
||||
recurringInAppPaymentRepository.rotateSubscriberId(subscriberType).andThen(createPaymentMethod(paymentSourceType, retryOn409 = false))
|
||||
} else {
|
||||
serviceResponse.flattenResult()
|
||||
}
|
||||
@@ -230,24 +235,24 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
|
||||
): Completable {
|
||||
return Single.fromCallable {
|
||||
Log.d(TAG, "Getting the subscriber...")
|
||||
SignalStore.donationsValues().requireSubscriber()
|
||||
}.flatMap {
|
||||
InAppPaymentsRepository.requireSubscriber(subscriberType)
|
||||
}.flatMapCompletable { subscriberRecord ->
|
||||
Log.d(TAG, "Setting default payment method via Signal service...")
|
||||
Single.fromCallable {
|
||||
if (paymentSourceType == PaymentSourceType.Stripe.IDEAL) {
|
||||
ApplicationDependencies
|
||||
.getDonationsService()
|
||||
.setDefaultIdealPaymentMethod(it.subscriberId, setupIntentId)
|
||||
.setDefaultIdealPaymentMethod(subscriberRecord.subscriberId, setupIntentId)
|
||||
} else {
|
||||
ApplicationDependencies
|
||||
.getDonationsService()
|
||||
.setDefaultStripePaymentMethod(it.subscriberId, paymentMethodId)
|
||||
.setDefaultStripePaymentMethod(subscriberRecord.subscriberId, paymentMethodId)
|
||||
}
|
||||
}.flatMap(ServiceResponse<EmptyResponse>::flattenResult).ignoreElement().doOnComplete {
|
||||
Log.d(TAG, "Set default payment method via Signal service!")
|
||||
Log.d(TAG, "Storing the subscription payment source type locally.")
|
||||
SignalDatabase.inAppPaymentSubscribers.setPaymentMethod(subscriberRecord.subscriberId, paymentSourceType.toPaymentMethodType())
|
||||
}
|
||||
}.flatMap(ServiceResponse<EmptyResponse>::flattenResult).ignoreElement().doOnComplete {
|
||||
Log.d(TAG, "Set default payment method via Signal service!")
|
||||
Log.d(TAG, "Storing the subscription payment source type locally.")
|
||||
SignalStore.donationsValues().setSubscriptionPaymentSourceType(paymentSourceType)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,13 +9,21 @@ import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.badges.self.expired.MonthlyDonationCanceledBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPendingBottomSheet
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPendingBottomSheetArgs
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSData
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragmentArgs
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
/**
|
||||
@@ -52,8 +60,30 @@ class TerminalDonationDelegate(
|
||||
val verifiedMonthlyDonation: Stripe3DSData? = SignalStore.donationsValues().consumeVerifiedSubscription3DSData()
|
||||
if (verifiedMonthlyDonation != null) {
|
||||
DonationPendingBottomSheet().apply {
|
||||
arguments = DonationPendingBottomSheetArgs.Builder(verifiedMonthlyDonation.gatewayRequest).build().toBundle()
|
||||
arguments = DonationPendingBottomSheetArgs.Builder(verifiedMonthlyDonation.inAppPayment).build().toBundle()
|
||||
}.show(fragmentManager, null)
|
||||
}
|
||||
|
||||
handleInAppPaymentSheets()
|
||||
}
|
||||
|
||||
private fun handleInAppPaymentSheets() {
|
||||
lifecycleDisposable += Single.fromCallable {
|
||||
SignalDatabase.inAppPayments.consumeInAppPaymentsToNotifyUser()
|
||||
}.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribeBy { inAppPayments ->
|
||||
for (payment in inAppPayments) {
|
||||
if (payment.data.error == null && payment.state == InAppPaymentTable.State.END) {
|
||||
ThanksForYourSupportBottomSheetDialogFragment()
|
||||
.apply { arguments = ThanksForYourSupportBottomSheetDialogFragmentArgs.Builder(Badges.fromDatabaseBadge(payment.data.badge!!)).build().toBundle() }
|
||||
.show(fragmentManager, null)
|
||||
} else if (payment.data.error != null && payment.state == InAppPaymentTable.State.PENDING) {
|
||||
DonationPendingBottomSheet().apply {
|
||||
arguments = DonationPendingBottomSheetArgs.Builder(payment).build().toBundle()
|
||||
}.show(fragmentManager, null)
|
||||
} else if (payment.data.error != null && payment.data.cancellation != null && payment.data.cancellation.reason != InAppPaymentData.Cancellation.Reason.MANUAL && SignalStore.donationsValues().showMonthlyDonationCanceledDialog) {
|
||||
MonthlyDonationCanceledBottomSheetDialogFragment.show(fragmentManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ class SetCurrencyFragment : DSLSettingsBottomSheetFragment() {
|
||||
private val viewModel: SetCurrencyViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
val args = SetCurrencyFragmentArgs.fromBundle(requireArguments())
|
||||
SetCurrencyViewModel.Factory(args.isBoost, args.supportedCurrencyCodes.toList())
|
||||
SetCurrencyViewModel.Factory(args.inAppPaymentType, args.supportedCurrencyCodes.toList())
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -5,24 +5,27 @@ import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.subscription.Subscriber
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
|
||||
class SetCurrencyViewModel(
|
||||
private val isOneTime: Boolean,
|
||||
private val inAppPaymentType: InAppPaymentTable.Type,
|
||||
supportedCurrencyCodes: List<String>
|
||||
) : ViewModel() {
|
||||
|
||||
private val store = Store(
|
||||
SetCurrencyState(
|
||||
selectedCurrencyCode = if (isOneTime) {
|
||||
SignalStore.donationsValues().getOneTimeCurrency().currencyCode
|
||||
selectedCurrencyCode = if (inAppPaymentType.recurring) {
|
||||
SignalStore.donationsValues().getSubscriptionCurrency(inAppPaymentType.requireSubscriberType()).currencyCode
|
||||
} else {
|
||||
SignalStore.donationsValues().getSubscriptionCurrency().currencyCode
|
||||
SignalStore.donationsValues().getOneTimeCurrency().currencyCode
|
||||
},
|
||||
currencies = supportedCurrencyCodes
|
||||
.map(Currency::getInstance)
|
||||
@@ -35,19 +38,22 @@ class SetCurrencyViewModel(
|
||||
fun setSelectedCurrency(selectedCurrencyCode: String) {
|
||||
store.update { it.copy(selectedCurrencyCode = selectedCurrencyCode) }
|
||||
|
||||
if (isOneTime) {
|
||||
if (!inAppPaymentType.recurring) {
|
||||
SignalStore.donationsValues().setOneTimeCurrency(Currency.getInstance(selectedCurrencyCode))
|
||||
} else {
|
||||
val currency = Currency.getInstance(selectedCurrencyCode)
|
||||
val subscriber = SignalStore.donationsValues().getSubscriber(currency)
|
||||
val subscriber = InAppPaymentsRepository.getSubscriber(currency, inAppPaymentType.requireSubscriberType())
|
||||
|
||||
if (subscriber != null) {
|
||||
SignalStore.donationsValues().setSubscriber(subscriber)
|
||||
InAppPaymentsRepository.setSubscriber(subscriber)
|
||||
} else {
|
||||
SignalStore.donationsValues().setSubscriber(
|
||||
Subscriber(
|
||||
InAppPaymentsRepository.setSubscriber(
|
||||
InAppPaymentSubscriberRecord(
|
||||
subscriberId = SubscriberId.generate(),
|
||||
currencyCode = currency.currencyCode
|
||||
currencyCode = currency.currencyCode,
|
||||
type = inAppPaymentType.requireSubscriberType(),
|
||||
requiresCancel = false,
|
||||
paymentMethodType = InAppPaymentData.PaymentMethodType.UNKNOWN
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -83,9 +89,9 @@ class SetCurrencyViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(private val isOneTime: Boolean, private val supportedCurrencyCodes: List<String>) : ViewModelProvider.Factory {
|
||||
class Factory(private val inAppPaymentType: InAppPaymentTable.Type, private val supportedCurrencyCodes: List<String>) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(SetCurrencyViewModel(isOneTime, supportedCurrencyCodes))!!
|
||||
return modelClass.cast(SetCurrencyViewModel(inAppPaymentType, supportedCurrencyCodes))!!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
|
||||
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
|
||||
sealed class DonateToSignalAction {
|
||||
data class DisplayCurrencySelectionDialog(val donateToSignalType: DonateToSignalType, val supportedCurrencies: List<String>) : DonateToSignalAction()
|
||||
data class DisplayGatewaySelectorDialog(val gatewayRequest: GatewayRequest) : DonateToSignalAction()
|
||||
data class CancelSubscription(val gatewayRequest: GatewayRequest) : DonateToSignalAction()
|
||||
data class UpdateSubscription(val gatewayRequest: GatewayRequest, val isLongRunning: Boolean) : DonateToSignalAction()
|
||||
data class DisplayCurrencySelectionDialog(val inAppPaymentType: InAppPaymentTable.Type, val supportedCurrencies: List<String>) : DonateToSignalAction()
|
||||
data class DisplayGatewaySelectorDialog(val inAppPayment: InAppPaymentTable.InAppPayment) : DonateToSignalAction()
|
||||
object CancelSubscription : DonateToSignalAction()
|
||||
data class UpdateSubscription(val inAppPayment: InAppPaymentTable.InAppPayment, val isLongRunning: Boolean) : DonateToSignalAction()
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
|
||||
/**
|
||||
* Activity wrapper for donate to signal screen. An activity is needed because Google Pay uses the
|
||||
@@ -20,7 +21,7 @@ class DonateToSignalActivity : FragmentWrapperActivity(), DonationPaymentCompone
|
||||
override val googlePayResultPublisher: Subject<DonationPaymentComponent.GooglePayResult> = PublishSubject.create()
|
||||
|
||||
override fun getFragment(): Fragment {
|
||||
return NavHostFragment.create(R.navigation.donate_to_signal, DonateToSignalFragmentArgs.Builder(DonateToSignalType.ONE_TIME).build().toBundle())
|
||||
return NavHostFragment.create(R.navigation.donate_to_signal, DonateToSignalFragmentArgs.Builder(InAppPaymentTable.Type.ONE_TIME_DONATION).build().toBundle())
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
|
||||
@@ -19,6 +19,7 @@ import org.signal.core.util.dp
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.badges.models.BadgePreview
|
||||
import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
@@ -27,12 +28,10 @@ import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.databinding.DonateToSignalFragmentBinding
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
@@ -68,9 +67,9 @@ class DonateToSignalFragment :
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun create(donateToSignalType: DonateToSignalType): DialogFragment {
|
||||
fun create(inAppPaymentType: InAppPaymentTable.Type): DialogFragment {
|
||||
return Dialog().apply {
|
||||
arguments = DonateToSignalFragmentArgs.Builder(donateToSignalType).build().toBundle()
|
||||
arguments = DonateToSignalFragmentArgs.Builder(inAppPaymentType).build().toBundle()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,7 +107,11 @@ class DonateToSignalFragment :
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: MappingAdapter) {
|
||||
donationCheckoutDelegate = DonationCheckoutDelegate(this, this, viewModel.uiSessionKey, DonationErrorSource.ONE_TIME, DonationErrorSource.MONTHLY)
|
||||
donationCheckoutDelegate = DonationCheckoutDelegate(
|
||||
this,
|
||||
this,
|
||||
viewModel.inAppPaymentId
|
||||
)
|
||||
|
||||
val recyclerView = this.recyclerView!!
|
||||
recyclerView.overScrollMode = RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS
|
||||
@@ -137,7 +140,7 @@ class DonateToSignalFragment :
|
||||
when (action) {
|
||||
is DonateToSignalAction.DisplayCurrencySelectionDialog -> {
|
||||
val navAction = DonateToSignalFragmentDirections.actionDonateToSignalFragmentToSetDonationCurrencyFragment(
|
||||
action.donateToSignalType == DonateToSignalType.ONE_TIME,
|
||||
action.inAppPaymentType,
|
||||
action.supportedCurrencies.toTypedArray()
|
||||
)
|
||||
|
||||
@@ -145,8 +148,8 @@ class DonateToSignalFragment :
|
||||
}
|
||||
|
||||
is DonateToSignalAction.DisplayGatewaySelectorDialog -> {
|
||||
Log.d(TAG, "Presenting gateway selector for ${action.gatewayRequest}")
|
||||
val navAction = DonateToSignalFragmentDirections.actionDonateToSignalFragmentToGatewaySelectorBottomSheetDialog(action.gatewayRequest)
|
||||
Log.d(TAG, "Presenting gateway selector for ${action.inAppPayment}")
|
||||
val navAction = DonateToSignalFragmentDirections.actionDonateToSignalFragmentToGatewaySelectorBottomSheetDialog(action.inAppPayment)
|
||||
|
||||
findNavController().safeNavigate(navAction)
|
||||
}
|
||||
@@ -155,7 +158,8 @@ class DonateToSignalFragment :
|
||||
findNavController().safeNavigate(
|
||||
DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
|
||||
DonationProcessorAction.CANCEL_SUBSCRIPTION,
|
||||
action.gatewayRequest
|
||||
null,
|
||||
InAppPaymentTable.Type.RECURRING_DONATION
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -164,7 +168,8 @@ class DonateToSignalFragment :
|
||||
findNavController().safeNavigate(
|
||||
DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
|
||||
DonationProcessorAction.UPDATE_SUBSCRIPTION,
|
||||
action.gatewayRequest
|
||||
action.inAppPayment,
|
||||
action.inAppPayment.type
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -234,7 +239,7 @@ class DonateToSignalFragment :
|
||||
|
||||
customPref(
|
||||
DonationPillToggle.Model(
|
||||
selected = state.donateToSignalType,
|
||||
selected = state.inAppPaymentType,
|
||||
onClick = {
|
||||
viewModel.toggleDonationType()
|
||||
}
|
||||
@@ -243,15 +248,15 @@ class DonateToSignalFragment :
|
||||
|
||||
space(10.dp)
|
||||
|
||||
when (state.donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> displayOneTimeSelection(state.areFieldsEnabled, state.oneTimeDonationState)
|
||||
DonateToSignalType.MONTHLY -> displayMonthlySelection(state.areFieldsEnabled, state.monthlyDonationState)
|
||||
DonateToSignalType.GIFT -> error("This fragment does not support gifts.")
|
||||
when (state.inAppPaymentType) {
|
||||
InAppPaymentTable.Type.ONE_TIME_DONATION -> displayOneTimeSelection(state.areFieldsEnabled, state.oneTimeDonationState)
|
||||
InAppPaymentTable.Type.RECURRING_DONATION -> displayMonthlySelection(state.areFieldsEnabled, state.monthlyDonationState)
|
||||
else -> error("This fragment does not support ${state.inAppPaymentType}.")
|
||||
}
|
||||
|
||||
space(20.dp)
|
||||
|
||||
if (state.donateToSignalType == DonateToSignalType.MONTHLY && state.monthlyDonationState.isSubscriptionActive) {
|
||||
if (state.inAppPaymentType == InAppPaymentTable.Type.RECURRING_DONATION && state.monthlyDonationState.isSubscriptionActive) {
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(R.string.SubscribeFragment__update_subscription),
|
||||
isEnabled = state.canUpdate,
|
||||
@@ -317,7 +322,7 @@ class DonateToSignalFragment :
|
||||
}
|
||||
|
||||
private fun showDonationPendingDialog(state: DonateToSignalState) {
|
||||
val message = if (state.donateToSignalType == DonateToSignalType.ONE_TIME) {
|
||||
val message = if (state.inAppPaymentType == InAppPaymentTable.Type.ONE_TIME_DONATION) {
|
||||
if (state.oneTimeDonationState.isOneTimeDonationLongRunning) {
|
||||
R.string.DonateToSignalFragment__bank_transfers_usually_take_1_business_day_to_process_onetime
|
||||
} else if (state.oneTimeDonationState.isNonVerifiedIdeal) {
|
||||
@@ -437,33 +442,34 @@ class DonateToSignalFragment :
|
||||
}
|
||||
}
|
||||
|
||||
override fun navigateToStripePaymentInProgress(gatewayRequest: GatewayRequest) {
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(DonationProcessorAction.PROCESS_NEW_DONATION, gatewayRequest))
|
||||
override fun navigateToStripePaymentInProgress(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(DonationProcessorAction.PROCESS_NEW_DONATION, inAppPayment, inAppPayment.type))
|
||||
}
|
||||
|
||||
override fun navigateToPayPalPaymentInProgress(gatewayRequest: GatewayRequest) {
|
||||
override fun navigateToPayPalPaymentInProgress(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
findNavController().safeNavigate(
|
||||
DonateToSignalFragmentDirections.actionDonateToSignalFragmentToPaypalPaymentInProgressFragment(
|
||||
DonationProcessorAction.PROCESS_NEW_DONATION,
|
||||
gatewayRequest
|
||||
inAppPayment,
|
||||
inAppPayment.type
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun navigateToCreditCardForm(gatewayRequest: GatewayRequest) {
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(gatewayRequest))
|
||||
override fun navigateToCreditCardForm(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(inAppPayment))
|
||||
}
|
||||
|
||||
override fun navigateToIdealDetailsFragment(gatewayRequest: GatewayRequest) {
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToIdealTransferDetailsFragment(gatewayRequest))
|
||||
override fun navigateToIdealDetailsFragment(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToIdealTransferDetailsFragment(inAppPayment))
|
||||
}
|
||||
|
||||
override fun navigateToBankTransferMandate(gatewayResponse: GatewayResponse) {
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToBankTransferMandateFragment(gatewayResponse))
|
||||
override fun navigateToBankTransferMandate(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToBankTransferMandateFragment(inAppPayment))
|
||||
}
|
||||
|
||||
override fun onPaymentComplete(gatewayRequest: GatewayRequest) {
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToThanksForYourSupportBottomSheetDialog(gatewayRequest.badge))
|
||||
override fun onPaymentComplete(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToThanksForYourSupportBottomSheetDialog(Badges.fromDatabaseBadge(inAppPayment.data.badge!!)))
|
||||
}
|
||||
|
||||
override fun onProcessorActionProcessed() {
|
||||
@@ -481,7 +487,7 @@ class DonateToSignalFragment :
|
||||
|
||||
override fun onUserLaunchedAnExternalApplication() = Unit
|
||||
|
||||
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) {
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToDonationPendingBottomSheet(gatewayRequest))
|
||||
override fun navigateToDonationPending(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToDonationPendingBottomSheet(inAppPayment))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.NonVerifiedMonthlyDonation
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
|
||||
import org.thoughtcrime.securesms.database.model.isLongRunning
|
||||
import org.thoughtcrime.securesms.database.model.isPending
|
||||
@@ -16,72 +18,72 @@ import java.util.Currency
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class DonateToSignalState(
|
||||
val donateToSignalType: DonateToSignalType,
|
||||
val inAppPaymentType: InAppPaymentTable.Type,
|
||||
val oneTimeDonationState: OneTimeDonationState = OneTimeDonationState(),
|
||||
val monthlyDonationState: MonthlyDonationState = MonthlyDonationState()
|
||||
) {
|
||||
|
||||
val areFieldsEnabled: Boolean
|
||||
get() = when (donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> oneTimeDonationState.donationStage == DonationStage.READY
|
||||
DonateToSignalType.MONTHLY -> monthlyDonationState.donationStage == DonationStage.READY
|
||||
DonateToSignalType.GIFT -> error("This flow does not support gifts")
|
||||
get() = when (inAppPaymentType) {
|
||||
InAppPaymentTable.Type.ONE_TIME_DONATION -> oneTimeDonationState.donationStage == DonationStage.READY
|
||||
InAppPaymentTable.Type.RECURRING_DONATION -> monthlyDonationState.donationStage == DonationStage.READY
|
||||
else -> error("This flow does not support $inAppPaymentType")
|
||||
}
|
||||
|
||||
val badge: Badge?
|
||||
get() = when (donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> oneTimeDonationState.badge
|
||||
DonateToSignalType.MONTHLY -> monthlyDonationState.selectedSubscription?.badge
|
||||
DonateToSignalType.GIFT -> error("This flow does not support gifts")
|
||||
get() = when (inAppPaymentType) {
|
||||
InAppPaymentTable.Type.ONE_TIME_DONATION -> oneTimeDonationState.badge
|
||||
InAppPaymentTable.Type.RECURRING_DONATION -> monthlyDonationState.selectedSubscription?.badge
|
||||
else -> error("This flow does not support $inAppPaymentType")
|
||||
}
|
||||
|
||||
val canSetCurrency: Boolean
|
||||
get() = when (donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> areFieldsEnabled && !oneTimeDonationState.isOneTimeDonationPending
|
||||
DonateToSignalType.MONTHLY -> areFieldsEnabled && !monthlyDonationState.isSubscriptionActive
|
||||
DonateToSignalType.GIFT -> error("This flow does not support gifts")
|
||||
get() = when (inAppPaymentType) {
|
||||
InAppPaymentTable.Type.ONE_TIME_DONATION -> areFieldsEnabled && !oneTimeDonationState.isOneTimeDonationPending
|
||||
InAppPaymentTable.Type.RECURRING_DONATION -> areFieldsEnabled && !monthlyDonationState.isSubscriptionActive
|
||||
else -> error("This flow does not support $inAppPaymentType")
|
||||
}
|
||||
|
||||
val selectedCurrency: Currency
|
||||
get() = when (donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> oneTimeDonationState.selectedCurrency
|
||||
DonateToSignalType.MONTHLY -> monthlyDonationState.selectedCurrency
|
||||
DonateToSignalType.GIFT -> error("This flow does not support gifts")
|
||||
get() = when (inAppPaymentType) {
|
||||
InAppPaymentTable.Type.ONE_TIME_DONATION -> oneTimeDonationState.selectedCurrency
|
||||
InAppPaymentTable.Type.RECURRING_DONATION -> monthlyDonationState.selectedCurrency
|
||||
else -> error("This flow does not support $inAppPaymentType")
|
||||
}
|
||||
|
||||
val selectableCurrencyCodes: List<String>
|
||||
get() = when (donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> oneTimeDonationState.selectableCurrencyCodes
|
||||
DonateToSignalType.MONTHLY -> monthlyDonationState.selectableCurrencyCodes
|
||||
DonateToSignalType.GIFT -> error("This flow does not support gifts")
|
||||
get() = when (inAppPaymentType) {
|
||||
InAppPaymentTable.Type.ONE_TIME_DONATION -> oneTimeDonationState.selectableCurrencyCodes
|
||||
InAppPaymentTable.Type.RECURRING_DONATION -> monthlyDonationState.selectableCurrencyCodes
|
||||
else -> error("This flow does not support $inAppPaymentType")
|
||||
}
|
||||
|
||||
val level: Int
|
||||
get() = when (donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> 1
|
||||
DonateToSignalType.MONTHLY -> monthlyDonationState.selectedSubscription!!.level
|
||||
DonateToSignalType.GIFT -> error("This flow does not support gifts")
|
||||
get() = when (inAppPaymentType) {
|
||||
InAppPaymentTable.Type.ONE_TIME_DONATION -> 1
|
||||
InAppPaymentTable.Type.RECURRING_DONATION -> monthlyDonationState.selectedSubscription!!.level
|
||||
else -> error("This flow does not support $inAppPaymentType")
|
||||
}
|
||||
|
||||
val continueEnabled: Boolean
|
||||
get() = when (donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> areFieldsEnabled && oneTimeDonationState.isSelectionValid && InAppDonations.hasAtLeastOnePaymentMethodAvailable()
|
||||
DonateToSignalType.MONTHLY -> areFieldsEnabled && monthlyDonationState.isSelectionValid && InAppDonations.hasAtLeastOnePaymentMethodAvailable()
|
||||
DonateToSignalType.GIFT -> error("This flow does not support gifts")
|
||||
get() = when (inAppPaymentType) {
|
||||
InAppPaymentTable.Type.ONE_TIME_DONATION -> areFieldsEnabled && oneTimeDonationState.isSelectionValid && InAppDonations.hasAtLeastOnePaymentMethodAvailable()
|
||||
InAppPaymentTable.Type.RECURRING_DONATION -> areFieldsEnabled && monthlyDonationState.isSelectionValid && InAppDonations.hasAtLeastOnePaymentMethodAvailable()
|
||||
else -> error("This flow does not support $inAppPaymentType")
|
||||
}
|
||||
|
||||
val canContinue: Boolean
|
||||
get() = when (donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> continueEnabled && !oneTimeDonationState.isOneTimeDonationPending
|
||||
DonateToSignalType.MONTHLY -> continueEnabled && !monthlyDonationState.isSubscriptionActive && !monthlyDonationState.transactionState.isInProgress
|
||||
DonateToSignalType.GIFT -> error("This flow does not support gifts")
|
||||
get() = when (inAppPaymentType) {
|
||||
InAppPaymentTable.Type.ONE_TIME_DONATION -> continueEnabled && !oneTimeDonationState.isOneTimeDonationPending
|
||||
InAppPaymentTable.Type.RECURRING_DONATION -> continueEnabled && !monthlyDonationState.isSubscriptionActive && !monthlyDonationState.transactionState.isInProgress
|
||||
else -> error("This flow does not support $inAppPaymentType")
|
||||
}
|
||||
|
||||
val canUpdate: Boolean
|
||||
get() = when (donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> false
|
||||
DonateToSignalType.MONTHLY -> areFieldsEnabled && monthlyDonationState.isSelectionValid
|
||||
DonateToSignalType.GIFT -> error("This flow does not support gifts")
|
||||
get() = when (inAppPaymentType) {
|
||||
InAppPaymentTable.Type.ONE_TIME_DONATION -> false
|
||||
InAppPaymentTable.Type.RECURRING_DONATION -> areFieldsEnabled && monthlyDonationState.isSelectionValid
|
||||
else -> error("This flow does not support $inAppPaymentType")
|
||||
}
|
||||
|
||||
val isUpdateLongRunning: Boolean
|
||||
@@ -112,7 +114,7 @@ data class DonateToSignalState(
|
||||
}
|
||||
|
||||
data class MonthlyDonationState(
|
||||
val selectedCurrency: Currency = SignalStore.donationsValues().getSubscriptionCurrency(),
|
||||
val selectedCurrency: Currency = SignalStore.donationsValues().getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.DONATION),
|
||||
val subscriptions: List<Subscription> = emptyList(),
|
||||
private val _activeSubscription: ActiveSubscription? = null,
|
||||
val selectedSubscription: Subscription? = null,
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
|
||||
@Parcelize
|
||||
enum class DonateToSignalType(val requestCode: Short) : Parcelable {
|
||||
ONE_TIME(16141),
|
||||
MONTHLY(16142),
|
||||
GIFT(16143);
|
||||
|
||||
fun toErrorSource(): DonationErrorSource {
|
||||
return when (this) {
|
||||
ONE_TIME -> DonationErrorSource.ONE_TIME
|
||||
MONTHLY -> DonationErrorSource.MONTHLY
|
||||
GIFT -> DonationErrorSource.GIFT
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,30 +3,36 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.processors.BehaviorProcessor
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import org.signal.core.util.StringUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.core.util.money.PlatformCurrencyUtil
|
||||
import org.signal.core.util.orNull
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeDonationRepository
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatValue
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeInAppPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.DonationRedemptionJobStatus
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.DonationRedemptionJobWatcher
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
|
||||
import org.thoughtcrime.securesms.database.model.isExpired
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.subscription.LevelUpdate
|
||||
import org.thoughtcrime.securesms.subscription.Subscriber
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.thoughtcrime.securesms.util.InternetConnectionObserver
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
@@ -44,28 +50,30 @@ import java.util.Optional
|
||||
* only in charge of rendering our "current view of the world."
|
||||
*/
|
||||
class DonateToSignalViewModel(
|
||||
startType: DonateToSignalType,
|
||||
private val subscriptionsRepository: MonthlyDonationRepository,
|
||||
private val oneTimeDonationRepository: OneTimeDonationRepository
|
||||
startType: InAppPaymentTable.Type,
|
||||
private val subscriptionsRepository: RecurringInAppPaymentRepository,
|
||||
private val oneTimeInAppPaymentRepository: OneTimeInAppPaymentRepository
|
||||
) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(DonateToSignalViewModel::class.java)
|
||||
}
|
||||
|
||||
private val store = RxStore(DonateToSignalState(donateToSignalType = startType))
|
||||
private val store = RxStore(DonateToSignalState(inAppPaymentType = startType))
|
||||
private val oneTimeDonationDisposables = CompositeDisposable()
|
||||
private val monthlyDonationDisposables = CompositeDisposable()
|
||||
private val networkDisposable = CompositeDisposable()
|
||||
private val actionDisposable = CompositeDisposable()
|
||||
private val _actions = PublishSubject.create<DonateToSignalAction>()
|
||||
private val _activeSubscription = PublishSubject.create<ActiveSubscription>()
|
||||
private val _inAppPaymentId = BehaviorProcessor.create<InAppPaymentTable.InAppPaymentId>()
|
||||
|
||||
val state = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
|
||||
val actions: Observable<DonateToSignalAction> = _actions.observeOn(AndroidSchedulers.mainThread())
|
||||
val uiSessionKey: Long = System.currentTimeMillis()
|
||||
val inAppPaymentId: Flowable<InAppPaymentTable.InAppPaymentId> = _inAppPaymentId.onBackpressureLatest().distinctUntilChanged()
|
||||
|
||||
init {
|
||||
initializeOneTimeDonationState(oneTimeDonationRepository)
|
||||
initializeOneTimeDonationState(oneTimeInAppPaymentRepository)
|
||||
initializeMonthlyDonationState(subscriptionsRepository)
|
||||
|
||||
networkDisposable += InternetConnectionObserver
|
||||
@@ -89,45 +97,49 @@ class DonateToSignalViewModel(
|
||||
fun retryOneTimeDonationState() {
|
||||
if (!oneTimeDonationDisposables.isDisposed && store.state.oneTimeDonationState.donationStage == DonateToSignalState.DonationStage.FAILURE) {
|
||||
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(donationStage = DonateToSignalState.DonationStage.INIT)) }
|
||||
initializeOneTimeDonationState(oneTimeDonationRepository)
|
||||
initializeOneTimeDonationState(oneTimeInAppPaymentRepository)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestChangeCurrency() {
|
||||
val snapshot = store.state
|
||||
if (snapshot.canSetCurrency) {
|
||||
_actions.onNext(DonateToSignalAction.DisplayCurrencySelectionDialog(snapshot.donateToSignalType, snapshot.selectableCurrencyCodes))
|
||||
_actions.onNext(DonateToSignalAction.DisplayCurrencySelectionDialog(snapshot.inAppPaymentType, snapshot.selectableCurrencyCodes))
|
||||
}
|
||||
}
|
||||
|
||||
fun requestSelectGateway() {
|
||||
val snapshot = store.state
|
||||
if (snapshot.areFieldsEnabled) {
|
||||
_actions.onNext(DonateToSignalAction.DisplayGatewaySelectorDialog(createGatewayRequest(snapshot)))
|
||||
actionDisposable += createInAppPayment(snapshot).subscribeBy {
|
||||
_actions.onNext(DonateToSignalAction.DisplayGatewaySelectorDialog(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSubscription() {
|
||||
val snapshot = store.state
|
||||
if (snapshot.areFieldsEnabled) {
|
||||
_actions.onNext(DonateToSignalAction.UpdateSubscription(createGatewayRequest(snapshot), snapshot.isUpdateLongRunning))
|
||||
actionDisposable += createInAppPayment(snapshot).subscribeBy {
|
||||
_actions.onNext(DonateToSignalAction.UpdateSubscription(it, snapshot.isUpdateLongRunning))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelSubscription() {
|
||||
val snapshot = store.state
|
||||
if (snapshot.areFieldsEnabled) {
|
||||
_actions.onNext(DonateToSignalAction.CancelSubscription(createGatewayRequest(snapshot)))
|
||||
_actions.onNext(DonateToSignalAction.CancelSubscription)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleDonationType() {
|
||||
store.update {
|
||||
it.copy(
|
||||
donateToSignalType = when (it.donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> DonateToSignalType.MONTHLY
|
||||
DonateToSignalType.MONTHLY -> DonateToSignalType.ONE_TIME
|
||||
DonateToSignalType.GIFT -> error("We are in an illegal state")
|
||||
inAppPaymentType = when (it.inAppPaymentType) {
|
||||
InAppPaymentTable.Type.ONE_TIME_DONATION -> InAppPaymentTable.Type.RECURRING_DONATION
|
||||
InAppPaymentTable.Type.RECURRING_DONATION -> InAppPaymentTable.Type.ONE_TIME_DONATION
|
||||
else -> error("Should never get here.")
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -169,7 +181,7 @@ class DonateToSignalViewModel(
|
||||
|
||||
fun refreshActiveSubscription() {
|
||||
subscriptionsRepository
|
||||
.getActiveSubscription()
|
||||
.getActiveSubscription(InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
.subscribeBy(
|
||||
onSuccess = {
|
||||
_activeSubscription.onNext(it)
|
||||
@@ -180,25 +192,39 @@ class DonateToSignalViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
private fun createGatewayRequest(snapshot: DonateToSignalState): GatewayRequest {
|
||||
private fun createInAppPayment(snapshot: DonateToSignalState): Single<InAppPaymentTable.InAppPayment> {
|
||||
val amount = getAmount(snapshot)
|
||||
return GatewayRequest(
|
||||
uiSessionKey = uiSessionKey,
|
||||
donateToSignalType = snapshot.donateToSignalType,
|
||||
badge = snapshot.badge!!,
|
||||
label = snapshot.badge!!.description,
|
||||
price = amount.amount,
|
||||
currencyCode = amount.currency.currencyCode,
|
||||
level = snapshot.level.toLong(),
|
||||
recipientId = Recipient.self().id
|
||||
)
|
||||
|
||||
return Single.fromCallable {
|
||||
SignalDatabase.inAppPayments.clearCreated()
|
||||
val id = SignalDatabase.inAppPayments.insert(
|
||||
type = snapshot.inAppPaymentType,
|
||||
state = InAppPaymentTable.State.CREATED,
|
||||
subscriberId = null,
|
||||
endOfPeriod = null,
|
||||
inAppPaymentData = InAppPaymentData(
|
||||
badge = snapshot.badge?.let { Badges.toDatabaseBadge(it) },
|
||||
label = snapshot.badge?.description ?: "",
|
||||
amount = amount.toFiatValue(),
|
||||
level = snapshot.level.toLong(),
|
||||
recipientId = Recipient.self().id.serialize(),
|
||||
paymentMethodType = InAppPaymentData.PaymentMethodType.UNKNOWN,
|
||||
redemption = InAppPaymentData.RedemptionState(
|
||||
stage = InAppPaymentData.RedemptionState.Stage.INIT
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
_inAppPaymentId.onNext(id)
|
||||
SignalDatabase.inAppPayments.getById(id)!!
|
||||
}
|
||||
}
|
||||
|
||||
private fun getAmount(snapshot: DonateToSignalState): FiatMoney {
|
||||
return when (snapshot.donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> getOneTimeAmount(snapshot.oneTimeDonationState)
|
||||
DonateToSignalType.MONTHLY -> getSelectedSubscriptionCost()
|
||||
DonateToSignalType.GIFT -> error("This ViewModel does not support gifts.")
|
||||
return when (snapshot.inAppPaymentType) {
|
||||
InAppPaymentTable.Type.ONE_TIME_DONATION -> getOneTimeAmount(snapshot.oneTimeDonationState)
|
||||
InAppPaymentTable.Type.RECURRING_DONATION -> getSelectedSubscriptionCost()
|
||||
else -> error("This ViewModel does not support ${snapshot.inAppPaymentType}.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,8 +236,8 @@ class DonateToSignalViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializeOneTimeDonationState(oneTimeDonationRepository: OneTimeDonationRepository) {
|
||||
val oneTimeDonationFromJob: Observable<Optional<PendingOneTimeDonation>> = DonationRedemptionJobWatcher.watchOneTimeRedemption().map {
|
||||
private fun initializeOneTimeDonationState(oneTimeInAppPaymentRepository: OneTimeInAppPaymentRepository) {
|
||||
val oneTimeDonationFromJob: Observable<Optional<PendingOneTimeDonation>> = InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentTable.Type.ONE_TIME_DONATION).map {
|
||||
when (it) {
|
||||
is DonationRedemptionJobStatus.PendingExternalVerification -> Optional.ofNullable(it.pendingOneTimeDonation)
|
||||
|
||||
@@ -238,7 +264,7 @@ class DonateToSignalViewModel(
|
||||
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(pendingOneTimeDonation = pendingOneTimeDonation.orNull())) }
|
||||
}
|
||||
|
||||
oneTimeDonationDisposables += oneTimeDonationRepository.getBoostBadge().subscribeBy(
|
||||
oneTimeDonationDisposables += oneTimeInAppPaymentRepository.getBoostBadge().subscribeBy(
|
||||
onSuccess = { badge ->
|
||||
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(badge = badge)) }
|
||||
},
|
||||
@@ -247,7 +273,7 @@ class DonateToSignalViewModel(
|
||||
}
|
||||
)
|
||||
|
||||
oneTimeDonationDisposables += oneTimeDonationRepository.getMinimumDonationAmounts().subscribeBy(
|
||||
oneTimeDonationDisposables += oneTimeInAppPaymentRepository.getMinimumDonationAmounts().subscribeBy(
|
||||
onSuccess = { amountMap ->
|
||||
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(minimumDonationAmounts = amountMap)) }
|
||||
},
|
||||
@@ -256,7 +282,7 @@ class DonateToSignalViewModel(
|
||||
}
|
||||
)
|
||||
|
||||
val boosts: Observable<Map<Currency, List<Boost>>> = oneTimeDonationRepository.getBoosts().toObservable()
|
||||
val boosts: Observable<Map<Currency, List<Boost>>> = oneTimeInAppPaymentRepository.getBoosts().toObservable()
|
||||
val oneTimeCurrency: Observable<Currency> = SignalStore.donationsValues().observableOneTimeCurrency
|
||||
|
||||
oneTimeDonationDisposables += Observable.combineLatest(boosts, oneTimeCurrency) { boostMap, currency ->
|
||||
@@ -294,7 +320,7 @@ class DonateToSignalViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
private fun initializeMonthlyDonationState(subscriptionsRepository: MonthlyDonationRepository) {
|
||||
private fun initializeMonthlyDonationState(subscriptionsRepository: RecurringInAppPaymentRepository) {
|
||||
monitorLevelUpdateProcessing()
|
||||
|
||||
val allSubscriptions = subscriptionsRepository.getSubscriptions()
|
||||
@@ -305,7 +331,7 @@ class DonateToSignalViewModel(
|
||||
}
|
||||
|
||||
private fun monitorLevelUpdateProcessing() {
|
||||
val redemptionJobStatus: Observable<DonationRedemptionJobStatus> = DonationRedemptionJobWatcher.watchSubscriptionRedemption()
|
||||
val redemptionJobStatus: Observable<DonationRedemptionJobStatus> = InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentTable.Type.RECURRING_DONATION)
|
||||
|
||||
monthlyDonationDisposables += Observable
|
||||
.combineLatest(redemptionJobStatus, LevelUpdate.isProcessing, ::Pair)
|
||||
@@ -361,13 +387,13 @@ class DonateToSignalViewModel(
|
||||
onSuccess = { subscriptions ->
|
||||
if (subscriptions.isNotEmpty()) {
|
||||
val priceCurrencies = subscriptions[0].prices.map { it.currency }
|
||||
val selectedCurrency = SignalStore.donationsValues().getSubscriptionCurrency()
|
||||
val selectedCurrency = SignalStore.donationsValues().getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
|
||||
if (selectedCurrency !in priceCurrencies) {
|
||||
Log.w(TAG, "Unsupported currency selection. Defaulting to USD. $selectedCurrency isn't supported.")
|
||||
val usd = PlatformCurrencyUtil.USD
|
||||
val newSubscriber = SignalStore.donationsValues().getSubscriber(usd) ?: Subscriber(SubscriberId.generate(), usd.currencyCode)
|
||||
SignalStore.donationsValues().setSubscriber(newSubscriber)
|
||||
val newSubscriber = InAppPaymentsRepository.getSubscriber(usd, InAppPaymentSubscriberRecord.Type.DONATION) ?: InAppPaymentSubscriberRecord(SubscriberId.generate(), usd.currencyCode, InAppPaymentSubscriberRecord.Type.DONATION, false, InAppPaymentData.PaymentMethodType.UNKNOWN)
|
||||
InAppPaymentsRepository.setSubscriber(newSubscriber)
|
||||
subscriptionsRepository.syncAccountRecord().subscribe()
|
||||
}
|
||||
}
|
||||
@@ -377,7 +403,7 @@ class DonateToSignalViewModel(
|
||||
}
|
||||
|
||||
private fun monitorSubscriptionCurrency() {
|
||||
monthlyDonationDisposables += SignalStore.donationsValues().observableSubscriptionCurrency.subscribe {
|
||||
monthlyDonationDisposables += SignalStore.donationsValues().observableRecurringDonationCurrency.subscribe {
|
||||
store.update { state ->
|
||||
state.copy(monthlyDonationState = state.monthlyDonationState.copy(selectedCurrency = it))
|
||||
}
|
||||
@@ -389,16 +415,17 @@ class DonateToSignalViewModel(
|
||||
oneTimeDonationDisposables.clear()
|
||||
monthlyDonationDisposables.clear()
|
||||
networkDisposable.clear()
|
||||
actionDisposable.clear()
|
||||
store.dispose()
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val startType: DonateToSignalType,
|
||||
private val subscriptionsRepository: MonthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService()),
|
||||
private val oneTimeDonationRepository: OneTimeDonationRepository = OneTimeDonationRepository(ApplicationDependencies.getDonationsService())
|
||||
private val startType: InAppPaymentTable.Type,
|
||||
private val subscriptionsRepository: RecurringInAppPaymentRepository = RecurringInAppPaymentRepository(ApplicationDependencies.getDonationsService()),
|
||||
private val oneTimeInAppPaymentRepository: OneTimeInAppPaymentRepository = OneTimeInAppPaymentRepository(ApplicationDependencies.getDonationsService())
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(DonateToSignalViewModel(startType, subscriptionsRepository, oneTimeDonationRepository)) as T
|
||||
return modelClass.cast(DonateToSignalViewModel(startType, subscriptionsRepository, oneTimeInAppPaymentRepository)) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,10 @@ import com.google.android.gms.wallet.PaymentData
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
import org.signal.core.util.logging.Log
|
||||
@@ -22,10 +24,11 @@ import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.GooglePayApi
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.card.CreditCardFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewaySelectorBottomSheet
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal.PayPalPaymentInProgressFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment
|
||||
@@ -34,12 +37,12 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.tr
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorParams
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
|
||||
/**
|
||||
* Abstracts out some common UI-level interactions between gift flow and normal donate flow.
|
||||
@@ -47,9 +50,7 @@ import java.util.Currency
|
||||
class DonationCheckoutDelegate(
|
||||
private val fragment: Fragment,
|
||||
private val callback: Callback,
|
||||
private val uiSessionKey: Long,
|
||||
errorSource: DonationErrorSource,
|
||||
vararg additionalSources: DonationErrorSource
|
||||
inAppPaymentIdSource: Flowable<InAppPaymentTable.InAppPaymentId>
|
||||
) : DefaultLifecycleObserver {
|
||||
|
||||
companion object {
|
||||
@@ -70,7 +71,7 @@ class DonationCheckoutDelegate(
|
||||
|
||||
init {
|
||||
fragment.viewLifecycleOwner.lifecycle.addObserver(this)
|
||||
ErrorHandler().attach(fragment, callback, uiSessionKey, errorSource, *additionalSources)
|
||||
ErrorHandler().attach(fragment, callback, inAppPaymentIdSource)
|
||||
}
|
||||
|
||||
override fun onCreate(owner: LifecycleOwner) {
|
||||
@@ -82,8 +83,8 @@ class DonationCheckoutDelegate(
|
||||
if (bundle.containsKey(GatewaySelectorBottomSheet.FAILURE_KEY)) {
|
||||
callback.showSepaEuroMaximumDialog(FiatMoney(bundle.getSerializable(GatewaySelectorBottomSheet.SEPA_EURO_MAX) as BigDecimal, CurrencyUtil.EURO))
|
||||
} else {
|
||||
val response: GatewayResponse = bundle.getParcelableCompat(GatewaySelectorBottomSheet.REQUEST_KEY, GatewayResponse::class.java)!!
|
||||
handleGatewaySelectionResponse(response)
|
||||
val inAppPayment: InAppPaymentTable.InAppPayment = bundle.getParcelableCompat(GatewaySelectorBottomSheet.REQUEST_KEY, InAppPaymentTable.InAppPayment::class.java)!!
|
||||
handleGatewaySelectionResponse(inAppPayment)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,8 +104,8 @@ class DonationCheckoutDelegate(
|
||||
}
|
||||
|
||||
fragment.setFragmentResultListener(BankTransferRequestKeys.PENDING_KEY) { _, bundle ->
|
||||
val request: GatewayRequest = bundle.getParcelableCompat(BankTransferRequestKeys.PENDING_KEY, GatewayRequest::class.java)!!
|
||||
callback.navigateToDonationPending(gatewayRequest = request)
|
||||
val request: InAppPaymentTable.InAppPayment = bundle.getParcelableCompat(BankTransferRequestKeys.PENDING_KEY, InAppPaymentTable.InAppPayment::class.java)!!
|
||||
callback.navigateToDonationPending(inAppPayment = request)
|
||||
}
|
||||
|
||||
fragment.setFragmentResultListener(PayPalPaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
|
||||
@@ -113,17 +114,18 @@ class DonationCheckoutDelegate(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleGatewaySelectionResponse(gatewayResponse: GatewayResponse) {
|
||||
if (InAppDonations.isPaymentSourceAvailable(gatewayResponse.gateway.toPaymentSourceType(), gatewayResponse.request.donateToSignalType)) {
|
||||
when (gatewayResponse.gateway) {
|
||||
GatewayResponse.Gateway.GOOGLE_PAY -> launchGooglePay(gatewayResponse)
|
||||
GatewayResponse.Gateway.PAYPAL -> launchPayPal(gatewayResponse)
|
||||
GatewayResponse.Gateway.CREDIT_CARD -> launchCreditCard(gatewayResponse)
|
||||
GatewayResponse.Gateway.SEPA_DEBIT -> launchBankTransfer(gatewayResponse)
|
||||
GatewayResponse.Gateway.IDEAL -> launchBankTransfer(gatewayResponse)
|
||||
private fun handleGatewaySelectionResponse(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
if (InAppDonations.isPaymentSourceAvailable(inAppPayment.data.paymentMethodType.toPaymentSourceType(), inAppPayment.type)) {
|
||||
when (inAppPayment.data.paymentMethodType) {
|
||||
InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> launchGooglePay(inAppPayment)
|
||||
InAppPaymentData.PaymentMethodType.PAYPAL -> launchPayPal(inAppPayment)
|
||||
InAppPaymentData.PaymentMethodType.CARD -> launchCreditCard(inAppPayment)
|
||||
InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> launchBankTransfer(inAppPayment)
|
||||
InAppPaymentData.PaymentMethodType.IDEAL -> launchBankTransfer(inAppPayment)
|
||||
else -> error("Unsupported payment method type")
|
||||
}
|
||||
} else {
|
||||
error("Unsupported combination! ${gatewayResponse.gateway} ${gatewayResponse.request.donateToSignalType}")
|
||||
error("Unsupported combination! ${inAppPayment.data.paymentMethodType} ${inAppPayment.type}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,8 +142,7 @@ class DonationCheckoutDelegate(
|
||||
if (result.action == DonationProcessorAction.CANCEL_SUBSCRIPTION) {
|
||||
Snackbar.make(fragment.requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG).show()
|
||||
} else {
|
||||
SignalStore.donationsValues().removeTerminalDonation(result.request.level)
|
||||
callback.onPaymentComplete(result.request)
|
||||
callback.onPaymentComplete(result.inAppPayment!!)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,28 +160,28 @@ class DonationCheckoutDelegate(
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchPayPal(gatewayResponse: GatewayResponse) {
|
||||
callback.navigateToPayPalPaymentInProgress(gatewayResponse.request)
|
||||
private fun launchPayPal(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
callback.navigateToPayPalPaymentInProgress(inAppPayment)
|
||||
}
|
||||
|
||||
private fun launchGooglePay(gatewayResponse: GatewayResponse) {
|
||||
viewModel.provideGatewayRequestForGooglePay(gatewayResponse.request)
|
||||
private fun launchGooglePay(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
viewModel.provideGatewayRequestForGooglePay(inAppPayment)
|
||||
donationPaymentComponent.stripeRepository.requestTokenFromGooglePay(
|
||||
price = FiatMoney(gatewayResponse.request.price, Currency.getInstance(gatewayResponse.request.currencyCode)),
|
||||
label = gatewayResponse.request.label,
|
||||
requestCode = gatewayResponse.request.donateToSignalType.requestCode.toInt()
|
||||
price = inAppPayment.data.amount!!.toFiatMoney(),
|
||||
label = inAppPayment.data.label,
|
||||
requestCode = InAppPaymentsRepository.getGooglePayRequestCode(inAppPayment.type)
|
||||
)
|
||||
}
|
||||
|
||||
private fun launchCreditCard(gatewayResponse: GatewayResponse) {
|
||||
callback.navigateToCreditCardForm(gatewayResponse.request)
|
||||
private fun launchCreditCard(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
callback.navigateToCreditCardForm(inAppPayment)
|
||||
}
|
||||
|
||||
private fun launchBankTransfer(gatewayResponse: GatewayResponse) {
|
||||
if (gatewayResponse.request.donateToSignalType != DonateToSignalType.MONTHLY && gatewayResponse.gateway == GatewayResponse.Gateway.IDEAL) {
|
||||
callback.navigateToIdealDetailsFragment(gatewayResponse.request)
|
||||
private fun launchBankTransfer(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
if (!inAppPayment.type.recurring && inAppPayment.data.paymentMethodType == InAppPaymentData.PaymentMethodType.IDEAL) {
|
||||
callback.navigateToIdealDetailsFragment(inAppPayment)
|
||||
} else {
|
||||
callback.navigateToBankTransferMandate(gatewayResponse)
|
||||
callback.navigateToBankTransferMandate(inAppPayment)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,26 +201,26 @@ class DonationCheckoutDelegate(
|
||||
)
|
||||
}
|
||||
|
||||
inner class GooglePayRequestCallback(private val request: GatewayRequest) : GooglePayApi.PaymentRequestCallback {
|
||||
inner class GooglePayRequestCallback(private val inAppPayment: InAppPaymentTable.InAppPayment) : GooglePayApi.PaymentRequestCallback {
|
||||
override fun onSuccess(paymentData: PaymentData) {
|
||||
Log.d(TAG, "Successfully retrieved payment data from Google Pay", true)
|
||||
stripePaymentViewModel.providePaymentData(paymentData)
|
||||
callback.navigateToStripePaymentInProgress(request)
|
||||
callback.navigateToStripePaymentInProgress(inAppPayment)
|
||||
}
|
||||
|
||||
override fun onError(googlePayException: GooglePayApi.GooglePayException) {
|
||||
Log.w(TAG, "Failed to retrieve payment data from Google Pay", googlePayException, true)
|
||||
|
||||
val error = DonationError.getGooglePayRequestTokenError(
|
||||
source = when (request.donateToSignalType) {
|
||||
DonateToSignalType.MONTHLY -> DonationErrorSource.MONTHLY
|
||||
DonateToSignalType.ONE_TIME -> DonationErrorSource.ONE_TIME
|
||||
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
|
||||
},
|
||||
throwable = googlePayException
|
||||
)
|
||||
|
||||
DonationError.routeDonationError(fragment.requireContext(), error)
|
||||
InAppPaymentsRepository.updateInAppPayment(
|
||||
inAppPayment.copy(
|
||||
notified = false,
|
||||
data = inAppPayment.data.copy(
|
||||
error = InAppPaymentData.Error(
|
||||
type = InAppPaymentData.Error.Type.GOOGLE_PAY_REQUEST_TOKEN
|
||||
)
|
||||
)
|
||||
)
|
||||
).subscribe()
|
||||
}
|
||||
|
||||
override fun onCancelled() {
|
||||
@@ -236,7 +237,19 @@ class DonationCheckoutDelegate(
|
||||
private var errorDialog: DialogInterface? = null
|
||||
private var errorHandlerCallback: ErrorHandlerCallback? = null
|
||||
|
||||
fun attach(fragment: Fragment, errorHandlerCallback: ErrorHandlerCallback?, uiSessionKey: Long, errorSource: DonationErrorSource, vararg additionalSources: DonationErrorSource) {
|
||||
fun attach(
|
||||
fragment: Fragment,
|
||||
errorHandlerCallback: ErrorHandlerCallback?,
|
||||
inAppPaymentId: InAppPaymentTable.InAppPaymentId
|
||||
) {
|
||||
attach(fragment, errorHandlerCallback, Flowable.just(inAppPaymentId))
|
||||
}
|
||||
|
||||
fun attach(
|
||||
fragment: Fragment,
|
||||
errorHandlerCallback: ErrorHandlerCallback?,
|
||||
inAppPaymentIdSource: Flowable<InAppPaymentTable.InAppPaymentId>
|
||||
) {
|
||||
this.fragment = fragment
|
||||
this.errorHandlerCallback = errorHandlerCallback
|
||||
|
||||
@@ -244,12 +257,26 @@ class DonationCheckoutDelegate(
|
||||
fragment.viewLifecycleOwner.lifecycle.addObserver(this)
|
||||
|
||||
disposables.bindTo(fragment.viewLifecycleOwner)
|
||||
disposables += registerErrorSource(errorSource)
|
||||
additionalSources.forEach { source ->
|
||||
disposables += registerErrorSource(source)
|
||||
}
|
||||
disposables += inAppPaymentIdSource
|
||||
.switchMap { filterUnnotifiedErrors(it) }
|
||||
.doOnNext {
|
||||
SignalDatabase.inAppPayments.update(it.copy(notified = true))
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeBy {
|
||||
showErrorDialog(it)
|
||||
}
|
||||
|
||||
disposables += registerUiSession(uiSessionKey)
|
||||
disposables += inAppPaymentIdSource
|
||||
.switchMap { InAppPaymentsRepository.observeTemporaryErrors(it) }
|
||||
.onBackpressureLatest()
|
||||
.concatMapSingle { (id, err) -> Single.fromCallable { SignalDatabase.inAppPayments.getById(id)!! to err } }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeBy { (inAppPayment, error) ->
|
||||
handleTemporaryError(inAppPayment, error)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
@@ -258,95 +285,105 @@ class DonationCheckoutDelegate(
|
||||
errorHandlerCallback = null
|
||||
}
|
||||
|
||||
private fun registerErrorSource(errorSource: DonationErrorSource): Disposable {
|
||||
return DonationError.getErrorsForSource(errorSource)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { error ->
|
||||
showErrorDialog(error)
|
||||
private fun filterUnnotifiedErrors(inAppPaymentId: InAppPaymentTable.InAppPaymentId): Flowable<InAppPaymentTable.InAppPayment> {
|
||||
return InAppPaymentsRepository.observeUpdates(inAppPaymentId)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.filter {
|
||||
!it.notified && it.data.error != null
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerUiSession(uiSessionKey: Long): Disposable {
|
||||
return DonationError.getErrorsForUiSessionKey(uiSessionKey)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe {
|
||||
showErrorDialog(it)
|
||||
private fun handleTemporaryError(inAppPayment: InAppPaymentTable.InAppPayment, throwable: Throwable) {
|
||||
when (throwable) {
|
||||
is DonationError.UserCancelledPaymentError -> {
|
||||
Log.d(TAG, "User cancelled out of payment flow.", true)
|
||||
}
|
||||
is DonationError.BadgeRedemptionError.DonationPending -> {
|
||||
Log.d(TAG, "User launched an external application.", true)
|
||||
errorHandlerCallback?.onUserLaunchedAnExternalApplication()
|
||||
}
|
||||
is DonationError.UserLaunchedExternalApplication -> {
|
||||
Log.d(TAG, "Long-running donation is still pending.", true)
|
||||
errorHandlerCallback?.navigateToDonationPending(inAppPayment)
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "Displaying donation error dialog.", true)
|
||||
errorDialog = DonationErrorDialogs.show(
|
||||
fragment!!.requireContext(),
|
||||
throwable,
|
||||
DialogHandler()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showErrorDialog(throwable: Throwable) {
|
||||
private fun showErrorDialog(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
if (errorDialog != null) {
|
||||
Log.d(TAG, "Already displaying an error dialog. Skipping.", throwable, true)
|
||||
Log.d(TAG, "Already displaying an error dialog. Skipping. ${inAppPayment.data.error}", true)
|
||||
return
|
||||
}
|
||||
|
||||
if (throwable is DonationError.UserCancelledPaymentError) {
|
||||
Log.d(TAG, "User cancelled out of payment flow.", true)
|
||||
|
||||
val error = inAppPayment.data.error
|
||||
if (error == null) {
|
||||
Log.d(TAG, "InAppPayment does not contain an error. Skipping.", true)
|
||||
return
|
||||
}
|
||||
|
||||
if (throwable is DonationError.UserLaunchedExternalApplication) {
|
||||
Log.d(TAG, "User launched an external application.", true)
|
||||
errorHandlerCallback?.onUserLaunchedAnExternalApplication()
|
||||
return
|
||||
}
|
||||
|
||||
if (throwable is DonationError.BadgeRedemptionError.DonationPending) {
|
||||
Log.d(TAG, "Long-running donation is still pending.", true)
|
||||
errorHandlerCallback?.navigateToDonationPending(throwable.gatewayRequest)
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Displaying donation error dialog.", true)
|
||||
errorDialog = DonationErrorDialogs.show(
|
||||
fragment!!.requireContext(),
|
||||
throwable,
|
||||
object : DonationErrorDialogs.DialogCallback() {
|
||||
var tryAgain = false
|
||||
|
||||
override fun onTryCreditCardAgain(context: Context): DonationErrorParams.ErrorAction<Unit> {
|
||||
return DonationErrorParams.ErrorAction(
|
||||
label = R.string.DeclineCode__try,
|
||||
action = {
|
||||
tryAgain = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onTryBankTransferAgain(context: Context): DonationErrorParams.ErrorAction<Unit> {
|
||||
return DonationErrorParams.ErrorAction(
|
||||
label = R.string.DeclineCode__try,
|
||||
action = {
|
||||
tryAgain = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDialogDismissed() {
|
||||
errorDialog = null
|
||||
if (!tryAgain) {
|
||||
tryAgain = false
|
||||
fragment?.findNavController()?.popBackStack()
|
||||
}
|
||||
}
|
||||
when (error.type) {
|
||||
else -> {
|
||||
Log.d(TAG, "Displaying donation error dialog.", true)
|
||||
errorDialog = DonationErrorDialogs.show(
|
||||
fragment!!.requireContext(),
|
||||
inAppPayment,
|
||||
DialogHandler()
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class DialogHandler : DonationErrorDialogs.DialogCallback() {
|
||||
var tryAgain = false
|
||||
|
||||
override fun onTryCreditCardAgain(context: Context): DonationErrorParams.ErrorAction<Unit> {
|
||||
return DonationErrorParams.ErrorAction(
|
||||
label = R.string.DeclineCode__try,
|
||||
action = {
|
||||
tryAgain = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onTryBankTransferAgain(context: Context): DonationErrorParams.ErrorAction<Unit> {
|
||||
return DonationErrorParams.ErrorAction(
|
||||
label = R.string.DeclineCode__try,
|
||||
action = {
|
||||
tryAgain = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDialogDismissed() {
|
||||
errorDialog = null
|
||||
if (!tryAgain) {
|
||||
tryAgain = false
|
||||
fragment?.findNavController()?.popBackStack()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ErrorHandlerCallback {
|
||||
fun onUserLaunchedAnExternalApplication()
|
||||
fun navigateToDonationPending(gatewayRequest: GatewayRequest)
|
||||
fun navigateToDonationPending(inAppPayment: InAppPaymentTable.InAppPayment)
|
||||
}
|
||||
|
||||
interface Callback : ErrorHandlerCallback {
|
||||
fun navigateToStripePaymentInProgress(gatewayRequest: GatewayRequest)
|
||||
fun navigateToPayPalPaymentInProgress(gatewayRequest: GatewayRequest)
|
||||
fun navigateToCreditCardForm(gatewayRequest: GatewayRequest)
|
||||
fun navigateToIdealDetailsFragment(gatewayRequest: GatewayRequest)
|
||||
fun navigateToBankTransferMandate(gatewayResponse: GatewayResponse)
|
||||
fun onPaymentComplete(gatewayRequest: GatewayRequest)
|
||||
fun navigateToStripePaymentInProgress(inAppPayment: InAppPaymentTable.InAppPayment)
|
||||
fun navigateToPayPalPaymentInProgress(inAppPayment: InAppPaymentTable.InAppPayment)
|
||||
fun navigateToCreditCardForm(inAppPayment: InAppPaymentTable.InAppPayment)
|
||||
fun navigateToIdealDetailsFragment(inAppPayment: InAppPaymentTable.InAppPayment)
|
||||
fun navigateToBankTransferMandate(inAppPayment: InAppPaymentTable.InAppPayment)
|
||||
fun onPaymentComplete(inAppPayment: InAppPaymentTable.InAppPayment)
|
||||
fun onProcessorActionProcessed()
|
||||
fun showSepaEuroMaximumDialog(sepaEuroMaximum: FiatMoney)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.whispersystems.signalservice.api.util.Preconditions
|
||||
|
||||
/**
|
||||
@@ -14,17 +14,17 @@ class DonationCheckoutViewModel : ViewModel() {
|
||||
private val TAG = Log.tag(DonationCheckoutViewModel::class.java)
|
||||
}
|
||||
|
||||
private var gatewayRequest: GatewayRequest? = null
|
||||
private var inAppPayment: InAppPaymentTable.InAppPayment? = null
|
||||
|
||||
fun provideGatewayRequestForGooglePay(request: GatewayRequest) {
|
||||
fun provideGatewayRequestForGooglePay(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
Log.d(TAG, "Provided with a gateway request.")
|
||||
Preconditions.checkState(gatewayRequest == null)
|
||||
gatewayRequest = request
|
||||
Preconditions.checkState(this.inAppPayment == null)
|
||||
this.inAppPayment = inAppPayment
|
||||
}
|
||||
|
||||
fun consumeGatewayRequestForGooglePay(): GatewayRequest? {
|
||||
val request = gatewayRequest
|
||||
gatewayRequest = null
|
||||
fun consumeGatewayRequestForGooglePay(): InAppPaymentTable.InAppPayment? {
|
||||
val request = inAppPayment
|
||||
inAppPayment = null
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate
|
||||
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.databinding.DonationPillToggleBinding
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
|
||||
@@ -15,7 +16,7 @@ object DonationPillToggle {
|
||||
}
|
||||
|
||||
class Model(
|
||||
val selected: DonateToSignalType,
|
||||
val selected: InAppPaymentTable.Type,
|
||||
val onClick: () -> Unit
|
||||
) : MappingModel<Model> {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean = true
|
||||
@@ -28,13 +29,13 @@ object DonationPillToggle {
|
||||
private class ViewHolder(binding: DonationPillToggleBinding) : BindingViewHolder<Model, DonationPillToggleBinding>(binding) {
|
||||
override fun bind(model: Model) {
|
||||
when (model.selected) {
|
||||
DonateToSignalType.ONE_TIME -> {
|
||||
InAppPaymentTable.Type.ONE_TIME_DONATION -> {
|
||||
presentButtons(model, binding.oneTime, binding.monthly)
|
||||
}
|
||||
DonateToSignalType.MONTHLY -> {
|
||||
InAppPaymentTable.Type.RECURRING_DONATION -> {
|
||||
presentButtons(model, binding.monthly, binding.oneTime)
|
||||
}
|
||||
DonateToSignalType.GIFT -> {
|
||||
else -> {
|
||||
error("Unsupported donation type.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
|
||||
@Parcelize
|
||||
class DonationProcessorActionResult(
|
||||
val action: DonationProcessorAction,
|
||||
val request: GatewayRequest,
|
||||
val inAppPayment: InAppPaymentTable.InAppPayment?,
|
||||
val status: Status
|
||||
) : Parcelable {
|
||||
enum class Status {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate
|
||||
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
|
||||
/**
|
||||
* Wraps an InAppPaymentData.Error in a throwable.
|
||||
*/
|
||||
class InAppPaymentError(
|
||||
val inAppPaymentDataError: InAppPaymentData.Error
|
||||
) : Exception() {
|
||||
companion object {
|
||||
fun fromDonationError(donationError: DonationError): InAppPaymentError? {
|
||||
val inAppPaymentDataError: InAppPaymentData.Error? = when (donationError) {
|
||||
is DonationError.BadgeRedemptionError.DonationPending -> null
|
||||
is DonationError.BadgeRedemptionError.FailedToValidateCredentialError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.CREDENTIAL_VALIDATION)
|
||||
is DonationError.BadgeRedemptionError.GenericError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.REDEMPTION)
|
||||
is DonationError.BadgeRedemptionError.TimeoutWaitingForTokenError -> null
|
||||
is DonationError.PaymentProcessingError.GenericError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.PAYMENT_PROCESSING)
|
||||
DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.INVALID_GIFT_RECIPIENT)
|
||||
is DonationError.GooglePayError.RequestTokenError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.GOOGLE_PAY_REQUEST_TOKEN)
|
||||
is DonationError.OneTimeDonationError.AmountTooLargeError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.ONE_TIME_AMOUNT_TOO_LARGE)
|
||||
is DonationError.OneTimeDonationError.AmountTooSmallError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.ONE_TIME_AMOUNT_TOO_SMALL)
|
||||
is DonationError.OneTimeDonationError.InvalidCurrencyError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.INVALID_CURRENCY)
|
||||
is DonationError.PaymentSetupError.GenericError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.PAYMENT_SETUP)
|
||||
is DonationError.PaymentSetupError.PayPalCodedError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.PAYPAL_CODED_ERROR, data_ = donationError.errorCode.toString())
|
||||
is DonationError.PaymentSetupError.PayPalDeclinedError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.PAYPAL_DECLINED_ERROR, data_ = donationError.code.code.toString())
|
||||
is DonationError.PaymentSetupError.StripeCodedError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.STRIPE_CODED_ERROR, data_ = donationError.errorCode)
|
||||
is DonationError.PaymentSetupError.StripeDeclinedError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.STRIPE_DECLINED_ERROR, data_ = donationError.declineCode.rawCode)
|
||||
is DonationError.PaymentSetupError.StripeFailureCodeError -> InAppPaymentData.Error(type = InAppPaymentData.Error.Type.STRIPE_FAILURE, data_ = donationError.failureCode.rawCode)
|
||||
is DonationError.UserCancelledPaymentError -> null
|
||||
is DonationError.UserLaunchedExternalApplication -> null
|
||||
}
|
||||
|
||||
return inAppPaymentDataError?.let { InAppPaymentError(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,13 +20,13 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationCheckoutDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorAction
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorActionResult
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.databinding.CreditCardFragmentBinding
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
@@ -48,14 +48,7 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
TemporaryScreenshotSecurity.bindToViewLifecycleOwner(this)
|
||||
|
||||
val errorSource: DonationErrorSource = when (args.request.donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> DonationErrorSource.ONE_TIME
|
||||
DonateToSignalType.MONTHLY -> DonationErrorSource.MONTHLY
|
||||
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
|
||||
}
|
||||
|
||||
DonationCheckoutDelegate.ErrorHandler().attach(this, null, args.request.uiSessionKey, errorSource)
|
||||
DonationCheckoutDelegate.ErrorHandler().attach(this, null, args.inAppPayment.id)
|
||||
|
||||
setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
|
||||
val result: DonationProcessorActionResult = bundle.getParcelableCompat(StripePaymentInProgressFragment.REQUEST_KEY, DonationProcessorActionResult::class.java)!!
|
||||
@@ -65,13 +58,14 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
|
||||
}
|
||||
}
|
||||
|
||||
binding.continueButton.text = if (args.request.donateToSignalType == DonateToSignalType.MONTHLY) {
|
||||
// TODO [message-backups] Copy for this button in backups checkout flow.
|
||||
binding.continueButton.text = if (args.inAppPayment.type == InAppPaymentTable.Type.RECURRING_DONATION) {
|
||||
getString(
|
||||
R.string.CreditCardFragment__donate_s_month,
|
||||
FiatMoneyUtil.format(resources, args.request.fiat, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
|
||||
FiatMoneyUtil.format(resources, args.inAppPayment.data.amount!!.toFiatMoney(), FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
|
||||
)
|
||||
} else {
|
||||
getString(R.string.CreditCardFragment__donate_s, FiatMoneyUtil.format(resources, args.request.fiat))
|
||||
getString(R.string.CreditCardFragment__donate_s, FiatMoneyUtil.format(resources, args.inAppPayment.data.amount!!.toFiatMoney()))
|
||||
}
|
||||
|
||||
binding.description.setLinkColor(ContextCompat.getColor(requireContext(), R.color.signal_colorPrimary))
|
||||
@@ -122,7 +116,8 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
|
||||
findNavController().safeNavigate(
|
||||
CreditCardFragmentDirections.actionCreditCardFragmentToStripePaymentInProgressFragment(
|
||||
DonationProcessorAction.PROCESS_NEW_DONATION,
|
||||
args.request
|
||||
args.inAppPayment,
|
||||
args.inAppPayment.type
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.c
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.signal.donations.StripeApi
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
|
||||
/**
|
||||
* Encapsulates data returned from the credit card form that can be used
|
||||
@@ -11,6 +11,6 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.ga
|
||||
*/
|
||||
@Parcelize
|
||||
data class CreditCardResult(
|
||||
val gatewayRequest: GatewayRequest,
|
||||
val inAppPayment: InAppPaymentTable.InAppPayment,
|
||||
val creditCardData: StripeApi.CardData
|
||||
) : Parcelable
|
||||
|
||||
@@ -8,39 +8,40 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.g
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil
|
||||
import org.signal.core.util.orNull
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
sealed interface GatewayOrderStrategy {
|
||||
|
||||
val orderedGateways: Set<GatewayResponse.Gateway>
|
||||
val orderedGateways: Set<InAppPaymentData.PaymentMethodType>
|
||||
|
||||
private object Default : GatewayOrderStrategy {
|
||||
override val orderedGateways: Set<GatewayResponse.Gateway> = setOf(
|
||||
GatewayResponse.Gateway.CREDIT_CARD,
|
||||
GatewayResponse.Gateway.PAYPAL,
|
||||
GatewayResponse.Gateway.GOOGLE_PAY,
|
||||
GatewayResponse.Gateway.SEPA_DEBIT,
|
||||
GatewayResponse.Gateway.IDEAL
|
||||
override val orderedGateways: Set<InAppPaymentData.PaymentMethodType> = setOf(
|
||||
InAppPaymentData.PaymentMethodType.CARD,
|
||||
InAppPaymentData.PaymentMethodType.PAYPAL,
|
||||
InAppPaymentData.PaymentMethodType.GOOGLE_PAY,
|
||||
InAppPaymentData.PaymentMethodType.SEPA_DEBIT,
|
||||
InAppPaymentData.PaymentMethodType.IDEAL
|
||||
)
|
||||
}
|
||||
|
||||
private object NorthAmerica : GatewayOrderStrategy {
|
||||
override val orderedGateways: Set<GatewayResponse.Gateway> = setOf(
|
||||
GatewayResponse.Gateway.GOOGLE_PAY,
|
||||
GatewayResponse.Gateway.PAYPAL,
|
||||
GatewayResponse.Gateway.CREDIT_CARD,
|
||||
GatewayResponse.Gateway.SEPA_DEBIT,
|
||||
GatewayResponse.Gateway.IDEAL
|
||||
override val orderedGateways: Set<InAppPaymentData.PaymentMethodType> = setOf(
|
||||
InAppPaymentData.PaymentMethodType.GOOGLE_PAY,
|
||||
InAppPaymentData.PaymentMethodType.PAYPAL,
|
||||
InAppPaymentData.PaymentMethodType.CARD,
|
||||
InAppPaymentData.PaymentMethodType.SEPA_DEBIT,
|
||||
InAppPaymentData.PaymentMethodType.IDEAL
|
||||
)
|
||||
}
|
||||
|
||||
private object Netherlands : GatewayOrderStrategy {
|
||||
override val orderedGateways: Set<GatewayResponse.Gateway> = setOf(
|
||||
GatewayResponse.Gateway.IDEAL,
|
||||
GatewayResponse.Gateway.PAYPAL,
|
||||
GatewayResponse.Gateway.GOOGLE_PAY,
|
||||
GatewayResponse.Gateway.CREDIT_CARD,
|
||||
GatewayResponse.Gateway.SEPA_DEBIT
|
||||
override val orderedGateways: Set<InAppPaymentData.PaymentMethodType> = setOf(
|
||||
InAppPaymentData.PaymentMethodType.IDEAL,
|
||||
InAppPaymentData.PaymentMethodType.PAYPAL,
|
||||
InAppPaymentData.PaymentMethodType.GOOGLE_PAY,
|
||||
InAppPaymentData.PaymentMethodType.CARD,
|
||||
InAppPaymentData.PaymentMethodType.SEPA_DEBIT
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
|
||||
@Parcelize
|
||||
data class GatewayRequest(
|
||||
val uiSessionKey: Long,
|
||||
val donateToSignalType: DonateToSignalType,
|
||||
val badge: Badge,
|
||||
val label: String,
|
||||
val price: BigDecimal,
|
||||
val currencyCode: String,
|
||||
val level: Long,
|
||||
val recipientId: RecipientId,
|
||||
val additionalMessage: String? = null
|
||||
) : Parcelable {
|
||||
@IgnoredOnParcel
|
||||
val fiat: FiatMoney = FiatMoney(price, Currency.getInstance(currencyCode))
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.signal.donations.PaymentSourceType
|
||||
|
||||
@Parcelize
|
||||
data class GatewayResponse(val gateway: Gateway, val request: GatewayRequest) : Parcelable {
|
||||
enum class Gateway {
|
||||
GOOGLE_PAY,
|
||||
PAYPAL,
|
||||
CREDIT_CARD,
|
||||
SEPA_DEBIT,
|
||||
IDEAL;
|
||||
|
||||
fun toPaymentSourceType(): PaymentSourceType {
|
||||
return when (this) {
|
||||
GOOGLE_PAY -> PaymentSourceType.Stripe.GooglePay
|
||||
PAYPAL -> PaymentSourceType.PayPal
|
||||
CREDIT_CARD -> PaymentSourceType.Stripe.CreditCard
|
||||
SEPA_DEBIT -> PaymentSourceType.Stripe.SEPADebit
|
||||
IDEAL -> PaymentSourceType.Stripe.IDEAL
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,11 @@ import androidx.fragment.app.setFragmentResult
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.dp
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.badges.models.BadgeDisplay112
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
@@ -18,11 +20,13 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.NO_TINT
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.PayPalButton
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
@@ -55,16 +59,17 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
|
||||
private fun getConfiguration(state: GatewaySelectorState): DSLConfiguration {
|
||||
return configure {
|
||||
// TODO [message-backups] -- No badge on message backups.
|
||||
customPref(
|
||||
BadgeDisplay112.Model(
|
||||
badge = state.badge,
|
||||
badge = state.inAppPayment.data.badge!!.let { Badges.fromDatabaseBadge(it) },
|
||||
withDisplayText = false
|
||||
)
|
||||
)
|
||||
|
||||
space(12.dp)
|
||||
|
||||
presentTitleAndSubtitle(requireContext(), args.request)
|
||||
presentTitleAndSubtitle(requireContext(), state.inAppPayment)
|
||||
|
||||
space(16.dp)
|
||||
|
||||
@@ -75,13 +80,14 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
return@configure
|
||||
}
|
||||
|
||||
state.gatewayOrderStrategy.orderedGateways.forEachIndexed { index, gateway ->
|
||||
state.gatewayOrderStrategy.orderedGateways.forEach { gateway ->
|
||||
when (gateway) {
|
||||
GatewayResponse.Gateway.GOOGLE_PAY -> renderGooglePayButton(state)
|
||||
GatewayResponse.Gateway.PAYPAL -> renderPayPalButton(state)
|
||||
GatewayResponse.Gateway.CREDIT_CARD -> renderCreditCardButton(state)
|
||||
GatewayResponse.Gateway.SEPA_DEBIT -> renderSEPADebitButton(state)
|
||||
GatewayResponse.Gateway.IDEAL -> renderIDEALButton(state)
|
||||
InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> renderGooglePayButton(state)
|
||||
InAppPaymentData.PaymentMethodType.PAYPAL -> renderPayPalButton(state)
|
||||
InAppPaymentData.PaymentMethodType.CARD -> renderCreditCardButton(state)
|
||||
InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> renderSEPADebitButton(state)
|
||||
InAppPaymentData.PaymentMethodType.IDEAL -> renderIDEALButton(state)
|
||||
InAppPaymentData.PaymentMethodType.UNKNOWN -> error("Unsupported payment method.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,9 +103,11 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
GooglePayButton.Model(
|
||||
isEnabled = true,
|
||||
onClick = {
|
||||
findNavController().popBackStack()
|
||||
val response = GatewayResponse(GatewayResponse.Gateway.GOOGLE_PAY, args.request)
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
|
||||
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.GOOGLE_PAY)
|
||||
.subscribeBy {
|
||||
findNavController().popBackStack()
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -113,9 +121,11 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
customPref(
|
||||
PayPalButton.Model(
|
||||
onClick = {
|
||||
findNavController().popBackStack()
|
||||
val response = GatewayResponse(GatewayResponse.Gateway.PAYPAL, args.request)
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
|
||||
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.PAYPAL)
|
||||
.subscribeBy {
|
||||
findNavController().popBackStack()
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
|
||||
}
|
||||
},
|
||||
isEnabled = true
|
||||
)
|
||||
@@ -130,10 +140,13 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__credit_or_debit_card),
|
||||
icon = DSLSettingsIcon.from(R.drawable.credit_card, R.color.signal_colorOnCustom),
|
||||
disableOnClick = true,
|
||||
onClick = {
|
||||
findNavController().popBackStack()
|
||||
val response = GatewayResponse(GatewayResponse.Gateway.CREDIT_CARD, args.request)
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
|
||||
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.CARD)
|
||||
.subscribeBy {
|
||||
findNavController().popBackStack()
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -146,18 +159,21 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
tonalButton(
|
||||
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__bank_transfer),
|
||||
icon = DSLSettingsIcon.from(R.drawable.bank_transfer),
|
||||
disableOnClick = true,
|
||||
onClick = {
|
||||
val price = args.inAppPayment.data.amount!!.toFiatMoney()
|
||||
if (state.sepaEuroMaximum != null &&
|
||||
args.request.fiat.currency == CurrencyUtil.EURO &&
|
||||
args.request.fiat.amount > state.sepaEuroMaximum.amount
|
||||
price.currency == CurrencyUtil.EURO &&
|
||||
price.amount > state.sepaEuroMaximum.amount
|
||||
) {
|
||||
findNavController().popBackStack()
|
||||
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(FAILURE_KEY to true, SEPA_EURO_MAX to state.sepaEuroMaximum.amount))
|
||||
} else {
|
||||
findNavController().popBackStack()
|
||||
val response = GatewayResponse(GatewayResponse.Gateway.SEPA_DEBIT, args.request)
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
|
||||
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.SEPA_DEBIT)
|
||||
.subscribeBy {
|
||||
findNavController().popBackStack()
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -171,10 +187,13 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
tonalButton(
|
||||
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__ideal),
|
||||
icon = DSLSettingsIcon.from(R.drawable.logo_ideal, NO_TINT),
|
||||
disableOnClick = true,
|
||||
onClick = {
|
||||
findNavController().popBackStack()
|
||||
val response = GatewayResponse(GatewayResponse.Gateway.IDEAL, args.request)
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
|
||||
lifecycleDisposable += viewModel.updateInAppPaymentMethod(InAppPaymentData.PaymentMethodType.IDEAL)
|
||||
.subscribeBy {
|
||||
findNavController().popBackStack()
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to it))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -185,18 +204,20 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
const val FAILURE_KEY = "gateway_failure"
|
||||
const val SEPA_EURO_MAX = "sepa_euro_max"
|
||||
|
||||
fun DSLConfiguration.presentTitleAndSubtitle(context: Context, request: GatewayRequest) {
|
||||
when (request.donateToSignalType) {
|
||||
DonateToSignalType.MONTHLY -> presentMonthlyText(context, request)
|
||||
DonateToSignalType.ONE_TIME -> presentOneTimeText(context, request)
|
||||
DonateToSignalType.GIFT -> presentGiftText(context, request)
|
||||
fun DSLConfiguration.presentTitleAndSubtitle(context: Context, inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
when (inAppPayment.type) {
|
||||
InAppPaymentTable.Type.UNKNOWN -> error("Unsupported type UNKNOWN")
|
||||
InAppPaymentTable.Type.RECURRING_BACKUP -> error("This type is not supported") // TODO [message-backups] necessary?
|
||||
InAppPaymentTable.Type.RECURRING_DONATION -> presentMonthlyText(context, inAppPayment)
|
||||
InAppPaymentTable.Type.ONE_TIME_DONATION -> presentOneTimeText(context, inAppPayment)
|
||||
InAppPaymentTable.Type.ONE_TIME_GIFT -> presentGiftText(context, inAppPayment)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.presentMonthlyText(context: Context, request: GatewayRequest) {
|
||||
private fun DSLConfiguration.presentMonthlyText(context: Context, inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(
|
||||
context.getString(R.string.GatewaySelectorBottomSheet__donate_s_month_to_signal, FiatMoneyUtil.format(context.resources, request.fiat)),
|
||||
context.getString(R.string.GatewaySelectorBottomSheet__donate_s_month_to_signal, FiatMoneyUtil.format(context.resources, inAppPayment.data.amount!!.toFiatMoney())),
|
||||
DSLSettingsText.CenterModifier,
|
||||
DSLSettingsText.TitleLargeModifier
|
||||
)
|
||||
@@ -204,7 +225,7 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
space(6.dp)
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(
|
||||
context.getString(R.string.GatewaySelectorBottomSheet__get_a_s_badge, request.badge.name),
|
||||
context.getString(R.string.GatewaySelectorBottomSheet__get_a_s_badge, inAppPayment.data.badge!!.name),
|
||||
DSLSettingsText.CenterModifier,
|
||||
DSLSettingsText.BodyLargeModifier,
|
||||
DSLSettingsText.ColorModifier(ContextCompat.getColor(context, R.color.signal_colorOnSurfaceVariant))
|
||||
@@ -212,10 +233,10 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.presentOneTimeText(context: Context, request: GatewayRequest) {
|
||||
private fun DSLConfiguration.presentOneTimeText(context: Context, inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(
|
||||
context.getString(R.string.GatewaySelectorBottomSheet__donate_s_to_signal, FiatMoneyUtil.format(context.resources, request.fiat)),
|
||||
context.getString(R.string.GatewaySelectorBottomSheet__donate_s_to_signal, FiatMoneyUtil.format(context.resources, inAppPayment.data.amount!!.toFiatMoney())),
|
||||
DSLSettingsText.CenterModifier,
|
||||
DSLSettingsText.TitleLargeModifier
|
||||
)
|
||||
@@ -223,7 +244,7 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
space(6.dp)
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(
|
||||
context.resources.getQuantityString(R.plurals.GatewaySelectorBottomSheet__get_a_s_badge_for_d_days, 30, request.badge.name, 30),
|
||||
context.resources.getQuantityString(R.plurals.GatewaySelectorBottomSheet__get_a_s_badge_for_d_days, 30, inAppPayment.data.badge!!.name, 30),
|
||||
DSLSettingsText.CenterModifier,
|
||||
DSLSettingsText.BodyLargeModifier,
|
||||
DSLSettingsText.ColorModifier(ContextCompat.getColor(context, R.color.signal_colorOnSurfaceVariant))
|
||||
@@ -231,10 +252,10 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.presentGiftText(context: Context, request: GatewayRequest) {
|
||||
private fun DSLConfiguration.presentGiftText(context: Context, inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(
|
||||
context.getString(R.string.GatewaySelectorBottomSheet__donate_s_to_signal, FiatMoneyUtil.format(context.resources, request.fiat)),
|
||||
context.getString(R.string.GatewaySelectorBottomSheet__donate_s_to_signal, FiatMoneyUtil.format(context.resources, inAppPayment.data.amount!!.toFiatMoney())),
|
||||
DSLSettingsText.CenterModifier,
|
||||
DSLSettingsText.TitleLargeModifier
|
||||
)
|
||||
|
||||
@@ -2,7 +2,11 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.g
|
||||
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.getAvailablePaymentMethods
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
|
||||
import org.whispersystems.signalservice.api.services.DonationsService
|
||||
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
|
||||
@@ -18,10 +22,10 @@ class GatewaySelectorRepository(
|
||||
.map { configuration ->
|
||||
val available = configuration.getAvailablePaymentMethods(currencyCode).map {
|
||||
when (it) {
|
||||
SubscriptionsConfiguration.PAYPAL -> listOf(GatewayResponse.Gateway.PAYPAL)
|
||||
SubscriptionsConfiguration.CARD -> listOf(GatewayResponse.Gateway.CREDIT_CARD, GatewayResponse.Gateway.GOOGLE_PAY)
|
||||
SubscriptionsConfiguration.SEPA_DEBIT -> listOf(GatewayResponse.Gateway.SEPA_DEBIT)
|
||||
SubscriptionsConfiguration.IDEAL -> listOf(GatewayResponse.Gateway.IDEAL)
|
||||
SubscriptionsConfiguration.PAYPAL -> listOf(InAppPaymentData.PaymentMethodType.PAYPAL)
|
||||
SubscriptionsConfiguration.CARD -> listOf(InAppPaymentData.PaymentMethodType.CARD, InAppPaymentData.PaymentMethodType.GOOGLE_PAY)
|
||||
SubscriptionsConfiguration.SEPA_DEBIT -> listOf(InAppPaymentData.PaymentMethodType.SEPA_DEBIT)
|
||||
SubscriptionsConfiguration.IDEAL -> listOf(InAppPaymentData.PaymentMethodType.IDEAL)
|
||||
else -> listOf()
|
||||
}
|
||||
}.flatten().toSet()
|
||||
@@ -33,8 +37,20 @@ class GatewaySelectorRepository(
|
||||
}
|
||||
}
|
||||
|
||||
fun setInAppPaymentMethodType(inAppPayment: InAppPaymentTable.InAppPayment, paymentMethodType: InAppPaymentData.PaymentMethodType): Single<InAppPaymentTable.InAppPayment> {
|
||||
return Single.fromCallable {
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment.copy(
|
||||
data = inAppPayment.data.copy(
|
||||
paymentMethodType = paymentMethodType
|
||||
)
|
||||
)
|
||||
)
|
||||
}.flatMap { InAppPaymentsRepository.requireInAppPayment(inAppPayment.id) }
|
||||
}
|
||||
|
||||
data class GatewayConfiguration(
|
||||
val availableGateways: Set<GatewayResponse.Gateway>,
|
||||
val availableGateways: Set<InAppPaymentData.PaymentMethodType>,
|
||||
val sepaEuroMaximum: FiatMoney?
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
|
||||
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
|
||||
data class GatewaySelectorState(
|
||||
val gatewayOrderStrategy: GatewayOrderStrategy,
|
||||
val inAppPayment: InAppPaymentTable.InAppPayment,
|
||||
val loading: Boolean = true,
|
||||
val badge: Badge,
|
||||
val isGooglePayAvailable: Boolean = false,
|
||||
val isPayPalAvailable: Boolean = false,
|
||||
val isCreditCardAvailable: Boolean = false,
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.g
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
@@ -9,6 +10,8 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
@@ -16,18 +19,18 @@ import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
class GatewaySelectorViewModel(
|
||||
args: GatewaySelectorBottomSheetArgs,
|
||||
repository: StripeRepository,
|
||||
gatewaySelectorRepository: GatewaySelectorRepository
|
||||
private val gatewaySelectorRepository: GatewaySelectorRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val store = RxStore(
|
||||
GatewaySelectorState(
|
||||
gatewayOrderStrategy = GatewayOrderStrategy.getStrategy(),
|
||||
badge = args.request.badge,
|
||||
isGooglePayAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.GooglePay, args.request.donateToSignalType),
|
||||
isCreditCardAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.CreditCard, args.request.donateToSignalType),
|
||||
isPayPalAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.PayPal, args.request.donateToSignalType),
|
||||
isSEPADebitAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.SEPADebit, args.request.donateToSignalType),
|
||||
isIDEALAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.IDEAL, args.request.donateToSignalType)
|
||||
inAppPayment = args.inAppPayment,
|
||||
isCreditCardAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.CreditCard, args.inAppPayment.type),
|
||||
isGooglePayAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.GooglePay, args.inAppPayment.type),
|
||||
isPayPalAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.PayPal, args.inAppPayment.type),
|
||||
isSEPADebitAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.SEPADebit, args.inAppPayment.type),
|
||||
isIDEALAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.IDEAL, args.inAppPayment.type)
|
||||
)
|
||||
)
|
||||
private val disposables = CompositeDisposable()
|
||||
@@ -36,17 +39,18 @@ class GatewaySelectorViewModel(
|
||||
|
||||
init {
|
||||
val isGooglePayAvailable = repository.isGooglePayAvailable().toSingleDefault(true).onErrorReturnItem(false)
|
||||
val gatewayConfiguration = gatewaySelectorRepository.getAvailableGatewayConfiguration(currencyCode = args.request.currencyCode)
|
||||
val gatewayConfiguration = gatewaySelectorRepository.getAvailableGatewayConfiguration(currencyCode = args.inAppPayment.data.amount!!.currencyCode)
|
||||
|
||||
disposables += Single.zip(isGooglePayAvailable, gatewayConfiguration, ::Pair).subscribeBy { (googlePayAvailable, gatewayConfiguration) ->
|
||||
SignalStore.donationsValues().isGooglePayReady = googlePayAvailable
|
||||
store.update {
|
||||
it.copy(
|
||||
loading = false,
|
||||
isCreditCardAvailable = it.isCreditCardAvailable && gatewayConfiguration.availableGateways.contains(GatewayResponse.Gateway.CREDIT_CARD),
|
||||
isGooglePayAvailable = it.isGooglePayAvailable && googlePayAvailable && gatewayConfiguration.availableGateways.contains(GatewayResponse.Gateway.GOOGLE_PAY),
|
||||
isPayPalAvailable = it.isPayPalAvailable && gatewayConfiguration.availableGateways.contains(GatewayResponse.Gateway.PAYPAL),
|
||||
isSEPADebitAvailable = it.isSEPADebitAvailable && gatewayConfiguration.availableGateways.contains(GatewayResponse.Gateway.SEPA_DEBIT),
|
||||
isIDEALAvailable = it.isIDEALAvailable && gatewayConfiguration.availableGateways.contains(GatewayResponse.Gateway.IDEAL),
|
||||
isCreditCardAvailable = it.isCreditCardAvailable && gatewayConfiguration.availableGateways.contains(InAppPaymentData.PaymentMethodType.CARD),
|
||||
isGooglePayAvailable = it.isGooglePayAvailable && googlePayAvailable && gatewayConfiguration.availableGateways.contains(InAppPaymentData.PaymentMethodType.GOOGLE_PAY),
|
||||
isPayPalAvailable = it.isPayPalAvailable && gatewayConfiguration.availableGateways.contains(InAppPaymentData.PaymentMethodType.PAYPAL),
|
||||
isSEPADebitAvailable = it.isSEPADebitAvailable && gatewayConfiguration.availableGateways.contains(InAppPaymentData.PaymentMethodType.SEPA_DEBIT),
|
||||
isIDEALAvailable = it.isIDEALAvailable && gatewayConfiguration.availableGateways.contains(InAppPaymentData.PaymentMethodType.IDEAL),
|
||||
sepaEuroMaximum = gatewayConfiguration.sepaEuroMaximum
|
||||
)
|
||||
}
|
||||
@@ -58,6 +62,10 @@ class GatewaySelectorViewModel(
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun updateInAppPaymentMethod(inAppPaymentMethodType: InAppPaymentData.PaymentMethodType): Single<InAppPaymentTable.InAppPayment> {
|
||||
return gatewaySelectorRepository.setInAppPaymentMethodType(store.state.inAppPayment, inAppPaymentMethodType).observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val args: GatewaySelectorBottomSheetArgs,
|
||||
private val repository: StripeRepository,
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import org.signal.core.util.dp
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.badges.models.BadgeDisplay112
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
@@ -43,14 +44,14 @@ class PayPalCompleteOrderBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
return configure {
|
||||
customPref(
|
||||
BadgeDisplay112.Model(
|
||||
badge = args.request.badge,
|
||||
badge = Badges.fromDatabaseBadge(args.inAppPayment.data.badge!!),
|
||||
withDisplayText = false
|
||||
)
|
||||
)
|
||||
|
||||
space(12.dp)
|
||||
|
||||
presentTitleAndSubtitle(requireContext(), args.request)
|
||||
presentTitleAndSubtitle(requireContext(), args.inAppPayment)
|
||||
|
||||
space(24.dp)
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.Do
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorActionResult
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorStage
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.databinding.DonationInProgressFragmentBinding
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentIntentResponse
|
||||
@@ -59,15 +60,14 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
|
||||
viewModel.onBeginNewAction()
|
||||
when (args.action) {
|
||||
DonationProcessorAction.PROCESS_NEW_DONATION -> {
|
||||
viewModel.processNewDonation(args.request, this::oneTimeConfirmationPipeline, this::monthlyConfirmationPipeline)
|
||||
viewModel.processNewDonation(args.inAppPayment!!, this::oneTimeConfirmationPipeline, this::monthlyConfirmationPipeline)
|
||||
}
|
||||
DonationProcessorAction.UPDATE_SUBSCRIPTION -> {
|
||||
viewModel.updateSubscription(args.request)
|
||||
viewModel.updateSubscription(args.inAppPayment!!)
|
||||
}
|
||||
DonationProcessorAction.CANCEL_SUBSCRIPTION -> {
|
||||
viewModel.cancelSubscription()
|
||||
viewModel.cancelSubscription(InAppPaymentSubscriberRecord.Type.DONATION) // TODO [message-backups] Remove hardcode
|
||||
}
|
||||
else -> error("Unsupported action: ${args.action}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
|
||||
bundleOf(
|
||||
REQUEST_KEY to DonationProcessorActionResult(
|
||||
action = args.action,
|
||||
request = args.request,
|
||||
inAppPayment = args.inAppPayment,
|
||||
status = DonationProcessorActionResult.Status.FAILURE
|
||||
)
|
||||
)
|
||||
@@ -103,7 +103,7 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
|
||||
bundleOf(
|
||||
REQUEST_KEY to DonationProcessorActionResult(
|
||||
action = args.action,
|
||||
request = args.request,
|
||||
inAppPayment = args.inAppPayment,
|
||||
status = DonationProcessorActionResult.Status.SUCCESS
|
||||
)
|
||||
)
|
||||
@@ -128,7 +128,7 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
|
||||
if (result != null) {
|
||||
emitter.onSuccess(result.copy(paymentId = createPaymentIntentResponse.paymentId))
|
||||
} else {
|
||||
emitter.onError(DonationError.UserCancelledPaymentError(args.request.donateToSignalType.toErrorSource()))
|
||||
emitter.onError(DonationError.UserCancelledPaymentError(args.inAppPaymentType.toErrorSource()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
|
||||
if (result) {
|
||||
emitter.onSuccess(PayPalPaymentMethodId(createPaymentIntentResponse.token))
|
||||
} else {
|
||||
emitter.onError(DonationError.UserCancelledPaymentError(args.request.donateToSignalType.toErrorSource()))
|
||||
emitter.onError(DonationError.UserCancelledPaymentError(args.inAppPaymentType.toErrorSource()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,29 +12,30 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeDonationRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeInAppPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.PayPalRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorStage
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.toDonationError
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentIntentResponse
|
||||
import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentMethodResponse
|
||||
import org.whispersystems.signalservice.api.util.Preconditions
|
||||
import org.whispersystems.signalservice.internal.push.DonationProcessor
|
||||
import org.whispersystems.signalservice.internal.push.exceptions.DonationProcessorError
|
||||
|
||||
class PayPalPaymentInProgressViewModel(
|
||||
private val payPalRepository: PayPalRepository,
|
||||
private val monthlyDonationRepository: MonthlyDonationRepository,
|
||||
private val oneTimeDonationRepository: OneTimeDonationRepository
|
||||
private val recurringInAppPaymentRepository: RecurringInAppPaymentRepository,
|
||||
private val oneTimeInAppPaymentRepository: OneTimeInAppPaymentRepository
|
||||
) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
@@ -66,24 +67,24 @@ class PayPalPaymentInProgressViewModel(
|
||||
}
|
||||
|
||||
fun processNewDonation(
|
||||
request: GatewayRequest,
|
||||
inAppPayment: InAppPaymentTable.InAppPayment,
|
||||
routeToOneTimeConfirmation: (PayPalCreatePaymentIntentResponse) -> Single<PayPalConfirmationResult>,
|
||||
routeToMonthlyConfirmation: (PayPalCreatePaymentMethodResponse) -> Single<PayPalPaymentMethodId>
|
||||
) {
|
||||
Log.d(TAG, "Proceeding with donation...", true)
|
||||
|
||||
return when (request.donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> proceedOneTime(request, routeToOneTimeConfirmation)
|
||||
DonateToSignalType.MONTHLY -> proceedMonthly(request, routeToMonthlyConfirmation)
|
||||
DonateToSignalType.GIFT -> proceedOneTime(request, routeToOneTimeConfirmation)
|
||||
return if (inAppPayment.type.recurring) {
|
||||
proceedMonthly(inAppPayment, routeToMonthlyConfirmation)
|
||||
} else {
|
||||
proceedOneTime(inAppPayment, routeToOneTimeConfirmation)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSubscription(request: GatewayRequest) {
|
||||
fun updateSubscription(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
Log.d(TAG, "Beginning subscription update...", true)
|
||||
|
||||
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
|
||||
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request, false))
|
||||
disposables += recurringInAppPaymentRepository.cancelActiveSubscriptionIfNecessary(inAppPayment.type.requireSubscriberType()).andThen(recurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, PaymentSourceType.PayPal))
|
||||
.subscribeBy(
|
||||
onComplete = {
|
||||
Log.w(TAG, "Completed subscription update", true)
|
||||
@@ -91,28 +92,22 @@ class PayPalPaymentInProgressViewModel(
|
||||
},
|
||||
onError = { throwable ->
|
||||
Log.w(TAG, "Failed to update subscription", throwable, true)
|
||||
val donationError: DonationError = when (throwable) {
|
||||
is DonationError -> throwable
|
||||
is DonationProcessorError -> throwable.toDonationError(DonationErrorSource.MONTHLY, PaymentSourceType.PayPal)
|
||||
else -> DonationError.genericBadgeRedemptionFailure(DonationErrorSource.MONTHLY)
|
||||
}
|
||||
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
|
||||
|
||||
store.update { DonationProcessorStage.FAILED }
|
||||
InAppPaymentsRepository.handlePipelineError(inAppPayment.id, DonationErrorSource.MONTHLY, PaymentSourceType.PayPal, throwable)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun cancelSubscription() {
|
||||
fun cancelSubscription(subscriberType: InAppPaymentSubscriberRecord.Type) {
|
||||
Log.d(TAG, "Beginning cancellation...", true)
|
||||
|
||||
store.update { DonationProcessorStage.CANCELLING }
|
||||
disposables += monthlyDonationRepository.cancelActiveSubscription().subscribeBy(
|
||||
disposables += recurringInAppPaymentRepository.cancelActiveSubscription(subscriberType).subscribeBy(
|
||||
onComplete = {
|
||||
Log.d(TAG, "Cancellation succeeded", true)
|
||||
SignalStore.donationsValues().updateLocalStateForManualCancellation()
|
||||
SignalStore.donationsValues().updateLocalStateForManualCancellation(subscriberType)
|
||||
MultiDeviceSubscriptionSyncRequestJob.enqueue()
|
||||
monthlyDonationRepository.syncAccountRecord().subscribe()
|
||||
recurringInAppPaymentRepository.syncAccountRecord().subscribe()
|
||||
store.update { DonationProcessorStage.COMPLETE }
|
||||
},
|
||||
onError = { throwable ->
|
||||
@@ -123,13 +118,13 @@ class PayPalPaymentInProgressViewModel(
|
||||
}
|
||||
|
||||
private fun proceedOneTime(
|
||||
request: GatewayRequest,
|
||||
inAppPayment: InAppPaymentTable.InAppPayment,
|
||||
routeToPaypalConfirmation: (PayPalCreatePaymentIntentResponse) -> Single<PayPalConfirmationResult>
|
||||
) {
|
||||
Log.d(TAG, "Proceeding with one-time payment pipeline...", true)
|
||||
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
|
||||
val verifyUser = if (request.donateToSignalType == DonateToSignalType.GIFT) {
|
||||
OneTimeDonationRepository.verifyRecipientIsAllowedToReceiveAGift(request.recipientId)
|
||||
val verifyUser = if (inAppPayment.type == InAppPaymentTable.Type.ONE_TIME_GIFT) {
|
||||
OneTimeInAppPaymentRepository.verifyRecipientIsAllowedToReceiveAGift(RecipientId.from(inAppPayment.data.recipientId!!))
|
||||
} else {
|
||||
Completable.complete()
|
||||
}
|
||||
@@ -138,24 +133,23 @@ class PayPalPaymentInProgressViewModel(
|
||||
.andThen(
|
||||
payPalRepository
|
||||
.createOneTimePaymentIntent(
|
||||
amount = request.fiat,
|
||||
badgeRecipient = request.recipientId,
|
||||
badgeLevel = request.level
|
||||
amount = inAppPayment.data.amount!!.toFiatMoney(),
|
||||
badgeRecipient = inAppPayment.data.recipientId?.let { RecipientId.from(it) } ?: Recipient.self().id,
|
||||
badgeLevel = inAppPayment.data.level
|
||||
)
|
||||
)
|
||||
.flatMap(routeToPaypalConfirmation)
|
||||
.flatMap { result ->
|
||||
payPalRepository.confirmOneTimePaymentIntent(
|
||||
amount = request.fiat,
|
||||
badgeLevel = request.level,
|
||||
amount = inAppPayment.data.amount.toFiatMoney(),
|
||||
badgeLevel = inAppPayment.data.level,
|
||||
paypalConfirmationResult = result
|
||||
)
|
||||
}
|
||||
.flatMapCompletable { response ->
|
||||
oneTimeDonationRepository.waitForOneTimeRedemption(
|
||||
gatewayRequest = request,
|
||||
oneTimeInAppPaymentRepository.waitForOneTimeRedemption(
|
||||
inAppPayment = inAppPayment,
|
||||
paymentIntentId = response.paymentId,
|
||||
donationProcessor = DonationProcessor.PAYPAL,
|
||||
paymentSourceType = PaymentSourceType.PayPal
|
||||
)
|
||||
}
|
||||
@@ -164,13 +158,7 @@ class PayPalPaymentInProgressViewModel(
|
||||
onError = { throwable ->
|
||||
Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true)
|
||||
store.update { DonationProcessorStage.FAILED }
|
||||
|
||||
val donationError: DonationError = when (throwable) {
|
||||
is DonationError -> throwable
|
||||
is DonationProcessorError -> throwable.toDonationError(request.donateToSignalType.toErrorSource(), PaymentSourceType.PayPal)
|
||||
else -> DonationError.genericBadgeRedemptionFailure(request.donateToSignalType.toErrorSource())
|
||||
}
|
||||
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
|
||||
InAppPaymentsRepository.handlePipelineError(inAppPayment.id, DonationErrorSource.ONE_TIME, PaymentSourceType.PayPal, throwable)
|
||||
},
|
||||
onComplete = {
|
||||
Log.d(TAG, "Finished one-time payment pipeline...", true)
|
||||
@@ -179,28 +167,22 @@ class PayPalPaymentInProgressViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
private fun proceedMonthly(request: GatewayRequest, routeToPaypalConfirmation: (PayPalCreatePaymentMethodResponse) -> Single<PayPalPaymentMethodId>) {
|
||||
private fun proceedMonthly(inAppPayment: InAppPaymentTable.InAppPayment, routeToPaypalConfirmation: (PayPalCreatePaymentMethodResponse) -> Single<PayPalPaymentMethodId>) {
|
||||
Log.d(TAG, "Proceeding with monthly payment pipeline...")
|
||||
|
||||
val setup = monthlyDonationRepository.ensureSubscriberId()
|
||||
.andThen(monthlyDonationRepository.cancelActiveSubscriptionIfNecessary())
|
||||
.andThen(payPalRepository.createPaymentMethod())
|
||||
val setup = recurringInAppPaymentRepository.ensureSubscriberId(inAppPayment.type.requireSubscriberType())
|
||||
.andThen(recurringInAppPaymentRepository.cancelActiveSubscriptionIfNecessary(inAppPayment.type.requireSubscriberType()))
|
||||
.andThen(payPalRepository.createPaymentMethod(inAppPayment.type.requireSubscriberType()))
|
||||
.flatMap(routeToPaypalConfirmation)
|
||||
.flatMapCompletable { payPalRepository.setDefaultPaymentMethod(it.paymentId) }
|
||||
.flatMapCompletable { payPalRepository.setDefaultPaymentMethod(inAppPayment.type.requireSubscriberType(), it.paymentId) }
|
||||
.onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.MONTHLY, it, PaymentSourceType.PayPal)) }
|
||||
|
||||
disposables += setup.andThen(monthlyDonationRepository.setSubscriptionLevel(request, false))
|
||||
disposables += setup.andThen(recurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, PaymentSourceType.PayPal))
|
||||
.subscribeBy(
|
||||
onError = { throwable ->
|
||||
Log.w(TAG, "Failure in monthly payment pipeline...", throwable, true)
|
||||
store.update { DonationProcessorStage.FAILED }
|
||||
|
||||
val donationError: DonationError = when (throwable) {
|
||||
is DonationError -> throwable
|
||||
is DonationProcessorError -> throwable.toDonationError(DonationErrorSource.MONTHLY, PaymentSourceType.PayPal)
|
||||
else -> DonationError.genericBadgeRedemptionFailure(DonationErrorSource.MONTHLY)
|
||||
}
|
||||
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
|
||||
InAppPaymentsRepository.handlePipelineError(inAppPayment.id, DonationErrorSource.MONTHLY, PaymentSourceType.PayPal, throwable)
|
||||
},
|
||||
onComplete = {
|
||||
Log.d(TAG, "Finished subscription payment pipeline...", true)
|
||||
@@ -211,11 +193,11 @@ class PayPalPaymentInProgressViewModel(
|
||||
|
||||
class Factory(
|
||||
private val payPalRepository: PayPalRepository = PayPalRepository(ApplicationDependencies.getDonationsService()),
|
||||
private val monthlyDonationRepository: MonthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService()),
|
||||
private val oneTimeDonationRepository: OneTimeDonationRepository = OneTimeDonationRepository(ApplicationDependencies.getDonationsService())
|
||||
private val recurringInAppPaymentRepository: RecurringInAppPaymentRepository = RecurringInAppPaymentRepository(ApplicationDependencies.getDonationsService()),
|
||||
private val oneTimeInAppPaymentRepository: OneTimeInAppPaymentRepository = OneTimeInAppPaymentRepository(ApplicationDependencies.getDonationsService())
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(PayPalPaymentInProgressViewModel(payPalRepository, monthlyDonationRepository, oneTimeDonationRepository)) as T
|
||||
return modelClass.cast(PayPalPaymentInProgressViewModel(payPalRepository, recurringInAppPaymentRepository, oneTimeInAppPaymentRepository)) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,13 @@ import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.signal.donations.StripeIntentAccessor
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toBigDecimal
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toDecimalValue
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentMethodType
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.ExternalLaunchTransactionState
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.FiatValue
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Encapsulates the data required to complete a pending external transaction
|
||||
@@ -23,14 +23,14 @@ import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
@Parcelize
|
||||
data class Stripe3DSData(
|
||||
val stripeIntentAccessor: StripeIntentAccessor,
|
||||
val gatewayRequest: GatewayRequest,
|
||||
val inAppPayment: InAppPaymentTable.InAppPayment,
|
||||
private val rawPaymentSourceType: String
|
||||
) : Parcelable {
|
||||
@IgnoredOnParcel
|
||||
val paymentSourceType: PaymentSourceType = PaymentSourceType.fromCode(rawPaymentSourceType)
|
||||
|
||||
@IgnoredOnParcel
|
||||
val isLongRunning: Boolean = paymentSourceType == PaymentSourceType.Stripe.SEPADebit || (gatewayRequest.donateToSignalType == DonateToSignalType.MONTHLY && paymentSourceType.isBankTransfer)
|
||||
val isLongRunning: Boolean = paymentSourceType == PaymentSourceType.Stripe.SEPADebit || (inAppPayment.type.recurring && paymentSourceType.isBankTransfer)
|
||||
|
||||
fun toProtoBytes(): ByteArray {
|
||||
return ExternalLaunchTransactionState(
|
||||
@@ -43,25 +43,27 @@ data class Stripe3DSData(
|
||||
intentClientSecret = stripeIntentAccessor.intentClientSecret
|
||||
),
|
||||
gatewayRequest = ExternalLaunchTransactionState.GatewayRequest(
|
||||
donateToSignalType = when (gatewayRequest.donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.ONE_TIME
|
||||
DonateToSignalType.MONTHLY -> ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.MONTHLY
|
||||
DonateToSignalType.GIFT -> ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.GIFT
|
||||
donateToSignalType = when (inAppPayment.type) {
|
||||
InAppPaymentTable.Type.UNKNOWN -> error("Unsupported type UNKNOWN")
|
||||
InAppPaymentTable.Type.ONE_TIME_DONATION -> ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.ONE_TIME
|
||||
InAppPaymentTable.Type.RECURRING_DONATION -> ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.MONTHLY
|
||||
InAppPaymentTable.Type.ONE_TIME_GIFT -> ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.GIFT
|
||||
InAppPaymentTable.Type.RECURRING_BACKUP -> error("Unimplemented") // TODO [message-backups] do we still need this?
|
||||
},
|
||||
badge = Badges.toDatabaseBadge(gatewayRequest.badge),
|
||||
label = gatewayRequest.label,
|
||||
price = gatewayRequest.price.toDecimalValue(),
|
||||
currencyCode = gatewayRequest.currencyCode,
|
||||
level = gatewayRequest.level,
|
||||
recipient_id = gatewayRequest.recipientId.toLong(),
|
||||
additionalMessage = gatewayRequest.additionalMessage ?: ""
|
||||
badge = inAppPayment.data.badge,
|
||||
label = inAppPayment.data.label,
|
||||
price = inAppPayment.data.amount!!.amount,
|
||||
currencyCode = inAppPayment.data.amount.currencyCode,
|
||||
level = inAppPayment.data.level,
|
||||
recipient_id = inAppPayment.data.recipientId?.toLong() ?: Recipient.self().id.toLong(),
|
||||
additionalMessage = inAppPayment.data.additionalMessage ?: ""
|
||||
),
|
||||
paymentSourceType = paymentSourceType.code
|
||||
).encode()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromProtoBytes(byteArray: ByteArray, uiSessionKey: Long): Stripe3DSData {
|
||||
fun fromProtoBytes(byteArray: ByteArray): Stripe3DSData {
|
||||
val proto = ExternalLaunchTransactionState.ADAPTER.decode(byteArray)
|
||||
return Stripe3DSData(
|
||||
stripeIntentAccessor = StripeIntentAccessor(
|
||||
@@ -72,20 +74,33 @@ data class Stripe3DSData(
|
||||
intentId = proto.stripeIntentAccessor.intentId,
|
||||
intentClientSecret = proto.stripeIntentAccessor.intentClientSecret
|
||||
),
|
||||
gatewayRequest = GatewayRequest(
|
||||
uiSessionKey = uiSessionKey,
|
||||
donateToSignalType = when (proto.gatewayRequest!!.donateToSignalType) {
|
||||
ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.MONTHLY -> DonateToSignalType.MONTHLY
|
||||
ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.ONE_TIME -> DonateToSignalType.ONE_TIME
|
||||
ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.GIFT -> DonateToSignalType.GIFT
|
||||
inAppPayment = InAppPaymentTable.InAppPayment(
|
||||
id = InAppPaymentTable.InAppPaymentId(-1), // TODO [alex] -- can we start writing this in for new transactions?
|
||||
type = when (proto.gatewayRequest!!.donateToSignalType) {
|
||||
ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.MONTHLY -> InAppPaymentTable.Type.RECURRING_DONATION
|
||||
ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.ONE_TIME -> InAppPaymentTable.Type.ONE_TIME_DONATION
|
||||
ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.GIFT -> InAppPaymentTable.Type.ONE_TIME_GIFT
|
||||
// TODO [message-backups] -- Backups?
|
||||
},
|
||||
badge = Badges.fromDatabaseBadge(proto.gatewayRequest.badge!!),
|
||||
label = proto.gatewayRequest.label,
|
||||
price = proto.gatewayRequest.price!!.toBigDecimal(),
|
||||
currencyCode = proto.gatewayRequest.currencyCode,
|
||||
level = proto.gatewayRequest.level,
|
||||
recipientId = RecipientId.from(proto.gatewayRequest.recipient_id),
|
||||
additionalMessage = proto.gatewayRequest.additionalMessage.takeIf { it.isNotBlank() }
|
||||
endOfPeriod = 0.milliseconds,
|
||||
updatedAt = 0.milliseconds,
|
||||
insertedAt = 0.milliseconds,
|
||||
notified = true,
|
||||
state = InAppPaymentTable.State.WAITING_FOR_AUTHORIZATION,
|
||||
subscriberId = null,
|
||||
data = InAppPaymentData(
|
||||
paymentMethodType = PaymentSourceType.fromCode(proto.paymentSourceType).toPaymentMethodType(),
|
||||
badge = proto.gatewayRequest.badge,
|
||||
label = proto.gatewayRequest.label,
|
||||
amount = FiatValue(amount = proto.gatewayRequest.price, currencyCode = proto.gatewayRequest.currencyCode),
|
||||
level = proto.gatewayRequest.level,
|
||||
recipientId = null,
|
||||
additionalMessage = "",
|
||||
waitForAuth = InAppPaymentData.WaitingForAuthorizationState(
|
||||
stripeClientSecret = proto.stripeIntentAccessor.intentClientSecret,
|
||||
stripeIntentId = proto.stripeIntentAccessor.intentId
|
||||
)
|
||||
)
|
||||
),
|
||||
rawPaymentSourceType = proto.paymentSourceType
|
||||
)
|
||||
|
||||
@@ -20,13 +20,17 @@ import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.donations.StripeIntentAccessor
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationWebViewOnBackPressedCallback
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.databinding.DonationWebviewFragmentBinding
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
@@ -50,6 +54,8 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
|
||||
|
||||
var result: Bundle? = null
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen)
|
||||
@@ -57,6 +63,8 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled", "SetTextI18n")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner)
|
||||
|
||||
dialog!!.window!!.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
|
||||
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED
|
||||
@@ -76,7 +84,7 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
|
||||
)
|
||||
)
|
||||
|
||||
if (FeatureFlags.internalUser() && args.stripe3DSData.paymentSourceType == PaymentSourceType.Stripe.IDEAL) {
|
||||
if (FeatureFlags.internalUser() && args.inAppPayment.data.paymentMethodType == InAppPaymentData.PaymentMethodType.IDEAL) {
|
||||
val openApp = MaterialButton(requireContext()).apply {
|
||||
text = "Open App"
|
||||
layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
|
||||
@@ -98,14 +106,20 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
|
||||
}
|
||||
|
||||
private fun handleLaunchExternal(intent: Intent) {
|
||||
SignalStore.donationsValues().setPending3DSData(args.stripe3DSData)
|
||||
lifecycleDisposable += Completable
|
||||
.fromAction {
|
||||
SignalDatabase.inAppPayments.update(args.inAppPayment)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe {
|
||||
result = bundleOf(
|
||||
LAUNCHED_EXTERNAL to true
|
||||
)
|
||||
|
||||
result = bundleOf(
|
||||
LAUNCHED_EXTERNAL to true
|
||||
)
|
||||
|
||||
startActivity(intent)
|
||||
dismissAllowingStateLoss()
|
||||
startActivity(intent)
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class Stripe3DSWebClient : WebViewClient() {
|
||||
|
||||
@@ -8,10 +8,11 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.s
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.signal.donations.StripeApi
|
||||
import org.signal.donations.StripeIntentAccessor
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
|
||||
fun interface StripeNextActionHandler {
|
||||
fun handle(
|
||||
action: StripeApi.Secure3DSAction,
|
||||
stripe3DSData: Stripe3DSData
|
||||
inAppPayment: InAppPaymentTable.InAppPayment
|
||||
): Single<StripeIntentAccessor>
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.Do
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorActionResult
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorStage
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.databinding.DonationInProgressFragmentBinding
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
@@ -63,13 +64,13 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
|
||||
viewModel.onBeginNewAction()
|
||||
when (args.action) {
|
||||
DonationProcessorAction.PROCESS_NEW_DONATION -> {
|
||||
viewModel.processNewDonation(args.request, this::handleSecure3dsAction)
|
||||
viewModel.processNewDonation(args.inAppPayment!!, this::handleSecure3dsAction)
|
||||
}
|
||||
DonationProcessorAction.UPDATE_SUBSCRIPTION -> {
|
||||
viewModel.updateSubscription(args.request, args.isLongRunning)
|
||||
viewModel.updateSubscription(args.inAppPayment!!)
|
||||
}
|
||||
DonationProcessorAction.CANCEL_SUBSCRIPTION -> {
|
||||
viewModel.cancelSubscription()
|
||||
viewModel.cancelSubscription(args.inAppPaymentType.requireSubscriberType())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,7 +93,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
|
||||
bundleOf(
|
||||
REQUEST_KEY to DonationProcessorActionResult(
|
||||
action = args.action,
|
||||
request = args.request,
|
||||
inAppPayment = args.inAppPayment,
|
||||
status = DonationProcessorActionResult.Status.FAILURE
|
||||
)
|
||||
)
|
||||
@@ -106,7 +107,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
|
||||
bundleOf(
|
||||
REQUEST_KEY to DonationProcessorActionResult(
|
||||
action = args.action,
|
||||
request = args.request,
|
||||
inAppPayment = args.inAppPayment,
|
||||
status = DonationProcessorActionResult.Status.SUCCESS
|
||||
)
|
||||
)
|
||||
@@ -116,7 +117,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSecure3dsAction(secure3dsAction: StripeApi.Secure3DSAction, stripe3DSData: Stripe3DSData): Single<StripeIntentAccessor> {
|
||||
private fun handleSecure3dsAction(secure3dsAction: StripeApi.Secure3DSAction, inAppPayment: InAppPaymentTable.InAppPayment): Single<StripeIntentAccessor> {
|
||||
return when (secure3dsAction) {
|
||||
is StripeApi.Secure3DSAction.NotNeeded -> {
|
||||
Log.d(TAG, "No 3DS action required.")
|
||||
@@ -132,16 +133,16 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
|
||||
} else {
|
||||
val didLaunchExternal = bundle.getBoolean(Stripe3DSDialogFragment.LAUNCHED_EXTERNAL, false)
|
||||
if (didLaunchExternal) {
|
||||
emitter.onError(DonationError.UserLaunchedExternalApplication(args.request.donateToSignalType.toErrorSource()))
|
||||
emitter.onError(DonationError.UserLaunchedExternalApplication(args.inAppPaymentType.toErrorSource()))
|
||||
} else {
|
||||
emitter.onError(DonationError.UserCancelledPaymentError(args.request.donateToSignalType.toErrorSource()))
|
||||
emitter.onError(DonationError.UserCancelledPaymentError(args.inAppPaymentType.toErrorSource()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parentFragmentManager.setFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY, this, listener)
|
||||
|
||||
findNavController().safeNavigate(StripePaymentInProgressFragmentDirections.actionStripePaymentInProgressFragmentToStripe3dsDialogFragment(secure3dsAction.uri, secure3dsAction.returnUri, stripe3DSData))
|
||||
findNavController().safeNavigate(StripePaymentInProgressFragmentDirections.actionStripePaymentInProgressFragmentToStripe3dsDialogFragment(secure3dsAction.uri, secure3dsAction.returnUri, inAppPayment))
|
||||
|
||||
emitter.setCancellable {
|
||||
parentFragmentManager.clearFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY)
|
||||
|
||||
@@ -10,32 +10,38 @@ import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.donations.GooglePayPaymentSource
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.signal.donations.StripeApi
|
||||
import org.signal.donations.StripeIntentAccessor
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeDonationRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeInAppPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorStage
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.toDonationError
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
import org.whispersystems.signalservice.api.util.Preconditions
|
||||
import org.whispersystems.signalservice.internal.push.DonationProcessor
|
||||
import org.whispersystems.signalservice.internal.push.exceptions.DonationProcessorError
|
||||
|
||||
class StripePaymentInProgressViewModel(
|
||||
private val stripeRepository: StripeRepository,
|
||||
private val monthlyDonationRepository: MonthlyDonationRepository,
|
||||
private val oneTimeDonationRepository: OneTimeDonationRepository
|
||||
private val recurringInAppPaymentRepository: RecurringInAppPaymentRepository,
|
||||
private val oneTimeInAppPaymentRepository: OneTimeInAppPaymentRepository
|
||||
) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
@@ -69,21 +75,15 @@ class StripePaymentInProgressViewModel(
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun processNewDonation(request: GatewayRequest, nextActionHandler: StripeNextActionHandler) {
|
||||
fun processNewDonation(inAppPayment: InAppPaymentTable.InAppPayment, nextActionHandler: StripeNextActionHandler) {
|
||||
Log.d(TAG, "Proceeding with donation...", true)
|
||||
|
||||
val errorSource = when (request.donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> DonationErrorSource.ONE_TIME
|
||||
DonateToSignalType.MONTHLY -> DonationErrorSource.MONTHLY
|
||||
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
|
||||
}
|
||||
val paymentSourceProvider: PaymentSourceProvider = resolvePaymentSourceProvider(inAppPayment.type.toErrorSource())
|
||||
|
||||
val paymentSourceProvider: PaymentSourceProvider = resolvePaymentSourceProvider(errorSource)
|
||||
|
||||
return when (request.donateToSignalType) {
|
||||
DonateToSignalType.MONTHLY -> proceedMonthly(request, paymentSourceProvider, nextActionHandler)
|
||||
DonateToSignalType.ONE_TIME -> proceedOneTime(request, paymentSourceProvider, nextActionHandler)
|
||||
DonateToSignalType.GIFT -> proceedOneTime(request, paymentSourceProvider, nextActionHandler)
|
||||
return if (inAppPayment.type.recurring) {
|
||||
proceedMonthly(inAppPayment, paymentSourceProvider, nextActionHandler)
|
||||
} else {
|
||||
proceedOneTime(inAppPayment, paymentSourceProvider, nextActionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,27 +142,31 @@ class StripePaymentInProgressViewModel(
|
||||
stripePaymentData = null
|
||||
}
|
||||
|
||||
private fun proceedMonthly(request: GatewayRequest, paymentSourceProvider: PaymentSourceProvider, nextActionHandler: StripeNextActionHandler) {
|
||||
val ensureSubscriberId: Completable = monthlyDonationRepository.ensureSubscriberId()
|
||||
private fun proceedMonthly(inAppPayment: InAppPaymentTable.InAppPayment, paymentSourceProvider: PaymentSourceProvider, nextActionHandler: StripeNextActionHandler) {
|
||||
val ensureSubscriberId: Completable = recurringInAppPaymentRepository.ensureSubscriberId(inAppPayment.type.requireSubscriberType())
|
||||
val createAndConfirmSetupIntent: Single<StripeApi.Secure3DSAction> = paymentSourceProvider.paymentSource.flatMap {
|
||||
stripeRepository.createAndConfirmSetupIntent(it, paymentSourceProvider.paymentSourceType as PaymentSourceType.Stripe)
|
||||
}
|
||||
|
||||
val setLevel: Completable = monthlyDonationRepository.setSubscriptionLevel(request, paymentSourceProvider.paymentSourceType.isBankTransfer)
|
||||
val setLevel: Completable = recurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, paymentSourceProvider.paymentSourceType)
|
||||
|
||||
Log.d(TAG, "Starting subscription payment pipeline...", true)
|
||||
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
|
||||
|
||||
val setup: Completable = ensureSubscriberId
|
||||
.andThen(monthlyDonationRepository.cancelActiveSubscriptionIfNecessary())
|
||||
.andThen(recurringInAppPaymentRepository.cancelActiveSubscriptionIfNecessary(inAppPayment.type.requireSubscriberType()))
|
||||
.andThen(createAndConfirmSetupIntent)
|
||||
.flatMap { secure3DSAction ->
|
||||
nextActionHandler.handle(
|
||||
action = secure3DSAction,
|
||||
Stripe3DSData(
|
||||
secure3DSAction.stripeIntentAccessor,
|
||||
request,
|
||||
paymentSourceProvider.paymentSourceType.code
|
||||
inAppPayment = inAppPayment.copy(
|
||||
data = inAppPayment.data.copy(
|
||||
redemption = null,
|
||||
waitForAuth = InAppPaymentData.WaitingForAuthorizationState(
|
||||
stripeIntentId = secure3DSAction.stripeIntentAccessor.intentId,
|
||||
stripeClientSecret = secure3DSAction.stripeIntentAccessor.intentClientSecret
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.flatMap { secure3DSResult -> stripeRepository.getStatusAndPaymentMethodId(secure3DSResult, secure3DSAction.paymentMethodId) }
|
||||
@@ -180,13 +184,7 @@ class StripePaymentInProgressViewModel(
|
||||
onError = { throwable ->
|
||||
Log.w(TAG, "Failure in subscription payment pipeline...", throwable, true)
|
||||
store.update { DonationProcessorStage.FAILED }
|
||||
|
||||
val donationError: DonationError = if (throwable is DonationError) {
|
||||
throwable
|
||||
} else {
|
||||
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.MONTHLY)
|
||||
}
|
||||
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
|
||||
InAppPaymentsRepository.handlePipelineError(inAppPayment.id, DonationErrorSource.MONTHLY, paymentSourceProvider.paymentSourceType, throwable)
|
||||
},
|
||||
onComplete = {
|
||||
Log.d(TAG, "Finished subscription payment pipeline...", true)
|
||||
@@ -196,41 +194,45 @@ class StripePaymentInProgressViewModel(
|
||||
}
|
||||
|
||||
private fun proceedOneTime(
|
||||
request: GatewayRequest,
|
||||
inAppPayment: InAppPaymentTable.InAppPayment,
|
||||
paymentSourceProvider: PaymentSourceProvider,
|
||||
nextActionHandler: StripeNextActionHandler
|
||||
) {
|
||||
Log.w(TAG, "Beginning one-time payment pipeline...", true)
|
||||
|
||||
val amount = request.fiat
|
||||
val verifyUser = if (request.donateToSignalType == DonateToSignalType.GIFT) {
|
||||
OneTimeDonationRepository.verifyRecipientIsAllowedToReceiveAGift(request.recipientId)
|
||||
val amount = inAppPayment.data.amount!!.toFiatMoney()
|
||||
val recipientId = inAppPayment.data.recipientId?.let { RecipientId.from(it) } ?: Recipient.self().id
|
||||
val verifyUser = if (inAppPayment.type == InAppPaymentTable.Type.ONE_TIME_GIFT) {
|
||||
OneTimeInAppPaymentRepository.verifyRecipientIsAllowedToReceiveAGift(recipientId)
|
||||
} else {
|
||||
Completable.complete()
|
||||
}
|
||||
|
||||
val continuePayment: Single<StripeIntentAccessor> = verifyUser.andThen(stripeRepository.continuePayment(amount, request.recipientId, request.level, paymentSourceProvider.paymentSourceType))
|
||||
val continuePayment: Single<StripeIntentAccessor> = verifyUser.andThen(stripeRepository.continuePayment(amount, recipientId, inAppPayment.data.level, paymentSourceProvider.paymentSourceType))
|
||||
val intentAndSource: Single<Pair<StripeIntentAccessor, StripeApi.PaymentSource>> = Single.zip(continuePayment, paymentSourceProvider.paymentSource, ::Pair)
|
||||
|
||||
disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) ->
|
||||
stripeRepository.confirmPayment(paymentSource, paymentIntent, request.recipientId)
|
||||
stripeRepository.confirmPayment(paymentSource, paymentIntent, recipientId)
|
||||
.flatMap { action ->
|
||||
nextActionHandler
|
||||
.handle(
|
||||
action,
|
||||
Stripe3DSData(
|
||||
action.stripeIntentAccessor,
|
||||
request,
|
||||
paymentSourceProvider.paymentSourceType.code
|
||||
action = action,
|
||||
inAppPayment = inAppPayment.copy(
|
||||
data = inAppPayment.data.copy(
|
||||
redemption = null,
|
||||
waitForAuth = InAppPaymentData.WaitingForAuthorizationState(
|
||||
stripeIntentId = action.stripeIntentAccessor.intentId,
|
||||
stripeClientSecret = action.stripeIntentAccessor.intentClientSecret
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.flatMap { stripeRepository.getStatusAndPaymentMethodId(it, action.paymentMethodId) }
|
||||
}
|
||||
.flatMapCompletable {
|
||||
oneTimeDonationRepository.waitForOneTimeRedemption(
|
||||
gatewayRequest = request,
|
||||
oneTimeInAppPaymentRepository.waitForOneTimeRedemption(
|
||||
inAppPayment = inAppPayment,
|
||||
paymentIntentId = paymentIntent.intentId,
|
||||
donationProcessor = DonationProcessor.STRIPE,
|
||||
paymentSourceType = paymentSource.type
|
||||
)
|
||||
}
|
||||
@@ -238,13 +240,7 @@ class StripePaymentInProgressViewModel(
|
||||
onError = { throwable ->
|
||||
Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true)
|
||||
store.update { DonationProcessorStage.FAILED }
|
||||
|
||||
val donationError: DonationError = when (throwable) {
|
||||
is DonationError -> throwable
|
||||
is DonationProcessorError -> throwable.toDonationError(request.donateToSignalType.toErrorSource(), paymentSourceProvider.paymentSourceType)
|
||||
else -> DonationError.genericBadgeRedemptionFailure(request.donateToSignalType.toErrorSource())
|
||||
}
|
||||
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
|
||||
InAppPaymentsRepository.handlePipelineError(inAppPayment.id, DonationErrorSource.ONE_TIME, paymentSourceProvider.paymentSourceType, throwable)
|
||||
},
|
||||
onComplete = {
|
||||
Log.w(TAG, "Completed one-time payment pipeline...", true)
|
||||
@@ -253,14 +249,14 @@ class StripePaymentInProgressViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
fun cancelSubscription() {
|
||||
fun cancelSubscription(subscriberType: InAppPaymentSubscriberRecord.Type) {
|
||||
Log.d(TAG, "Beginning cancellation...", true)
|
||||
|
||||
store.update { DonationProcessorStage.CANCELLING }
|
||||
disposables += monthlyDonationRepository.cancelActiveSubscription().subscribeBy(
|
||||
disposables += recurringInAppPaymentRepository.cancelActiveSubscription(subscriberType).subscribeBy(
|
||||
onComplete = {
|
||||
Log.d(TAG, "Cancellation succeeded", true)
|
||||
SignalStore.donationsValues().updateLocalStateForManualCancellation()
|
||||
SignalStore.donationsValues().updateLocalStateForManualCancellation(subscriberType)
|
||||
MultiDeviceSubscriptionSyncRequestJob.enqueue()
|
||||
stripeRepository.scheduleSyncForAccountRecordChange()
|
||||
store.update { DonationProcessorStage.COMPLETE }
|
||||
@@ -272,10 +268,13 @@ class StripePaymentInProgressViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
fun updateSubscription(request: GatewayRequest, isLongRunning: Boolean) {
|
||||
fun updateSubscription(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
Log.d(TAG, "Beginning subscription update...", true)
|
||||
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
|
||||
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request, isLongRunning))
|
||||
disposables += recurringInAppPaymentRepository
|
||||
.cancelActiveSubscriptionIfNecessary(inAppPayment.type.requireSubscriberType())
|
||||
.andThen(recurringInAppPaymentRepository.getPaymentSourceTypeOfLatestSubscription(inAppPayment.type.requireSubscriberType()))
|
||||
.flatMapCompletable { paymentSourceType -> recurringInAppPaymentRepository.setSubscriptionLevel(inAppPayment, paymentSourceType) }
|
||||
.subscribeBy(
|
||||
onComplete = {
|
||||
Log.w(TAG, "Completed subscription update", true)
|
||||
@@ -283,14 +282,11 @@ class StripePaymentInProgressViewModel(
|
||||
},
|
||||
onError = { throwable ->
|
||||
Log.w(TAG, "Failed to update subscription", throwable, true)
|
||||
val donationError: DonationError = when (throwable) {
|
||||
is DonationError -> throwable
|
||||
is DonationProcessorError -> throwable.toDonationError(DonationErrorSource.MONTHLY, PaymentSourceType.Stripe.GooglePay)
|
||||
else -> DonationError.genericBadgeRedemptionFailure(DonationErrorSource.MONTHLY)
|
||||
}
|
||||
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
|
||||
|
||||
store.update { DonationProcessorStage.FAILED }
|
||||
SignalExecutors.BOUNDED_IO.execute {
|
||||
val paymentSourceType = InAppPaymentsRepository.getLatestPaymentMethodType(inAppPayment.type.requireSubscriberType()).toPaymentSourceType()
|
||||
InAppPaymentsRepository.handlePipelineError(inAppPayment.id, DonationErrorSource.MONTHLY, paymentSourceType, throwable)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -309,11 +305,11 @@ class StripePaymentInProgressViewModel(
|
||||
|
||||
class Factory(
|
||||
private val stripeRepository: StripeRepository,
|
||||
private val monthlyDonationRepository: MonthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService()),
|
||||
private val oneTimeDonationRepository: OneTimeDonationRepository = OneTimeDonationRepository(ApplicationDependencies.getDonationsService())
|
||||
private val recurringInAppPaymentRepository: RecurringInAppPaymentRepository = RecurringInAppPaymentRepository(ApplicationDependencies.getDonationsService()),
|
||||
private val oneTimeInAppPaymentRepository: OneTimeInAppPaymentRepository = OneTimeInAppPaymentRepository(ApplicationDependencies.getDonationsService())
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(StripePaymentInProgressViewModel(stripeRepository, monthlyDonationRepository, oneTimeDonationRepository)) as T
|
||||
return modelClass.cast(StripePaymentInProgressViewModel(stripeRepository, recurringInAppPaymentRepository, oneTimeInAppPaymentRepository)) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,17 +56,16 @@ import org.signal.core.util.getParcelableCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationCheckoutDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorAction
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorActionResult
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.BankTransferRequestKeys
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details.BankTransferDetailsViewModel.Field
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
@@ -90,13 +89,7 @@ class BankTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate.
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
TemporaryScreenshotSecurity.bindToViewLifecycleOwner(this)
|
||||
|
||||
val errorSource: DonationErrorSource = when (args.request.donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> DonationErrorSource.ONE_TIME
|
||||
DonateToSignalType.MONTHLY -> DonationErrorSource.MONTHLY
|
||||
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
|
||||
}
|
||||
|
||||
DonationCheckoutDelegate.ErrorHandler().attach(this, this, args.request.uiSessionKey, errorSource)
|
||||
DonationCheckoutDelegate.ErrorHandler().attach(this, this, args.inAppPayment.id)
|
||||
|
||||
setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
|
||||
val result: DonationProcessorActionResult = bundle.getParcelableCompat(StripePaymentInProgressFragment.REQUEST_KEY, DonationProcessorActionResult::class.java)!!
|
||||
@@ -111,16 +104,16 @@ class BankTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate.
|
||||
override fun FragmentContent() {
|
||||
val state: BankTransferDetailsState by viewModel.state
|
||||
|
||||
val donateLabel = remember(args.request) {
|
||||
if (args.request.donateToSignalType == DonateToSignalType.MONTHLY) {
|
||||
val donateLabel = remember(args.inAppPayment) {
|
||||
if (args.inAppPayment.type.recurring) { // TODO [message-requests] backups copy
|
||||
getString(
|
||||
R.string.BankTransferDetailsFragment__donate_s_month,
|
||||
FiatMoneyUtil.format(resources, args.request.fiat, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
|
||||
FiatMoneyUtil.format(resources, args.inAppPayment.data.amount!!.toFiatMoney(), FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
|
||||
)
|
||||
} else {
|
||||
getString(
|
||||
R.string.BankTransferDetailsFragment__donate_s,
|
||||
FiatMoneyUtil.format(resources, args.request.fiat)
|
||||
FiatMoneyUtil.format(resources, args.inAppPayment.data.amount!!.toFiatMoney())
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -154,15 +147,16 @@ class BankTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate.
|
||||
findNavController().safeNavigate(
|
||||
BankTransferDetailsFragmentDirections.actionBankTransferDetailsFragmentToStripePaymentInProgressFragment(
|
||||
DonationProcessorAction.PROCESS_NEW_DONATION,
|
||||
args.request
|
||||
args.inAppPayment,
|
||||
args.inAppPayment.type
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onUserLaunchedAnExternalApplication() = Unit
|
||||
|
||||
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) {
|
||||
setFragmentResult(BankTransferRequestKeys.PENDING_KEY, bundleOf(BankTransferRequestKeys.PENDING_KEY to gatewayRequest))
|
||||
override fun navigateToDonationPending(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
setFragmentResult(BankTransferRequestKeys.PENDING_KEY, bundleOf(BankTransferRequestKeys.PENDING_KEY to inAppPayment))
|
||||
viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
findNavController().popBackStack(R.id.donateToSignalFragment, false)
|
||||
|
||||
@@ -57,17 +57,16 @@ import org.signal.core.util.getParcelableCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationCheckoutDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorAction
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorActionResult
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.BankTransferRequestKeys
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.ideal.IdealTransferDetailsViewModel.Field
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
@@ -81,7 +80,7 @@ class IdealTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate
|
||||
|
||||
private val args: IdealTransferDetailsFragmentArgs by navArgs()
|
||||
private val viewModel: IdealTransferDetailsViewModel by viewModel {
|
||||
IdealTransferDetailsViewModel(args.request.donateToSignalType == DonateToSignalType.MONTHLY)
|
||||
IdealTransferDetailsViewModel(args.inAppPayment.type.recurring)
|
||||
}
|
||||
|
||||
private val stripePaymentViewModel: StripePaymentInProgressViewModel by navGraphViewModels(
|
||||
@@ -94,13 +93,7 @@ class IdealTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
TemporaryScreenshotSecurity.bindToViewLifecycleOwner(this)
|
||||
|
||||
val errorSource: DonationErrorSource = when (args.request.donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> DonationErrorSource.ONE_TIME
|
||||
DonateToSignalType.MONTHLY -> DonationErrorSource.MONTHLY
|
||||
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
|
||||
}
|
||||
|
||||
DonationCheckoutDelegate.ErrorHandler().attach(this, this, args.request.uiSessionKey, errorSource)
|
||||
DonationCheckoutDelegate.ErrorHandler().attach(this, this, args.inAppPayment.id)
|
||||
|
||||
setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
|
||||
val result: DonationProcessorActionResult = bundle.getParcelableCompat(StripePaymentInProgressFragment.REQUEST_KEY, DonationProcessorActionResult::class.java)!!
|
||||
@@ -120,22 +113,22 @@ class IdealTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state
|
||||
|
||||
val donateLabel = remember(args.request) {
|
||||
if (args.request.donateToSignalType == DonateToSignalType.MONTHLY) {
|
||||
val donateLabel = remember(args.inAppPayment) {
|
||||
if (args.inAppPayment.type.recurring) { // TODO [message-request] -- Handle backups
|
||||
getString(
|
||||
R.string.BankTransferDetailsFragment__donate_s_month,
|
||||
FiatMoneyUtil.format(resources, args.request.fiat, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
|
||||
FiatMoneyUtil.format(resources, args.inAppPayment.data.amount!!.toFiatMoney(), FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
|
||||
)
|
||||
} else {
|
||||
getString(
|
||||
R.string.BankTransferDetailsFragment__donate_s,
|
||||
FiatMoneyUtil.format(resources, args.request.fiat)
|
||||
FiatMoneyUtil.format(resources, args.inAppPayment.data.amount!!.toFiatMoney())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val idealDirections = remember(args.request) {
|
||||
if (args.request.donateToSignalType == DonateToSignalType.MONTHLY) {
|
||||
val idealDirections = remember(args.inAppPayment) {
|
||||
if (args.inAppPayment.type.recurring) { // TODO [message-request] -- Handle backups
|
||||
R.string.IdealTransferDetailsFragment__enter_your_bank
|
||||
} else {
|
||||
R.string.IdealTransferDetailsFragment__enter_your_bank_details_one_time
|
||||
@@ -164,12 +157,13 @@ class IdealTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate
|
||||
findNavController().safeNavigate(
|
||||
IdealTransferDetailsFragmentDirections.actionBankTransferDetailsFragmentToStripePaymentInProgressFragment(
|
||||
DonationProcessorAction.PROCESS_NEW_DONATION,
|
||||
args.request
|
||||
args.inAppPayment,
|
||||
args.inAppPayment.type
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (args.request.donateToSignalType == DonateToSignalType.MONTHLY) {
|
||||
if (args.inAppPayment.type.recurring) { // TODO [message-requests] -- handle backup
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(getString(R.string.IdealTransferDetailsFragment__confirm_your_donation_with_s, getString(state.idealBank!!.getUIValues().name)))
|
||||
.setMessage(R.string.IdealTransferDetailsFragment__monthly_ideal_warning)
|
||||
@@ -191,8 +185,8 @@ class IdealTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate
|
||||
})
|
||||
}
|
||||
|
||||
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) {
|
||||
setFragmentResult(BankTransferRequestKeys.PENDING_KEY, bundleOf(BankTransferRequestKeys.PENDING_KEY to gatewayRequest))
|
||||
override fun navigateToDonationPending(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
setFragmentResult(BankTransferRequestKeys.PENDING_KEY, bundleOf(BankTransferRequestKeys.PENDING_KEY to inAppPayment))
|
||||
viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
findNavController().popBackStack(R.id.donateToSignalFragment, false)
|
||||
|
||||
@@ -58,9 +58,9 @@ import org.signal.core.ui.Texts
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.compose.StatusBarColorAnimator
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
@@ -112,13 +112,13 @@ class BankTransferMandateFragment : ComposeFragment() {
|
||||
}
|
||||
|
||||
private fun onContinueClick() {
|
||||
if (args.response.gateway == GatewayResponse.Gateway.SEPA_DEBIT) {
|
||||
if (args.inAppPayment.data.paymentMethodType == InAppPaymentData.PaymentMethodType.SEPA_DEBIT) {
|
||||
findNavController().safeNavigate(
|
||||
BankTransferMandateFragmentDirections.actionBankTransferMandateFragmentToBankTransferDetailsFragment(args.response.request)
|
||||
BankTransferMandateFragmentDirections.actionBankTransferMandateFragmentToBankTransferDetailsFragment(args.inAppPayment)
|
||||
)
|
||||
} else {
|
||||
findNavController().safeNavigate(
|
||||
BankTransferMandateFragmentDirections.actionBankTransferMandateFragmentToIdealTransferDetailsFragment(args.response.request)
|
||||
BankTransferMandateFragmentDirections.actionBankTransferMandateFragmentToIdealTransferDetailsFragment(args.inAppPayment)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,16 +9,20 @@ import org.signal.donations.PaymentSourceType
|
||||
import org.signal.donations.StripeDeclineCode
|
||||
import org.signal.donations.StripeError
|
||||
import org.signal.donations.StripeFailureCode
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
|
||||
|
||||
/**
|
||||
* @deprecated Replaced with InAppDonationData.Error
|
||||
*
|
||||
* This needs to remain until all the old jobs are through people's systems (90 days from release + timeout)
|
||||
*/
|
||||
sealed class DonationError(val source: DonationErrorSource, cause: Throwable) : Exception(cause) {
|
||||
|
||||
/**
|
||||
* Google Pay errors, which happen well before a user would ever be charged.
|
||||
*/
|
||||
sealed class GooglePayError(source: DonationErrorSource, cause: Throwable) : DonationError(source, cause) {
|
||||
class NotAvailableError(source: DonationErrorSource, cause: Throwable) : GooglePayError(source, cause)
|
||||
class RequestTokenError(source: DonationErrorSource, cause: Throwable) : GooglePayError(source, cause)
|
||||
}
|
||||
|
||||
@@ -38,8 +42,6 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
|
||||
*/
|
||||
sealed class GiftRecipientVerificationError(cause: Throwable) : DonationError(DonationErrorSource.GIFT, cause) {
|
||||
object SelectedRecipientIsInvalid : GiftRecipientVerificationError(Exception("Selected recipient is invalid."))
|
||||
object SelectedRecipientDoesNotSupportGifts : GiftRecipientVerificationError(Exception("Selected recipient does not support gifts."))
|
||||
class FailedToFetchProfile(cause: Throwable) : GiftRecipientVerificationError(Exception("Failed to fetch recipient profile.", cause))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,7 +108,7 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
|
||||
* Timeout elapsed while the user was waiting for badge redemption to complete for a long-running payment.
|
||||
* This is not an indication that redemption failed, just that it could take a few days to process the payment.
|
||||
*/
|
||||
class DonationPending(source: DonationErrorSource, val gatewayRequest: GatewayRequest) : BadgeRedemptionError(source, Exception("Long-running donation is still pending."))
|
||||
class DonationPending(source: DonationErrorSource, val inAppPayment: InAppPaymentTable.InAppPayment) : BadgeRedemptionError(source, Exception("Long-running donation is still pending."))
|
||||
|
||||
/**
|
||||
* Timeout elapsed while the user was waiting for badge redemption to complete. This is not an indication that
|
||||
@@ -133,20 +135,11 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
|
||||
source to PublishSubject.create()
|
||||
}
|
||||
|
||||
private val donationErrorsSubjectUiSessionMap: MutableMap<Long, Subject<DonationError>> = mutableMapOf()
|
||||
|
||||
@JvmStatic
|
||||
fun getErrorsForSource(donationErrorSource: DonationErrorSource): Observable<DonationError> {
|
||||
return donationErrorSubjectSourceMap[donationErrorSource]!!
|
||||
}
|
||||
|
||||
fun getErrorsForUiSessionKey(uiSessionKey: Long): Observable<DonationError> {
|
||||
val subject: Subject<DonationError> = donationErrorsSubjectUiSessionMap[uiSessionKey] ?: PublishSubject.create()
|
||||
donationErrorsSubjectUiSessionMap[uiSessionKey] = subject
|
||||
|
||||
return subject
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun DonationError.toDonationErrorValue(): DonationErrorValue {
|
||||
return when (this) {
|
||||
@@ -182,7 +175,6 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
|
||||
@JvmOverloads
|
||||
fun routeBackgroundError(
|
||||
context: Context,
|
||||
uiSessionKey: Long,
|
||||
error: DonationError,
|
||||
suppressNotification: Boolean = true
|
||||
) {
|
||||
@@ -191,17 +183,12 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
|
||||
return
|
||||
}
|
||||
|
||||
val subject: Subject<DonationError>? = donationErrorsSubjectUiSessionMap[uiSessionKey]
|
||||
when {
|
||||
subject != null && subject.hasObservers() -> {
|
||||
Log.i(TAG, "Routing background donation error to uiSessionKey $uiSessionKey dialog", error)
|
||||
subject.onNext(error)
|
||||
}
|
||||
suppressNotification -> {
|
||||
Log.i(TAG, "Suppressing notification for error.", error)
|
||||
}
|
||||
else -> {
|
||||
Log.i(TAG, "Routing background donation error to uiSessionKey $uiSessionKey notification", error)
|
||||
Log.i(TAG, "Routing background donation error to notification", error)
|
||||
DonationErrorNotifications.displayErrorNotification(context, error)
|
||||
}
|
||||
}
|
||||
@@ -211,8 +198,7 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
|
||||
* Route a given donation error, which will either pipe it out to an appropriate subject
|
||||
* or, if the subject has no observers, post it as a notification.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun routeDonationError(context: Context, error: DonationError) {
|
||||
private fun routeDonationError(context: Context, error: DonationError) {
|
||||
val subject: Subject<DonationError> = donationErrorSubjectSourceMap[error.source]!!
|
||||
when {
|
||||
subject.hasObservers() -> {
|
||||
@@ -226,11 +212,6 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getGooglePayRequestTokenError(source: DonationErrorSource, throwable: Throwable): DonationError {
|
||||
return GooglePayError.RequestTokenError(source, throwable)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a throwable into a payment setup error. This should only be used when
|
||||
* handling errors handed back via the Stripe API or via PayPal, when we know for sure that no
|
||||
@@ -260,21 +241,6 @@ sealed class DonationError(val source: DonationErrorSource, cause: Throwable) :
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun oneTimeDonationAmountTooSmall(source: DonationErrorSource): DonationError = OneTimeDonationError.AmountTooSmallError(source)
|
||||
|
||||
@JvmStatic
|
||||
fun oneTimeDonationAmountTooLarge(source: DonationErrorSource): DonationError = OneTimeDonationError.AmountTooLargeError(source)
|
||||
|
||||
@JvmStatic
|
||||
fun invalidCurrencyForOneTimeDonation(source: DonationErrorSource): DonationError = OneTimeDonationError.InvalidCurrencyError(source)
|
||||
|
||||
@JvmStatic
|
||||
fun timeoutWaitingForToken(source: DonationErrorSource): DonationError = BadgeRedemptionError.TimeoutWaitingForTokenError(source)
|
||||
|
||||
@JvmStatic
|
||||
fun donationPending(source: DonationErrorSource, gatewayRequest: GatewayRequest) = BadgeRedemptionError.DonationPending(source, gatewayRequest)
|
||||
|
||||
@JvmStatic
|
||||
fun genericBadgeRedemptionFailure(source: DonationErrorSource): DonationError = BadgeRedemptionError.GenericError(source)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.DialogInterface
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.help.HelpFragment
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
|
||||
@@ -12,6 +13,7 @@ import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
* Donation Error Dialogs.
|
||||
*/
|
||||
object DonationErrorDialogs {
|
||||
|
||||
/**
|
||||
* Displays a dialog, and returns a handle to it for dismissal.
|
||||
*/
|
||||
@@ -36,6 +38,30 @@ object DonationErrorDialogs {
|
||||
return builder.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a dialog, and returns a handle to it for dismissal.
|
||||
*/
|
||||
fun show(context: Context, inAppPayment: InAppPaymentTable.InAppPayment, callback: DialogCallback): DialogInterface {
|
||||
val builder = MaterialAlertDialogBuilder(context)
|
||||
|
||||
builder.setOnDismissListener { callback.onDialogDismissed() }
|
||||
|
||||
val params = DonationErrorParams.create(context, inAppPayment, callback)
|
||||
|
||||
builder.setTitle(params.title)
|
||||
.setMessage(params.message)
|
||||
|
||||
if (params.positiveAction != null) {
|
||||
builder.setPositiveButton(params.positiveAction.label) { _, _ -> params.positiveAction.action() }
|
||||
}
|
||||
|
||||
if (params.negativeAction != null) {
|
||||
builder.setNegativeButton(params.negativeAction.label) { _, _ -> params.negativeAction.action() }
|
||||
}
|
||||
|
||||
return builder.show()
|
||||
}
|
||||
|
||||
abstract class DialogCallback : DonationErrorParams.Callback<Unit> {
|
||||
|
||||
override fun onCancel(context: Context): DonationErrorParams.ErrorAction<Unit>? {
|
||||
|
||||
@@ -6,6 +6,10 @@ import org.signal.donations.PaymentSourceType
|
||||
import org.signal.donations.StripeDeclineCode
|
||||
import org.signal.donations.StripeFailureCode
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toInAppPaymentType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
|
||||
class DonationErrorParams<V> private constructor(
|
||||
@StringRes val title: Int,
|
||||
@@ -25,46 +29,61 @@ class DonationErrorParams<V> private constructor(
|
||||
callback: Callback<V>
|
||||
): DonationErrorParams<V> {
|
||||
return when (throwable) {
|
||||
is DonationError.GiftRecipientVerificationError -> getVerificationErrorParams(context, throwable, callback)
|
||||
is DonationError.PaymentSetupError.StripeDeclinedError -> getStripeDeclinedErrorParams(context, throwable, callback)
|
||||
is DonationError.PaymentSetupError.StripeFailureCodeError -> getStripeFailureCodeErrorParams(context, throwable, callback)
|
||||
is DonationError.PaymentSetupError.PayPalDeclinedError -> getPayPalDeclinedErrorParams(context, throwable, callback)
|
||||
is DonationError.GiftRecipientVerificationError -> getVerificationErrorParams(context, callback)
|
||||
is DonationError.PaymentSetupError.StripeDeclinedError -> getStripeDeclinedErrorParams(context, throwable.method, throwable.declineCode, callback)
|
||||
is DonationError.PaymentSetupError.StripeFailureCodeError -> getStripeFailureCodeErrorParams(context, throwable.method, throwable.failureCode, callback)
|
||||
is DonationError.PaymentSetupError.PayPalDeclinedError -> getPayPalDeclinedErrorParams(context, throwable.code, callback)
|
||||
is DonationError.PaymentSetupError -> getGenericPaymentSetupErrorParams(context, callback)
|
||||
|
||||
is DonationError.BadgeRedemptionError.DonationPending -> DonationErrorParams(
|
||||
title = R.string.DonationsErrors__still_processing,
|
||||
message = R.string.DonationsErrors__your_payment_is_still,
|
||||
positiveAction = callback.onOk(context),
|
||||
negativeAction = null
|
||||
)
|
||||
|
||||
is DonationError.BadgeRedemptionError.TimeoutWaitingForTokenError -> DonationErrorParams(
|
||||
title = R.string.DonationsErrors__still_processing,
|
||||
message = R.string.DonationsErrors__your_payment_is_still,
|
||||
positiveAction = callback.onOk(context),
|
||||
negativeAction = null
|
||||
)
|
||||
|
||||
is DonationError.BadgeRedemptionError.FailedToValidateCredentialError -> DonationErrorParams(
|
||||
title = R.string.DonationsErrors__failed_to_validate_badge,
|
||||
message = R.string.DonationsErrors__could_not_validate,
|
||||
positiveAction = callback.onContactSupport(context),
|
||||
negativeAction = null
|
||||
)
|
||||
|
||||
is DonationError.BadgeRedemptionError.GenericError -> getGenericRedemptionError(context, throwable, callback)
|
||||
else -> DonationErrorParams(
|
||||
title = R.string.DonationsErrors__couldnt_add_badge,
|
||||
message = R.string.DonationsErrors__your_badge_could_not,
|
||||
positiveAction = callback.onContactSupport(context),
|
||||
negativeAction = null
|
||||
)
|
||||
is DonationError.BadgeRedemptionError.DonationPending -> getStillProcessingErrorParams(context, callback)
|
||||
is DonationError.BadgeRedemptionError.TimeoutWaitingForTokenError -> getStillProcessingErrorParams(context, callback)
|
||||
is DonationError.BadgeRedemptionError.FailedToValidateCredentialError -> getBadgeCredentialValidationErrorParams(context, callback)
|
||||
is DonationError.BadgeRedemptionError.GenericError -> getGenericRedemptionError(context, throwable.source.toInAppPaymentType(), callback)
|
||||
else -> getGenericRedemptionError(context, InAppPaymentTable.Type.ONE_TIME_DONATION, callback)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <V> getGenericRedemptionError(context: Context, genericError: DonationError.BadgeRedemptionError.GenericError, callback: Callback<V>): DonationErrorParams<V> {
|
||||
return when (genericError.source) {
|
||||
DonationErrorSource.GIFT -> DonationErrorParams(
|
||||
fun <V> create(
|
||||
context: Context,
|
||||
inAppPayment: InAppPaymentTable.InAppPayment,
|
||||
callback: Callback<V>
|
||||
): DonationErrorParams<V> {
|
||||
return when (inAppPayment.data.error?.type) {
|
||||
InAppPaymentData.Error.Type.UNKNOWN -> getGenericRedemptionError(context, inAppPayment.type, callback)
|
||||
InAppPaymentData.Error.Type.GOOGLE_PAY_REQUEST_TOKEN -> getGenericPaymentSetupErrorParams(context, callback)
|
||||
InAppPaymentData.Error.Type.INVALID_GIFT_RECIPIENT -> getVerificationErrorParams(context, callback)
|
||||
InAppPaymentData.Error.Type.ONE_TIME_AMOUNT_TOO_SMALL -> getGenericPaymentSetupErrorParams(context, callback)
|
||||
InAppPaymentData.Error.Type.ONE_TIME_AMOUNT_TOO_LARGE -> getGenericPaymentSetupErrorParams(context, callback)
|
||||
InAppPaymentData.Error.Type.INVALID_CURRENCY -> getGenericPaymentSetupErrorParams(context, callback)
|
||||
InAppPaymentData.Error.Type.PAYMENT_SETUP -> getGenericPaymentSetupErrorParams(context, callback)
|
||||
InAppPaymentData.Error.Type.STRIPE_CODED_ERROR -> getGenericPaymentSetupErrorParams(context, callback)
|
||||
InAppPaymentData.Error.Type.STRIPE_DECLINED_ERROR -> getStripeDeclinedErrorParams(
|
||||
context = context,
|
||||
paymentSourceType = inAppPayment.data.paymentMethodType.toPaymentSourceType() as PaymentSourceType.Stripe,
|
||||
declineCode = StripeDeclineCode.getFromCode(inAppPayment.data.error.data_),
|
||||
callback = callback
|
||||
)
|
||||
InAppPaymentData.Error.Type.STRIPE_FAILURE -> getStripeFailureCodeErrorParams(
|
||||
context = context,
|
||||
paymentSourceType = inAppPayment.data.paymentMethodType.toPaymentSourceType() as PaymentSourceType.Stripe,
|
||||
failureCode = StripeFailureCode.getFromCode(inAppPayment.data.error.data_),
|
||||
callback = callback
|
||||
)
|
||||
InAppPaymentData.Error.Type.PAYPAL_CODED_ERROR -> getGenericPaymentSetupErrorParams(context, callback)
|
||||
InAppPaymentData.Error.Type.PAYPAL_DECLINED_ERROR -> getPayPalDeclinedErrorParams(
|
||||
context = context,
|
||||
payPalDeclineCode = PayPalDeclineCode.KnownCode.fromCode(inAppPayment.data.error.data_!!.toInt())!!,
|
||||
callback = callback
|
||||
)
|
||||
InAppPaymentData.Error.Type.PAYMENT_PROCESSING -> getGenericRedemptionError(context, inAppPayment.type, callback)
|
||||
InAppPaymentData.Error.Type.CREDENTIAL_VALIDATION -> getBadgeCredentialValidationErrorParams(context, callback)
|
||||
InAppPaymentData.Error.Type.REDEMPTION -> getGenericRedemptionError(context, inAppPayment.type, callback)
|
||||
null -> error("No error in data!")
|
||||
}
|
||||
}
|
||||
|
||||
private fun <V> getGenericRedemptionError(context: Context, type: InAppPaymentTable.Type, callback: Callback<V>): DonationErrorParams<V> {
|
||||
return when (type) {
|
||||
InAppPaymentTable.Type.ONE_TIME_GIFT -> DonationErrorParams(
|
||||
title = R.string.DonationsErrors__donation_failed,
|
||||
message = R.string.DonationsErrors__your_payment_was_processed_but,
|
||||
positiveAction = callback.onContactSupport(context),
|
||||
@@ -80,65 +99,65 @@ class DonationErrorParams<V> private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun <V> getVerificationErrorParams(context: Context, verificationError: DonationError.GiftRecipientVerificationError, callback: Callback<V>): DonationErrorParams<V> {
|
||||
return when (verificationError) {
|
||||
is DonationError.GiftRecipientVerificationError.FailedToFetchProfile -> DonationErrorParams(
|
||||
title = R.string.DonationsErrors__cannot_send_donation,
|
||||
message = R.string.DonationsErrors__your_donation_could_not_be_sent,
|
||||
positiveAction = callback.onOk(context),
|
||||
negativeAction = null
|
||||
)
|
||||
|
||||
else -> DonationErrorParams(
|
||||
title = R.string.DonationsErrors__cannot_send_donation,
|
||||
message = R.string.DonationsErrors__this_user_cant_receive_donations_until,
|
||||
positiveAction = callback.onOk(context),
|
||||
negativeAction = null
|
||||
)
|
||||
}
|
||||
private fun <V> getVerificationErrorParams(context: Context, callback: Callback<V>): DonationErrorParams<V> {
|
||||
return DonationErrorParams(
|
||||
title = R.string.DonationsErrors__cannot_send_donation,
|
||||
message = R.string.DonationsErrors__this_user_cant_receive_donations_until,
|
||||
positiveAction = callback.onOk(context),
|
||||
negativeAction = null
|
||||
)
|
||||
}
|
||||
|
||||
private fun <V> getPayPalDeclinedErrorParams(context: Context, declinedError: DonationError.PaymentSetupError.PayPalDeclinedError, callback: Callback<V>): DonationErrorParams<V> {
|
||||
return when (declinedError.code) {
|
||||
private fun <V> getPayPalDeclinedErrorParams(
|
||||
context: Context,
|
||||
payPalDeclineCode: PayPalDeclineCode.KnownCode,
|
||||
callback: Callback<V>
|
||||
): DonationErrorParams<V> {
|
||||
return when (payPalDeclineCode) {
|
||||
PayPalDeclineCode.KnownCode.DECLINED -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_another_payment_method_or_contact_your_bank_for_more_information_if_this_was_a_paypal)
|
||||
else -> getLearnMoreParams(context, callback, R.string.DeclineCode__try_another_payment_method_or_contact_your_bank)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <V> getStripeDeclinedErrorParams(context: Context, declinedError: DonationError.PaymentSetupError.StripeDeclinedError, callback: Callback<V>): DonationErrorParams<V> {
|
||||
if (!declinedError.method.hasDeclineCodeSupport()) {
|
||||
private fun <V> getStripeDeclinedErrorParams(
|
||||
context: Context,
|
||||
paymentSourceType: PaymentSourceType.Stripe,
|
||||
declineCode: StripeDeclineCode,
|
||||
callback: Callback<V>
|
||||
): DonationErrorParams<V> {
|
||||
if (!paymentSourceType.hasDeclineCodeSupport()) {
|
||||
return getGenericPaymentSetupErrorParams(context, callback)
|
||||
}
|
||||
|
||||
fun unexpectedDeclinedError(declinedError: DonationError.PaymentSetupError.StripeDeclinedError): Nothing {
|
||||
error("Unexpected declined error: ${declinedError.declineCode} during ${declinedError.method} processing.")
|
||||
fun unexpectedDeclinedError(declineCode: StripeDeclineCode, paymentSourceType: PaymentSourceType.Stripe): Nothing {
|
||||
error("Unexpected declined error: $declineCode during $paymentSourceType processing.")
|
||||
}
|
||||
|
||||
val getStripeDeclineCodePositiveActionParams: (Context, Callback<V>, Int) -> DonationErrorParams<V> = when (declinedError.method) {
|
||||
val getStripeDeclineCodePositiveActionParams: (Context, Callback<V>, Int) -> DonationErrorParams<V> = when (paymentSourceType) {
|
||||
PaymentSourceType.Stripe.CreditCard -> this::getTryCreditCardAgainParams
|
||||
PaymentSourceType.Stripe.GooglePay -> this::getGoToGooglePayParams
|
||||
else -> this::getLearnMoreParams
|
||||
}
|
||||
|
||||
return when (declinedError.declineCode) {
|
||||
is StripeDeclineCode.Known -> when (declinedError.declineCode.code) {
|
||||
return when (declineCode) {
|
||||
is StripeDeclineCode.Known -> when (declineCode.code) {
|
||||
StripeDeclineCode.Code.APPROVE_WITH_ID -> getStripeDeclineCodePositiveActionParams(
|
||||
context,
|
||||
callback,
|
||||
when (declinedError.method) {
|
||||
when (paymentSourceType) {
|
||||
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__verify_your_card_details_are_correct_and_try_again
|
||||
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again
|
||||
else -> unexpectedDeclinedError(declinedError)
|
||||
else -> unexpectedDeclinedError(declineCode, paymentSourceType)
|
||||
}
|
||||
)
|
||||
|
||||
StripeDeclineCode.Code.CALL_ISSUER -> getStripeDeclineCodePositiveActionParams(
|
||||
context,
|
||||
callback,
|
||||
when (declinedError.method) {
|
||||
when (paymentSourceType) {
|
||||
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__verify_your_card_details_are_correct_and_try_again_if_the_problem_continues
|
||||
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again_if_the_problem
|
||||
else -> unexpectedDeclinedError(declinedError)
|
||||
else -> unexpectedDeclinedError(declineCode, paymentSourceType)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -146,30 +165,30 @@ class DonationErrorParams<V> private constructor(
|
||||
StripeDeclineCode.Code.EXPIRED_CARD -> getStripeDeclineCodePositiveActionParams(
|
||||
context,
|
||||
callback,
|
||||
when (declinedError.method) {
|
||||
when (paymentSourceType) {
|
||||
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_card_has_expired_verify_your_card_details
|
||||
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_card_has_expired
|
||||
else -> unexpectedDeclinedError(declinedError)
|
||||
else -> unexpectedDeclinedError(declineCode, paymentSourceType)
|
||||
}
|
||||
)
|
||||
|
||||
StripeDeclineCode.Code.INCORRECT_NUMBER -> getStripeDeclineCodePositiveActionParams(
|
||||
context,
|
||||
callback,
|
||||
when (declinedError.method) {
|
||||
when (paymentSourceType) {
|
||||
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_card_number_is_incorrect_verify_your_card_details
|
||||
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_card_number_is_incorrect
|
||||
else -> unexpectedDeclinedError(declinedError)
|
||||
else -> unexpectedDeclinedError(declineCode, paymentSourceType)
|
||||
}
|
||||
)
|
||||
|
||||
StripeDeclineCode.Code.INCORRECT_CVC -> getStripeDeclineCodePositiveActionParams(
|
||||
context,
|
||||
callback,
|
||||
when (declinedError.method) {
|
||||
when (paymentSourceType) {
|
||||
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect_verify_your_card_details
|
||||
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect
|
||||
else -> unexpectedDeclinedError(declinedError)
|
||||
else -> unexpectedDeclinedError(declineCode, paymentSourceType)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -177,40 +196,40 @@ class DonationErrorParams<V> private constructor(
|
||||
StripeDeclineCode.Code.INVALID_CVC -> getStripeDeclineCodePositiveActionParams(
|
||||
context,
|
||||
callback,
|
||||
when (declinedError.method) {
|
||||
when (paymentSourceType) {
|
||||
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect_verify_your_card_details
|
||||
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect
|
||||
else -> unexpectedDeclinedError(declinedError)
|
||||
else -> unexpectedDeclinedError(declineCode, paymentSourceType)
|
||||
}
|
||||
)
|
||||
|
||||
StripeDeclineCode.Code.INVALID_EXPIRY_MONTH -> getStripeDeclineCodePositiveActionParams(
|
||||
context,
|
||||
callback,
|
||||
when (declinedError.method) {
|
||||
when (paymentSourceType) {
|
||||
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__the_expiration_month_on_your_card_is_incorrect
|
||||
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__the_expiration_month
|
||||
else -> unexpectedDeclinedError(declinedError)
|
||||
else -> unexpectedDeclinedError(declineCode, paymentSourceType)
|
||||
}
|
||||
)
|
||||
|
||||
StripeDeclineCode.Code.INVALID_EXPIRY_YEAR -> getStripeDeclineCodePositiveActionParams(
|
||||
context,
|
||||
callback,
|
||||
when (declinedError.method) {
|
||||
when (paymentSourceType) {
|
||||
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__the_expiration_year_on_your_card_is_incorrect
|
||||
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__the_expiration_year
|
||||
else -> unexpectedDeclinedError(declinedError)
|
||||
else -> unexpectedDeclinedError(declineCode, paymentSourceType)
|
||||
}
|
||||
)
|
||||
|
||||
StripeDeclineCode.Code.INVALID_NUMBER -> getStripeDeclineCodePositiveActionParams(
|
||||
context,
|
||||
callback,
|
||||
when (declinedError.method) {
|
||||
when (paymentSourceType) {
|
||||
PaymentSourceType.Stripe.CreditCard -> R.string.DeclineCode__your_card_number_is_incorrect_verify_your_card_details
|
||||
PaymentSourceType.Stripe.GooglePay -> R.string.DeclineCode__your_card_number_is_incorrect
|
||||
else -> unexpectedDeclinedError(declinedError)
|
||||
else -> unexpectedDeclinedError(declineCode, paymentSourceType)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -224,15 +243,20 @@ class DonationErrorParams<V> private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun <V> getStripeFailureCodeErrorParams(context: Context, failureCodeError: DonationError.PaymentSetupError.StripeFailureCodeError, callback: Callback<V>): DonationErrorParams<V> {
|
||||
if (!failureCodeError.method.hasFailureCodeSupport()) {
|
||||
private fun <V> getStripeFailureCodeErrorParams(
|
||||
context: Context,
|
||||
paymentSourceType: PaymentSourceType.Stripe,
|
||||
failureCode: StripeFailureCode,
|
||||
callback: Callback<V>
|
||||
): DonationErrorParams<V> {
|
||||
if (!paymentSourceType.hasFailureCodeSupport()) {
|
||||
return getGenericPaymentSetupErrorParams(context, callback)
|
||||
}
|
||||
|
||||
return when (failureCodeError.failureCode) {
|
||||
return when (failureCode) {
|
||||
is StripeFailureCode.Known -> {
|
||||
val errorText = failureCodeError.failureCode.mapToErrorStringResource()
|
||||
when (failureCodeError.failureCode.code) {
|
||||
val errorText = failureCode.mapToErrorStringResource()
|
||||
when (failureCode.code) {
|
||||
StripeFailureCode.Code.REFER_TO_CUSTOMER -> getTryBankTransferAgainParams(context, callback, errorText)
|
||||
StripeFailureCode.Code.INSUFFICIENT_FUNDS -> getLearnMoreParams(context, callback, errorText)
|
||||
StripeFailureCode.Code.DEBIT_DISPUTED -> getLearnMoreParams(context, callback, errorText)
|
||||
@@ -248,10 +272,29 @@ class DonationErrorParams<V> private constructor(
|
||||
StripeFailureCode.Code.GENERIC_COULD_NOT_PROCESS -> getTryBankTransferAgainParams(context, callback, errorText)
|
||||
}
|
||||
}
|
||||
|
||||
is StripeFailureCode.Unknown -> getGenericPaymentSetupErrorParams(context, callback)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <V> getStillProcessingErrorParams(context: Context, callback: Callback<V>): DonationErrorParams<V> {
|
||||
return DonationErrorParams(
|
||||
title = R.string.DonationsErrors__still_processing,
|
||||
message = R.string.DonationsErrors__your_payment_is_still,
|
||||
positiveAction = callback.onOk(context),
|
||||
negativeAction = null
|
||||
)
|
||||
}
|
||||
|
||||
private fun <V> getBadgeCredentialValidationErrorParams(context: Context, callback: Callback<V>): DonationErrorParams<V> {
|
||||
return DonationErrorParams(
|
||||
title = R.string.DonationsErrors__failed_to_validate_badge,
|
||||
message = R.string.DonationsErrors__could_not_validate,
|
||||
positiveAction = callback.onContactSupport(context),
|
||||
negativeAction = null
|
||||
)
|
||||
}
|
||||
|
||||
private fun <V> getGenericPaymentSetupErrorParams(context: Context, callback: Callback<V>): DonationErrorParams<V> {
|
||||
return DonationErrorParams(
|
||||
title = R.string.DonationsErrors__error_processing_payment,
|
||||
|
||||
@@ -9,7 +9,6 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.databinding.MySupportPreferenceBinding
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
@@ -33,6 +32,7 @@ object ActiveSubscriptionPreference {
|
||||
val renewalTimestamp: Long = -1L,
|
||||
val redemptionState: ManageDonationsState.RedemptionState,
|
||||
val activeSubscription: ActiveSubscription.Subscription?,
|
||||
val subscriberRequiresCancel: Boolean,
|
||||
val onContactSupport: () -> Unit,
|
||||
val onPendingClick: (FiatMoney) -> Unit
|
||||
) : PreferenceModel<Model>() {
|
||||
@@ -104,7 +104,7 @@ object ActiveSubscriptionPreference {
|
||||
}
|
||||
|
||||
private fun presentFailureState(model: Model) {
|
||||
if (model.activeSubscription?.isFailedPayment == true || SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt) {
|
||||
if (model.activeSubscription?.isFailedPayment == true || model.subscriberRequiresCancel) {
|
||||
presentPaymentFailureState(model)
|
||||
} else {
|
||||
presentRedemptionFailureState(model)
|
||||
|
||||
@@ -2,7 +2,9 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSData
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
@@ -16,6 +18,8 @@ import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Allows observer to poll for the status of the latest pending, running, or completed redemption job for subscriptions or one time payments.
|
||||
*
|
||||
* @deprecated This object is deprecated and will be removed once we are sure all jobs have drained.
|
||||
*/
|
||||
object DonationRedemptionJobWatcher {
|
||||
|
||||
@@ -24,7 +28,6 @@ object DonationRedemptionJobWatcher {
|
||||
ONE_TIME
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@WorkerThread
|
||||
fun hasPendingRedemptionJob(): Boolean {
|
||||
return getDonationRedemptionJobStatus(RedemptionType.SUBSCRIPTION).isInProgress() || getDonationRedemptionJobStatus(RedemptionType.ONE_TIME).isInProgress()
|
||||
@@ -113,9 +116,9 @@ object DonationRedemptionJobWatcher {
|
||||
}
|
||||
|
||||
return DonationSerializationHelper.createPendingOneTimeDonationProto(
|
||||
badge = stripe3DSData.gatewayRequest.badge,
|
||||
badge = Badges.fromDatabaseBadge(stripe3DSData.inAppPayment.data.badge!!),
|
||||
paymentSourceType = stripe3DSData.paymentSourceType,
|
||||
amount = stripe3DSData.gatewayRequest.fiat
|
||||
amount = stripe3DSData.inAppPayment.data.amount!!.toFiatMoney()
|
||||
).copy(
|
||||
timestamp = createTime,
|
||||
pendingVerification = true,
|
||||
@@ -130,8 +133,8 @@ object DonationRedemptionJobWatcher {
|
||||
|
||||
return NonVerifiedMonthlyDonation(
|
||||
timestamp = createTime,
|
||||
price = stripe3DSData.gatewayRequest.fiat,
|
||||
level = stripe3DSData.gatewayRequest.level.toInt(),
|
||||
price = stripe3DSData.inAppPayment.data.amount!!.toFiatMoney(),
|
||||
level = stripe3DSData.inAppPayment.data.level.toInt(),
|
||||
checkedVerification = runAttempt > 0
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,12 +21,12 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.TerminalDonationDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
@@ -70,7 +70,7 @@ class ManageDonationsFragment :
|
||||
|
||||
private val viewModel: ManageDonationsViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
ManageDonationsViewModel.Factory(MonthlyDonationRepository(ApplicationDependencies.getDonationsService()))
|
||||
ManageDonationsViewModel.Factory(RecurringInAppPaymentRepository(ApplicationDependencies.getDonationsService()))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -167,7 +167,7 @@ class ManageDonationsFragment :
|
||||
primaryWrappedButton(
|
||||
text = DSLSettingsText.from(R.string.ManageDonationsFragment__donate_to_signal),
|
||||
onClick = {
|
||||
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonateToSignalFragment(DonateToSignalType.ONE_TIME))
|
||||
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonateToSignalFragment(InAppPaymentTable.Type.ONE_TIME_DONATION))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -274,6 +274,7 @@ class ManageDonationsFragment :
|
||||
requireActivity().startActivity(AppSettingsActivity.help(requireContext(), HelpFragment.DONATION_INDEX))
|
||||
},
|
||||
activeSubscription = activeSubscription,
|
||||
subscriberRequiresCancel = state.subscriberRequiresCancel,
|
||||
onPendingClick = {
|
||||
displayPendingDialog(it)
|
||||
}
|
||||
@@ -295,6 +296,7 @@ class ManageDonationsFragment :
|
||||
redemptionState = ManageDonationsState.RedemptionState.IN_PROGRESS,
|
||||
onContactSupport = {},
|
||||
activeSubscription = null,
|
||||
subscriberRequiresCancel = state.subscriberRequiresCancel,
|
||||
onPendingClick = {}
|
||||
)
|
||||
)
|
||||
@@ -318,7 +320,7 @@ class ManageDonationsFragment :
|
||||
icon = DSLSettingsIcon.from(R.drawable.symbol_person_24),
|
||||
isEnabled = state.getMonthlyDonorRedemptionState() != ManageDonationsState.RedemptionState.IN_PROGRESS,
|
||||
onClick = {
|
||||
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonateToSignalFragment(DonateToSignalType.MONTHLY))
|
||||
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonateToSignalFragment(InAppPaymentTable.Type.RECURRING_DONATION))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -422,6 +424,7 @@ class ManageDonationsFragment :
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
else -> {
|
||||
val message = if (isIdeal) {
|
||||
R.string.DonationsErrors__your_ideal_couldnt_be_processed
|
||||
@@ -445,6 +448,6 @@ class ManageDonationsFragment :
|
||||
}
|
||||
|
||||
override fun onMakeAMonthlyDonation() {
|
||||
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonateToSignalFragment(DonateToSignalType.MONTHLY))
|
||||
findNavController().safeNavigate(ManageDonationsFragmentDirections.actionManageDonationsFragmentToDonateToSignalFragment(InAppPaymentTable.Type.RECURRING_DONATION))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ data class ManageDonationsState(
|
||||
val availableSubscriptions: List<Subscription> = emptyList(),
|
||||
val pendingOneTimeDonation: PendingOneTimeDonation? = null,
|
||||
val nonVerifiedMonthlyDonation: NonVerifiedMonthlyDonation? = null,
|
||||
val subscriberRequiresCancel: Boolean = false,
|
||||
private val subscriptionRedemptionState: RedemptionState = RedemptionState.NONE
|
||||
) {
|
||||
|
||||
|
||||
@@ -12,8 +12,11 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.orNull
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.subscription.LevelUpdate
|
||||
@@ -23,7 +26,7 @@ import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import java.util.Optional
|
||||
|
||||
class ManageDonationsViewModel(
|
||||
private val subscriptionsRepository: MonthlyDonationRepository
|
||||
private val subscriptionsRepository: RecurringInAppPaymentRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val store = Store(ManageDonationsState())
|
||||
@@ -62,7 +65,15 @@ class ManageDonationsViewModel(
|
||||
disposables.clear()
|
||||
|
||||
val levelUpdateOperationEdges: Observable<Boolean> = LevelUpdate.isProcessing.distinctUntilChanged()
|
||||
val activeSubscription: Single<ActiveSubscription> = subscriptionsRepository.getActiveSubscription()
|
||||
val activeSubscription: Single<ActiveSubscription> = subscriptionsRepository.getActiveSubscription(InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
|
||||
disposables += Single.fromCallable {
|
||||
InAppPaymentsRepository.getShouldCancelSubscriptionBeforeNextSubscribeAttempt(InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
}.subscribeBy { requiresCancel ->
|
||||
store.update {
|
||||
it.copy(subscriberRequiresCancel = requiresCancel)
|
||||
}
|
||||
}
|
||||
|
||||
disposables += Recipient.observable(Recipient.self().id).map { it.badges }.subscribeBy { badges ->
|
||||
store.update { state ->
|
||||
@@ -76,7 +87,7 @@ class ManageDonationsViewModel(
|
||||
store.update { it.copy(hasReceipts = hasReceipts) }
|
||||
}
|
||||
|
||||
disposables += DonationRedemptionJobWatcher.watchSubscriptionRedemption().subscribeBy { redemptionStatus ->
|
||||
disposables += InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentTable.Type.RECURRING_DONATION).subscribeBy { redemptionStatus ->
|
||||
store.update { manageDonationsState ->
|
||||
manageDonationsState.copy(
|
||||
nonVerifiedMonthlyDonation = if (redemptionStatus is DonationRedemptionJobStatus.PendingExternalVerification) redemptionStatus.nonVerifiedMonthlyDonation else null,
|
||||
@@ -87,7 +98,7 @@ class ManageDonationsViewModel(
|
||||
|
||||
disposables += Observable.combineLatest(
|
||||
SignalStore.donationsValues().observablePendingOneTimeDonation,
|
||||
DonationRedemptionJobWatcher.watchOneTimeRedemption()
|
||||
InAppPaymentsRepository.observeInAppPaymentRedemption(InAppPaymentTable.Type.ONE_TIME_DONATION)
|
||||
) { pendingFromStore, pendingFromJob ->
|
||||
if (pendingFromStore.isPresent) {
|
||||
pendingFromStore
|
||||
@@ -145,7 +156,7 @@ class ManageDonationsViewModel(
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val subscriptionsRepository: MonthlyDonationRepository
|
||||
private val subscriptionsRepository: RecurringInAppPaymentRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(ManageDonationsViewModel(subscriptionsRepository))!!
|
||||
|
||||
@@ -19,7 +19,10 @@ object PayPalButton {
|
||||
class ViewHolder(binding: PaypalButtonBinding) : BindingViewHolder<Model, PaypalButtonBinding>(binding) {
|
||||
override fun bind(model: Model) {
|
||||
binding.paypalButton.isEnabled = model.isEnabled
|
||||
binding.paypalButton.setOnClickListener { model.onClick() }
|
||||
binding.paypalButton.setOnClickListener {
|
||||
binding.paypalButton.isEnabled = false
|
||||
model.onClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,10 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.database.model.RecipientRecord
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
@@ -28,7 +30,6 @@ import org.thoughtcrime.securesms.mms.OutgoingMessage
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.subscription.Subscriber
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
@@ -236,7 +237,8 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
|
||||
if (recipient.isSelf) {
|
||||
sectionHeaderPref(DSLSettingsText.from("Donations"))
|
||||
|
||||
val subscriber: Subscriber? = SignalStore.donationsValues().getSubscriber()
|
||||
// TODO [alex] - DB on main thread!
|
||||
val subscriber: InAppPaymentSubscriberRecord? = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
val summary = if (subscriber != null) {
|
||||
"""currency code: ${subscriber.currencyCode}
|
||||
|subscriber id: ${subscriber.subscriberId.serialize()}
|
||||
|
||||
@@ -160,18 +160,20 @@ class DSLConfiguration {
|
||||
text: DSLSettingsText,
|
||||
icon: DSLSettingsIcon? = null,
|
||||
isEnabled: Boolean = true,
|
||||
disableOnClick: Boolean = false,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val preference = Button.Model.Primary(text, icon, isEnabled, onClick)
|
||||
val preference = Button.Model.Primary(text, icon, isEnabled, disableOnClick, onClick)
|
||||
children.add(preference)
|
||||
}
|
||||
|
||||
fun primaryWrappedButton(
|
||||
text: DSLSettingsText,
|
||||
isEnabled: Boolean = true,
|
||||
disableOnClick: Boolean = false,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val preference = Button.Model.PrimaryWrapped(text, null, isEnabled, onClick)
|
||||
val preference = Button.Model.PrimaryWrapped(text, null, isEnabled, disableOnClick, onClick)
|
||||
children.add(preference)
|
||||
}
|
||||
|
||||
@@ -179,9 +181,10 @@ class DSLConfiguration {
|
||||
text: DSLSettingsText,
|
||||
icon: DSLSettingsIcon? = null,
|
||||
isEnabled: Boolean = true,
|
||||
disableOnClick: Boolean = false,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val preference = Button.Model.Tonal(text, icon, isEnabled, onClick)
|
||||
val preference = Button.Model.Tonal(text, icon, isEnabled, disableOnClick, onClick)
|
||||
children.add(preference)
|
||||
}
|
||||
|
||||
@@ -189,9 +192,10 @@ class DSLConfiguration {
|
||||
text: DSLSettingsText,
|
||||
icon: DSLSettingsIcon? = null,
|
||||
isEnabled: Boolean = true,
|
||||
disableOnClick: Boolean = false,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val preference = Button.Model.TonalWrapped(text, icon, isEnabled, onClick)
|
||||
val preference = Button.Model.TonalWrapped(text, icon, isEnabled, disableOnClick, onClick)
|
||||
children.add(preference)
|
||||
}
|
||||
|
||||
@@ -199,9 +203,10 @@ class DSLConfiguration {
|
||||
text: DSLSettingsText,
|
||||
icon: DSLSettingsIcon? = null,
|
||||
isEnabled: Boolean = true,
|
||||
disableOnClick: Boolean = false,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val preference = Button.Model.SecondaryNoOutline(text, icon, isEnabled, onClick)
|
||||
val preference = Button.Model.SecondaryNoOutline(text, icon, isEnabled, disableOnClick, onClick)
|
||||
children.add(preference)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ object Button {
|
||||
title: DSLSettingsText?,
|
||||
icon: DSLSettingsIcon?,
|
||||
isEnabled: Boolean,
|
||||
val disableOnClick: Boolean,
|
||||
val onClick: () -> Unit
|
||||
) : PreferenceModel<T>(
|
||||
title = title,
|
||||
@@ -37,8 +38,9 @@ object Button {
|
||||
title: DSLSettingsText?,
|
||||
icon: DSLSettingsIcon?,
|
||||
isEnabled: Boolean,
|
||||
disableOnClick: Boolean,
|
||||
onClick: () -> Unit
|
||||
) : Model<Primary>(title, icon, isEnabled, onClick)
|
||||
) : Model<Primary>(title, icon, isEnabled, disableOnClick, onClick)
|
||||
|
||||
/**
|
||||
* Large primary button with width set to wrap_content
|
||||
@@ -47,29 +49,33 @@ object Button {
|
||||
title: DSLSettingsText?,
|
||||
icon: DSLSettingsIcon?,
|
||||
isEnabled: Boolean,
|
||||
disableOnClick: Boolean,
|
||||
onClick: () -> Unit
|
||||
) : Model<PrimaryWrapped>(title, icon, isEnabled, onClick)
|
||||
) : Model<PrimaryWrapped>(title, icon, isEnabled, disableOnClick, onClick)
|
||||
|
||||
class Tonal(
|
||||
title: DSLSettingsText?,
|
||||
icon: DSLSettingsIcon?,
|
||||
isEnabled: Boolean,
|
||||
disableOnClick: Boolean,
|
||||
onClick: () -> Unit
|
||||
) : Model<Tonal>(title, icon, isEnabled, onClick)
|
||||
) : Model<Tonal>(title, icon, isEnabled, disableOnClick, onClick)
|
||||
|
||||
class TonalWrapped(
|
||||
title: DSLSettingsText?,
|
||||
icon: DSLSettingsIcon?,
|
||||
isEnabled: Boolean,
|
||||
disableOnClick: Boolean,
|
||||
onClick: () -> Unit
|
||||
) : Model<TonalWrapped>(title, icon, isEnabled, onClick)
|
||||
) : Model<TonalWrapped>(title, icon, isEnabled, disableOnClick, onClick)
|
||||
|
||||
class SecondaryNoOutline(
|
||||
title: DSLSettingsText?,
|
||||
icon: DSLSettingsIcon?,
|
||||
isEnabled: Boolean,
|
||||
disableOnClick: Boolean,
|
||||
onClick: () -> Unit
|
||||
) : Model<SecondaryNoOutline>(title, icon, isEnabled, onClick)
|
||||
) : Model<SecondaryNoOutline>(title, icon, isEnabled, disableOnClick, onClick)
|
||||
}
|
||||
|
||||
class ViewHolder<T : Model<T>>(itemView: View) : MappingViewHolder<T>(itemView) {
|
||||
@@ -79,6 +85,7 @@ object Button {
|
||||
override fun bind(model: T) {
|
||||
button.text = model.title?.resolve(context)
|
||||
button.setOnClickListener {
|
||||
button.isEnabled = model.isEnabled && !model.disableOnClick
|
||||
model.onClick()
|
||||
}
|
||||
button.icon = model.icon?.resolve(context)
|
||||
|
||||
@@ -125,7 +125,6 @@ import org.thoughtcrime.securesms.components.mention.MentionAnnotation
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft
|
||||
@@ -196,6 +195,7 @@ import org.thoughtcrime.securesms.conversation.v2.items.ChatColorsDrawable
|
||||
import org.thoughtcrime.securesms.conversation.v2.items.InteractiveConversationElement
|
||||
import org.thoughtcrime.securesms.conversation.v2.keyboard.AttachmentKeyboardFragment
|
||||
import org.thoughtcrime.securesms.database.DraftTable
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.model.IdentityRecord
|
||||
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.Mention
|
||||
@@ -2894,7 +2894,7 @@ class ConversationFragment :
|
||||
requireActivity()
|
||||
.supportFragmentManager
|
||||
.beginTransaction()
|
||||
.add(DonateToSignalFragment.Dialog.create(DonateToSignalType.ONE_TIME), "one_time_nav")
|
||||
.add(DonateToSignalFragment.Dialog.create(InAppPaymentTable.Type.ONE_TIME_DONATION), "one_time_nav")
|
||||
.commitNow()
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ public class DatabaseObserver {
|
||||
|
||||
private static final String KEY_CALL_UPDATES = "CallUpdates";
|
||||
private static final String KEY_CALL_LINK_UPDATES = "CallLinkUpdates";
|
||||
private static final String KEY_IN_APP_PAYMENTS = "InAppPayments";
|
||||
|
||||
private final Application application;
|
||||
private final Executor executor;
|
||||
@@ -69,6 +70,7 @@ public class DatabaseObserver {
|
||||
private final Map<RecipientId, Set<Observer>> storyObservers;
|
||||
private final Set<Observer> callUpdateObservers;
|
||||
private final Map<CallLinkRoomId, Set<Observer>> callLinkObservers;
|
||||
private final Set<InAppPaymentObserver> inAppPaymentObservers;
|
||||
|
||||
public DatabaseObserver(Application application) {
|
||||
this.application = application;
|
||||
@@ -90,6 +92,7 @@ public class DatabaseObserver {
|
||||
this.scheduledMessageObservers = new HashMap<>();
|
||||
this.callUpdateObservers = new HashSet<>();
|
||||
this.callLinkObservers = new HashMap<>();
|
||||
this.inAppPaymentObservers = new HashSet<>();
|
||||
}
|
||||
|
||||
public void registerConversationListObserver(@NonNull Observer listener) {
|
||||
@@ -195,6 +198,10 @@ public class DatabaseObserver {
|
||||
});
|
||||
}
|
||||
|
||||
public void registerInAppPaymentObserver(@NonNull InAppPaymentObserver observer) {
|
||||
executor.execute(() -> inAppPaymentObservers.add(observer));
|
||||
}
|
||||
|
||||
public void unregisterObserver(@NonNull Observer listener) {
|
||||
executor.execute(() -> {
|
||||
conversationListObservers.remove(listener);
|
||||
@@ -221,6 +228,12 @@ public class DatabaseObserver {
|
||||
});
|
||||
}
|
||||
|
||||
public void unregisterObserver(@NonNull InAppPaymentObserver listener) {
|
||||
executor.execute(() -> {
|
||||
inAppPaymentObservers.remove(listener);
|
||||
});
|
||||
}
|
||||
|
||||
public void notifyConversationListeners(Set<Long> threadIds) {
|
||||
for (long threadId : threadIds) {
|
||||
notifyConversationListeners(threadId);
|
||||
@@ -356,6 +369,12 @@ public class DatabaseObserver {
|
||||
runPostSuccessfulTransaction(KEY_CALL_LINK_UPDATES, () -> notifyMapped(callLinkObservers, callLinkRoomId));
|
||||
}
|
||||
|
||||
public void notifyInAppPaymentsObservers(@NonNull InAppPaymentTable.InAppPayment inAppPayment) {
|
||||
runPostSuccessfulTransaction(KEY_IN_APP_PAYMENTS, () -> {
|
||||
inAppPaymentObservers.forEach(item -> item.onInAppPaymentChanged(inAppPayment));
|
||||
});
|
||||
}
|
||||
|
||||
private void runPostSuccessfulTransaction(@NonNull String dedupeKey, @NonNull Runnable runnable) {
|
||||
SignalDatabase.runPostSuccessfulTransaction(dedupeKey, () -> {
|
||||
executor.execute(runnable);
|
||||
@@ -421,4 +440,8 @@ public class DatabaseObserver {
|
||||
public interface MessageObserver {
|
||||
void onMessageChanged(@NonNull MessageId messageId);
|
||||
}
|
||||
|
||||
public interface InAppPaymentObserver {
|
||||
void onInAppPaymentChanged(@NonNull InAppPaymentTable.InAppPayment inAppPayment);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.core.content.contentValuesOf
|
||||
import org.signal.core.util.DatabaseSerializer
|
||||
import org.signal.core.util.Serializer
|
||||
import org.signal.core.util.insertInto
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.readToSingleObject
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireNonNullString
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.update
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
|
||||
/**
|
||||
* A table matching up SubscriptionIds to currency codes and type
|
||||
*/
|
||||
class InAppPaymentSubscriberTable(
|
||||
context: Context,
|
||||
databaseHelper: SignalDatabase
|
||||
) : DatabaseTable(context, databaseHelper) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(InAppPaymentSubscriberRecord::class.java)
|
||||
|
||||
@VisibleForTesting
|
||||
const val TABLE_NAME = "in_app_payment_subscriber"
|
||||
|
||||
/** Row ID */
|
||||
private const val ID = "_id"
|
||||
|
||||
/** The serialized subscriber id */
|
||||
private const val SUBSCRIBER_ID = "subscriber_id"
|
||||
|
||||
/** The currency code for this subscriber id */
|
||||
private const val CURRENCY_CODE = "currency_code"
|
||||
|
||||
/** The type of subscription used by this subscriber id */
|
||||
private const val TYPE = "type"
|
||||
|
||||
/** Specifies whether we should try to cancel any current subscription before starting a new one with this ID */
|
||||
private const val REQUIRES_CANCEL = "requires_cancel"
|
||||
|
||||
/** Specifies which payment method was utilized for the latest transaction with this id */
|
||||
private const val PAYMENT_METHOD_TYPE = "payment_method_type"
|
||||
|
||||
const val CREATE_TABLE = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$ID INTEGER PRIMARY KEY,
|
||||
$SUBSCRIBER_ID TEXT NOT NULL UNIQUE,
|
||||
$CURRENCY_CODE TEXT NOT NULL,
|
||||
$TYPE INTEGER NOT NULL,
|
||||
$REQUIRES_CANCEL INTEGER DEFAULT 0,
|
||||
$PAYMENT_METHOD_TYPE INTEGER DEFAULT 0,
|
||||
UNIQUE($CURRENCY_CODE, $TYPE)
|
||||
)
|
||||
"""
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts this subscriber, replacing any that it conflicts with.
|
||||
*
|
||||
* This is a destructive, mutating operation. For setting specific values, prefer the alternative setters available on this table class.
|
||||
*/
|
||||
fun insertOrReplace(inAppPaymentSubscriberRecord: InAppPaymentSubscriberRecord) {
|
||||
Log.i(TAG, "Setting subscriber for currency ${inAppPaymentSubscriberRecord.currencyCode}", Exception(), true)
|
||||
|
||||
writableDatabase.withinTransaction { db ->
|
||||
db.insertInto(TABLE_NAME)
|
||||
.values(InAppPaymentSubscriberSerializer.serialize(inAppPaymentSubscriberRecord))
|
||||
.run(conflictStrategy = SQLiteDatabase.CONFLICT_REPLACE)
|
||||
|
||||
SignalStore.donationsValues().setSubscriberCurrency(
|
||||
inAppPaymentSubscriberRecord.currencyCode,
|
||||
inAppPaymentSubscriberRecord.type
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the subscriber in question requires a cancellation before a new subscription can be created.
|
||||
*/
|
||||
fun setRequiresCancel(subscriberId: SubscriberId, requiresCancel: Boolean) {
|
||||
writableDatabase.update(TABLE_NAME)
|
||||
.values(REQUIRES_CANCEL to requiresCancel)
|
||||
.where("$SUBSCRIBER_ID = ?", subscriberId.serialize())
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the payment method on a subscriber.
|
||||
*/
|
||||
fun setPaymentMethod(subscriberId: SubscriberId, paymentMethodType: InAppPaymentData.PaymentMethodType) {
|
||||
writableDatabase.update(TABLE_NAME)
|
||||
.values(PAYMENT_METHOD_TYPE to paymentMethodType.value)
|
||||
.where("$SUBSCRIBER_ID = ?", subscriberId.serialize())
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a subscriber for the given type by the currency code.
|
||||
*/
|
||||
fun getByCurrencyCode(currencyCode: String, type: InAppPaymentSubscriberRecord.Type): InAppPaymentSubscriberRecord? {
|
||||
return readableDatabase.select()
|
||||
.from(TABLE_NAME)
|
||||
.where("$TYPE = ? AND $CURRENCY_CODE = ?", TypeSerializer.serialize(type), currencyCode.uppercase())
|
||||
.run()
|
||||
.readToSingleObject(InAppPaymentSubscriberSerializer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a subscriber by SubscriberId
|
||||
*/
|
||||
fun getBySubscriberId(subscriberId: SubscriberId): InAppPaymentSubscriberRecord? {
|
||||
return readableDatabase.select()
|
||||
.from(TABLE_NAME)
|
||||
.where("$SUBSCRIBER_ID = ?", subscriberId.serialize())
|
||||
.run()
|
||||
.readToSingleObject(InAppPaymentSubscriberSerializer)
|
||||
}
|
||||
|
||||
object InAppPaymentSubscriberSerializer : DatabaseSerializer<InAppPaymentSubscriberRecord> {
|
||||
override fun serialize(data: InAppPaymentSubscriberRecord): ContentValues {
|
||||
return contentValuesOf(
|
||||
SUBSCRIBER_ID to data.subscriberId.serialize(),
|
||||
CURRENCY_CODE to data.currencyCode.uppercase(),
|
||||
TYPE to TypeSerializer.serialize(data.type),
|
||||
REQUIRES_CANCEL to data.requiresCancel,
|
||||
PAYMENT_METHOD_TYPE to data.paymentMethodType.value
|
||||
)
|
||||
}
|
||||
|
||||
override fun deserialize(input: Cursor): InAppPaymentSubscriberRecord {
|
||||
return InAppPaymentSubscriberRecord(
|
||||
subscriberId = SubscriberId.deserialize(input.requireNonNullString(SUBSCRIBER_ID)),
|
||||
currencyCode = input.requireNonNullString(CURRENCY_CODE),
|
||||
type = TypeSerializer.deserialize(input.requireInt(TYPE)),
|
||||
requiresCancel = input.requireBoolean(REQUIRES_CANCEL),
|
||||
paymentMethodType = InAppPaymentData.PaymentMethodType.fromValue(input.requireInt(PAYMENT_METHOD_TYPE)) ?: InAppPaymentData.PaymentMethodType.UNKNOWN
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object TypeSerializer : Serializer<InAppPaymentSubscriberRecord.Type, Int> {
|
||||
override fun serialize(data: InAppPaymentSubscriberRecord.Type): Int = data.code
|
||||
override fun deserialize(input: Int): InAppPaymentSubscriberRecord.Type = InAppPaymentSubscriberRecord.Type.values().first { it.code == input }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.os.Parcelable
|
||||
import androidx.core.content.contentValuesOf
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.parcelize.TypeParceler
|
||||
import org.signal.core.util.DatabaseId
|
||||
import org.signal.core.util.DatabaseSerializer
|
||||
import org.signal.core.util.Serializer
|
||||
import org.signal.core.util.delete
|
||||
import org.signal.core.util.exists
|
||||
import org.signal.core.util.insertInto
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.readToSingleObject
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireNonNullBlob
|
||||
import org.signal.core.util.requireString
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.update
|
||||
import org.signal.core.util.updateAll
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.util.parcelers.MillisecondDurationParceler
|
||||
import org.thoughtcrime.securesms.util.parcelers.NullableSubscriberIdParceler
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Centralizes state information for donations and backups payments and redemption.
|
||||
*
|
||||
* Each entry in this database has a 1:1 relationship with a redeemable token, which can be for one of the following:
|
||||
* * A Gift Badge
|
||||
* * A Boost Badge
|
||||
* * A Subscription Badge
|
||||
* * A Backup Subscription
|
||||
*/
|
||||
class InAppPaymentTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTable(context, databaseHelper) {
|
||||
|
||||
companion object {
|
||||
private const val TABLE_NAME = "in_app_payment"
|
||||
|
||||
/**
|
||||
* Row ID
|
||||
*/
|
||||
private const val ID = "_id"
|
||||
|
||||
/**
|
||||
* What kind of payment this row represents
|
||||
*/
|
||||
private const val TYPE = "type"
|
||||
|
||||
/**
|
||||
* The current state of the given payment
|
||||
*/
|
||||
private const val STATE = "state"
|
||||
|
||||
/**
|
||||
* When the payment was first inserted into the database
|
||||
*/
|
||||
private const val INSERTED_AT = "inserted_at"
|
||||
|
||||
/**
|
||||
* The last time the payment was updated
|
||||
*/
|
||||
private const val UPDATED_AT = "updated_at"
|
||||
|
||||
/**
|
||||
* Whether the user has been notified of the payment's terminal state.
|
||||
*/
|
||||
private const val NOTIFIED = "notified"
|
||||
|
||||
/**
|
||||
* The subscriber id associated with the payment.
|
||||
*/
|
||||
private const val SUBSCRIBER_ID = "subscriber_id"
|
||||
|
||||
/**
|
||||
* The end of period related to the subscription, if this column represents a recurring payment.
|
||||
* A zero here indicates that we do not have an end of period yet for this recurring payment, OR
|
||||
* that this row does not represent a recurring payment.
|
||||
*/
|
||||
private const val END_OF_PERIOD = "end_of_period"
|
||||
|
||||
/**
|
||||
* Extraneous data that may or may not be common among payments
|
||||
*/
|
||||
private const val DATA = "data"
|
||||
|
||||
val CREATE_TABLE = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$ID INTEGER PRIMARY KEY,
|
||||
$TYPE INTEGER NOT NULL,
|
||||
$STATE INTEGER NOT NULL,
|
||||
$INSERTED_AT INTEGER NOT NULL,
|
||||
$UPDATED_AT INTEGER NOT NULL,
|
||||
$NOTIFIED INTEGER DEFAULT 1,
|
||||
$SUBSCRIBER_ID TEXT,
|
||||
$END_OF_PERIOD INTEGER DEFAULT 0,
|
||||
$DATA BLOB NOT NULL
|
||||
)
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when we create a new InAppPayment while in the checkout screen. At this point in
|
||||
* the flow, we know that there should not be any other InAppPayment objects currently in this
|
||||
* state.
|
||||
*/
|
||||
fun clearCreated() {
|
||||
writableDatabase.delete(TABLE_NAME)
|
||||
.where("$STATE = ?", State.serialize(State.CREATED))
|
||||
.run()
|
||||
}
|
||||
|
||||
fun insert(
|
||||
type: Type,
|
||||
state: State,
|
||||
subscriberId: SubscriberId?,
|
||||
endOfPeriod: Duration?,
|
||||
inAppPaymentData: InAppPaymentData
|
||||
): InAppPaymentId {
|
||||
val now = System.currentTimeMillis()
|
||||
return writableDatabase.insertInto(TABLE_NAME)
|
||||
.values(
|
||||
TYPE to type.code,
|
||||
STATE to state.code,
|
||||
INSERTED_AT to now,
|
||||
UPDATED_AT to now,
|
||||
SUBSCRIBER_ID to subscriberId?.serialize(),
|
||||
END_OF_PERIOD to (endOfPeriod?.inWholeSeconds ?: 0L),
|
||||
DATA to InAppPaymentData.ADAPTER.encode(inAppPaymentData),
|
||||
NOTIFIED to 1
|
||||
)
|
||||
.run()
|
||||
.let { InAppPaymentId(it) }
|
||||
}
|
||||
|
||||
fun update(
|
||||
inAppPayment: InAppPayment
|
||||
) {
|
||||
val updated = inAppPayment.copy(updatedAt = System.currentTimeMillis().milliseconds)
|
||||
writableDatabase.update(TABLE_NAME)
|
||||
.values(InAppPayment.serialize(updated))
|
||||
.where(ID_WHERE, inAppPayment.id)
|
||||
.run()
|
||||
|
||||
ApplicationDependencies.getDatabaseObserver().notifyInAppPaymentsObservers(inAppPayment)
|
||||
}
|
||||
|
||||
fun getAllWaitingForAuth(): List<InAppPayment> {
|
||||
return readableDatabase.select()
|
||||
.from(TABLE_NAME)
|
||||
.where("$STATE = ?", State.serialize(State.WAITING_FOR_AUTHORIZATION))
|
||||
.run()
|
||||
.readToList { InAppPayment.deserialize(it) }
|
||||
}
|
||||
|
||||
fun consumeInAppPaymentsToNotifyUser(): List<InAppPayment> {
|
||||
return writableDatabase.withinTransaction { db ->
|
||||
val payments = db.select()
|
||||
.from(TABLE_NAME)
|
||||
.where("$NOTIFIED = ?", 0)
|
||||
.run()
|
||||
.readToList(mapper = { InAppPayment.deserialize(it) })
|
||||
|
||||
db.updateAll(TABLE_NAME).values(NOTIFIED to 1).run()
|
||||
|
||||
payments
|
||||
}
|
||||
}
|
||||
|
||||
fun getById(id: InAppPaymentId): InAppPayment? {
|
||||
return readableDatabase.select()
|
||||
.from(TABLE_NAME)
|
||||
.where(ID_WHERE, id)
|
||||
.run()
|
||||
.readToSingleObject(InAppPayment.Companion)
|
||||
}
|
||||
|
||||
fun getByEndOfPeriod(type: Type, endOfPeriod: Duration): InAppPayment? {
|
||||
return readableDatabase.select()
|
||||
.from(TABLE_NAME)
|
||||
.where("$TYPE = ? AND $END_OF_PERIOD = ?", Type.serialize(type), endOfPeriod.inWholeSeconds)
|
||||
.run()
|
||||
.readToSingleObject(InAppPayment.Companion)
|
||||
}
|
||||
|
||||
fun getByLatestEndOfPeriod(type: Type): InAppPayment? {
|
||||
return readableDatabase.select()
|
||||
.from(TABLE_NAME)
|
||||
.where("$TYPE = ? AND $END_OF_PERIOD > 0", Type.serialize(type))
|
||||
.orderBy("$END_OF_PERIOD DESC")
|
||||
.limit(1)
|
||||
.run()
|
||||
.readToSingleObject(InAppPayment.Companion)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the latest entry in the table for the given subscriber id.
|
||||
*/
|
||||
fun getLatestBySubscriberId(subscriberId: SubscriberId): InAppPayment? {
|
||||
return readableDatabase.select()
|
||||
.from(TABLE_NAME)
|
||||
.where("$SUBSCRIBER_ID = ?", subscriberId.serialize())
|
||||
.orderBy("$END_OF_PERIOD DESC")
|
||||
.limit(1)
|
||||
.run()
|
||||
.readToSingleObject(InAppPayment.Companion)
|
||||
}
|
||||
|
||||
fun markSubscriptionManuallyCanceled(subscriberId: SubscriberId) {
|
||||
writableDatabase.withinTransaction {
|
||||
val inAppPayment = getLatestBySubscriberId(subscriberId) ?: return@withinTransaction
|
||||
|
||||
update(
|
||||
inAppPayment.copy(
|
||||
data = inAppPayment.data.copy(
|
||||
cancellation = InAppPaymentData.Cancellation(
|
||||
reason = InAppPaymentData.Cancellation.Reason.MANUAL
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether there are any pending donations in the database.
|
||||
*/
|
||||
fun hasPendingDonation(): Boolean {
|
||||
return readableDatabase.exists(TABLE_NAME)
|
||||
.where(
|
||||
"$STATE = ? AND ($TYPE = ? OR $TYPE = ? OR $TYPE = ?)",
|
||||
State.serialize(State.PENDING),
|
||||
Type.serialize(Type.RECURRING_DONATION),
|
||||
Type.serialize(Type.ONE_TIME_DONATION),
|
||||
Type.serialize(Type.ONE_TIME_GIFT)
|
||||
)
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether there are any pending donations in the database.
|
||||
*/
|
||||
fun hasPending(type: Type): Boolean {
|
||||
return readableDatabase.exists(TABLE_NAME)
|
||||
.where(
|
||||
"$STATE = ? AND $TYPE = ?",
|
||||
State.serialize(State.PENDING),
|
||||
Type.serialize(type)
|
||||
)
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves from the database the latest payment of the given type that is either in the PENDING or WAITING_FOR_AUTHORIZATION state.
|
||||
*/
|
||||
fun getLatestInAppPaymentByType(type: Type): InAppPayment? {
|
||||
return readableDatabase.select()
|
||||
.from(TABLE_NAME)
|
||||
.where(
|
||||
"($STATE = ? OR $STATE = ? OR $STATE = ?) AND $TYPE = ?",
|
||||
State.serialize(State.PENDING),
|
||||
State.serialize(State.WAITING_FOR_AUTHORIZATION),
|
||||
State.serialize(State.END),
|
||||
Type.serialize(type)
|
||||
)
|
||||
.orderBy("$INSERTED_AT DESC")
|
||||
.limit(1)
|
||||
.run()
|
||||
.readToSingleObject(InAppPayment.Companion)
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a database row. Nicer than returning a raw value.
|
||||
*/
|
||||
@Parcelize
|
||||
data class InAppPaymentId(
|
||||
val rowId: Long
|
||||
) : DatabaseId, Parcelable {
|
||||
init {
|
||||
check(rowId > 0)
|
||||
}
|
||||
|
||||
override fun serialize(): String = rowId.toString()
|
||||
|
||||
override fun toString(): String = serialize()
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a single token payment.
|
||||
*/
|
||||
@Parcelize
|
||||
@TypeParceler<Duration, MillisecondDurationParceler>
|
||||
@TypeParceler<SubscriberId?, NullableSubscriberIdParceler>
|
||||
data class InAppPayment(
|
||||
val id: InAppPaymentId,
|
||||
val type: Type,
|
||||
val state: State,
|
||||
val insertedAt: Duration,
|
||||
val updatedAt: Duration,
|
||||
val notified: Boolean,
|
||||
val subscriberId: SubscriberId?,
|
||||
val endOfPeriod: Duration,
|
||||
val data: InAppPaymentData
|
||||
) : Parcelable {
|
||||
|
||||
@IgnoredOnParcel
|
||||
val endOfPeriodSeconds: Long = endOfPeriod.inWholeSeconds
|
||||
|
||||
companion object : DatabaseSerializer<InAppPayment> {
|
||||
|
||||
override fun serialize(data: InAppPayment): ContentValues {
|
||||
return contentValuesOf(
|
||||
ID to data.id.serialize(),
|
||||
TYPE to data.type.apply { check(this != Type.UNKNOWN) }.code,
|
||||
STATE to data.state.code,
|
||||
INSERTED_AT to data.insertedAt.inWholeSeconds,
|
||||
UPDATED_AT to data.updatedAt.inWholeSeconds,
|
||||
NOTIFIED to data.notified,
|
||||
SUBSCRIBER_ID to data.subscriberId?.serialize(),
|
||||
END_OF_PERIOD to data.endOfPeriod.inWholeSeconds,
|
||||
DATA to data.data.encode()
|
||||
)
|
||||
}
|
||||
|
||||
override fun deserialize(input: Cursor): InAppPayment {
|
||||
return InAppPayment(
|
||||
id = InAppPaymentId(input.requireLong(ID)),
|
||||
type = Type.deserialize(input.requireInt(TYPE)),
|
||||
state = State.deserialize(input.requireInt(STATE)),
|
||||
insertedAt = input.requireLong(INSERTED_AT).seconds,
|
||||
updatedAt = input.requireLong(UPDATED_AT).seconds,
|
||||
notified = input.requireBoolean(NOTIFIED),
|
||||
subscriberId = input.requireString(SUBSCRIBER_ID)?.let { SubscriberId.deserialize(it) },
|
||||
endOfPeriod = input.requireLong(END_OF_PERIOD).seconds,
|
||||
data = InAppPaymentData.ADAPTER.decode(input.requireNonNullBlob(DATA))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class Type(val code: Int, val recurring: Boolean) {
|
||||
/**
|
||||
* Used explicitly for mapping DonationErrorSource. Writing this value
|
||||
* into an InAppPayment is an error.
|
||||
*/
|
||||
UNKNOWN(-1, false),
|
||||
|
||||
/**
|
||||
* This payment is for a gift badge
|
||||
*/
|
||||
ONE_TIME_GIFT(0, false),
|
||||
|
||||
/**
|
||||
* This payment is for a one-time donation
|
||||
*/
|
||||
ONE_TIME_DONATION(1, false),
|
||||
|
||||
/**
|
||||
* This payment is for a recurring donation
|
||||
*/
|
||||
RECURRING_DONATION(2, true),
|
||||
|
||||
/**
|
||||
* This payment is for a recurring backup payment
|
||||
*/
|
||||
RECURRING_BACKUP(3, true);
|
||||
|
||||
companion object : Serializer<Type, Int> {
|
||||
override fun serialize(data: Type): Int = data.code
|
||||
override fun deserialize(input: Int): Type = values().first { it.code == input }
|
||||
}
|
||||
|
||||
fun toErrorSource(): DonationErrorSource {
|
||||
return when (this) {
|
||||
UNKNOWN -> DonationErrorSource.UNKNOWN
|
||||
ONE_TIME_GIFT -> DonationErrorSource.GIFT
|
||||
ONE_TIME_DONATION -> DonationErrorSource.ONE_TIME
|
||||
RECURRING_DONATION -> DonationErrorSource.MONTHLY
|
||||
RECURRING_BACKUP -> DonationErrorSource.UNKNOWN // TODO [message-backups] error handling
|
||||
}
|
||||
}
|
||||
|
||||
fun toSubscriberType(): InAppPaymentSubscriberRecord.Type? {
|
||||
return when (this) {
|
||||
RECURRING_BACKUP -> InAppPaymentSubscriberRecord.Type.BACKUP
|
||||
RECURRING_DONATION -> InAppPaymentSubscriberRecord.Type.DONATION
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun requireSubscriberType(): InAppPaymentSubscriberRecord.Type {
|
||||
return requireNotNull(toSubscriberType())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the payment pipeline state for a given in-app payment
|
||||
*
|
||||
* ```mermaid
|
||||
* flowchart TD
|
||||
* CREATED -- Auth required --> WAITING_FOR_AUTHORIZATION
|
||||
* CREATED -- Auth not required --> PENDING
|
||||
* WAITING_FOR_AUTHORIZATION -- User completes auth --> PENDING
|
||||
* WAITING_FOR_AUTHORIZATION -- User does not complete auth --> END
|
||||
* PENDING --> END
|
||||
* PENDING --> RETRY
|
||||
* PENDING --> END
|
||||
* RETRY --> PENDING
|
||||
* RETRY --> END
|
||||
* ```
|
||||
*/
|
||||
enum class State(val code: Int) {
|
||||
/**
|
||||
* This payment has been created, but not submitted for processing yet.
|
||||
*/
|
||||
CREATED(0),
|
||||
|
||||
/**
|
||||
* This payment is awaiting the user to return from an external authorization2
|
||||
* such as a 3DS flow or IDEAL confirmation.
|
||||
*/
|
||||
WAITING_FOR_AUTHORIZATION(1),
|
||||
|
||||
/**
|
||||
* This payment is authorized and is waiting to be processed.
|
||||
*/
|
||||
PENDING(2),
|
||||
|
||||
/**
|
||||
* This payment pipeline has been completed. Check the data to see the state.
|
||||
*/
|
||||
END(3);
|
||||
|
||||
companion object : Serializer<State, Int> {
|
||||
override fun serialize(data: State): Int = data.code
|
||||
override fun deserialize(input: Int): State = State.values().first { it.code == input }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,8 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
|
||||
val kyberPreKeyTable: KyberPreKeyTable = KyberPreKeyTable(context, this)
|
||||
val callLinkTable: CallLinkTable = CallLinkTable(context, this)
|
||||
val nameCollisionTables: NameCollisionTables = NameCollisionTables(context, this)
|
||||
val inAppPaymentTable: InAppPaymentTable = InAppPaymentTable(context, this)
|
||||
val inAppPaymentSubscriberTable: InAppPaymentSubscriberTable = InAppPaymentSubscriberTable(context, this)
|
||||
|
||||
override fun onOpen(db: net.zetetic.database.sqlcipher.SQLiteDatabase) {
|
||||
db.setForeignKeyConstraintsEnabled(true)
|
||||
@@ -111,6 +113,8 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
|
||||
db.execSQL(CallTable.CREATE_TABLE)
|
||||
db.execSQL(KyberPreKeyTable.CREATE_TABLE)
|
||||
NameCollisionTables.createTables(db)
|
||||
db.execSQL(InAppPaymentTable.CREATE_TABLE)
|
||||
db.execSQL(InAppPaymentSubscriberTable.CREATE_TABLE)
|
||||
executeStatements(db, SearchTable.CREATE_TABLE)
|
||||
executeStatements(db, RemappedRecordTables.CREATE_TABLE)
|
||||
executeStatements(db, MessageSendLogTables.CREATE_TABLE)
|
||||
@@ -535,5 +539,15 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
|
||||
@get:JvmName("nameCollisions")
|
||||
val nameCollisions: NameCollisionTables
|
||||
get() = instance!!.nameCollisionTables
|
||||
|
||||
@get:JvmStatic
|
||||
@get:JvmName("inAppPayments")
|
||||
val inAppPayments: InAppPaymentTable
|
||||
get() = instance!!.inAppPaymentTable
|
||||
|
||||
@get:JvmStatic
|
||||
@get:JvmName("inAppPaymentSubscribers")
|
||||
val inAppPaymentSubscribers: InAppPaymentSubscriberTable
|
||||
get() = instance!!.inAppPaymentSubscriberTable
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V228_AddNameCollisi
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V229_MarkMissedCallEventsNotified
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V230_UnreadCountIndices
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V231_ArchiveThumbnailColumns
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V232_CreateInAppPaymentTable
|
||||
|
||||
/**
|
||||
* Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness.
|
||||
@@ -180,10 +181,11 @@ object SignalDatabaseMigrations {
|
||||
228 to V228_AddNameCollisionTables,
|
||||
229 to V229_MarkMissedCallEventsNotified,
|
||||
230 to V230_UnreadCountIndices,
|
||||
231 to V231_ArchiveThumbnailColumns
|
||||
231 to V231_ArchiveThumbnailColumns,
|
||||
232 to V232_CreateInAppPaymentTable
|
||||
)
|
||||
|
||||
const val DATABASE_VERSION = 231
|
||||
const val DATABASE_VERSION = 232
|
||||
|
||||
@JvmStatic
|
||||
fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.database.helpers.migration
|
||||
|
||||
import android.app.Application
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
||||
|
||||
/**
|
||||
* Create the table and migrate necessary data.
|
||||
*/
|
||||
@Suppress("ClassName")
|
||||
object V232_CreateInAppPaymentTable : SignalDatabaseMigration {
|
||||
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE in_app_payment (
|
||||
_id INTEGER PRIMARY KEY,
|
||||
type INTEGER NOT NULL,
|
||||
state INTEGER NOT NULL,
|
||||
inserted_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
notified INTEGER DEFAULT 0,
|
||||
subscriber_id TEXT,
|
||||
end_of_period INTEGER DEFAULT 0,
|
||||
data BLOB NOT NULL
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE in_app_payment_subscriber (
|
||||
_id INTEGER PRIMARY KEY,
|
||||
subscriber_id TEXT NOT NULL UNIQUE,
|
||||
currency_code TEXT NOT NULL,
|
||||
type INTEGER NOT NULL,
|
||||
requires_cancel INTEGER DEFAULT 0,
|
||||
payment_method_type INTEGER DEFAULT 0,
|
||||
UNIQUE(currency_code, type)
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.database.model
|
||||
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
|
||||
/**
|
||||
* Represents a SubscriberId and metadata that can be used for a recurring
|
||||
* subscription of the given type. Stored in InAppPaymentSubscriberTable
|
||||
*/
|
||||
data class InAppPaymentSubscriberRecord(
|
||||
val subscriberId: SubscriberId,
|
||||
val currencyCode: String,
|
||||
val type: Type,
|
||||
val requiresCancel: Boolean,
|
||||
val paymentMethodType: InAppPaymentData.PaymentMethodType
|
||||
) {
|
||||
/**
|
||||
* Serves as the mutex by which to perform mutations to subscriptions.
|
||||
*/
|
||||
enum class Type(val code: Int, val jobQueue: String, val inAppPaymentType: InAppPaymentTable.Type) {
|
||||
/**
|
||||
* A recurring donation
|
||||
*/
|
||||
DONATION(0, "recurring-donations", InAppPaymentTable.Type.RECURRING_DONATION),
|
||||
|
||||
/**
|
||||
* A recurring backups subscription
|
||||
*/
|
||||
BACKUP(1, "recurring-backups", InAppPaymentTable.Type.RECURRING_BACKUP)
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,13 @@ import com.google.i18n.phonenumbers.PhoneNumberUtil;
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository;
|
||||
import org.thoughtcrime.securesms.database.GroupTable;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.GroupRecord;
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.groups.GroupManager;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.subscription.Subscriber;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.whispersystems.signalservice.api.util.PhoneNumberFormatter;
|
||||
import org.whispersystems.signalservice.internal.EmptyResponse;
|
||||
@@ -45,11 +45,11 @@ class DeleteAccountRepository {
|
||||
|
||||
void deleteAccount(@NonNull Consumer<DeleteAccountEvent> onDeleteAccountEvent) {
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
if (SignalStore.donationsValues().getSubscriber() != null) {
|
||||
if (InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION) != null) {
|
||||
Log.i(TAG, "deleteAccount: attempting to cancel subscription");
|
||||
onDeleteAccountEvent.accept(DeleteAccountEvent.CancelingSubscription.INSTANCE);
|
||||
|
||||
Subscriber subscriber = SignalStore.donationsValues().requireSubscriber();
|
||||
InAppPaymentSubscriberRecord subscriber = InAppPaymentsRepository.requireSubscriber(InAppPaymentSubscriberRecord.Type.DONATION);
|
||||
ServiceResponse<EmptyResponse> cancelSubscriptionResponse = ApplicationDependencies.getDonationsService()
|
||||
.cancelSubscription(subscriber.getSubscriberId());
|
||||
|
||||
|
||||
@@ -43,7 +43,10 @@ import okio.ByteString;
|
||||
/**
|
||||
* Job responsible for submitting ReceiptCredentialRequest objects to the server until
|
||||
* we get a response.
|
||||
*
|
||||
* @deprecated Replaced with InAppPaymentOneTimeContextJob
|
||||
*/
|
||||
@Deprecated
|
||||
public class BoostReceiptRequestResponseJob extends BaseJob {
|
||||
|
||||
private static final String TAG = Log.tag(BoostReceiptRequestResponseJob.class);
|
||||
@@ -226,7 +229,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
||||
ReceiptCredential receiptCredential = getReceiptCredential(response.getResult().get());
|
||||
|
||||
if (!isCredentialValid(receiptCredential)) {
|
||||
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.badgeCredentialVerificationFailure(donationErrorSource));
|
||||
DonationError.routeBackgroundError(context, DonationError.badgeCredentialVerificationFailure(donationErrorSource));
|
||||
setPendingOneTimeDonationGenericRedemptionError(-1);
|
||||
throw new IOException("Could not validate receipt credential");
|
||||
}
|
||||
@@ -315,12 +318,12 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
||||
throw new RetryableException();
|
||||
case 400:
|
||||
Log.w(TAG, "Receipt credential request failed to validate.", applicationException, true);
|
||||
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(donationErrorSource));
|
||||
DonationError.routeBackgroundError(context, DonationError.genericBadgeRedemptionFailure(donationErrorSource));
|
||||
setPendingOneTimeDonationGenericRedemptionError(response.getStatus());
|
||||
throw new Exception(applicationException);
|
||||
case 402:
|
||||
Log.w(TAG, "User payment failed.", applicationException, true);
|
||||
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericPaymentFailure(donationErrorSource), terminalDonation.isLongRunningPaymentMethod);
|
||||
DonationError.routeBackgroundError(context, DonationError.genericPaymentFailure(donationErrorSource), terminalDonation.isLongRunningPaymentMethod);
|
||||
|
||||
if (applicationException instanceof DonationReceiptCredentialError) {
|
||||
setPendingOneTimeDonationChargeFailureError(((DonationReceiptCredentialError) applicationException).getChargeFailure());
|
||||
@@ -331,7 +334,7 @@ public class BoostReceiptRequestResponseJob extends BaseJob {
|
||||
throw new Exception(applicationException);
|
||||
case 409:
|
||||
Log.w(TAG, "Receipt already redeemed with a different request credential.", response.getApplicationError().get(), true);
|
||||
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(donationErrorSource));
|
||||
DonationError.routeBackgroundError(context, DonationError.genericBadgeRedemptionFailure(donationErrorSource));
|
||||
setPendingOneTimeDonationGenericRedemptionError(response.getStatus());
|
||||
throw new Exception(applicationException);
|
||||
default:
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Do
|
||||
import org.thoughtcrime.securesms.database.MessageTable;
|
||||
import org.thoughtcrime.securesms.database.NoSuchMessageException;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge;
|
||||
@@ -32,7 +33,10 @@ import java.util.Objects;
|
||||
/**
|
||||
* Job to redeem a verified donation receipt. It is up to the Job prior in the chain to specify a valid
|
||||
* presentation object via setOutputData. This is expected to be the byte[] blob of a ReceiptCredentialPresentation object.
|
||||
*
|
||||
* @deprecated Replaced with InAppPaymentRedemptionJob
|
||||
*/
|
||||
@Deprecated
|
||||
public class DonationReceiptRedemptionJob extends BaseJob {
|
||||
private static final String TAG = Log.tag(DonationReceiptRedemptionJob.class);
|
||||
private static final long NO_ID = -1L;
|
||||
@@ -100,29 +104,6 @@ public class DonationReceiptRedemptionJob extends BaseJob {
|
||||
.then(multiDeviceProfileContentUpdateJob);
|
||||
}
|
||||
|
||||
public static JobManager.Chain createJobChainForGift(long messageId, boolean primary) {
|
||||
DonationReceiptRedemptionJob redeemReceiptJob = new DonationReceiptRedemptionJob(
|
||||
messageId,
|
||||
primary,
|
||||
DonationErrorSource.GIFT_REDEMPTION,
|
||||
-1L,
|
||||
new Job.Parameters
|
||||
.Builder()
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setQueue("GiftReceiptRedemption-" + messageId)
|
||||
.setMaxAttempts(MAX_RETRIES)
|
||||
.setLifespan(Parameters.IMMORTAL)
|
||||
.build());
|
||||
|
||||
RefreshOwnProfileJob refreshOwnProfileJob = new RefreshOwnProfileJob();
|
||||
MultiDeviceProfileContentUpdateJob multiDeviceProfileContentUpdateJob = new MultiDeviceProfileContentUpdateJob();
|
||||
|
||||
return ApplicationDependencies.getJobManager()
|
||||
.startChain(redeemReceiptJob)
|
||||
.then(refreshOwnProfileJob)
|
||||
.then(multiDeviceProfileContentUpdateJob);
|
||||
}
|
||||
|
||||
private DonationReceiptRedemptionJob(long giftMessageId, boolean primary, @NonNull DonationErrorSource errorSource, long uiSessionKey, @NonNull Job.Parameters parameters) {
|
||||
super(parameters);
|
||||
this.giftMessageId = giftMessageId;
|
||||
@@ -176,7 +157,7 @@ public class DonationReceiptRedemptionJob extends BaseJob {
|
||||
@Override
|
||||
protected void onRun() throws Exception {
|
||||
if (isForSubscription()) {
|
||||
synchronized (SubscriptionReceiptRequestResponseJob.MUTEX) {
|
||||
synchronized (InAppPaymentSubscriberRecord.Type.DONATION) {
|
||||
doRun();
|
||||
}
|
||||
} else {
|
||||
@@ -222,7 +203,7 @@ public class DonationReceiptRedemptionJob extends BaseJob {
|
||||
throw new RetryableException();
|
||||
} else {
|
||||
Log.w(TAG, "Encountered a non-recoverable exception " + response.getStatus(), response.getApplicationError().get(), true);
|
||||
DonationError.routeBackgroundError(context, uiSessionKey, DonationError.genericBadgeRedemptionFailure(errorSource));
|
||||
DonationError.routeBackgroundError(context, DonationError.genericBadgeRedemptionFailure(errorSource));
|
||||
|
||||
if (isForOneTimeDonation()) {
|
||||
DonationErrorValue donationErrorValue = new DonationErrorValue.Builder()
|
||||
|
||||
@@ -11,33 +11,34 @@ import org.signal.donations.PaymentSourceType
|
||||
import org.signal.donations.StripeApi
|
||||
import org.signal.donations.StripeIntentAccessor
|
||||
import org.signal.donations.json.StripeIntentStatus
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSData
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError.Companion.toDonationErrorValue
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.subscription.LevelUpdate
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import org.whispersystems.signalservice.internal.push.DonationProcessor
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
/**
|
||||
* Proceeds with an externally approved (say, in a bank app) donation
|
||||
* and continues to process it.
|
||||
*/
|
||||
@Deprecated("Replaced with InAppPaymentAuthCheckJob")
|
||||
class ExternalLaunchDonationJob private constructor(
|
||||
private val stripe3DSData: Stripe3DSData,
|
||||
parameters: Parameters
|
||||
@@ -50,64 +51,8 @@ class ExternalLaunchDonationJob private constructor(
|
||||
|
||||
private val TAG = Log.tag(ExternalLaunchDonationJob::class.java)
|
||||
|
||||
@JvmStatic
|
||||
fun enqueueIfNecessary() {
|
||||
val stripe3DSData = SignalStore.donationsValues().consumePending3DSData(-1L) ?: return
|
||||
|
||||
Log.i(TAG, "Consumed 3DS data")
|
||||
|
||||
val jobChain = when (stripe3DSData.gatewayRequest.donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> BoostReceiptRequestResponseJob.createJobChainForBoost(
|
||||
stripe3DSData.stripeIntentAccessor.intentId,
|
||||
DonationProcessor.STRIPE,
|
||||
-1L,
|
||||
TerminalDonationQueue.TerminalDonation(
|
||||
level = stripe3DSData.gatewayRequest.level,
|
||||
isLongRunningPaymentMethod = stripe3DSData.paymentSourceType == PaymentSourceType.Stripe.SEPADebit
|
||||
)
|
||||
)
|
||||
|
||||
DonateToSignalType.MONTHLY -> SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(
|
||||
-1L,
|
||||
TerminalDonationQueue.TerminalDonation(
|
||||
level = stripe3DSData.gatewayRequest.level,
|
||||
isLongRunningPaymentMethod = stripe3DSData.paymentSourceType.isBankTransfer
|
||||
)
|
||||
)
|
||||
|
||||
DonateToSignalType.GIFT -> BoostReceiptRequestResponseJob.createJobChainForGift(
|
||||
stripe3DSData.stripeIntentAccessor.intentId,
|
||||
stripe3DSData.gatewayRequest.recipientId,
|
||||
stripe3DSData.gatewayRequest.additionalMessage,
|
||||
stripe3DSData.gatewayRequest.level,
|
||||
DonationProcessor.STRIPE,
|
||||
-1L,
|
||||
TerminalDonationQueue.TerminalDonation(
|
||||
level = stripe3DSData.gatewayRequest.level,
|
||||
isLongRunningPaymentMethod = stripe3DSData.paymentSourceType == PaymentSourceType.Stripe.SEPADebit
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val checkJob = ExternalLaunchDonationJob(
|
||||
stripe3DSData,
|
||||
Parameters.Builder()
|
||||
.setQueue(if (stripe3DSData.gatewayRequest.donateToSignalType == DonateToSignalType.MONTHLY) DonationReceiptRedemptionJob.SUBSCRIPTION_QUEUE else DonationReceiptRedemptionJob.ONE_TIME_QUEUE)
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setMaxAttempts(Parameters.UNLIMITED)
|
||||
.setLifespan(1.days.inWholeMilliseconds)
|
||||
.build()
|
||||
)
|
||||
|
||||
jobChain.after(checkJob).enqueue()
|
||||
}
|
||||
|
||||
private fun createDonationError(stripe3DSData: Stripe3DSData, throwable: Throwable): DonationError {
|
||||
val source = when (stripe3DSData.gatewayRequest.donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> DonationErrorSource.ONE_TIME
|
||||
DonateToSignalType.MONTHLY -> DonationErrorSource.MONTHLY
|
||||
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
|
||||
}
|
||||
val source = stripe3DSData.inAppPayment.type.toErrorSource()
|
||||
return DonationError.PaymentSetupError.GenericError(source, throwable)
|
||||
}
|
||||
}
|
||||
@@ -122,30 +67,30 @@ class ExternalLaunchDonationJob private constructor(
|
||||
|
||||
override fun onFailure() {
|
||||
if (donationError != null) {
|
||||
when (stripe3DSData.gatewayRequest.donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> {
|
||||
when (stripe3DSData.inAppPayment.type) {
|
||||
InAppPaymentTable.Type.ONE_TIME_DONATION -> {
|
||||
SignalStore.donationsValues().setPendingOneTimeDonation(
|
||||
DonationSerializationHelper.createPendingOneTimeDonationProto(
|
||||
stripe3DSData.gatewayRequest.badge,
|
||||
Badges.fromDatabaseBadge(stripe3DSData.inAppPayment.data.badge!!),
|
||||
stripe3DSData.paymentSourceType,
|
||||
stripe3DSData.gatewayRequest.fiat
|
||||
stripe3DSData.inAppPayment.data.amount!!.toFiatMoney()
|
||||
).copy(
|
||||
error = donationError?.toDonationErrorValue()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
DonateToSignalType.MONTHLY -> {
|
||||
InAppPaymentTable.Type.RECURRING_DONATION -> {
|
||||
SignalStore.donationsValues().appendToTerminalDonationQueue(
|
||||
TerminalDonationQueue.TerminalDonation(
|
||||
level = stripe3DSData.gatewayRequest.level,
|
||||
level = stripe3DSData.inAppPayment.data.level,
|
||||
isLongRunningPaymentMethod = stripe3DSData.isLongRunning,
|
||||
error = donationError?.toDonationErrorValue()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
else -> Log.w(TAG, "Job failed with donation error for type: ${stripe3DSData.gatewayRequest.donateToSignalType}")
|
||||
else -> Log.w(TAG, "Job failed with donation error for type: ${stripe3DSData.inAppPayment.type}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,10 +113,10 @@ class ExternalLaunchDonationJob private constructor(
|
||||
checkIntentStatus(stripePaymentIntent.status)
|
||||
|
||||
Log.i(TAG, "Creating and inserting donation receipt record.", true)
|
||||
val donationReceiptRecord = if (stripe3DSData.gatewayRequest.donateToSignalType == DonateToSignalType.ONE_TIME) {
|
||||
DonationReceiptRecord.createForBoost(stripe3DSData.gatewayRequest.fiat)
|
||||
val donationReceiptRecord = if (stripe3DSData.inAppPayment.type == InAppPaymentTable.Type.ONE_TIME_DONATION) {
|
||||
DonationReceiptRecord.createForBoost(stripe3DSData.inAppPayment.data.amount!!.toFiatMoney())
|
||||
} else {
|
||||
DonationReceiptRecord.createForGift(stripe3DSData.gatewayRequest.fiat)
|
||||
DonationReceiptRecord.createForGift(stripe3DSData.inAppPayment.data.amount!!.toFiatMoney())
|
||||
}
|
||||
|
||||
SignalDatabase.donationReceipts.addReceipt(donationReceiptRecord)
|
||||
@@ -179,9 +124,9 @@ class ExternalLaunchDonationJob private constructor(
|
||||
Log.i(TAG, "Creating and inserting one-time pending donation.", true)
|
||||
SignalStore.donationsValues().setPendingOneTimeDonation(
|
||||
DonationSerializationHelper.createPendingOneTimeDonationProto(
|
||||
stripe3DSData.gatewayRequest.badge,
|
||||
Badges.fromDatabaseBadge(stripe3DSData.inAppPayment.data.badge!!),
|
||||
stripe3DSData.paymentSourceType,
|
||||
stripe3DSData.gatewayRequest.fiat
|
||||
stripe3DSData.inAppPayment.data.amount.toFiatMoney()
|
||||
)
|
||||
)
|
||||
|
||||
@@ -193,7 +138,7 @@ class ExternalLaunchDonationJob private constructor(
|
||||
val stripeSetupIntent = stripeApi.getSetupIntent(stripe3DSData.stripeIntentAccessor)
|
||||
checkIntentStatus(stripeSetupIntent.status)
|
||||
|
||||
val subscriber = SignalStore.donationsValues().requireSubscriber()
|
||||
val subscriber = InAppPaymentsRepository.requireSubscriber(InAppPaymentSubscriberRecord.Type.DONATION)
|
||||
|
||||
Log.i(TAG, "Setting default payment method...", true)
|
||||
val setPaymentMethodResponse = if (stripe3DSData.paymentSourceType == PaymentSourceType.Stripe.IDEAL) {
|
||||
@@ -210,10 +155,10 @@ class ExternalLaunchDonationJob private constructor(
|
||||
Log.i(TAG, "Storing the subscription payment source type locally.", true)
|
||||
SignalStore.donationsValues().setSubscriptionPaymentSourceType(stripe3DSData.paymentSourceType)
|
||||
|
||||
val subscriptionLevel = stripe3DSData.gatewayRequest.level.toString()
|
||||
val subscriptionLevel = stripe3DSData.inAppPayment.data.level.toString()
|
||||
|
||||
try {
|
||||
val levelUpdateOperation = MonthlyDonationRepository.getOrCreateLevelUpdateOperation(TAG, subscriptionLevel)
|
||||
val levelUpdateOperation = RecurringInAppPaymentRepository.getOrCreateLevelUpdateOperation(TAG, subscriptionLevel)
|
||||
Log.d(TAG, "Attempting to set user subscription level to $subscriptionLevel", true)
|
||||
|
||||
val updateSubscriptionLevelResponse = ApplicationDependencies.getDonationsService().updateSubscriptionLevel(
|
||||
@@ -221,7 +166,7 @@ class ExternalLaunchDonationJob private constructor(
|
||||
subscriptionLevel,
|
||||
subscriber.currencyCode,
|
||||
levelUpdateOperation.idempotencyKey.serialize(),
|
||||
SubscriptionReceiptRequestResponseJob.MUTEX
|
||||
subscriber.type
|
||||
)
|
||||
|
||||
getResultOrThrow(updateSubscriptionLevelResponse, doOnApplicationError = {
|
||||
@@ -230,7 +175,7 @@ class ExternalLaunchDonationJob private constructor(
|
||||
|
||||
if (updateSubscriptionLevelResponse.status in listOf(200, 204)) {
|
||||
Log.d(TAG, "Successfully set user subscription to level $subscriptionLevel with response code ${updateSubscriptionLevelResponse.status}", true)
|
||||
SignalStore.donationsValues().updateLocalStateForLocalSubscribe()
|
||||
SignalStore.donationsValues().updateLocalStateForLocalSubscribe(subscriber.type)
|
||||
SignalStore.donationsValues().setVerifiedSubscription3DSData(stripe3DSData)
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
@@ -279,7 +224,7 @@ class ExternalLaunchDonationJob private constructor(
|
||||
|
||||
SignalStore.donationsValues().appendToTerminalDonationQueue(
|
||||
TerminalDonationQueue.TerminalDonation(
|
||||
level = stripe3DSData.gatewayRequest.level,
|
||||
level = stripe3DSData.inAppPayment.data.level,
|
||||
isLongRunningPaymentMethod = stripe3DSData.isLongRunning,
|
||||
error = DonationErrorValue(
|
||||
DonationErrorValue.Type.PAYMENT,
|
||||
@@ -316,7 +261,7 @@ class ExternalLaunchDonationJob private constructor(
|
||||
|
||||
companion object {
|
||||
fun parseSerializedData(serializedData: ByteArray): Stripe3DSData {
|
||||
return Stripe3DSData.fromProtoBytes(serializedData, -1L)
|
||||
return Stripe3DSData.fromProtoBytes(serializedData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import java.util.concurrent.TimeUnit
|
||||
* Sends a message to the given recipient containing a redeemable badge token.
|
||||
* This job assumes that the client has already determined whether the given recipient can receive a gift badge.
|
||||
*/
|
||||
@Deprecated("Replaced with InAppPaymentGiftSendJob")
|
||||
class GiftSendJob private constructor(parameters: Parameters, private val recipientId: RecipientId, private val additionalMessage: String?) : Job(parameters) {
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.signal.donations.StripeApi
|
||||
import org.signal.donations.StripeIntentAccessor
|
||||
import org.signal.donations.json.StripeIntentStatus
|
||||
import org.signal.donations.json.StripePaymentIntent
|
||||
import org.signal.donations.json.StripeSetupIntent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.subscription.LevelUpdate
|
||||
import org.thoughtcrime.securesms.subscription.LevelUpdateOperation
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
/**
|
||||
* Responsible for checking payment state after an external launch, such as for iDEAL
|
||||
*/
|
||||
class InAppPaymentAuthCheckJob private constructor(parameters: Parameters) : BaseJob(parameters), StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper {
|
||||
|
||||
constructor() : this(
|
||||
Parameters.Builder()
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setMaxAttempts(Parameters.UNLIMITED)
|
||||
.setLifespan(1.days.inWholeMilliseconds)
|
||||
.setMaxInstancesForFactory(1)
|
||||
.build()
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(InAppPaymentAuthCheckJob::class.java)
|
||||
const val KEY = "InAppPaymentAuthCheckJob"
|
||||
}
|
||||
|
||||
private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, ApplicationDependencies.getOkHttpClient())
|
||||
|
||||
override fun serialize(): ByteArray? = null
|
||||
|
||||
override fun getFactoryKey(): String = KEY
|
||||
|
||||
override fun onFailure() = Unit
|
||||
|
||||
override fun onRun() {
|
||||
migrateLegacyData()
|
||||
|
||||
val unauthorizedInAppPayments = SignalDatabase.inAppPayments.getAllWaitingForAuth()
|
||||
Log.i(TAG, "Found ${unauthorizedInAppPayments.size} payments awaiting authorization.", true)
|
||||
|
||||
var hasRetry = false
|
||||
for (payment in unauthorizedInAppPayments) {
|
||||
val verificationStatus: CheckResult<Unit> = if (payment.type.recurring) {
|
||||
synchronized(payment.type.requireSubscriberType().inAppPaymentType) {
|
||||
checkRecurringPayment(payment)
|
||||
}
|
||||
} else {
|
||||
checkOneTimePayment(payment)
|
||||
}
|
||||
|
||||
when (verificationStatus) {
|
||||
is CheckResult.Failure -> {
|
||||
markFailed(payment, verificationStatus.errorData)
|
||||
}
|
||||
|
||||
CheckResult.Retry -> {
|
||||
markChecked(payment)
|
||||
hasRetry = true
|
||||
}
|
||||
|
||||
is CheckResult.Success -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
if (hasRetry) {
|
||||
throw InAppPaymentRetryException()
|
||||
}
|
||||
}
|
||||
|
||||
private fun migrateLegacyData() {
|
||||
val pending3DSData = SignalStore.donationsValues().consumePending3DSData()
|
||||
if (pending3DSData != null) {
|
||||
Log.i(TAG, "Found legacy data. Performing migration.", true)
|
||||
|
||||
SignalDatabase.inAppPayments.insert(
|
||||
type = pending3DSData.inAppPayment.type,
|
||||
state = InAppPaymentTable.State.WAITING_FOR_AUTHORIZATION,
|
||||
subscriberId = if (pending3DSData.inAppPayment.type == InAppPaymentTable.Type.RECURRING_DONATION) {
|
||||
InAppPaymentsRepository.requireSubscriber(InAppPaymentSubscriberRecord.Type.DONATION).subscriberId
|
||||
} else {
|
||||
null
|
||||
},
|
||||
endOfPeriod = null,
|
||||
inAppPaymentData = pending3DSData.inAppPayment.data
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkOneTimePayment(inAppPayment: InAppPaymentTable.InAppPayment): CheckResult<Unit> {
|
||||
if (inAppPayment.data.waitForAuth == null) {
|
||||
Log.d(TAG, "Could not check one-time payment without data.waitForAuth", true)
|
||||
return CheckResult.Failure()
|
||||
}
|
||||
|
||||
Log.d(TAG, "Downloading payment intent.")
|
||||
val stripeIntentData: StripePaymentIntent = stripeApi.getPaymentIntent(
|
||||
StripeIntentAccessor(
|
||||
objectType = StripeIntentAccessor.ObjectType.PAYMENT_INTENT,
|
||||
intentId = inAppPayment.data.waitForAuth.stripeIntentId,
|
||||
intentClientSecret = inAppPayment.data.waitForAuth.stripeClientSecret
|
||||
)
|
||||
)
|
||||
|
||||
checkIntentStatus(stripeIntentData.status)
|
||||
|
||||
Log.i(TAG, "Creating and inserting receipt.", true)
|
||||
val receipt = when (inAppPayment.type) {
|
||||
InAppPaymentTable.Type.ONE_TIME_DONATION -> DonationReceiptRecord.createForBoost(inAppPayment.data.amount!!.toFiatMoney())
|
||||
InAppPaymentTable.Type.ONE_TIME_GIFT -> DonationReceiptRecord.createForGift(inAppPayment.data.amount!!.toFiatMoney())
|
||||
else -> {
|
||||
Log.e(TAG, "Unexpected type ${inAppPayment.type}", true)
|
||||
return CheckResult.Failure()
|
||||
}
|
||||
}
|
||||
|
||||
SignalDatabase.donationReceipts.addReceipt(receipt)
|
||||
|
||||
Log.i(TAG, "Verified payment. Updating InAppPayment::${inAppPayment.id.serialize()}")
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment = inAppPayment.copy(
|
||||
state = InAppPaymentTable.State.PENDING,
|
||||
data = inAppPayment.data.copy(
|
||||
waitForAuth = null,
|
||||
redemption = InAppPaymentData.RedemptionState(
|
||||
stage = InAppPaymentData.RedemptionState.Stage.INIT,
|
||||
paymentIntentId = inAppPayment.data.waitForAuth.stripeIntentId
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Log.i(TAG, "Enqueuing job chain.")
|
||||
val updatedPayment = SignalDatabase.inAppPayments.getById(inAppPayment.id)
|
||||
InAppPaymentOneTimeContextJob.createJobChain(updatedPayment!!).enqueue()
|
||||
return CheckResult.Success(Unit)
|
||||
}
|
||||
|
||||
private fun checkRecurringPayment(inAppPayment: InAppPaymentTable.InAppPayment): CheckResult<Unit> {
|
||||
if (inAppPayment.data.waitForAuth == null) {
|
||||
Log.d(TAG, "Could not check recurring payment without data.waitForAuth", true)
|
||||
return CheckResult.Failure()
|
||||
}
|
||||
|
||||
Log.d(TAG, "Downloading setup intent.")
|
||||
val stripeSetupIntent: StripeSetupIntent = stripeApi.getSetupIntent(
|
||||
StripeIntentAccessor(
|
||||
objectType = StripeIntentAccessor.ObjectType.SETUP_INTENT,
|
||||
intentId = inAppPayment.data.waitForAuth.stripeIntentId,
|
||||
intentClientSecret = inAppPayment.data.waitForAuth.stripeClientSecret
|
||||
)
|
||||
)
|
||||
|
||||
val checkIntentStatusResult = checkIntentStatus(stripeSetupIntent.status)
|
||||
if (checkIntentStatusResult !is CheckResult.Success) {
|
||||
return checkIntentStatusResult
|
||||
}
|
||||
|
||||
val subscriber = InAppPaymentsRepository.requireSubscriber(
|
||||
when (inAppPayment.type) {
|
||||
InAppPaymentTable.Type.RECURRING_DONATION -> InAppPaymentSubscriberRecord.Type.DONATION
|
||||
InAppPaymentTable.Type.RECURRING_BACKUP -> InAppPaymentSubscriberRecord.Type.BACKUP
|
||||
else -> {
|
||||
Log.e(TAG, "Expected recurring type but found ${inAppPayment.type}", true)
|
||||
return CheckResult.Failure()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (subscriber.subscriberId != inAppPayment.subscriberId) {
|
||||
Log.w(TAG, "Found an old subscription with a subscriber id mismatch. Dropping.", true)
|
||||
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment = inAppPayment.copy(
|
||||
state = InAppPaymentTable.State.END,
|
||||
notified = true,
|
||||
data = InAppPaymentData(
|
||||
waitForAuth = InAppPaymentData.WaitingForAuthorizationState("", "")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return CheckResult.Failure()
|
||||
}
|
||||
|
||||
Log.i(TAG, "Setting default payment method...", true)
|
||||
val setPaymentMethodResponse = if (inAppPayment.data.paymentMethodType == InAppPaymentData.PaymentMethodType.IDEAL) {
|
||||
ApplicationDependencies.getDonationsService().setDefaultIdealPaymentMethod(subscriber.subscriberId, stripeSetupIntent.id)
|
||||
} else {
|
||||
ApplicationDependencies.getDonationsService().setDefaultStripePaymentMethod(subscriber.subscriberId, stripeSetupIntent.paymentMethod)
|
||||
}
|
||||
|
||||
when (val result = checkResult(setPaymentMethodResponse)) {
|
||||
is CheckResult.Failure -> return CheckResult.Failure(result.errorData)
|
||||
is CheckResult.Retry -> return CheckResult.Retry
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
Log.d(TAG, "Set default payment method via Signal service.", true)
|
||||
|
||||
val level = inAppPayment.data.level.toString()
|
||||
|
||||
try {
|
||||
val updateOperation: LevelUpdateOperation = RecurringInAppPaymentRepository.getOrCreateLevelUpdateOperation(TAG, level)
|
||||
Log.d(TAG, "Attempting to set user subscription level to $level", true)
|
||||
|
||||
val updateLevelResponse = ApplicationDependencies.getDonationsService().updateSubscriptionLevel(
|
||||
subscriber.subscriberId,
|
||||
level,
|
||||
subscriber.currencyCode,
|
||||
updateOperation.idempotencyKey.serialize(),
|
||||
subscriber.type
|
||||
)
|
||||
|
||||
val updateLevelResult = checkResult(updateLevelResponse)
|
||||
if (updateLevelResult is CheckResult.Failure) {
|
||||
SignalStore.donationsValues().clearLevelOperations()
|
||||
return CheckResult.Failure(updateLevelResult.errorData)
|
||||
}
|
||||
|
||||
if (updateLevelResult == CheckResult.Retry) {
|
||||
return CheckResult.Retry
|
||||
}
|
||||
|
||||
if (updateLevelResponse.status in listOf(200, 204)) {
|
||||
Log.d(TAG, "Successfully set user subscription to level $level with response code ${updateLevelResponse.status}", true)
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment = inAppPayment.copy(
|
||||
state = InAppPaymentTable.State.PENDING,
|
||||
data = inAppPayment.data.copy(
|
||||
waitForAuth = null,
|
||||
redemption = InAppPaymentData.RedemptionState(
|
||||
stage = InAppPaymentData.RedemptionState.Stage.INIT
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Log.d(TAG, "Reading fresh InAppPayment and enqueueing redemption chain.")
|
||||
with(SignalDatabase.inAppPayments.getById(inAppPayment.id)!!) {
|
||||
InAppPaymentRecurringContextJob.createJobChain(this).enqueue()
|
||||
}
|
||||
|
||||
return CheckResult.Success(Unit)
|
||||
} else {
|
||||
Log.e(TAG, "Unexpected status code ${updateLevelResponse.status} without an application error or execution error.")
|
||||
return CheckResult.Failure(errorData = updateLevelResponse.status.toString())
|
||||
}
|
||||
} finally {
|
||||
LevelUpdate.updateProcessingState(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <Result> checkResult(
|
||||
serviceResponse: ServiceResponse<Result>
|
||||
): CheckResult<Result> {
|
||||
if (serviceResponse.result.isPresent) {
|
||||
return CheckResult.Success(serviceResponse.result.get())
|
||||
} else if (serviceResponse.applicationError.isPresent) {
|
||||
Log.w(TAG, "An application error was present. ${serviceResponse.status}", serviceResponse.applicationError.get(), true)
|
||||
return CheckResult.Failure(errorData = serviceResponse.status.toString())
|
||||
} else if (serviceResponse.executionError.isPresent) {
|
||||
Log.w(TAG, "An execution error was present. ${serviceResponse.status}", serviceResponse.executionError.get(), true)
|
||||
return CheckResult.Retry
|
||||
}
|
||||
|
||||
error("Should never get here.")
|
||||
}
|
||||
|
||||
private fun checkIntentStatus(stripeIntentStatus: StripeIntentStatus?): CheckResult<Unit> {
|
||||
when (stripeIntentStatus) {
|
||||
null, StripeIntentStatus.SUCCEEDED -> {
|
||||
Log.i(TAG, "Stripe intent is in the SUCCEEDED state, we can proceed.", true)
|
||||
return CheckResult.Success(Unit)
|
||||
}
|
||||
|
||||
StripeIntentStatus.CANCELED -> {
|
||||
Log.i(TAG, "Stripe intent is in the cancelled state, we cannot proceed.", true)
|
||||
return CheckResult.Failure()
|
||||
}
|
||||
|
||||
StripeIntentStatus.REQUIRES_PAYMENT_METHOD -> {
|
||||
Log.i(TAG, "Stripe intent payment failed, we cannot proceed.", true)
|
||||
return CheckResult.Failure()
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.i(TAG, "Stripe intent is still processing, retry later", true)
|
||||
return CheckResult.Retry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun markFailed(inAppPayment: InAppPaymentTable.InAppPayment, errorData: String?) {
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment = inAppPayment.copy(
|
||||
state = InAppPaymentTable.State.END,
|
||||
notified = true,
|
||||
data = inAppPayment.data.copy(
|
||||
error = InAppPaymentData.Error(
|
||||
type = InAppPaymentData.Error.Type.PAYMENT_SETUP,
|
||||
data_ = errorData
|
||||
),
|
||||
waitForAuth = InAppPaymentData.WaitingForAuthorizationState("", ""),
|
||||
redemption = null
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun markChecked(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
val fresh = SignalDatabase.inAppPayments.getById(inAppPayment.id)
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment = fresh!!.copy(
|
||||
data = fresh.data.copy(
|
||||
waitForAuth = fresh.data.waitForAuth!!.copy(
|
||||
checkedVerification = true
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onShouldRetry(e: Exception): Boolean = e is InAppPaymentRetryException
|
||||
override fun fetchPaymentIntent(price: FiatMoney, level: Long, sourceType: PaymentSourceType.Stripe): Single<StripeIntentAccessor> {
|
||||
error("Not needed, this job should not be creating intents.")
|
||||
}
|
||||
|
||||
override fun fetchSetupIntent(sourceType: PaymentSourceType.Stripe): Single<StripeIntentAccessor> {
|
||||
error("Not needed, this job should not be creating intents.")
|
||||
}
|
||||
|
||||
private sealed interface CheckResult<out T> {
|
||||
data class Success<T>(val data: T) : CheckResult<T>
|
||||
data class Failure(val errorData: String? = null) : CheckResult<Nothing>
|
||||
object Retry : CheckResult<Nothing>
|
||||
}
|
||||
|
||||
class Factory : Job.Factory<InAppPaymentAuthCheckJob> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): InAppPaymentAuthCheckJob {
|
||||
return InAppPaymentAuthCheckJob(parameters)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.badges.gifts.Gifts
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.sharing.MultiShareArgs
|
||||
import org.thoughtcrime.securesms.sharing.MultiShareSender
|
||||
import org.thoughtcrime.securesms.sms.MessageSender
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Sends a message and redeemable token to the recipient contained within the InAppPayment
|
||||
*/
|
||||
class InAppPaymentGiftSendJob private constructor(
|
||||
private val inAppPaymentId: InAppPaymentTable.InAppPaymentId,
|
||||
parameters: Parameters
|
||||
) : BaseJob(parameters) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(InAppPaymentGiftSendJob::class.java)
|
||||
const val KEY = "InAppPurchaseOneTimeGiftSendJob"
|
||||
|
||||
fun create(inAppPayment: InAppPaymentTable.InAppPayment): Job {
|
||||
return InAppPaymentGiftSendJob(
|
||||
inAppPaymentId = inAppPayment.id,
|
||||
parameters = Parameters.Builder()
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun serialize(): ByteArray = inAppPaymentId.serialize().toByteArray()
|
||||
|
||||
override fun getFactoryKey(): String = KEY
|
||||
|
||||
override fun onFailure() {
|
||||
warning("Failed to send gift.")
|
||||
}
|
||||
|
||||
override fun onRun() {
|
||||
val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)
|
||||
|
||||
requireNotNull(inAppPayment, "Not found.")
|
||||
check(inAppPayment!!.type == InAppPaymentTable.Type.ONE_TIME_GIFT, "Invalid type: ${inAppPayment.type}")
|
||||
check(inAppPayment.state == InAppPaymentTable.State.PENDING, "Invalid state: ${inAppPayment.state}")
|
||||
requireNotNull(inAppPayment.data.redemption, "No redemption present on data")
|
||||
check(inAppPayment.data.redemption!!.stage == InAppPaymentData.RedemptionState.Stage.REDEMPTION_STARTED, "Invalid stage: ${inAppPayment.data.redemption.stage}")
|
||||
|
||||
val recipient = Recipient.resolved(RecipientId.from(requireNotNull(inAppPayment.data.recipientId, "No recipient on data.")))
|
||||
val token = requireNotNull(inAppPayment.data.redemption.receiptCredentialPresentation, "No presentation present on data.")
|
||||
|
||||
check(!recipient.isIndividual || recipient.registered != RecipientTable.RegisteredState.REGISTERED, "Invalid recipient ${recipient.id} for gift send.")
|
||||
|
||||
val thread = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
|
||||
val outgoingMessage = Gifts.createOutgoingGiftMessage(
|
||||
recipient = recipient,
|
||||
expiresIn = recipient.expiresInSeconds.toLong().seconds.inWholeMilliseconds,
|
||||
sentTimestamp = System.currentTimeMillis(),
|
||||
giftBadge = GiftBadge(redemptionToken = token)
|
||||
)
|
||||
|
||||
info("Sending gift badge to ${recipient.id}")
|
||||
var didInsert = false
|
||||
MessageSender.send(context, outgoingMessage, thread, MessageSender.SendType.SIGNAL, null) {
|
||||
didInsert = true
|
||||
}
|
||||
|
||||
if (didInsert) {
|
||||
info("Successfully inserted outbox message for gift.")
|
||||
|
||||
val trimmedMessage = inAppPayment.data.additionalMessage?.trim()
|
||||
if (!trimmedMessage.isNullOrBlank()) {
|
||||
info("Sending additional message...")
|
||||
|
||||
val result = MultiShareSender.sendSync(
|
||||
MultiShareArgs.Builder(setOf(ContactSearchKey.RecipientSearchKey(recipient.id, false)))
|
||||
.withDraftText(trimmedMessage)
|
||||
.build()
|
||||
)
|
||||
|
||||
if (result.containsFailures()) {
|
||||
warning("Failed to send additional message but gift is fine.")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warning("Failed to insert outbox message for gift.")
|
||||
}
|
||||
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment = inAppPayment.copy(
|
||||
state = InAppPaymentTable.State.END
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun check(condition: Boolean, message: String) {
|
||||
if (!condition) {
|
||||
warning(message)
|
||||
throw Exception(message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> requireNotNull(data: T?, message: String): T {
|
||||
if (data == null) {
|
||||
warning(message)
|
||||
throw Exception(message)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
override fun onShouldRetry(e: Exception): Boolean = e is InAppPaymentRetryException
|
||||
|
||||
private fun info(message: String, throwable: Throwable? = null) {
|
||||
Log.i(TAG, "InAppPayment $inAppPaymentId: $message", throwable, true)
|
||||
}
|
||||
private fun warning(message: String, throwable: Throwable? = null) {
|
||||
Log.w(TAG, "InAppPayment $inAppPaymentId: $message", throwable, true)
|
||||
}
|
||||
|
||||
class Factory : Job.Factory<InAppPaymentGiftSendJob> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): InAppPaymentGiftSendJob {
|
||||
return InAppPaymentGiftSendJob(
|
||||
inAppPaymentId = InAppPaymentTable.InAppPaymentId(serializedData!!.toString().toLong()),
|
||||
parameters = parameters
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toDecimalValue
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.DonationRedemptionJobStatus
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.DonationRedemptionJobWatcher
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.FiatValue
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobmanager.JsonJobData
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import org.whispersystems.signalservice.internal.EmptyResponse
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import java.util.Locale
|
||||
import kotlin.jvm.optionals.getOrNull
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Checks whether we need to create and process a new InAppPurchase for a new month
|
||||
*/
|
||||
class InAppPaymentKeepAliveJob private constructor(
|
||||
parameters: Parameters,
|
||||
val type: InAppPaymentSubscriberRecord.Type
|
||||
) : BaseJob(parameters) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(InAppPaymentKeepAliveJob::class.java)
|
||||
|
||||
const val KEY = "InAppPurchaseRecurringKeepAliveJob"
|
||||
|
||||
private val TIMEOUT = 3.days
|
||||
|
||||
private const val DATA_TYPE = "type"
|
||||
|
||||
fun create(type: InAppPaymentSubscriberRecord.Type): Job {
|
||||
return InAppPaymentKeepAliveJob(
|
||||
parameters = Parameters.Builder()
|
||||
.setQueue(type.jobQueue)
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setMaxInstancesForQueue(1)
|
||||
.setMaxAttempts(Parameters.UNLIMITED)
|
||||
.setLifespan(TIMEOUT.inWholeSeconds)
|
||||
.build(),
|
||||
type = type
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun enqueueAndTrackTimeIfNecessary() {
|
||||
// TODO -- This should only be enqueued if we are completely drained of old subscription jobs. (No pending, no runnning)
|
||||
val lastKeepAliveTime = SignalStore.donationsValues().getLastKeepAliveLaunchTime().milliseconds
|
||||
val now = System.currentTimeMillis().milliseconds
|
||||
|
||||
if (lastKeepAliveTime > now) {
|
||||
enqueueAndTrackTime(now)
|
||||
return
|
||||
}
|
||||
|
||||
val nextLaunchTime = lastKeepAliveTime + 3.days
|
||||
if (nextLaunchTime <= now) {
|
||||
enqueueAndTrackTime(now)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun enqueueAndTrackTime(now: Duration) {
|
||||
ApplicationDependencies.getJobManager().add(create(InAppPaymentSubscriberRecord.Type.DONATION))
|
||||
ApplicationDependencies.getJobManager().add(create(InAppPaymentSubscriberRecord.Type.BACKUP))
|
||||
SignalStore.donationsValues().setLastKeepAliveLaunchTime(now.inWholeMilliseconds)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRun() {
|
||||
synchronized(type) {
|
||||
doRun()
|
||||
}
|
||||
}
|
||||
|
||||
private fun doRun() {
|
||||
val subscriber: InAppPaymentSubscriberRecord? = InAppPaymentsRepository.getSubscriber(type)
|
||||
if (subscriber == null) {
|
||||
info(type, "Subscriber not present. Skipping.")
|
||||
return
|
||||
}
|
||||
|
||||
val response: ServiceResponse<EmptyResponse> = ApplicationDependencies.getDonationsService().putSubscription(subscriber.subscriberId)
|
||||
|
||||
verifyResponse(response)
|
||||
info(type, "Successful call to putSubscription")
|
||||
|
||||
val activeSubscriptionResponse: ServiceResponse<ActiveSubscription> = ApplicationDependencies.getDonationsService().getSubscription(subscriber.subscriberId)
|
||||
|
||||
verifyResponse(activeSubscriptionResponse)
|
||||
info(type, "Successful call to GET active subscription")
|
||||
|
||||
val activeSubscription: ActiveSubscription? = activeSubscriptionResponse.result.getOrNull()
|
||||
if (activeSubscription == null) {
|
||||
warn(type, "Failed to parse active subscription from response body. Exiting.")
|
||||
return
|
||||
}
|
||||
|
||||
val subscription: ActiveSubscription.Subscription? = activeSubscription.activeSubscription
|
||||
if (subscription == null) {
|
||||
info(type, "User does not have a subscription. Exiting.")
|
||||
return
|
||||
}
|
||||
|
||||
// Note that this can be removed once the old jobs are decommissioned. These jobs live in different queues, and should still be respected.
|
||||
if (type == InAppPaymentSubscriberRecord.Type.DONATION) {
|
||||
val legacyRedemptionStatus = DonationRedemptionJobWatcher.getSubscriptionRedemptionJobStatus()
|
||||
if (legacyRedemptionStatus != DonationRedemptionJobStatus.None && legacyRedemptionStatus != DonationRedemptionJobStatus.FailedSubscription) {
|
||||
info(type, "Already trying to redeem donation, current status: ${legacyRedemptionStatus.javaClass.simpleName}")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (SignalDatabase.inAppPayments.hasPending(type.inAppPaymentType)) {
|
||||
info(type, "Already trying to redeem $type. Exiting.")
|
||||
return
|
||||
}
|
||||
|
||||
val activeInAppPayment = getActiveInAppPayment(subscriber, subscription)
|
||||
if (activeInAppPayment == null) {
|
||||
warn(type, "Failed to generate active in-app payment. Exiting")
|
||||
return
|
||||
}
|
||||
|
||||
info(type, "Processing id: ${activeInAppPayment.id}")
|
||||
|
||||
when (activeInAppPayment.data.redemption?.stage) {
|
||||
InAppPaymentData.RedemptionState.Stage.INIT -> {
|
||||
info(type, "Transitioning payment from INIT to CONVERSION_STARTED and generating a request credential")
|
||||
val payment = activeInAppPayment.copy(
|
||||
data = activeInAppPayment.data.copy(
|
||||
redemption = activeInAppPayment.data.redemption.copy(
|
||||
stage = InAppPaymentData.RedemptionState.Stage.CONVERSION_STARTED,
|
||||
receiptCredentialRequestContext = InAppPaymentsRepository.generateRequestCredential().serialize().toByteString()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
SignalDatabase.inAppPayments.update(payment)
|
||||
InAppPaymentRecurringContextJob.createJobChain(payment).enqueue()
|
||||
}
|
||||
InAppPaymentData.RedemptionState.Stage.CONVERSION_STARTED -> {
|
||||
if (activeInAppPayment.data.redemption.receiptCredentialRequestContext == null) {
|
||||
warn(type, "We are in the CONVERSION_STARTED state without a request credential. Exiting.")
|
||||
return
|
||||
}
|
||||
|
||||
info(type, "We have a request credential we have not turned into a token.")
|
||||
InAppPaymentRecurringContextJob.createJobChain(activeInAppPayment).enqueue()
|
||||
}
|
||||
InAppPaymentData.RedemptionState.Stage.REDEMPTION_STARTED -> {
|
||||
if (activeInAppPayment.data.redemption.receiptCredentialPresentation == null) {
|
||||
warn(type, "We are in the REDEMPTION_STARTED state without a request credential. Exiting.")
|
||||
return
|
||||
}
|
||||
|
||||
info(type, "We have a receipt credential presentation but have not yet redeemed it.")
|
||||
InAppPaymentRedemptionJob.enqueueJobChainForRecurringKeepAlive(activeInAppPayment)
|
||||
}
|
||||
else -> info(type, "Nothing to do. Exiting.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> verifyResponse(serviceResponse: ServiceResponse<T>) {
|
||||
if (serviceResponse.executionError.isPresent) {
|
||||
val error = serviceResponse.executionError.get()
|
||||
warn(type, "Failed with an execution error. Scheduling retry.", error)
|
||||
throw InAppPaymentRetryException(error)
|
||||
}
|
||||
|
||||
if (serviceResponse.applicationError.isPresent) {
|
||||
val error = serviceResponse.applicationError.get()
|
||||
when (serviceResponse.status) {
|
||||
403, 404 -> {
|
||||
warn(type, "Invalid or malformed subscriber id. Status: ${serviceResponse.status}", error)
|
||||
}
|
||||
else -> {
|
||||
warn(type, "An unknown server error occurred: ${serviceResponse.status}", error)
|
||||
throw InAppPaymentRetryException(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getActiveInAppPayment(
|
||||
subscriber: InAppPaymentSubscriberRecord,
|
||||
subscription: ActiveSubscription.Subscription
|
||||
): InAppPaymentTable.InAppPayment? {
|
||||
val endOfCurrentPeriod = subscription.endOfCurrentPeriod.seconds
|
||||
val type = subscriber.type
|
||||
val current: InAppPaymentTable.InAppPayment? = SignalDatabase.inAppPayments.getByEndOfPeriod(type.inAppPaymentType, endOfCurrentPeriod)
|
||||
|
||||
return if (current == null) {
|
||||
val oldInAppPayment = SignalDatabase.inAppPayments.getByLatestEndOfPeriod(type.inAppPaymentType)
|
||||
val oldEndOfPeriod = oldInAppPayment?.endOfPeriod ?: SignalStore.donationsValues().getLastEndOfPeriod().seconds
|
||||
if (oldEndOfPeriod > endOfCurrentPeriod) {
|
||||
warn(type, "Active subscription returned an old end-of-period. Exiting. (old: $oldEndOfPeriod, new: $endOfCurrentPeriod)")
|
||||
return null
|
||||
}
|
||||
|
||||
val (badge, label) = if (oldInAppPayment == null) {
|
||||
info(type, "Old payment not found in database. Loading badge / label information from donations configuration.")
|
||||
val configuration = ApplicationDependencies.getDonationsService().getDonationsConfiguration(Locale.getDefault())
|
||||
if (configuration.result.isPresent) {
|
||||
val subscriptionConfig = configuration.result.get().levels[subscription.level]
|
||||
if (subscriptionConfig == null) {
|
||||
info(type, "Failed to load subscription configuration for level ${subscription.level} for type $type")
|
||||
null to ""
|
||||
} else {
|
||||
Badges.toDatabaseBadge(Badges.fromServiceBadge(subscriptionConfig.badge)) to subscriptionConfig.name
|
||||
}
|
||||
} else {
|
||||
warn(TAG, "Failed to load configuration while processing $type")
|
||||
null to ""
|
||||
}
|
||||
} else {
|
||||
oldInAppPayment.data.badge to oldInAppPayment.data.label
|
||||
}
|
||||
|
||||
info(type, "End of period has changed. Requesting receipt refresh. (old: $oldEndOfPeriod, new: $endOfCurrentPeriod)")
|
||||
if (type == InAppPaymentSubscriberRecord.Type.DONATION) {
|
||||
SignalStore.donationsValues().setLastEndOfPeriod(endOfCurrentPeriod.inWholeSeconds)
|
||||
}
|
||||
|
||||
val inAppPaymentId = SignalDatabase.inAppPayments.insert(
|
||||
type = type.inAppPaymentType,
|
||||
state = InAppPaymentTable.State.PENDING,
|
||||
subscriberId = subscriber.subscriberId,
|
||||
endOfPeriod = endOfCurrentPeriod,
|
||||
inAppPaymentData = InAppPaymentData(
|
||||
badge = badge,
|
||||
amount = FiatValue(
|
||||
currencyCode = subscriber.currencyCode,
|
||||
amount = subscription.amount.toDecimalValue()
|
||||
),
|
||||
error = null,
|
||||
level = subscription.level.toLong(),
|
||||
cancellation = null,
|
||||
label = label,
|
||||
recipientId = null,
|
||||
additionalMessage = null,
|
||||
redemption = InAppPaymentData.RedemptionState(
|
||||
stage = InAppPaymentData.RedemptionState.Stage.INIT,
|
||||
keepAlive = true
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
MultiDeviceSubscriptionSyncRequestJob.enqueue()
|
||||
SignalDatabase.inAppPayments.getById(inAppPaymentId)
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
|
||||
private fun info(type: InAppPaymentSubscriberRecord.Type, message: String) {
|
||||
Log.i(TAG, "[$type] $message", true)
|
||||
}
|
||||
|
||||
private fun warn(type: InAppPaymentSubscriberRecord.Type, message: String, throwable: Throwable? = null) {
|
||||
Log.w(TAG, "[$type] $message", throwable, true)
|
||||
}
|
||||
|
||||
override fun serialize(): ByteArray? = JsonJobData.Builder().putInt(DATA_TYPE, type.code).build().serialize()
|
||||
|
||||
override fun getFactoryKey(): String = KEY
|
||||
|
||||
override fun onFailure() = Unit
|
||||
|
||||
override fun onShouldRetry(e: Exception): Boolean = e is InAppPaymentRetryException
|
||||
|
||||
class Factory : Job.Factory<InAppPaymentKeepAliveJob> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): InAppPaymentKeepAliveJob {
|
||||
return InAppPaymentKeepAliveJob(
|
||||
parameters,
|
||||
InAppPaymentSubscriberRecord.Type.values().first { it.code == JsonJobData.deserialize(serializedData).getInt(DATA_TYPE) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.orNull
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredential
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequestContext
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toDonationProcessor
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobmanager.JobManager.Chain
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import org.whispersystems.signalservice.internal.push.exceptions.DonationReceiptCredentialError
|
||||
import java.io.IOException
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Handles processing and validation of one-time payments. This involves
|
||||
* generating and submitting a receipt request context to the server and
|
||||
* processing its returned parameters.
|
||||
*/
|
||||
class InAppPaymentOneTimeContextJob private constructor(
|
||||
private val inAppPaymentId: InAppPaymentTable.InAppPaymentId,
|
||||
parameters: Parameters
|
||||
) : BaseJob(parameters) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(InAppPaymentOneTimeContextJob::class.java)
|
||||
|
||||
const val KEY = "InAppPurchaseOneTimeContextJob"
|
||||
|
||||
private fun create(inAppPayment: InAppPaymentTable.InAppPayment): Job {
|
||||
return InAppPaymentOneTimeContextJob(
|
||||
inAppPayment.id,
|
||||
parameters = Parameters.Builder()
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setQueue(InAppPaymentsRepository.resolveJobQueueKey(inAppPayment))
|
||||
.setLifespan(InAppPaymentsRepository.resolveContextJobLifespan(inAppPayment).inWholeMilliseconds)
|
||||
.setMaxAttempts(Parameters.UNLIMITED)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
fun createJobChain(inAppPayment: InAppPaymentTable.InAppPayment, makePrimary: Boolean = false): Chain {
|
||||
return when (inAppPayment.type) {
|
||||
InAppPaymentTable.Type.ONE_TIME_DONATION -> {
|
||||
ApplicationDependencies.getJobManager()
|
||||
.startChain(create(inAppPayment))
|
||||
.then(InAppPaymentRedemptionJob.create(inAppPayment, makePrimary))
|
||||
.then(RefreshOwnProfileJob())
|
||||
.then(MultiDeviceProfileContentUpdateJob())
|
||||
}
|
||||
InAppPaymentTable.Type.ONE_TIME_GIFT -> {
|
||||
ApplicationDependencies.getJobManager()
|
||||
.startChain(create(inAppPayment))
|
||||
.then(InAppPaymentGiftSendJob.create(inAppPayment))
|
||||
}
|
||||
else -> error("Unsupported type: ${inAppPayment.type}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun serialize(): ByteArray = inAppPaymentId.serialize().toByteArray()
|
||||
|
||||
override fun getFactoryKey(): String = KEY
|
||||
|
||||
override fun onFailure() {
|
||||
warning("A permanent failure occurred.")
|
||||
|
||||
val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)
|
||||
if (inAppPayment != null && inAppPayment.data.error == null) {
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment.copy(
|
||||
notified = false,
|
||||
state = InAppPaymentTable.State.END,
|
||||
data = inAppPayment.data.copy(
|
||||
error = InAppPaymentData.Error(
|
||||
type = InAppPaymentData.Error.Type.REDEMPTION
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAdded() {
|
||||
val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)
|
||||
if (inAppPayment?.state == InAppPaymentTable.State.CREATED) {
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment.copy(
|
||||
state = InAppPaymentTable.State.PENDING
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRun() {
|
||||
val (inAppPayment, requestContext) = getAndValidateInAppPayment()
|
||||
|
||||
info("Submitting request context to server...")
|
||||
val serviceResponse = ApplicationDependencies.getDonationsService().submitBoostReceiptCredentialRequestSync(
|
||||
inAppPayment.data.redemption!!.paymentIntentId,
|
||||
requestContext.request,
|
||||
inAppPayment.data.paymentMethodType.toDonationProcessor()
|
||||
)
|
||||
|
||||
if (serviceResponse.applicationError.isPresent) {
|
||||
handleApplicationError(inAppPayment, serviceResponse)
|
||||
} else if (serviceResponse.result.isPresent) {
|
||||
val receiptCredential = try {
|
||||
ApplicationDependencies.getClientZkReceiptOperations().receiveReceiptCredential(requestContext, serviceResponse.result.get())
|
||||
} catch (e: VerificationFailedException) {
|
||||
warning("Failed to receive credential.", e)
|
||||
throw InAppPaymentRetryException(e)
|
||||
}
|
||||
|
||||
if (isCredentialValid(inAppPayment, receiptCredential)) {
|
||||
info("Validated credential. Getting presentation.")
|
||||
val receiptCredentialPresentation = try {
|
||||
ApplicationDependencies.getClientZkReceiptOperations().createReceiptCredentialPresentation(receiptCredential)
|
||||
} catch (e: VerificationFailedException) {
|
||||
warning("Failed to get presentation from credential.")
|
||||
throw InAppPaymentRetryException(e)
|
||||
}
|
||||
|
||||
info("Got presentation. Updating state and completing.")
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment.copy(
|
||||
data = inAppPayment.data.copy(
|
||||
redemption = InAppPaymentData.RedemptionState(
|
||||
stage = InAppPaymentData.RedemptionState.Stage.REDEMPTION_STARTED,
|
||||
receiptCredentialPresentation = receiptCredentialPresentation.serialize().toByteString()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
warning("Failed to validate credential.")
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment.copy(
|
||||
notified = false,
|
||||
state = InAppPaymentTable.State.END,
|
||||
data = inAppPayment.data.copy(
|
||||
error = InAppPaymentData.Error(
|
||||
type = InAppPaymentData.Error.Type.CREDENTIAL_VALIDATION
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
throw IOException("Could not validate credential.")
|
||||
}
|
||||
} else {
|
||||
info("Encountered a retryable error", serviceResponse.executionError.orNull())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onShouldRetry(e: Exception): Boolean = e is InAppPaymentRetryException
|
||||
|
||||
private fun getAndValidateInAppPayment(): Pair<InAppPaymentTable.InAppPayment, ReceiptCredentialRequestContext> {
|
||||
val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)
|
||||
if (inAppPayment == null) {
|
||||
warning("Not found in database.")
|
||||
throw IOException("InAppPayment not found in database")
|
||||
}
|
||||
|
||||
if (inAppPayment.type.recurring) {
|
||||
warning("Invalid type: ${inAppPayment.type}")
|
||||
throw IOException("InAppPayment is of unexpected type")
|
||||
}
|
||||
|
||||
if (inAppPayment.state != InAppPaymentTable.State.PENDING) {
|
||||
warning("Invalid state: ${inAppPayment.state} but expected PENDING")
|
||||
throw IOException("InAppPayment is in an invalid state")
|
||||
}
|
||||
|
||||
if (inAppPayment.data.redemption == null) {
|
||||
warning("Invalid data: not in redemption state.")
|
||||
throw IOException("InAppPayment does not have a redemption state. Still awaiting auth?")
|
||||
}
|
||||
|
||||
if (inAppPayment.data.redemption.stage != InAppPaymentData.RedemptionState.Stage.INIT && inAppPayment.data.redemption.stage != InAppPaymentData.RedemptionState.Stage.CONVERSION_STARTED) {
|
||||
warning("Invalid stage: Expected INIT or CONVERSION_STARTED, but got ${inAppPayment.data.redemption.stage}")
|
||||
throw IOException("InAppPayment is in an invalid stage.")
|
||||
}
|
||||
|
||||
if (inAppPayment.data.redemption.paymentIntentId == null) {
|
||||
warning("No payment id present on one-time redemption data. Exiting.")
|
||||
throw IOException("InAppPayment has no paymentIntentId.")
|
||||
}
|
||||
|
||||
val requestContext: ReceiptCredentialRequestContext = inAppPayment.data.redemption.receiptCredentialRequestContext?.let {
|
||||
ReceiptCredentialRequestContext(it.toByteArray())
|
||||
} ?: InAppPaymentsRepository.generateRequestCredential()
|
||||
|
||||
val updatedPayment = inAppPayment.copy(
|
||||
data = inAppPayment.data.copy(
|
||||
redemption = inAppPayment.data.redemption.copy(
|
||||
stage = InAppPaymentData.RedemptionState.Stage.CONVERSION_STARTED,
|
||||
receiptCredentialRequestContext = requestContext.serialize().toByteString()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
SignalDatabase.inAppPayments.update(updatedPayment)
|
||||
return updatedPayment to requestContext
|
||||
}
|
||||
|
||||
private fun <T> handleApplicationError(inAppPayment: InAppPaymentTable.InAppPayment, serviceResponse: ServiceResponse<T>) {
|
||||
val applicationError = serviceResponse.applicationError.get()
|
||||
when (serviceResponse.status) {
|
||||
204 -> {
|
||||
warning("Payment may not be completed yet. Retry later.", applicationError)
|
||||
throw InAppPaymentRetryException(applicationError)
|
||||
}
|
||||
|
||||
400 -> {
|
||||
warning("Receipt credential failed to validate.", applicationError)
|
||||
}
|
||||
|
||||
402 -> {
|
||||
warning("Payment has failed", applicationError)
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment.copy(
|
||||
notified = false,
|
||||
state = InAppPaymentTable.State.END,
|
||||
data = inAppPayment.data.copy(
|
||||
error = InAppPaymentsRepository.buildPaymentFailure(inAppPayment, (applicationError as? DonationReceiptCredentialError)?.chargeFailure)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
throw IOException(applicationError)
|
||||
}
|
||||
|
||||
409 -> {
|
||||
warning("Receipt already redeemed with a different request credential", applicationError)
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment.copy(
|
||||
notified = false,
|
||||
state = InAppPaymentTable.State.END,
|
||||
data = inAppPayment.data.copy(
|
||||
error = InAppPaymentData.Error(
|
||||
type = InAppPaymentData.Error.Type.REDEMPTION,
|
||||
data_ = "409"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
throw IOException(applicationError)
|
||||
}
|
||||
|
||||
else -> {
|
||||
warning("Encountered a server failure. Retry later", applicationError)
|
||||
throw InAppPaymentRetryException(applicationError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCredentialValid(inAppPayment: InAppPaymentTable.InAppPayment, receiptCredential: ReceiptCredential): Boolean {
|
||||
val now = System.currentTimeMillis().milliseconds
|
||||
val maxExpirationTime = now + 90.days
|
||||
val isCorrectLevel = receiptCredential.receiptLevel == inAppPayment.data.level
|
||||
val isExpiration86400 = receiptCredential.receiptExpirationTime % 86400 == 0L
|
||||
val isExpirationInTheFuture = receiptCredential.receiptExpirationTime.seconds > now
|
||||
val isExpirationWithinMax = receiptCredential.receiptExpirationTime.seconds <= maxExpirationTime
|
||||
|
||||
info(
|
||||
"""
|
||||
Credential Validation
|
||||
-
|
||||
isCorrectLevel $isCorrectLevel actual: ${receiptCredential.receiptLevel} expected: ${inAppPayment.data.level}
|
||||
isExpiration86400 $isExpiration86400
|
||||
isExpirationInTheFuture $isExpirationInTheFuture
|
||||
isExpirationWithinMax $isExpirationWithinMax
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
return isCorrectLevel && isExpiration86400 && isExpirationInTheFuture && isExpirationWithinMax
|
||||
}
|
||||
|
||||
private fun info(message: String, throwable: Throwable? = null) {
|
||||
Log.i(TAG, "InAppPayment[$inAppPaymentId]: $message", throwable, true)
|
||||
}
|
||||
|
||||
private fun warning(message: String, throwable: Throwable? = null) {
|
||||
Log.w(TAG, "InAppPayment[$inAppPaymentId]: $message", throwable, true)
|
||||
}
|
||||
|
||||
class Factory : Job.Factory<InAppPaymentOneTimeContextJob> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): InAppPaymentOneTimeContextJob {
|
||||
return InAppPaymentOneTimeContextJob(
|
||||
InAppPaymentTable.InAppPaymentId(serializedData!!.decodeToString().toLong()),
|
||||
parameters
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredential
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequestContext
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialResponse
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toInAppPaymentDataChargeFailure
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobmanager.JobManager.Chain
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription.ChargeFailure
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription.Subscription
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import java.io.IOException
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Given an inAppPaymentId, we want to take it's unconverted receipt context and
|
||||
* turn it into a receipt presentation.
|
||||
*/
|
||||
class InAppPaymentRecurringContextJob private constructor(
|
||||
private val inAppPaymentId: InAppPaymentTable.InAppPaymentId,
|
||||
parameters: Parameters
|
||||
) : BaseJob(parameters) {
|
||||
companion object {
|
||||
private val TAG = Log.tag(InAppPaymentRecurringContextJob::class.java)
|
||||
|
||||
const val KEY = "InAppPurchaseRecurringContextJob"
|
||||
|
||||
private fun create(inAppPayment: InAppPaymentTable.InAppPayment): Job {
|
||||
return InAppPaymentRecurringContextJob(
|
||||
inAppPaymentId = inAppPayment.id,
|
||||
parameters = Parameters.Builder()
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setQueue(InAppPaymentsRepository.resolveJobQueueKey(inAppPayment))
|
||||
.setLifespan(InAppPaymentsRepository.resolveContextJobLifespan(inAppPayment).inWholeMilliseconds)
|
||||
.setMaxAttempts(Parameters.UNLIMITED)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
fun createJobChain(inAppPayment: InAppPaymentTable.InAppPayment, makePrimary: Boolean = false): Chain {
|
||||
return ApplicationDependencies.getJobManager()
|
||||
.startChain(create(inAppPayment))
|
||||
.then(InAppPaymentRedemptionJob.create(inAppPayment, makePrimary))
|
||||
.then(RefreshOwnProfileJob())
|
||||
.then(MultiDeviceProfileContentUpdateJob())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAdded() {
|
||||
val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)
|
||||
if (inAppPayment?.state == InAppPaymentTable.State.CREATED) {
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment.copy(
|
||||
state = InAppPaymentTable.State.PENDING
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun serialize(): ByteArray = inAppPaymentId.serialize().toByteArray()
|
||||
|
||||
override fun getFactoryKey(): String = KEY
|
||||
|
||||
override fun onFailure() {
|
||||
warning("A permanent failure occurred.")
|
||||
|
||||
val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)
|
||||
if (inAppPayment != null && inAppPayment.data.error == null) {
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment.copy(
|
||||
notified = false,
|
||||
state = InAppPaymentTable.State.END,
|
||||
data = inAppPayment.data.copy(
|
||||
error = InAppPaymentData.Error(
|
||||
type = InAppPaymentData.Error.Type.REDEMPTION
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getNextRunAttemptBackoff(pastAttemptCount: Int, exception: java.lang.Exception): Long {
|
||||
val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)
|
||||
return if (inAppPayment != null) {
|
||||
when (inAppPayment.data.paymentMethodType) {
|
||||
InAppPaymentData.PaymentMethodType.SEPA_DEBIT, InAppPaymentData.PaymentMethodType.IDEAL -> 1.days.inWholeMilliseconds
|
||||
else -> super.getNextRunAttemptBackoff(pastAttemptCount, exception)
|
||||
}
|
||||
} else {
|
||||
super.getNextRunAttemptBackoff(pastAttemptCount, exception)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRun() {
|
||||
synchronized(InAppPaymentsRepository.resolveMutex(inAppPaymentId)) {
|
||||
doRun()
|
||||
}
|
||||
}
|
||||
|
||||
private fun doRun() {
|
||||
val (inAppPayment, requestContext) = getAndValidateInAppPayment()
|
||||
val activeSubscription = getActiveSubscription(inAppPayment)
|
||||
val subscription = activeSubscription.activeSubscription
|
||||
|
||||
if (subscription == null) {
|
||||
warning("Subscription is null. Retrying later.")
|
||||
throw InAppPaymentRetryException()
|
||||
}
|
||||
|
||||
handlePossibleFailedPayment(inAppPayment, activeSubscription, subscription)
|
||||
handlePossibleInactiveSubscription(inAppPayment, activeSubscription, subscription)
|
||||
|
||||
info("Subscription is valid, proceeding with request for ReceiptCredentialResponse")
|
||||
|
||||
val updatedInAppPayment: InAppPaymentTable.InAppPayment = if (inAppPayment.data.redemption!!.stage != InAppPaymentData.RedemptionState.Stage.CONVERSION_STARTED || inAppPayment.endOfPeriod.inWholeMilliseconds <= 0) {
|
||||
info("Updating payment state with endOfCurrentPeriod and proper stage.")
|
||||
|
||||
if (inAppPayment.type.requireSubscriberType() == InAppPaymentSubscriberRecord.Type.DONATION) {
|
||||
info("Recording last end of period.")
|
||||
SignalStore.donationsValues().setLastEndOfPeriod(subscription.endOfCurrentPeriod)
|
||||
}
|
||||
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment.copy(
|
||||
endOfPeriod = subscription.endOfCurrentPeriod.seconds,
|
||||
data = inAppPayment.data.copy(
|
||||
redemption = inAppPayment.data.redemption.copy(
|
||||
stage = InAppPaymentData.RedemptionState.Stage.CONVERSION_STARTED
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
requireNotNull(SignalDatabase.inAppPayments.getById(inAppPaymentId))
|
||||
} else {
|
||||
inAppPayment
|
||||
}
|
||||
|
||||
submitAndValidateCredentials(updatedInAppPayment, subscription, requestContext)
|
||||
}
|
||||
|
||||
private fun getAndValidateInAppPayment(): Pair<InAppPaymentTable.InAppPayment, ReceiptCredentialRequestContext> {
|
||||
val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)
|
||||
if (inAppPayment == null) {
|
||||
warning("Not found")
|
||||
throw IOException("InAppPayment for given ID not found.")
|
||||
}
|
||||
|
||||
if (inAppPayment.state != InAppPaymentTable.State.PENDING) {
|
||||
warning("Unexpected state. Got ${inAppPayment.state} but expected PENDING")
|
||||
throw IOException("InAppPayment in unexpected state.")
|
||||
}
|
||||
|
||||
if (!inAppPayment.type.recurring) {
|
||||
warning("Unexpected type. Got ${inAppPayment.type} but expected a recurring type.")
|
||||
throw IOException("InAppPayment is an unexpected type.")
|
||||
}
|
||||
|
||||
if (inAppPayment.subscriberId == null) {
|
||||
warning("Expected a subscriber id.")
|
||||
throw IOException("InAppPayment is missing its subscriber id")
|
||||
}
|
||||
|
||||
if (inAppPayment.data.redemption == null) {
|
||||
warning("Expected redemption state.")
|
||||
throw IOException("InAppPayment has no redemption state. Waiting for authorization?")
|
||||
}
|
||||
|
||||
if (inAppPayment.data.redemption.stage == InAppPaymentData.RedemptionState.Stage.REDEMPTION_STARTED || inAppPayment.data.redemption.stage == InAppPaymentData.RedemptionState.Stage.REDEEMED) {
|
||||
warning("Already began redemption.")
|
||||
throw IOException("InAppPayment has already started redemption.")
|
||||
}
|
||||
|
||||
return if (inAppPayment.data.redemption.receiptCredentialRequestContext == null) {
|
||||
val requestContext = InAppPaymentsRepository.generateRequestCredential()
|
||||
val updatedPayment = inAppPayment.copy(
|
||||
data = inAppPayment.data.copy(
|
||||
redemption = inAppPayment.data.redemption.copy(
|
||||
stage = InAppPaymentData.RedemptionState.Stage.CONVERSION_STARTED,
|
||||
receiptCredentialRequestContext = requestContext.serialize().toByteString()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
SignalDatabase.inAppPayments.update(updatedPayment)
|
||||
updatedPayment to requestContext
|
||||
} else {
|
||||
inAppPayment to ReceiptCredentialRequestContext(inAppPayment.data.redemption.receiptCredentialRequestContext.toByteArray())
|
||||
}
|
||||
}
|
||||
|
||||
private fun getActiveSubscription(inAppPayment: InAppPaymentTable.InAppPayment): ActiveSubscription {
|
||||
val activeSubscriptionResponse = ApplicationDependencies.getDonationsService().getSubscription(inAppPayment.subscriberId)
|
||||
return if (activeSubscriptionResponse.result.isPresent) {
|
||||
activeSubscriptionResponse.result.get()
|
||||
} else if (activeSubscriptionResponse.applicationError.isPresent) {
|
||||
warning("An application error occurred while trying to get the active subscription. Failing.", activeSubscriptionResponse.applicationError.get())
|
||||
throw IOException(activeSubscriptionResponse.applicationError.get())
|
||||
} else {
|
||||
warning("An execution error occurred. Retrying later.", activeSubscriptionResponse.executionError.get())
|
||||
throw InAppPaymentRetryException(activeSubscriptionResponse.executionError.get())
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePossibleFailedPayment(
|
||||
inAppPayment: InAppPaymentTable.InAppPayment,
|
||||
activeSubscription: ActiveSubscription,
|
||||
subscription: Subscription
|
||||
) {
|
||||
if (subscription.isFailedPayment) {
|
||||
val chargeFailure = activeSubscription.chargeFailure
|
||||
if (chargeFailure != null) {
|
||||
warning("Charge failure detected on active subscription: ${chargeFailure.code}: ${chargeFailure.message}")
|
||||
}
|
||||
|
||||
if (inAppPayment.data.redemption!!.keepAlive == true) {
|
||||
warning("Payment failure during keep-alive, allow keep-alive to retry later.")
|
||||
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment.copy(
|
||||
notified = true,
|
||||
data = inAppPayment.data.copy(
|
||||
error = InAppPaymentData.Error(
|
||||
type = InAppPaymentData.Error.Type.PAYMENT_PROCESSING,
|
||||
data_ = "keep-alive"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
throw Exception("Payment renewal is in keep-alive state, let keep-alive process retry later.")
|
||||
} else {
|
||||
handlePaymentFailure(inAppPayment, subscription, chargeFailure)
|
||||
throw Exception("New subscription has a payment failure: ${subscription.status}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePossibleInactiveSubscription(
|
||||
inAppPayment: InAppPaymentTable.InAppPayment,
|
||||
activeSubscription: ActiveSubscription,
|
||||
subscription: Subscription
|
||||
) {
|
||||
val isForKeepAlive = inAppPayment.data.redemption!!.keepAlive == true
|
||||
if (!subscription.isActive) {
|
||||
val chargeFailure = activeSubscription.chargeFailure
|
||||
if (chargeFailure != null) {
|
||||
warning("Subscription payment charge failure code: ${chargeFailure.code}, message: ${chargeFailure.message}")
|
||||
|
||||
if (!isForKeepAlive) {
|
||||
warning("Initial subscription payment failed, treating as a permanent failure.")
|
||||
handlePaymentFailure(inAppPayment, subscription, chargeFailure)
|
||||
throw Exception("New subscription has hit a payment failure.")
|
||||
}
|
||||
}
|
||||
|
||||
if (isForKeepAlive && subscription.isCanceled) {
|
||||
warning("Permanent payment failure in renewing subscription. Status: ${subscription.status}")
|
||||
handlePaymentFailure(inAppPayment, subscription, chargeFailure)
|
||||
throw Exception()
|
||||
}
|
||||
|
||||
warning("Subscription is not yet active. Status: ${subscription.status}")
|
||||
throw InAppPaymentRetryException()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePaymentFailure(inAppPayment: InAppPaymentTable.InAppPayment, subscription: Subscription, chargeFailure: ChargeFailure?) {
|
||||
val subscriber = SignalDatabase.inAppPaymentSubscribers.getBySubscriberId(inAppPayment.subscriberId!!)
|
||||
if (subscriber != null) {
|
||||
InAppPaymentsRepository.setShouldCancelSubscriptionBeforeNextSubscribeAttempt(subscriber, true)
|
||||
}
|
||||
|
||||
if (inAppPayment.data.redemption?.keepAlive == true) {
|
||||
info("Cancellation occurred during keep-alive. Setting cancellation state.")
|
||||
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment.copy(
|
||||
notified = false,
|
||||
state = InAppPaymentTable.State.END,
|
||||
data = inAppPayment.data.copy(
|
||||
cancellation = InAppPaymentData.Cancellation(
|
||||
reason = when (subscription.status) {
|
||||
"past_due" -> InAppPaymentData.Cancellation.Reason.PAST_DUE
|
||||
"canceled" -> InAppPaymentData.Cancellation.Reason.CANCELED
|
||||
"unpaid" -> InAppPaymentData.Cancellation.Reason.UNPAID
|
||||
else -> InAppPaymentData.Cancellation.Reason.UNKNOWN
|
||||
},
|
||||
chargeFailure = chargeFailure?.toInAppPaymentDataChargeFailure()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
MultiDeviceSubscriptionSyncRequestJob.enqueue()
|
||||
} else if (chargeFailure != null) {
|
||||
info("Charge failure detected: $chargeFailure")
|
||||
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment.copy(
|
||||
notified = false,
|
||||
state = InAppPaymentTable.State.END,
|
||||
data = inAppPayment.data.copy(
|
||||
error = InAppPaymentsRepository.buildPaymentFailure(inAppPayment, chargeFailure)
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
info("Generic payment failure detected.")
|
||||
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment.copy(
|
||||
notified = false,
|
||||
state = InAppPaymentTable.State.END,
|
||||
data = inAppPayment.data.copy(
|
||||
error = InAppPaymentData.Error(
|
||||
type = InAppPaymentData.Error.Type.PAYMENT_PROCESSING,
|
||||
data_ = subscription.status
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun submitAndValidateCredentials(
|
||||
inAppPayment: InAppPaymentTable.InAppPayment,
|
||||
subscription: Subscription,
|
||||
requestContext: ReceiptCredentialRequestContext
|
||||
) {
|
||||
info("Submitting receipt credential request")
|
||||
val response: ServiceResponse<ReceiptCredentialResponse> = when (inAppPayment.type) {
|
||||
InAppPaymentTable.Type.RECURRING_DONATION -> ApplicationDependencies.getDonationsService().submitReceiptCredentialRequestSync(inAppPayment.subscriberId!!, requestContext.request)
|
||||
else -> throw Exception("Unsupported type: ${inAppPayment.type}")
|
||||
}
|
||||
|
||||
if (response.applicationError.isPresent) {
|
||||
handleApplicationError(inAppPayment, response)
|
||||
} else if (response.result.isPresent) {
|
||||
handleResult(inAppPayment, subscription, requestContext, response.result.get())
|
||||
} else {
|
||||
warning("Encountered a retryable exception.", response.executionError.get())
|
||||
throw InAppPaymentRetryException(response.executionError.get())
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleApplicationError(
|
||||
inAppPayment: InAppPaymentTable.InAppPayment,
|
||||
serviceResponse: ServiceResponse<ReceiptCredentialResponse>
|
||||
) {
|
||||
val isForKeepAlive = inAppPayment.data.redemption!!.keepAlive == true
|
||||
val applicationError = serviceResponse.applicationError.get()
|
||||
when (serviceResponse.status) {
|
||||
204 -> {
|
||||
warning("Payment is still processing. Try again later.", applicationError)
|
||||
throw InAppPaymentRetryException(applicationError)
|
||||
}
|
||||
|
||||
400 -> {
|
||||
warning("Receipt credential request failed to validate.", applicationError)
|
||||
updateInAppPaymentWithGenericRedemptionError(inAppPayment)
|
||||
throw Exception(applicationError)
|
||||
}
|
||||
|
||||
402 -> {
|
||||
warning("Payment looks like a failure but may be retried", applicationError)
|
||||
throw InAppPaymentRetryException(applicationError)
|
||||
}
|
||||
|
||||
403 -> {
|
||||
warning("SubscriberId password mismatch or account auth was present", applicationError)
|
||||
updateInAppPaymentWithGenericRedemptionError(inAppPayment)
|
||||
throw Exception(applicationError)
|
||||
}
|
||||
|
||||
404 -> {
|
||||
warning("SubscriberId not found or malformed.", applicationError)
|
||||
updateInAppPaymentWithGenericRedemptionError(inAppPayment)
|
||||
throw Exception(applicationError)
|
||||
}
|
||||
|
||||
409 -> {
|
||||
if (isForKeepAlive) {
|
||||
warning("Already redeemed this token during keep-alive, ignoring.", applicationError)
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment.copy(
|
||||
state = InAppPaymentTable.State.END,
|
||||
data = inAppPayment.data.copy(redemption = inAppPayment.data.redemption.copy(stage = InAppPaymentData.RedemptionState.Stage.REDEEMED))
|
||||
)
|
||||
)
|
||||
} else {
|
||||
warning("Already redeemed this token during new subscription. Failing.", applicationError)
|
||||
updateInAppPaymentWithGenericRedemptionError(inAppPayment)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
warning("Encountered a server error.", applicationError)
|
||||
|
||||
throw InAppPaymentRetryException(applicationError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleResult(
|
||||
inAppPayment: InAppPaymentTable.InAppPayment,
|
||||
subscription: Subscription,
|
||||
requestContext: ReceiptCredentialRequestContext,
|
||||
response: ReceiptCredentialResponse
|
||||
) {
|
||||
val operations = ApplicationDependencies.getClientZkReceiptOperations()
|
||||
val receiptCredential: ReceiptCredential = try {
|
||||
operations.receiveReceiptCredential(requestContext, response)
|
||||
} catch (e: VerificationFailedException) {
|
||||
warning("Encountered an exception when receiving receipt credential from zk.", e)
|
||||
throw InAppPaymentRetryException(e)
|
||||
}
|
||||
|
||||
if (!isCredentialValid(subscription, receiptCredential)) {
|
||||
updateInAppPaymentWithGenericRedemptionError(inAppPayment)
|
||||
throw IOException("Could not validate receipt credential")
|
||||
}
|
||||
|
||||
val receiptCredentialPresentation: ReceiptCredentialPresentation = try {
|
||||
operations.createReceiptCredentialPresentation(receiptCredential)
|
||||
} catch (e: VerificationFailedException) {
|
||||
warning("Encountered an exception when creating receipt credential presentation via zk.", e)
|
||||
throw InAppPaymentRetryException(e)
|
||||
}
|
||||
|
||||
info("Validated credential. Recording receipt and handing off to redemption job.")
|
||||
SignalDatabase.donationReceipts.addReceipt(DonationReceiptRecord.createForSubscription(subscription))
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment = inAppPayment.copy(
|
||||
data = inAppPayment.data.copy(
|
||||
redemption = inAppPayment.data.redemption!!.copy(
|
||||
stage = InAppPaymentData.RedemptionState.Stage.REDEMPTION_STARTED,
|
||||
receiptCredentialPresentation = receiptCredentialPresentation.serialize().toByteString()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun isCredentialValid(subscription: Subscription, receiptCredential: ReceiptCredential): Boolean {
|
||||
val now = System.currentTimeMillis().milliseconds
|
||||
val maxExpirationTime = now + 90.days
|
||||
val isSameLevel = subscription.level.toLong() == receiptCredential.receiptLevel
|
||||
val isExpirationAfterSub = subscription.endOfCurrentPeriod < receiptCredential.receiptExpirationTime
|
||||
val isExpiration86400 = receiptCredential.receiptExpirationTime % 86400L == 0L
|
||||
val isExpirationInTheFuture = receiptCredential.receiptExpirationTime.seconds > now
|
||||
val isExpirationWithinMax = receiptCredential.receiptExpirationTime.seconds <= maxExpirationTime
|
||||
|
||||
info(
|
||||
"""
|
||||
Credential Validation
|
||||
isSameLevel $isSameLevel
|
||||
isExpirationAfterSub $isExpirationAfterSub
|
||||
isExpiration86400 $isExpiration86400
|
||||
isExpirationInTheFuture $isExpirationInTheFuture
|
||||
isExpirationWithinMax $isExpirationWithinMax
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
return isSameLevel && isExpirationAfterSub && isExpiration86400 && isExpirationInTheFuture && isExpirationWithinMax
|
||||
}
|
||||
|
||||
private fun updateInAppPaymentWithGenericRedemptionError(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment = inAppPayment.copy(
|
||||
state = InAppPaymentTable.State.END,
|
||||
data = inAppPayment.data.copy(
|
||||
error = InAppPaymentData.Error(
|
||||
type = InAppPaymentData.Error.Type.REDEMPTION
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onShouldRetry(e: Exception): Boolean = e is InAppPaymentRetryException
|
||||
|
||||
private fun info(message: String, throwable: Throwable? = null) {
|
||||
Log.i(TAG, "InAppPayment[$inAppPaymentId]: $message", throwable, true)
|
||||
}
|
||||
|
||||
private fun warning(message: String, throwable: Throwable? = null) {
|
||||
Log.w(TAG, "InAppPayment[$inAppPaymentId]: $message", throwable, true)
|
||||
}
|
||||
|
||||
class Factory : Job.Factory<InAppPaymentRecurringContextJob> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): InAppPaymentRecurringContextJob {
|
||||
return InAppPaymentRecurringContextJob(
|
||||
InAppPaymentTable.InAppPaymentId(serializedData!!.decodeToString().toLong()),
|
||||
parameters
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
|
||||
import org.thoughtcrime.securesms.jobs.protos.InAppPaymentRedemptionJobData
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.hasGiftBadge
|
||||
import org.thoughtcrime.securesms.util.requireGiftBadge
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Takes a ReceiptCredentialResponse and submits it to the server for redemption.
|
||||
*
|
||||
* Redemption is fairly straight forward:
|
||||
* 1. Verify we have all the peices of data we need and haven't already redeemed the token
|
||||
* 2. Attempt to redeem the token
|
||||
* 3. Either mark down the error or the success
|
||||
*/
|
||||
class InAppPaymentRedemptionJob private constructor(
|
||||
private val jobData: InAppPaymentRedemptionJobData,
|
||||
parameters: Parameters
|
||||
) : BaseJob(parameters) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(InAppPaymentRedemptionJob::class.java)
|
||||
const val KEY = "InAppPurchaseRedemptionJob"
|
||||
|
||||
private const val MAX_RETRIES = 1500
|
||||
|
||||
/**
|
||||
* Utilized when active subscription is in the REDEMPTION_STARTED stage of the redemption pipeline
|
||||
*/
|
||||
fun enqueueJobChainForRecurringKeepAlive(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
ApplicationDependencies.getJobManager()
|
||||
.startChain(create(inAppPayment, makePrimary = false))
|
||||
.then(RefreshOwnProfileJob())
|
||||
.then(MultiDeviceProfileContentUpdateJob())
|
||||
.enqueue()
|
||||
}
|
||||
|
||||
fun create(
|
||||
inAppPayment: InAppPaymentTable.InAppPayment? = null,
|
||||
makePrimary: Boolean = false
|
||||
): Job {
|
||||
return create(
|
||||
inAppPayment = inAppPayment,
|
||||
giftMessageId = null,
|
||||
makePrimary = makePrimary
|
||||
)
|
||||
}
|
||||
|
||||
fun create(
|
||||
giftMessageId: MessageId,
|
||||
makePrimary: Boolean
|
||||
): Job {
|
||||
return create(
|
||||
inAppPayment = null,
|
||||
giftMessageId = giftMessageId,
|
||||
makePrimary = makePrimary
|
||||
)
|
||||
}
|
||||
|
||||
private fun create(
|
||||
inAppPayment: InAppPaymentTable.InAppPayment? = null,
|
||||
makePrimary: Boolean = false,
|
||||
giftMessageId: MessageId? = null
|
||||
): Job {
|
||||
return InAppPaymentRedemptionJob(
|
||||
jobData = InAppPaymentRedemptionJobData(
|
||||
inAppPaymentId = inAppPayment?.id?.rowId,
|
||||
giftMessageId = giftMessageId?.id,
|
||||
makePrimary = makePrimary
|
||||
),
|
||||
parameters = Parameters.Builder()
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setQueue(inAppPayment?.let { InAppPaymentsRepository.resolveJobQueueKey(it) } ?: "InAppGiftReceiptRedemption-$giftMessageId")
|
||||
.setMaxAttempts(MAX_RETRIES)
|
||||
.setLifespan(Parameters.IMMORTAL)
|
||||
.build()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun serialize(): ByteArray = jobData.encode()
|
||||
|
||||
override fun getFactoryKey(): String = KEY
|
||||
|
||||
override fun onAdded() {
|
||||
if (jobData.giftMessageId != null) {
|
||||
Log.d(TAG, "GiftMessage with ID ${jobData.giftMessageId} will be marked as started")
|
||||
SignalDatabase.messages.markGiftRedemptionStarted(jobData.giftMessageId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure() {
|
||||
if (jobData.giftMessageId != null) {
|
||||
Log.d(TAG, "GiftMessage with ID ${jobData.giftMessageId} will be marked as a failure")
|
||||
SignalDatabase.messages.markGiftRedemptionFailed(jobData.giftMessageId)
|
||||
}
|
||||
|
||||
if (jobData.inAppPaymentId != null) {
|
||||
Log.w(TAG, "A permanent failure occurred.")
|
||||
|
||||
val inAppPayment = SignalDatabase.inAppPayments.getById(InAppPaymentTable.InAppPaymentId(jobData.inAppPaymentId))
|
||||
if (inAppPayment != null && inAppPayment.data.error == null) {
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment.copy(
|
||||
notified = false,
|
||||
state = InAppPaymentTable.State.END,
|
||||
data = inAppPayment.data.copy(
|
||||
error = InAppPaymentData.Error(
|
||||
type = InAppPaymentData.Error.Type.REDEMPTION
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRun() {
|
||||
if (jobData.inAppPaymentId != null) {
|
||||
onRunForInAppPayment(InAppPaymentTable.InAppPaymentId(jobData.inAppPaymentId))
|
||||
} else {
|
||||
onRunForGiftMessageId(jobData.giftMessageId!!)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRunForGiftMessageId(messageId: Long) {
|
||||
val giftBadge = SignalDatabase.messages.getMessageRecord(messageId)
|
||||
if (!giftBadge.hasGiftBadge()) {
|
||||
Log.w(TAG, "GiftMessage with ID $messageId not found. Failing.", true)
|
||||
return
|
||||
}
|
||||
|
||||
if (giftBadge.requireGiftBadge().redemptionState == GiftBadge.RedemptionState.REDEEMED) {
|
||||
Log.w(TAG, "GiftMessage with ID $messageId has already been redeemed. Exiting.", true)
|
||||
}
|
||||
|
||||
val credentialBytes = giftBadge.requireGiftBadge().redemptionToken.toByteArray()
|
||||
val receiptCredentialPresentation = ReceiptCredentialPresentation(credentialBytes)
|
||||
|
||||
Log.d(TAG, "Attempting to redeem receipt credential presentation...", true)
|
||||
val serviceResponse = ApplicationDependencies
|
||||
.getDonationsService()
|
||||
.redeemReceipt(
|
||||
receiptCredentialPresentation,
|
||||
SignalStore.donationsValues().getDisplayBadgesOnProfile(),
|
||||
jobData.makePrimary
|
||||
)
|
||||
|
||||
verifyServiceResponse(serviceResponse)
|
||||
|
||||
Log.d(TAG, "GiftMessage with ID $messageId has been redeemed.")
|
||||
SignalDatabase.messages.markGiftRedemptionCompleted(messageId)
|
||||
}
|
||||
|
||||
private fun onRunForInAppPayment(inAppPaymentId: InAppPaymentTable.InAppPaymentId) {
|
||||
val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)
|
||||
if (inAppPayment == null) {
|
||||
Log.w(TAG, "InAppPayment with ID $inAppPaymentId not found. Failing.", true)
|
||||
return
|
||||
}
|
||||
|
||||
if (inAppPayment.type.recurring) {
|
||||
synchronized(inAppPayment.type.requireSubscriberType()) {
|
||||
performInAppPaymentRedemption(inAppPayment)
|
||||
}
|
||||
} else {
|
||||
performInAppPaymentRedemption(inAppPayment)
|
||||
}
|
||||
}
|
||||
|
||||
private fun performInAppPaymentRedemption(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
val inAppPaymentId = inAppPayment.id
|
||||
if (inAppPayment.state != InAppPaymentTable.State.PENDING) {
|
||||
Log.w(TAG, "InAppPayment with ID $inAppPaymentId is in state ${inAppPayment.state}, expected PENDING. Exiting.", true)
|
||||
return
|
||||
}
|
||||
|
||||
if (inAppPayment.data.redemption == null || inAppPayment.data.redemption.stage != InAppPaymentData.RedemptionState.Stage.REDEMPTION_STARTED) {
|
||||
Log.w(TAG, "Recurring InAppPayment with ID $inAppPaymentId is in stage ${inAppPayment.data.redemption?.stage}. Expected REDEMPTION_STARTED. Exiting.", true)
|
||||
return
|
||||
}
|
||||
|
||||
val credentialBytes = inAppPayment.data.redemption.receiptCredentialPresentation
|
||||
if (credentialBytes == null) {
|
||||
Log.w(TAG, "InAppPayment with ID $inAppPaymentId does not have a receipt credential presentation. Nothing to redeem. Exiting.", true)
|
||||
return
|
||||
}
|
||||
|
||||
val receiptCredentialPresentation = ReceiptCredentialPresentation(credentialBytes.toByteArray())
|
||||
|
||||
Log.d(TAG, "Attempting to redeem receipt credential presentation...", true)
|
||||
val serviceResponse = ApplicationDependencies
|
||||
.getDonationsService()
|
||||
.redeemReceipt(
|
||||
receiptCredentialPresentation,
|
||||
SignalStore.donationsValues().getDisplayBadgesOnProfile(),
|
||||
jobData.makePrimary
|
||||
)
|
||||
|
||||
verifyServiceResponse(serviceResponse) {
|
||||
val protoError = InAppPaymentData.Error(
|
||||
type = InAppPaymentData.Error.Type.REDEMPTION,
|
||||
data_ = serviceResponse.status.toString()
|
||||
)
|
||||
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment = inAppPayment.copy(
|
||||
data = inAppPayment.data.copy(
|
||||
error = protoError
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.i(TAG, "InAppPayment with ID $inAppPaymentId was successfully redeemed. Response code: ${serviceResponse.status}")
|
||||
SignalDatabase.inAppPayments.update(
|
||||
inAppPayment = inAppPayment.copy(
|
||||
state = InAppPaymentTable.State.END,
|
||||
data = inAppPayment.data.copy(
|
||||
redemption = inAppPayment.data.redemption.copy(
|
||||
stage = InAppPaymentData.RedemptionState.Stage.REDEEMED
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun <T> verifyServiceResponse(serviceResponse: ServiceResponse<T>, onFatalError: (Int) -> Unit = {}) {
|
||||
if (serviceResponse.executionError.isPresent) {
|
||||
val error = serviceResponse.executionError.get()
|
||||
Log.w(TAG, "Encountered a retryable error.", error, true)
|
||||
throw InAppPaymentRetryException(error)
|
||||
}
|
||||
|
||||
if (serviceResponse.applicationError.isPresent) {
|
||||
val error = serviceResponse.applicationError.get()
|
||||
if (serviceResponse.status >= 500) {
|
||||
Log.w(TAG, "Encountered a retryable service error", error, true)
|
||||
throw InAppPaymentRetryException(error)
|
||||
} else {
|
||||
Log.w(TAG, "Encountered a non-recoverable error", error, true)
|
||||
onFatalError(serviceResponse.status)
|
||||
|
||||
throw IOException(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onShouldRetry(e: Exception): Boolean = e is InAppPaymentRetryException
|
||||
|
||||
class Factory : Job.Factory<InAppPaymentRedemptionJob> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): InAppPaymentRedemptionJob {
|
||||
return InAppPaymentRedemptionJob(
|
||||
InAppPaymentRedemptionJobData.ADAPTER.decode(serializedData!!),
|
||||
parameters
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
/**
|
||||
* Denotes that a thrown exception can be retried
|
||||
*/
|
||||
class InAppPaymentRetryException(
|
||||
cause: Throwable? = null
|
||||
) : Exception(cause)
|
||||
@@ -82,6 +82,7 @@ import org.thoughtcrime.securesms.migrations.StorageFixLocalUnknownMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.StorageServiceMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.StorageServiceSystemNameMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.StoryViewedReceiptsStateMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.SubscriberIdMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.Svr2MirrorMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.SyncDistributionListsMigrationJob;
|
||||
import org.thoughtcrime.securesms.migrations.SyncKeysMigrationJob;
|
||||
@@ -142,6 +143,12 @@ public final class JobManagerFactories {
|
||||
put(GroupCallPeekWorkerJob.KEY, new GroupCallPeekWorkerJob.Factory());
|
||||
put(GroupRingCleanupJob.KEY, new GroupRingCleanupJob.Factory());
|
||||
put(GroupV2UpdateSelfProfileKeyJob.KEY, new GroupV2UpdateSelfProfileKeyJob.Factory());
|
||||
put(InAppPaymentAuthCheckJob.KEY, new InAppPaymentAuthCheckJob.Factory());
|
||||
put(InAppPaymentGiftSendJob.KEY, new InAppPaymentGiftSendJob.Factory());
|
||||
put(InAppPaymentKeepAliveJob.KEY, new InAppPaymentKeepAliveJob.Factory());
|
||||
put(InAppPaymentRecurringContextJob.KEY, new InAppPaymentRecurringContextJob.Factory());
|
||||
put(InAppPaymentOneTimeContextJob.KEY, new InAppPaymentOneTimeContextJob.Factory());
|
||||
put(InAppPaymentRedemptionJob.KEY, new InAppPaymentRedemptionJob.Factory());
|
||||
put(IndividualSendJob.KEY, new IndividualSendJob.Factory());
|
||||
put(LeaveGroupV2Job.KEY, new LeaveGroupV2Job.Factory());
|
||||
put(LeaveGroupV2WorkerJob.KEY, new LeaveGroupV2WorkerJob.Factory());
|
||||
@@ -223,6 +230,7 @@ public final class JobManagerFactories {
|
||||
put(StorageSyncJob.KEY, new StorageSyncJob.Factory());
|
||||
put(SubscriptionKeepAliveJob.KEY, new SubscriptionKeepAliveJob.Factory());
|
||||
put(SubscriptionReceiptRequestResponseJob.KEY, new SubscriptionReceiptRequestResponseJob.Factory());
|
||||
put(SubscriberIdMigrationJob.KEY, new SubscriberIdMigrationJob.Factory());
|
||||
put(StoryOnboardingDownloadJob.KEY, new StoryOnboardingDownloadJob.Factory());
|
||||
put(SubmitRateLimitPushChallengeJob.KEY, new SubmitRateLimitPushChallengeJob.Factory());
|
||||
put(Svr2MirrorJob.KEY, new Svr2MirrorJob.Factory());
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user