Rewrite in-app-payment flows to prepare for backups support.

This commit is contained in:
Alex Hart
2024-04-19 17:04:15 -03:00
committed by Cody Henthorne
parent b36b00a11c
commit d719edf104
123 changed files with 5429 additions and 1586 deletions

View File

@@ -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();

View File

@@ -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()) {

View File

@@ -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(

View File

@@ -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()
}
)

View File

@@ -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
)

View File

@@ -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) {

View File

@@ -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")
}

View File

@@ -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 {

View File

@@ -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)
}
)

View File

@@ -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,

View File

@@ -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()
}

View File

@@ -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(

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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()
}
}
}

View File

@@ -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()))
}
)

View File

@@ -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))!!
}

View File

@@ -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()))
}
)

View File

@@ -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)))

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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())

View File

@@ -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() {

View File

@@ -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)

View File

@@ -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) }
}

View File

@@ -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

View File

@@ -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()

View File

@@ -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()
}
}

View File

@@ -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
}
}
}

View File

@@ -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()
}
}

View File

@@ -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())
}
}

View File

@@ -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()

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}
}
}

View File

@@ -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())
}
)

View File

@@ -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))!!
}
}
}

View File

@@ -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()
}

View File

@@ -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")

View File

@@ -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))
}
}

View File

@@ -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,

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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.")
}
}

View File

@@ -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 {

View File

@@ -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) }
}
}
}

View File

@@ -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
)
)
}

View File

@@ -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

View File

@@ -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
)
}

View File

@@ -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))
}

View File

@@ -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
}
}
}
}

View File

@@ -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
)

View File

@@ -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?
)
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)

View File

@@ -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()))
}
}

View File

@@ -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
}
}
}

View File

@@ -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
)

View File

@@ -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() {

View File

@@ -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>
}

View File

@@ -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)

View File

@@ -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
}
}
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)
)
}
}

View File

@@ -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)

View File

@@ -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>? {

View File

@@ -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,

View File

@@ -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)

View File

@@ -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
)
}

View File

@@ -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))
}
}

View File

@@ -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
) {

View File

@@ -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))!!

View File

@@ -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()
}
}
}
}

View File

@@ -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()}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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()
}

View File

@@ -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);
}
}

View File

@@ -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 }
}
}

View File

@@ -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 }
}
}
}

View File

@@ -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
}
}

View File

@@ -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) {

View File

@@ -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()
)
}
}

View File

@@ -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)
}
}

View File

@@ -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());

View File

@@ -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:

View File

@@ -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()

View File

@@ -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)
}
}
}

View File

@@ -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 {

View File

@@ -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)
}
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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) }
)
}
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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
)
}
}
}

View File

@@ -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)

View File

@@ -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