diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/BackupTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/BackupTest.kt index 77cff4430d..2f492b9ad4 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/BackupTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/BackupTest.kt @@ -24,13 +24,16 @@ import org.signal.core.util.toInt import org.signal.core.util.withinTransaction import org.signal.libsignal.zkgroup.profiles.ProfileKey import org.thoughtcrime.securesms.backup.v2.database.clearAllDataForBackupRestore +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository import org.thoughtcrime.securesms.database.CallTable import org.thoughtcrime.securesms.database.EmojiSearchTable import org.thoughtcrime.securesms.database.MessageTable import org.thoughtcrime.securesms.database.MessageTypes import org.thoughtcrime.securesms.database.RecipientTable import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList +import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues import org.thoughtcrime.securesms.keyvalue.SignalStore @@ -38,7 +41,6 @@ import org.thoughtcrime.securesms.mms.QuoteModel import org.thoughtcrime.securesms.profiles.ProfileName import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId -import org.thoughtcrime.securesms.subscription.Subscriber import org.thoughtcrime.securesms.testing.assertIs import org.thoughtcrime.securesms.util.TextSecurePreferences import org.whispersystems.signalservice.api.push.ServiceId.ACI @@ -251,8 +253,7 @@ class BackupTest { SignalDatabase.recipients.setProfileName(self.id, ProfileName.fromParts("Peter", "Parker")) SignalDatabase.recipients.setProfileAvatar(self.id, "https://example.com/") - SignalStore.donationsValues().markUserManuallyCancelled() - SignalStore.donationsValues().setSubscriber(Subscriber(SubscriberId.generate(), "USD")) + InAppPaymentsRepository.setSubscriber(InAppPaymentSubscriberRecord(SubscriberId.generate(), "USD", InAppPaymentSubscriberRecord.Type.DONATION, false, InAppPaymentData.PaymentMethodType.UNKNOWN)) SignalStore.donationsValues().setDisplayBadgesOnProfile(false) SignalStore.phoneNumberPrivacy().phoneNumberDiscoverabilityMode = PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/migrations/SubscriberIdMigrationJobTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/migrations/SubscriberIdMigrationJobTest.kt new file mode 100644 index 0000000000..8edde27604 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/migrations/SubscriberIdMigrationJobTest.kt @@ -0,0 +1,54 @@ +package org.thoughtcrime.securesms.migrations + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Test +import org.junit.runner.RunWith +import org.signal.core.util.count +import org.signal.core.util.readToSingleInt +import org.signal.donations.PaymentSourceType +import org.thoughtcrime.securesms.database.InAppPaymentSubscriberTable +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.keyvalue.SignalStore +import org.thoughtcrime.securesms.testing.assertIs +import org.thoughtcrime.securesms.testing.assertIsNotNull +import org.whispersystems.signalservice.api.subscriptions.SubscriberId + +@RunWith(AndroidJUnit4::class) +class SubscriberIdMigrationJobTest { + + private val testSubject = SubscriberIdMigrationJob() + + @Test + fun givenNoSubscriber_whenIRunSubscriberIdMigrationJob_thenIExpectNoDatabaseEntries() { + testSubject.run() + + val actual = SignalDatabase.inAppPaymentSubscribers.readableDatabase.count() + .from(InAppPaymentSubscriberTable.TABLE_NAME) + .run() + .readToSingleInt() + + actual assertIs 0 + } + + @Test + fun givenUSDSubscriber_whenIRunSubscriberIdMigrationJob_thenIExpectASingleEntry() { + val subscriberId = SubscriberId.generate() + SignalStore.donationsValues().setSubscriberCurrency("USD", InAppPaymentSubscriberRecord.Type.DONATION) + SignalStore.donationsValues().setSubscriber("USD", subscriberId) + SignalStore.donationsValues().setSubscriptionPaymentSourceType(PaymentSourceType.PayPal) + SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt = true + + testSubject.run() + + val actual = SignalDatabase.inAppPaymentSubscribers.getByCurrencyCode("USD", InAppPaymentSubscriberRecord.Type.DONATION) + + actual.assertIsNotNull() + actual!!.subscriberId.bytes assertIs subscriberId.bytes + actual.paymentMethodType assertIs InAppPaymentData.PaymentMethodType.PAYPAL + actual.requiresCancel assertIs true + actual.currencyCode assertIs "USD" + actual.type assertIs InAppPaymentSubscriberRecord.Type.DONATION + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index c3bcddebda..c2573e8d1f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/AccountDataProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/AccountDataProcessor.kt index d5e85d13e2..19316007e1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/AccountDataProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/AccountDataProcessor.kt @@ -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()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsCheckoutSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsCheckoutSheet.kt index c84ef99fd3..c386600aea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsCheckoutSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsCheckoutSheet.kt @@ -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, + availablePaymentMethods: List, 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, - onPaymentGatewaySelected: (GatewayResponse.Gateway) -> Unit + availablePaymentGateways: List, + 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( diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowActivity.kt index 0a5517d787..608a9b48e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowActivity.kt @@ -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() } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt index 63ccb5c761..384df3222e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt @@ -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 = emptyList(), - val selectedPaymentGateway: GatewayResponse.Gateway? = null, - val availablePaymentGateways: List = emptyList(), + val selectedPaymentMethod: InAppPaymentData.PaymentMethodType? = null, + val availablePaymentMethods: List = emptyList(), val pin: String = "", val pinKeyboardType: PinKeyboardType = SignalStore.pinValues().keyboardType ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt index 489ee6d37b..9d5d15768a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt index cb9af5b98b..ca36a9efd8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowConfirmationFragment.kt @@ -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(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(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") } diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowRepository.kt index faa0650606..8e33521340 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowRepository.kt @@ -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 { + 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> { return Single .fromCallable { diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowStartFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowStartFragment.kt index 1daf2db16c..b6ba572134 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowStartFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowStartFragment.kt @@ -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) } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowState.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowState.kt index a33c4b8308..61422e5220 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowState.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowViewModel.kt index b32055bdbe..3c72795d53 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/flow/GiftFlowViewModel.kt @@ -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 = store.stateFlowable val events: Observable = 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 { + 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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/received/ViewReceivedGiftViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/received/ViewReceivedGiftViewModel.kt index 1a1ad08d92..b952891c17 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/received/ViewReceivedGiftViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/gifts/viewgift/received/ViewReceivedGiftViewModel.kt @@ -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( diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/MonthlyDonationCanceledBottomSheetDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/MonthlyDonationCanceledBottomSheetDialogFragment.kt index 0c3812c71e..12db8dfab8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/MonthlyDonationCanceledBottomSheetDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/MonthlyDonationCanceledBottomSheetDialogFragment.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/MonthlyDonationCanceledState.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/MonthlyDonationCanceledState.kt new file mode 100644 index 0000000000..6ec7111fcc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/MonthlyDonationCanceledState.kt @@ -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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/MonthlyDonationCanceledViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/MonthlyDonationCanceledViewModel.kt new file mode 100644 index 0000000000..dddaaf421f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/expired/MonthlyDonationCanceledViewModel.kt @@ -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 = 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() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/none/BecomeASustainerFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/none/BecomeASustainerFragment.kt index 156fd4135a..f4a5512596 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/self/none/BecomeASustainerFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/none/BecomeASustainerFragment.kt @@ -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())) } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/none/BecomeASustainerViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/none/BecomeASustainerViewModel.kt index 34b6f8252f..6f86d203f4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/self/none/BecomeASustainerViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/none/BecomeASustainerViewModel.kt @@ -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 create(modelClass: Class): T { return modelClass.cast(BecomeASustainerViewModel(subscriptionsRepository))!! } diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewFragment.kt index b9e3880af7..e20a7d097f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewFragment.kt @@ -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())) } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewViewModel.kt index 08794193f8..5485853ba1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/overview/BadgesOverviewViewModel.kt @@ -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() @@ -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 create(modelClass: Class): T { return requireNotNull(modelClass.cast(BadgesOverviewViewModel(badgeRepository, subscriptionsRepository))) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt index 30fcf18ba6..c3177ac6e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt index b8a79e7a5a..51240c5427 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt index 56186d8742..3d2d676748 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt @@ -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()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index 2f6cd0d2f4..431b513102 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -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() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsRepository.kt index bb61b1943c..4c9065d717 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsRepository.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt index bfff5057eb..ad9332741c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt @@ -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) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/InternalDonorErrorConfigurationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/InternalDonorErrorConfigurationViewModel.kt index 5b5555ed30..e9f1dac075 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/InternalDonorErrorConfigurationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/InternalDonorErrorConfigurationViewModel.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPendingBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPendingBottomSheet.kt index 042b9d22b5..380be4f8f8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPendingBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationPendingBottomSheet.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppDonations.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppDonations.kt index f6b8f58550..c775b29ff6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppDonations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppDonations.kt @@ -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() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsRepository.kt new file mode 100644 index 0000000000..3a32995433 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsRepository.kt @@ -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>() + + /** + * 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> { + 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 { + 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 { + 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 { + val jobStatusObservable: Observable = 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 = 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 + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeDonationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeInAppPaymentRepository.kt similarity index 57% rename from app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeDonationRepository.kt rename to app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeInAppPaymentRepository.kt index 554a1dc8fc..010d3b7a67 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeDonationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeInAppPaymentRepository.kt @@ -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 handleCreatePaymentIntentError(throwable: Throwable, badgeRecipient: RecipientId, paymentSourceType: PaymentSourceType): Single { 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() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/PayPalRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/PayPalRepository.kt index 2bc0572bd6..799d673196 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/PayPalRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/PayPalRepository.kt @@ -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 { + fun createPaymentMethod(subscriberType: InAppPaymentSubscriberRecord.Type, retryOn409: Boolean = true): Single { 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()) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/MonthlyDonationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepository.kt similarity index 61% rename from app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/MonthlyDonationRepository.kt rename to app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepository.kt index 33a85b39c7..cc1e0a43db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/MonthlyDonationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepository.kt @@ -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 { - val localSubscription = SignalStore.donationsValues().getSubscriber() + fun getActiveSubscription(type: InAppPaymentSubscriberRecord.Type): Single { + val localSubscription = InAppPaymentsRepository.getSubscriber(type) return if (localSubscription != null) { Single.fromCallable { donationsService.getSubscription(localSubscription.subscriberId) } .subscribeOn(Schedulers.io()) .flatMap(ServiceResponse::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 { + 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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt index a06df8f543..067fa12d0a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/StripeRepository.kt @@ -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 { - 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::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::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) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/TerminalDonationDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/TerminalDonationDelegate.kt index 5add1735bf..36564b8c17 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/TerminalDonationDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/TerminalDonationDelegate.kt @@ -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) + } + } + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyFragment.kt index 75a0b0fece..49a23cee46 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyFragment.kt @@ -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()) } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyViewModel.kt index 8dd772503b..03cda61b89 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/currency/SetCurrencyViewModel.kt @@ -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 ) : 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) : ViewModelProvider.Factory { + class Factory(private val inAppPaymentType: InAppPaymentTable.Type, private val supportedCurrencyCodes: List) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return modelClass.cast(SetCurrencyViewModel(isOneTime, supportedCurrencyCodes))!! + return modelClass.cast(SetCurrencyViewModel(inAppPaymentType, supportedCurrencyCodes))!! } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalAction.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalAction.kt index e4a1987adb..b49a7b1996 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalAction.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalAction.kt @@ -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) : 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) : DonateToSignalAction() + data class DisplayGatewaySelectorDialog(val inAppPayment: InAppPaymentTable.InAppPayment) : DonateToSignalAction() + object CancelSubscription : DonateToSignalAction() + data class UpdateSubscription(val inAppPayment: InAppPaymentTable.InAppPayment, val isLongRunning: Boolean) : DonateToSignalAction() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalActivity.kt index b13405a7d3..dde2067f83 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalActivity.kt @@ -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 = 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") diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt index 1e797693a9..6755a8be14 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt @@ -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)) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalState.kt index 3ae60975bf..c65454977a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalState.kt @@ -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 - 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 = emptyList(), private val _activeSubscription: ActiveSubscription? = null, val selectedSubscription: Subscription? = null, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalType.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalType.kt deleted file mode 100644 index 2c2b46a7d3..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalType.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.thoughtcrime.securesms.components.settings.app.subscription.donate - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize -import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource - -@Parcelize -enum class DonateToSignalType(val requestCode: Short) : Parcelable { - ONE_TIME(16141), - MONTHLY(16142), - GIFT(16143); - - fun toErrorSource(): DonationErrorSource { - return when (this) { - ONE_TIME -> DonationErrorSource.ONE_TIME - MONTHLY -> DonationErrorSource.MONTHLY - GIFT -> DonationErrorSource.GIFT - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt index bd3a83dee3..ccda2094fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt @@ -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() private val _activeSubscription = PublishSubject.create() + private val _inAppPaymentId = BehaviorProcessor.create() val state = store.stateFlowable.observeOn(AndroidSchedulers.mainThread()) val actions: Observable = _actions.observeOn(AndroidSchedulers.mainThread()) - val uiSessionKey: Long = System.currentTimeMillis() + val inAppPaymentId: Flowable = _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 { 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> = DonationRedemptionJobWatcher.watchOneTimeRedemption().map { + private fun initializeOneTimeDonationState(oneTimeInAppPaymentRepository: OneTimeInAppPaymentRepository) { + val oneTimeDonationFromJob: Observable> = 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>> = oneTimeDonationRepository.getBoosts().toObservable() + val boosts: Observable>> = oneTimeInAppPaymentRepository.getBoosts().toObservable() val oneTimeCurrency: Observable = 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 = DonationRedemptionJobWatcher.watchSubscriptionRedemption() + val redemptionJobStatus: Observable = 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 create(modelClass: Class): T { - return modelClass.cast(DonateToSignalViewModel(startType, subscriptionsRepository, oneTimeDonationRepository)) as T + return modelClass.cast(DonateToSignalViewModel(startType, subscriptionsRepository, oneTimeInAppPaymentRepository)) as T } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt index 99ec816449..4b1a441b4f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutDelegate.kt @@ -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 ) : 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 + ) { 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 { + 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 { - return DonationErrorParams.ErrorAction( - label = R.string.DeclineCode__try, - action = { - tryAgain = true - } - ) - } - - override fun onTryBankTransferAgain(context: Context): DonationErrorParams.ErrorAction { - 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 { + return DonationErrorParams.ErrorAction( + label = R.string.DeclineCode__try, + action = { + tryAgain = true + } + ) + } + + override fun onTryBankTransferAgain(context: Context): DonationErrorParams.ErrorAction { + 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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutViewModel.kt index d0858d6be0..6a9314ef05 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationCheckoutViewModel.kt @@ -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 } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationPillToggle.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationPillToggle.kt index 62051ce05c..211781bb4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationPillToggle.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationPillToggle.kt @@ -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 { override fun areItemsTheSame(newItem: Model): Boolean = true @@ -28,13 +29,13 @@ object DonationPillToggle { private class ViewHolder(binding: DonationPillToggleBinding) : BindingViewHolder(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.") } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationProcessorActionResult.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationProcessorActionResult.kt index 0e07693778..fbb91b9b89 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationProcessorActionResult.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonationProcessorActionResult.kt @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/InAppPaymentError.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/InAppPaymentError.kt new file mode 100644 index 0000000000..302269f2c3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/InAppPaymentError.kt @@ -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) } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFragment.kt index 99a4ef7a09..3b48df6203 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardFragment.kt @@ -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 ) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardResult.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardResult.kt index c9d0153bac..c49658c416 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardResult.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/card/CreditCardResult.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayOrderStrategy.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayOrderStrategy.kt index 09a4efc1d9..04c0fc51d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayOrderStrategy.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayOrderStrategy.kt @@ -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 + val orderedGateways: Set private object Default : GatewayOrderStrategy { - override val orderedGateways: Set = setOf( - GatewayResponse.Gateway.CREDIT_CARD, - GatewayResponse.Gateway.PAYPAL, - GatewayResponse.Gateway.GOOGLE_PAY, - GatewayResponse.Gateway.SEPA_DEBIT, - GatewayResponse.Gateway.IDEAL + override val orderedGateways: Set = 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 = setOf( - GatewayResponse.Gateway.GOOGLE_PAY, - GatewayResponse.Gateway.PAYPAL, - GatewayResponse.Gateway.CREDIT_CARD, - GatewayResponse.Gateway.SEPA_DEBIT, - GatewayResponse.Gateway.IDEAL + override val orderedGateways: Set = 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 = setOf( - GatewayResponse.Gateway.IDEAL, - GatewayResponse.Gateway.PAYPAL, - GatewayResponse.Gateway.GOOGLE_PAY, - GatewayResponse.Gateway.CREDIT_CARD, - GatewayResponse.Gateway.SEPA_DEBIT + override val orderedGateways: Set = setOf( + InAppPaymentData.PaymentMethodType.IDEAL, + InAppPaymentData.PaymentMethodType.PAYPAL, + InAppPaymentData.PaymentMethodType.GOOGLE_PAY, + InAppPaymentData.PaymentMethodType.CARD, + InAppPaymentData.PaymentMethodType.SEPA_DEBIT ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayRequest.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayRequest.kt deleted file mode 100644 index 7778921c50..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayRequest.kt +++ /dev/null @@ -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)) -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayResponse.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayResponse.kt deleted file mode 100644 index 881a4ae4cd..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewayResponse.kt +++ /dev/null @@ -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 - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt index dd7e7ea7dc..7ba7712119 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt @@ -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 ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorRepository.kt index 5244d3cc95..ba2022cbd3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorRepository.kt @@ -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 { + 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, + val availableGateways: Set, val sepaEuroMaximum: FiatMoney? ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorState.kt index 6e3c65d7a2..c46b983b1f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorState.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt index a33d6d64f6..5b1d69c6be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorViewModel.kt @@ -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 { + return gatewaySelectorRepository.setInAppPaymentMethodType(store.state.inAppPayment, inAppPaymentMethodType).observeOn(AndroidSchedulers.mainThread()) + } + class Factory( private val args: GatewaySelectorBottomSheetArgs, private val repository: StripeRepository, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalCompleteOrderBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalCompleteOrderBottomSheet.kt index fc4cfd5b71..26c6bc6f9a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalCompleteOrderBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalCompleteOrderBottomSheet.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressFragment.kt index 7e074eb46e..34c0cbde73 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressFragment.kt @@ -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())) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt index 63259ff3fb..8619f15554 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/paypal/PayPalPaymentInProgressViewModel.kt @@ -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, routeToMonthlyConfirmation: (PayPalCreatePaymentMethodResponse) -> Single ) { 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 ) { 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) { + private fun proceedMonthly(inAppPayment: InAppPaymentTable.InAppPayment, routeToPaypalConfirmation: (PayPalCreatePaymentMethodResponse) -> Single) { 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 create(modelClass: Class): T { - return modelClass.cast(PayPalPaymentInProgressViewModel(payPalRepository, monthlyDonationRepository, oneTimeDonationRepository)) as T + return modelClass.cast(PayPalPaymentInProgressViewModel(payPalRepository, recurringInAppPaymentRepository, oneTimeInAppPaymentRepository)) as T } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/Stripe3DSData.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/Stripe3DSData.kt index 6cb5e037a5..14fba3352f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/Stripe3DSData.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/Stripe3DSData.kt @@ -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 ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/Stripe3DSDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/Stripe3DSDialogFragment.kt index 2ba2da9fce..daa7ab8f98 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/Stripe3DSDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/Stripe3DSDialogFragment.kt @@ -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() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripeNextActionHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripeNextActionHandler.kt index 5565e9e82f..d670ae2d08 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripeNextActionHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripeNextActionHandler.kt @@ -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 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt index 7e9eb261a0..5dbb0e9b40 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressFragment.kt @@ -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 { + private fun handleSecure3dsAction(secure3dsAction: StripeApi.Secure3DSAction, inAppPayment: InAppPaymentTable.InAppPayment): Single { 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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt index 82078c8361..a3cd06f267 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/stripe/StripePaymentInProgressViewModel.kt @@ -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 = 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 = verifyUser.andThen(stripeRepository.continuePayment(amount, request.recipientId, request.level, paymentSourceProvider.paymentSourceType)) + val continuePayment: Single = verifyUser.andThen(stripeRepository.continuePayment(amount, recipientId, inAppPayment.data.level, paymentSourceProvider.paymentSourceType)) val intentAndSource: Single> = 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 create(modelClass: Class): T { - return modelClass.cast(StripePaymentInProgressViewModel(stripeRepository, monthlyDonationRepository, oneTimeDonationRepository)) as T + return modelClass.cast(StripePaymentInProgressViewModel(stripeRepository, recurringInAppPaymentRepository, oneTimeInAppPaymentRepository)) as T } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt index c4fe9952fa..9b6de984b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/details/BankTransferDetailsFragment.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsFragment.kt index 2e6a6c3dfa..61ead332cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/ideal/IdealTransferDetailsFragment.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/mandate/BankTransferMandateFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/mandate/BankTransferMandateFragment.kt index 8a4d5b86cf..e8d4bf54af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/mandate/BankTransferMandateFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/transfer/mandate/BankTransferMandateFragment.kt @@ -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) ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt index 4120e114e3..3f9475fe08 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationError.kt @@ -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> = mutableMapOf() - @JvmStatic fun getErrorsForSource(donationErrorSource: DonationErrorSource): Observable { return donationErrorSubjectSourceMap[donationErrorSource]!! } - fun getErrorsForUiSessionKey(uiSessionKey: Long): Observable { - val subject: Subject = 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? = 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 = 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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorDialogs.kt index a57b49aa29..77608de853 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorDialogs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorDialogs.kt @@ -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 { override fun onCancel(context: Context): DonationErrorParams.ErrorAction? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorParams.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorParams.kt index 2391f1c7a5..2c737689be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorParams.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationErrorParams.kt @@ -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 private constructor( @StringRes val title: Int, @@ -25,46 +29,61 @@ class DonationErrorParams private constructor( callback: Callback ): DonationErrorParams { 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 getGenericRedemptionError(context: Context, genericError: DonationError.BadgeRedemptionError.GenericError, callback: Callback): DonationErrorParams { - return when (genericError.source) { - DonationErrorSource.GIFT -> DonationErrorParams( + fun create( + context: Context, + inAppPayment: InAppPaymentTable.InAppPayment, + callback: Callback + ): DonationErrorParams { + 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 getGenericRedemptionError(context: Context, type: InAppPaymentTable.Type, callback: Callback): DonationErrorParams { + 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 private constructor( } } - private fun getVerificationErrorParams(context: Context, verificationError: DonationError.GiftRecipientVerificationError, callback: Callback): DonationErrorParams { - 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 getVerificationErrorParams(context: Context, callback: Callback): DonationErrorParams { + 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 getPayPalDeclinedErrorParams(context: Context, declinedError: DonationError.PaymentSetupError.PayPalDeclinedError, callback: Callback): DonationErrorParams { - return when (declinedError.code) { + private fun getPayPalDeclinedErrorParams( + context: Context, + payPalDeclineCode: PayPalDeclineCode.KnownCode, + callback: Callback + ): DonationErrorParams { + 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 getStripeDeclinedErrorParams(context: Context, declinedError: DonationError.PaymentSetupError.StripeDeclinedError, callback: Callback): DonationErrorParams { - if (!declinedError.method.hasDeclineCodeSupport()) { + private fun getStripeDeclinedErrorParams( + context: Context, + paymentSourceType: PaymentSourceType.Stripe, + declineCode: StripeDeclineCode, + callback: Callback + ): DonationErrorParams { + 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, Int) -> DonationErrorParams = when (declinedError.method) { + val getStripeDeclineCodePositiveActionParams: (Context, Callback, Int) -> DonationErrorParams = 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 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 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 private constructor( } } - private fun getStripeFailureCodeErrorParams(context: Context, failureCodeError: DonationError.PaymentSetupError.StripeFailureCodeError, callback: Callback): DonationErrorParams { - if (!failureCodeError.method.hasFailureCodeSupport()) { + private fun getStripeFailureCodeErrorParams( + context: Context, + paymentSourceType: PaymentSourceType.Stripe, + failureCode: StripeFailureCode, + callback: Callback + ): DonationErrorParams { + 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 private constructor( StripeFailureCode.Code.GENERIC_COULD_NOT_PROCESS -> getTryBankTransferAgainParams(context, callback, errorText) } } + is StripeFailureCode.Unknown -> getGenericPaymentSetupErrorParams(context, callback) } } + private fun getStillProcessingErrorParams(context: Context, callback: Callback): DonationErrorParams { + return DonationErrorParams( + title = R.string.DonationsErrors__still_processing, + message = R.string.DonationsErrors__your_payment_is_still, + positiveAction = callback.onOk(context), + negativeAction = null + ) + } + + private fun getBadgeCredentialValidationErrorParams(context: Context, callback: Callback): DonationErrorParams { + 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 getGenericPaymentSetupErrorParams(context: Context, callback: Callback): DonationErrorParams { return DonationErrorParams( title = R.string.DonationsErrors__error_processing_payment, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt index b929d46af4..288080c59b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ActiveSubscriptionPreference.kt @@ -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() { @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/DonationRedemptionJobWatcher.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/DonationRedemptionJobWatcher.kt index 8668f07224..da49bf16f0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/DonationRedemptionJobWatcher.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/DonationRedemptionJobWatcher.kt @@ -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 ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt index fb8f7d0fc8..70099939c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt @@ -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)) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt index 107f944644..d212f09c37 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsState.kt @@ -13,6 +13,7 @@ data class ManageDonationsState( val availableSubscriptions: List = emptyList(), val pendingOneTimeDonation: PendingOneTimeDonation? = null, val nonVerifiedMonthlyDonation: NonVerifiedMonthlyDonation? = null, + val subscriberRequiresCancel: Boolean = false, private val subscriptionRedemptionState: RedemptionState = RedemptionState.NONE ) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt index 98d2e901fe..9c7278862c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsViewModel.kt @@ -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 = LevelUpdate.isProcessing.distinctUntilChanged() - val activeSubscription: Single = subscriptionsRepository.getActiveSubscription() + val activeSubscription: Single = 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 create(modelClass: Class): T { return modelClass.cast(ManageDonationsViewModel(subscriptionsRepository))!! diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/PayPalButton.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/PayPalButton.kt index 327ac60b94..035fb5e67f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/PayPalButton.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/models/PayPalButton.kt @@ -19,7 +19,10 @@ object PayPalButton { class ViewHolder(binding: PaypalButtonBinding) : BindingViewHolder(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() + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/InternalConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/InternalConversationSettingsFragment.kt index e3bf36faff..44755d17bf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/InternalConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/InternalConversationSettingsFragment.kt @@ -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()} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt index d4132b920c..a1aae2cc22 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/dsl.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/Button.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/Button.kt index da5b54fc7c..8c6070f7f0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/Button.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/Button.kt @@ -24,6 +24,7 @@ object Button { title: DSLSettingsText?, icon: DSLSettingsIcon?, isEnabled: Boolean, + val disableOnClick: Boolean, val onClick: () -> Unit ) : PreferenceModel( title = title, @@ -37,8 +38,9 @@ object Button { title: DSLSettingsText?, icon: DSLSettingsIcon?, isEnabled: Boolean, + disableOnClick: Boolean, onClick: () -> Unit - ) : Model(title, icon, isEnabled, onClick) + ) : Model(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(title, icon, isEnabled, onClick) + ) : Model(title, icon, isEnabled, disableOnClick, onClick) class Tonal( title: DSLSettingsText?, icon: DSLSettingsIcon?, isEnabled: Boolean, + disableOnClick: Boolean, onClick: () -> Unit - ) : Model(title, icon, isEnabled, onClick) + ) : Model(title, icon, isEnabled, disableOnClick, onClick) class TonalWrapped( title: DSLSettingsText?, icon: DSLSettingsIcon?, isEnabled: Boolean, + disableOnClick: Boolean, onClick: () -> Unit - ) : Model(title, icon, isEnabled, onClick) + ) : Model(title, icon, isEnabled, disableOnClick, onClick) class SecondaryNoOutline( title: DSLSettingsText?, icon: DSLSettingsIcon?, isEnabled: Boolean, + disableOnClick: Boolean, onClick: () -> Unit - ) : Model(title, icon, isEnabled, onClick) + ) : Model(title, icon, isEnabled, disableOnClick, onClick) } class ViewHolder>(itemView: View) : MappingViewHolder(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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 9a3228e2fa..0b2bcd99c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java index 02599ce569..f56a4f6f73 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java @@ -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> storyObservers; private final Set callUpdateObservers; private final Map> callLinkObservers; + private final Set 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 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); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/InAppPaymentSubscriberTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/InAppPaymentSubscriberTable.kt new file mode 100644 index 0000000000..49a77584c1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/InAppPaymentSubscriberTable.kt @@ -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 { + 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 { + override fun serialize(data: InAppPaymentSubscriberRecord.Type): Int = data.code + override fun deserialize(input: Int): InAppPaymentSubscriberRecord.Type = InAppPaymentSubscriberRecord.Type.values().first { it.code == input } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/InAppPaymentTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/InAppPaymentTable.kt new file mode 100644 index 0000000000..9264b09928 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/InAppPaymentTable.kt @@ -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 { + return readableDatabase.select() + .from(TABLE_NAME) + .where("$STATE = ?", State.serialize(State.WAITING_FOR_AUTHORIZATION)) + .run() + .readToList { InAppPayment.deserialize(it) } + } + + fun consumeInAppPaymentsToNotifyUser(): List { + 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 + @TypeParceler + 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 { + + 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 { + 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 { + override fun serialize(data: State): Int = data.code + override fun deserialize(input: Int): State = State.values().first { it.code == input } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt index 3fccabc29a..9919b110e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt @@ -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 } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index d5f4dbdc1d..0053e46eb6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V232_CreateInAppPaymentTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V232_CreateInAppPaymentTable.kt new file mode 100644 index 0000000000..294b7137dc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V232_CreateInAppPaymentTable.kt @@ -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() + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/InAppPaymentSubscriberRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/InAppPaymentSubscriberRecord.kt new file mode 100644 index 0000000000..109da4cc0e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/InAppPaymentSubscriberRecord.kt @@ -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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountRepository.java b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountRepository.java index afc1ba38f4..111787598e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountRepository.java @@ -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 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 cancelSubscriptionResponse = ApplicationDependencies.getDonationsService() .cancelSubscription(subscriber.getSubscriberId()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java index 6d63348f12..304ae8dc1d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java @@ -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: diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java index 84534b7ad9..22bd637bb5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/DonationReceiptRedemptionJob.java @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ExternalLaunchDonationJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/ExternalLaunchDonationJob.kt index 874caafb71..f3e272c5ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ExternalLaunchDonationJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ExternalLaunchDonationJob.kt @@ -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) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/GiftSendJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/GiftSendJob.kt index 7a7e4801e7..70bd68f9e4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/GiftSendJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/GiftSendJob.kt @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentAuthCheckJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentAuthCheckJob.kt new file mode 100644 index 0000000000..bf17c2dec2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentAuthCheckJob.kt @@ -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 = 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 { + 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 { + 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 checkResult( + serviceResponse: ServiceResponse + ): CheckResult { + 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 { + 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 { + error("Not needed, this job should not be creating intents.") + } + + override fun fetchSetupIntent(sourceType: PaymentSourceType.Stripe): Single { + error("Not needed, this job should not be creating intents.") + } + + private sealed interface CheckResult { + data class Success(val data: T) : CheckResult + data class Failure(val errorData: String? = null) : CheckResult + object Retry : CheckResult + } + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): InAppPaymentAuthCheckJob { + return InAppPaymentAuthCheckJob(parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentGiftSendJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentGiftSendJob.kt new file mode 100644 index 0000000000..b7811a3e62 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentGiftSendJob.kt @@ -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 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 { + override fun create(parameters: Parameters, serializedData: ByteArray?): InAppPaymentGiftSendJob { + return InAppPaymentGiftSendJob( + inAppPaymentId = InAppPaymentTable.InAppPaymentId(serializedData!!.toString().toLong()), + parameters = parameters + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentKeepAliveJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentKeepAliveJob.kt new file mode 100644 index 0000000000..6826b3cfb9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentKeepAliveJob.kt @@ -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 = ApplicationDependencies.getDonationsService().putSubscription(subscriber.subscriberId) + + verifyResponse(response) + info(type, "Successful call to putSubscription") + + val activeSubscriptionResponse: ServiceResponse = 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 verifyResponse(serviceResponse: ServiceResponse) { + 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 { + override fun create(parameters: Parameters, serializedData: ByteArray?): InAppPaymentKeepAliveJob { + return InAppPaymentKeepAliveJob( + parameters, + InAppPaymentSubscriberRecord.Type.values().first { it.code == JsonJobData.deserialize(serializedData).getInt(DATA_TYPE) } + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentOneTimeContextJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentOneTimeContextJob.kt new file mode 100644 index 0000000000..0c9f9df3d6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentOneTimeContextJob.kt @@ -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 { + 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 handleApplicationError(inAppPayment: InAppPaymentTable.InAppPayment, serviceResponse: ServiceResponse) { + 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 { + override fun create(parameters: Parameters, serializedData: ByteArray?): InAppPaymentOneTimeContextJob { + return InAppPaymentOneTimeContextJob( + InAppPaymentTable.InAppPaymentId(serializedData!!.decodeToString().toLong()), + parameters + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt new file mode 100644 index 0000000000..b7efe25ea9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt @@ -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 { + 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 = 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 + ) { + 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 { + override fun create(parameters: Parameters, serializedData: ByteArray?): InAppPaymentRecurringContextJob { + return InAppPaymentRecurringContextJob( + InAppPaymentTable.InAppPaymentId(serializedData!!.decodeToString().toLong()), + parameters + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRedemptionJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRedemptionJob.kt new file mode 100644 index 0000000000..f191b514b0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRedemptionJob.kt @@ -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 verifyServiceResponse(serviceResponse: ServiceResponse, 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 { + override fun create(parameters: Parameters, serializedData: ByteArray?): InAppPaymentRedemptionJob { + return InAppPaymentRedemptionJob( + InAppPaymentRedemptionJobData.ADAPTER.decode(serializedData!!), + parameters + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRetryException.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRetryException.kt new file mode 100644 index 0000000000..9d07225e57 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRetryException.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 061bb00e5b..2d8a4d1b0d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -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()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java index 4fd0010470..9d1b8ccd82 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java @@ -14,20 +14,18 @@ import org.signal.libsignal.zkgroup.profiles.ProfileKey; import org.thoughtcrime.securesms.badges.BadgeRepository; import org.thoughtcrime.securesms.badges.Badges; import org.thoughtcrime.securesms.badges.models.Badge; +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository; import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; import org.thoughtcrime.securesms.database.RecipientTable; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; -import org.thoughtcrime.securesms.keyvalue.AccountValues; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.profiles.ProfileName; import org.thoughtcrime.securesms.profiles.manage.UsernameRepository; import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.storage.StorageSyncHelper; -import org.thoughtcrime.securesms.subscription.Subscriber; -import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.ProfileUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; @@ -36,17 +34,13 @@ import org.whispersystems.signalservice.api.crypto.ProfileCipher; import org.whispersystems.signalservice.api.profiles.ProfileAndCredential; import org.whispersystems.signalservice.api.profiles.SignalServiceProfile; import org.whispersystems.signalservice.api.push.UsernameLinkComponents; -import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription; import org.whispersystems.signalservice.api.util.ExpiringProfileCredentialUtil; import org.whispersystems.signalservice.internal.ServiceResponse; -import org.whispersystems.signalservice.internal.push.ReserveUsernameResponse; import org.whispersystems.signalservice.internal.push.WhoAmIResponse; import java.io.IOException; -import java.util.Arrays; -import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Objects; @@ -387,9 +381,9 @@ public class RefreshOwnProfileJob extends BaseJob { Log.d(TAG, "Marking subscription badge as expired, should notify next time the conversation list is open.", true); SignalStore.donationsValues().setExpiredBadge(mostRecentExpiration); - if (!SignalStore.donationsValues().isUserManuallyCancelled()) { + if (!InAppPaymentsRepository.isUserManuallyCancelled(InAppPaymentSubscriberRecord.Type.DONATION)) { Log.d(TAG, "Detected an unexpected subscription expiry.", true); - Subscriber subscriber = SignalStore.donationsValues().getSubscriber(); + InAppPaymentSubscriberRecord subscriber = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION); boolean isDueToPaymentFailure = false; if (subscriber != null) { @@ -407,6 +401,8 @@ public class RefreshOwnProfileJob extends BaseJob { Log.d(TAG, "Active payment contains a charge failure: " + activeSubscription.getChargeFailure().getCode(), true); } } + + InAppPaymentsRepository.setShouldCancelSubscriptionBeforeNextSubscribeAttempt(subscriber, true); } if (!isDueToPaymentFailure) { @@ -414,7 +410,6 @@ public class RefreshOwnProfileJob extends BaseJob { } MultiDeviceSubscriptionSyncRequestJob.enqueue(); - SignalStore.donationsValues().setShouldCancelSubscriptionBeforeNextSubscribeAttempt(true); } } else if (!remoteHasBoostBadges && localHasBoostBadges) { Badge mostRecentExpiration = Recipient.self() diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionKeepAliveJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionKeepAliveJob.java index 76e7b296ee..57bd423353 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionKeepAliveJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionKeepAliveJob.java @@ -5,14 +5,14 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.signal.core.util.logging.Log; +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.model.InAppPaymentSubscriberRecord; 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.subscription.Subscriber; import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription; import org.whispersystems.signalservice.internal.EmptyResponse; import org.whispersystems.signalservice.internal.ServiceResponse; @@ -20,44 +20,21 @@ import org.whispersystems.signalservice.internal.ServiceResponse; import java.io.IOException; import java.util.Locale; import java.util.Objects; -import java.util.concurrent.TimeUnit; import okio.ByteString; /** * Job that, once there is a valid local subscriber id, should be run every 3 days * to ensure that a user's subscription does not lapse. + * + * @deprecated Replaced with InAppPaymentKeepAliveJob */ +@Deprecated() public class SubscriptionKeepAliveJob extends BaseJob { public static final String KEY = "SubscriptionKeepAliveJob"; private static final String TAG = Log.tag(SubscriptionKeepAliveJob.class); - private static final long JOB_TIMEOUT = TimeUnit.DAYS.toMillis(3); - - public static void enqueueAndTrackTimeIfNecessary() { - long nextLaunchTime = SignalStore.donationsValues().getLastKeepAliveLaunchTime() + TimeUnit.DAYS.toMillis(3); - long now = System.currentTimeMillis(); - - if (nextLaunchTime <= now) { - enqueueAndTrackTime(now); - } - } - - public static void enqueueAndTrackTime(long now) { - ApplicationDependencies.getJobManager().add(new SubscriptionKeepAliveJob()); - SignalStore.donationsValues().setLastKeepAliveLaunchTime(now); - } - - private SubscriptionKeepAliveJob() { - this(new Parameters.Builder() - .setQueue(KEY) - .addConstraint(NetworkConstraint.KEY) - .setMaxInstancesForQueue(1) - .setMaxAttempts(Parameters.UNLIMITED) - .setLifespan(JOB_TIMEOUT) - .build()); - } private SubscriptionKeepAliveJob(@NonNull Parameters parameters) { super(parameters); @@ -80,13 +57,13 @@ public class SubscriptionKeepAliveJob extends BaseJob { @Override protected void onRun() throws Exception { - synchronized (SubscriptionReceiptRequestResponseJob.MUTEX) { + synchronized (InAppPaymentSubscriberRecord.Type.DONATION) { doRun(); } } private void doRun() throws Exception { - Subscriber subscriber = SignalStore.donationsValues().getSubscriber(); + InAppPaymentSubscriberRecord subscriber = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION); if (subscriber == null) { return; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java index 0cfe6180fc..b5e05c8f2b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SubscriptionReceiptRequestResponseJob.java @@ -4,6 +4,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import org.signal.core.util.Base64; import org.signal.core.util.logging.Log; import org.signal.donations.PaymentSourceType; import org.signal.donations.StripeDeclineCode; @@ -16,22 +17,21 @@ 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.DonationsConfigurationExtensionsKt; +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository; 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.PayPalDeclineCode; 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.JsonJobData; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobmanager.JsonJobData; import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; import org.thoughtcrime.securesms.keyvalue.SignalStore; -import org.thoughtcrime.securesms.subscription.Subscriber; -import org.signal.core.util.Base64; -import org.whispersystems.signalservice.api.services.DonationsService; import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription; import org.whispersystems.signalservice.api.subscriptions.SubscriberId; import org.whispersystems.signalservice.internal.ServiceResponse; @@ -45,7 +45,10 @@ import okio.ByteString; /** * Job responsible for submitting ReceiptCredentialRequest objects to the server until * we get a response. + * + * * @deprecated Replaced with InAppPaymentRecurringContextJob */ +@Deprecated() public class SubscriptionReceiptRequestResponseJob extends BaseJob { private static final String TAG = Log.tag(SubscriptionReceiptRequestResponseJob.class); @@ -58,8 +61,6 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob { private static final String DATA_UI_SESSION_KEY = "data.ui.session.key"; private static final String DATA_TERMINAL_DONATION = "data.terminal.donation"; - public static final Object MUTEX = new Object(); - private final SubscriberId subscriberId; private final boolean isForKeepAlive; private final long uiSessionKey; @@ -85,13 +86,10 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob { ); } - public static JobManager.Chain createSubscriptionContinuationJobChain(long uiSessionKey, @NonNull TerminalDonationQueue.TerminalDonation terminalDonation) { - return createSubscriptionContinuationJobChain(false, uiSessionKey, terminalDonation); - } - public static JobManager.Chain createSubscriptionContinuationJobChain(boolean isForKeepAlive, long uiSessionKey, @NonNull TerminalDonationQueue.TerminalDonation terminalDonation) { - Subscriber subscriber = SignalStore.donationsValues().requireSubscriber(); - SubscriptionReceiptRequestResponseJob requestReceiptJob = createJob(subscriber.getSubscriberId(), isForKeepAlive, uiSessionKey, terminalDonation); + // TODO [alex] db on main? + InAppPaymentSubscriberRecord subscriber = InAppPaymentsRepository.requireSubscriber(InAppPaymentSubscriberRecord.Type.DONATION); + SubscriptionReceiptRequestResponseJob requestReceiptJob = createJob(subscriber.getSubscriberId(), isForKeepAlive, uiSessionKey, terminalDonation); DonationReceiptRedemptionJob redeemReceiptJob = DonationReceiptRedemptionJob.createJobForSubscription(requestReceiptJob.getErrorSource(), uiSessionKey, terminalDonation.isLongRunningPaymentMethod); RefreshOwnProfileJob refreshOwnProfileJob = RefreshOwnProfileJob.forSubscription(); MultiDeviceProfileContentUpdateJob multiDeviceProfileContentUpdateJob = new MultiDeviceProfileContentUpdateJob(); @@ -142,7 +140,7 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob { @Override protected void onRun() throws Exception { - synchronized (MUTEX) { + synchronized (InAppPaymentSubscriberRecord.Type.DONATION) { doRun(); } } @@ -329,7 +327,6 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob { DonationError.routeBackgroundError( context, - uiSessionKey, DonationError.genericBadgeRedemptionFailure(getErrorSource()) ); } @@ -341,7 +338,6 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob { DonationError.routeBackgroundError( context, - uiSessionKey, paymentFailure, terminalDonation.isLongRunningPaymentMethod ); @@ -358,7 +354,9 @@ public class SubscriptionReceiptRequestResponseJob extends BaseJob { * linked devices. */ private void onPaymentFailure(@NonNull ActiveSubscription.Subscription subscription, @Nullable ActiveSubscription.ChargeFailure chargeFailure, boolean isForKeepAlive) { - SignalStore.donationsValues().setShouldCancelSubscriptionBeforeNextSubscribeAttempt(true); + InAppPaymentSubscriberRecord subscriberRecord = InAppPaymentsRepository.requireSubscriber(InAppPaymentSubscriberRecord.Type.DONATION); + InAppPaymentsRepository.setShouldCancelSubscriptionBeforeNextSubscribeAttempt(subscriberRecord, true); + if (isForKeepAlive) { Log.d(TAG, "Subscription canceled during keep-alive. Setting UnexpectedSubscriptionCancelation state...", true); SignalStore.donationsValues().setUnexpectedSubscriptionCancelationChargeFailure(chargeFailure); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt index 4774c2b5d6..2e46492666 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/DonationsValues.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.keyvalue +import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.subjects.BehaviorSubject @@ -7,41 +8,42 @@ import io.reactivex.rxjava3.subjects.Subject import org.signal.core.util.logging.Log import org.signal.donations.PaymentSourceType import org.signal.donations.StripeApi -import org.signal.libsignal.zkgroup.InvalidInputException -import org.signal.libsignal.zkgroup.VerificationFailedException import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialRequestContext -import org.signal.libsignal.zkgroup.receipts.ReceiptSerial import org.thoughtcrime.securesms.badges.Badges import org.thoughtcrime.securesms.badges.models.Badge +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentMethodType import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSData +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord import org.thoughtcrime.securesms.database.model.databaseprotos.BadgeList import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue import org.thoughtcrime.securesms.database.model.isExpired -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies -import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob import org.thoughtcrime.securesms.payments.currency.CurrencyUtil import org.thoughtcrime.securesms.subscription.LevelUpdateOperation -import org.thoughtcrime.securesms.subscription.Subscriber -import org.thoughtcrime.securesms.util.Util import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey import org.whispersystems.signalservice.api.subscriptions.SubscriberId import org.whispersystems.signalservice.internal.util.JsonUtil -import java.security.SecureRandom import java.util.Currency import java.util.Locale import java.util.Optional import java.util.concurrent.TimeUnit +/** + * Key-Value store for donation related values. Note that most of this file will be deprecated after the release of + * InAppPayments (90day rollout window + 30day max job lifespan window) + */ internal class DonationsValues internal constructor(store: KeyValueStore) : SignalStoreValues(store) { companion object { private val TAG = Log.tag(DonationsValues::class.java) - private const val KEY_SUBSCRIPTION_CURRENCY_CODE = "donation.currency.code" + private const val KEY_DONATION_SUBSCRIPTION_CURRENCY_CODE = "donation.currency.code" + private const val KEY_BACKUPS_SUBSCRIPTION_CURRENCY_CODE = "donation.backups.currency.code" private const val KEY_CURRENCY_CODE_ONE_TIME = "donation.currency.code.boost" private const val KEY_SUBSCRIBER_ID_PREFIX = "donation.subscriber.id." private const val KEY_LAST_KEEP_ALIVE_LAUNCH = "donation.last.successful.ping" @@ -159,8 +161,11 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign SUBSCRIPTION_PAYMENT_SOURCE_TYPE ) - private val subscriptionCurrencyPublisher: Subject by lazy { BehaviorSubject.createDefault(getSubscriptionCurrency()) } - val observableSubscriptionCurrency: Observable by lazy { subscriptionCurrencyPublisher } + private val recurringDonationCurrencyPublisher: Subject by lazy { BehaviorSubject.createDefault(getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.DONATION)) } + val observableRecurringDonationCurrency: Observable by lazy { recurringDonationCurrencyPublisher } + + private val recurringBackupCurrencyPublisher: Subject by lazy { BehaviorSubject.createDefault(getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.BACKUP)) } + val observableRecurringBackupsCurrency: Observable by lazy { recurringBackupCurrencyPublisher } private val oneTimeCurrencyPublisher: Subject by lazy { BehaviorSubject.createDefault(getOneTimeCurrency()) } val observableOneTimeCurrency: Observable by lazy { oneTimeCurrencyPublisher } @@ -177,8 +182,13 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign } } - fun getSubscriptionCurrency(): Currency { - val currencyCode = getString(KEY_SUBSCRIPTION_CURRENCY_CODE, null) + fun getSubscriptionCurrency(subscriberType: InAppPaymentSubscriberRecord.Type): Currency { + val currencyCode = if (subscriberType == InAppPaymentSubscriberRecord.Type.DONATION) { + getString(KEY_DONATION_SUBSCRIPTION_CURRENCY_CODE, null) + } else { + getString(KEY_BACKUPS_SUBSCRIPTION_CURRENCY_CODE, null) + } + val currency: Currency? = if (currencyCode == null) { val localeCurrency = CurrencyUtil.getCurrencyByLocale(Locale.getDefault()) if (localeCurrency == null) { @@ -205,7 +215,7 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign fun getOneTimeCurrency(): Currency { val oneTimeCurrency = getString(KEY_CURRENCY_CODE_ONE_TIME, null) return if (oneTimeCurrency == null) { - val currency = getSubscriptionCurrency() + val currency = getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.DONATION) setOneTimeCurrency(currency) currency } else { @@ -218,34 +228,45 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign oneTimeCurrencyPublisher.onNext(currency) } - fun getSubscriber(currency: Currency): Subscriber? { + @VisibleForTesting + fun setSubscriber(currencyCode: String, subscriberId: SubscriberId) { + val subscriberIdBytes = subscriberId.bytes + + putBlob("$KEY_SUBSCRIBER_ID_PREFIX$currencyCode", subscriberIdBytes) + } + + @Deprecated("Replaced with InAppPaymentSubscriberTable") + fun getSubscriber(currency: Currency): InAppPaymentSubscriberRecord? { val currencyCode = currency.currencyCode val subscriberIdBytes = getBlob("$KEY_SUBSCRIBER_ID_PREFIX$currencyCode", null) return if (subscriberIdBytes == null) { null } else { - Subscriber(SubscriberId.fromBytes(subscriberIdBytes), currencyCode) + InAppPaymentSubscriberRecord( + SubscriberId.fromBytes(subscriberIdBytes), + currencyCode, + InAppPaymentSubscriberRecord.Type.DONATION, + shouldCancelSubscriptionBeforeNextSubscribeAttempt, + getSubscriptionPaymentSourceType().toPaymentMethodType() + ) } } - fun getSubscriber(): Subscriber? { - return getSubscriber(getSubscriptionCurrency()) - } + fun setSubscriberCurrency(currencyCode: String, type: InAppPaymentSubscriberRecord.Type) { + if (type == InAppPaymentSubscriberRecord.Type.DONATION) { + store.beginWrite() + .putString(KEY_DONATION_SUBSCRIPTION_CURRENCY_CODE, currencyCode) + .apply() - fun requireSubscriber(): Subscriber { - return getSubscriber() ?: throw Exception("Subscriber ID is not set.") - } + recurringDonationCurrencyPublisher.onNext(Currency.getInstance(currencyCode)) + } else { + store.beginWrite() + .putString(KEY_BACKUPS_SUBSCRIPTION_CURRENCY_CODE, currencyCode) + .apply() - fun setSubscriber(subscriber: Subscriber) { - Log.i(TAG, "Setting subscriber for currency ${subscriber.currencyCode}", Exception(), true) - val currencyCode = subscriber.currencyCode - store.beginWrite() - .putBlob("$KEY_SUBSCRIBER_ID_PREFIX$currencyCode", subscriber.subscriberId.bytes) - .putString(KEY_SUBSCRIPTION_CURRENCY_CODE, currencyCode) - .apply() - - subscriptionCurrencyPublisher.onNext(Currency.getInstance(currencyCode)) + recurringBackupCurrencyPublisher.onNext(Currency.getInstance(currencyCode)) + } } fun getLevelOperation(level: String): LevelUpdateOperation? { @@ -332,14 +353,12 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign return TimeUnit.SECONDS.toMillis(getLastEndOfPeriod()) > System.currentTimeMillis() } + @Deprecated("Use InAppPaymentsRepository.isUserManuallyCancelled instead.") fun isUserManuallyCancelled(): Boolean { return getBoolean(USER_MANUALLY_CANCELLED, false) } - fun markUserManuallyCancelled() { - putBoolean(USER_MANUALLY_CANCELLED, true) - } - + @Deprecated("Manual cancellation is stored in InAppPayment records. We no longer need to clear this value.") fun clearUserManuallyCancelled() { remove(USER_MANUALLY_CANCELLED) } @@ -365,6 +384,7 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign putBoolean(SUBSCRIPTION_REDEMPTION_FAILED, false) } + @Deprecated("Cancellation status is now stored in InAppPaymentTable") fun setUnexpectedSubscriptionCancelationChargeFailure(chargeFailure: ActiveSubscription.ChargeFailure?) { if (chargeFailure == null) { remove(SUBSCRIPTION_CANCELATION_CHARGE_FAILURE) @@ -382,8 +402,13 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign } } + @Deprecated("Cancellation status is now tracked in InAppPaymentTable") var unexpectedSubscriptionCancelationReason: String? by stringValue(SUBSCRIPTION_CANCELATION_REASON, null) + + @Deprecated("Cancellation status is now tracked in InAppPaymentTable") var unexpectedSubscriptionCancelationTimestamp: Long by longValue(SUBSCRIPTION_CANCELATION_TIMESTAMP, 0L) + + @Deprecated("Cancellation status is now tracked in InAppPaymentTable") var unexpectedSubscriptionCancelationWatermark: Long by longValue(SUBSCRIPTION_CANCELATION_WATERMARK, 0L) @get:JvmName("showCantProcessDialog") @@ -415,26 +440,30 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign * 1. Clears expired badge if it is for a subscription */ @WorkerThread - fun updateLocalStateForManualCancellation() { - synchronized(SubscriptionReceiptRequestResponseJob.MUTEX) { + fun updateLocalStateForManualCancellation(subscriberType: InAppPaymentSubscriberRecord.Type) { + synchronized(subscriberType) { Log.d(TAG, "[updateLocalStateForManualCancellation] Clearing donation values.") - setLastEndOfPeriod(0L) - clearLevelOperations() - markUserManuallyCancelled() - shouldCancelSubscriptionBeforeNextSubscribeAttempt = false - setUnexpectedSubscriptionCancelationChargeFailure(null) - unexpectedSubscriptionCancelationReason = null - unexpectedSubscriptionCancelationTimestamp = 0L + if (subscriberType == InAppPaymentSubscriberRecord.Type.DONATION) { + setLastEndOfPeriod(0L) + clearLevelOperations() + setUnexpectedSubscriptionCancelationChargeFailure(null) + unexpectedSubscriptionCancelationReason = null + unexpectedSubscriptionCancelationTimestamp = 0L - clearSubscriptionRequestCredential() - clearSubscriptionReceiptCredential() + clearSubscriptionRequestCredential() + clearSubscriptionReceiptCredential() - val expiredBadge = getExpiredBadge() - if (expiredBadge != null && expiredBadge.isSubscription()) { - Log.d(TAG, "[updateLocalStateForManualCancellation] Clearing expired badge.") - setExpiredBadge(null) + val expiredBadge = getExpiredBadge() + if (expiredBadge != null && expiredBadge.isSubscription()) { + Log.d(TAG, "[updateLocalStateForManualCancellation] Clearing expired badge.") + setExpiredBadge(null) + } } + + val subscriber = InAppPaymentsRepository.requireSubscriber(subscriberType) + InAppPaymentsRepository.setShouldCancelSubscriptionBeforeNextSubscribeAttempt(subscriber, true) + SignalDatabase.inAppPayments.markSubscriptionManuallyCanceled(subscriberId = subscriber.subscriberId) } } @@ -447,13 +476,12 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign * 1. Expired badge, if it is of a subscription */ @WorkerThread - fun updateLocalStateForLocalSubscribe() { - synchronized(SubscriptionReceiptRequestResponseJob.MUTEX) { + fun updateLocalStateForLocalSubscribe(subscriberType: InAppPaymentSubscriberRecord.Type) { + synchronized(subscriberType) { Log.d(TAG, "[updateLocalStateForLocalSubscribe] Clearing donation values.") clearUserManuallyCancelled() clearLevelOperations() - shouldCancelSubscriptionBeforeNextSubscribeAttempt = false setUnexpectedSubscriptionCancelationChargeFailure(null) unexpectedSubscriptionCancelationReason = null unexpectedSubscriptionCancelationTimestamp = 0L @@ -465,11 +493,14 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign Log.d(TAG, "[updateLocalStateForLocalSubscribe] Clearing expired badge.") setExpiredBadge(null) } + + val subscriber = InAppPaymentsRepository.requireSubscriber(subscriberType) + InAppPaymentsRepository.setShouldCancelSubscriptionBeforeNextSubscribeAttempt(subscriber, false) } } fun refreshSubscriptionRequestCredential() { - putBlob(SUBSCRIPTION_CREDENTIAL_REQUEST, generateRequestCredential().serialize()) + putBlob(SUBSCRIPTION_CREDENTIAL_REQUEST, InAppPaymentsRepository.generateRequestCredential().serialize()) } fun setSubscriptionRequestCredential(requestContext: ReceiptCredentialRequestContext) { @@ -500,10 +531,12 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign remove(SUBSCRIPTION_CREDENTIAL_RECEIPT) } + @Deprecated("This information is now stored in InAppPaymentTable") fun setSubscriptionPaymentSourceType(paymentSourceType: PaymentSourceType) { putString(SUBSCRIPTION_PAYMENT_SOURCE_TYPE, paymentSourceType.code) } + @Deprecated("This information is now stored in InAppPaymentTable") fun getSubscriptionPaymentSourceType(): PaymentSourceType { return PaymentSourceType.fromCode(getString(SUBSCRIPTION_PAYMENT_SOURCE_TYPE, null)) } @@ -536,15 +569,6 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign } } - fun removeTerminalDonation(level: Long) { - synchronized(this) { - val donationCompletionList = consumeTerminalDonations() - donationCompletionList.filterNot { it.level == level }.forEach { - appendToTerminalDonationQueue(it) - } - } - } - fun getPendingOneTimeDonation(): PendingOneTimeDonation? { return synchronized(this) { _pendingOneTimeDonation.takeUnless { it?.isExpired == true } @@ -569,31 +593,21 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign } } - fun consumePending3DSData(uiSessionKey: Long): Stripe3DSData? { + fun consumePending3DSData(): Stripe3DSData? { synchronized(this) { val data = getBlob(PENDING_3DS_DATA, null)?.let { - Stripe3DSData.fromProtoBytes(it, uiSessionKey) + Stripe3DSData.fromProtoBytes(it) } - setPending3DSData(null) + remove(PENDING_3DS_DATA) return data } } - fun setPending3DSData(stripe3DSData: Stripe3DSData?) { - synchronized(this) { - if (stripe3DSData != null) { - putBlob(PENDING_3DS_DATA, stripe3DSData.toProtoBytes()) - } else { - remove(PENDING_3DS_DATA) - } - } - } - fun consumeVerifiedSubscription3DSData(): Stripe3DSData? { synchronized(this) { val data = getBlob(VERIFIED_IDEAL_SUBSCRIPTION_3DS_DATA, null)?.let { - Stripe3DSData.fromProtoBytes(it, -1) + Stripe3DSData.fromProtoBytes(it) } setVerifiedSubscription3DSData(null) @@ -610,22 +624,4 @@ internal class DonationsValues internal constructor(store: KeyValueStore) : Sign } } } - - private 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) - } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionBadges.java b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionBadges.java index b9da5e61ca..6a368de1b0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionBadges.java +++ b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionBadges.java @@ -4,6 +4,11 @@ import android.content.Context; import androidx.annotation.NonNull; +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.InAppPaymentSubscriberRecord; +import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.recipients.Recipient; @@ -24,18 +29,58 @@ final class LogSectionBadges implements LogSection { return "Self not yet available!"; } - return new StringBuilder().append("Badge Count : ").append(Recipient.self().getBadges().size()).append("\n") - .append("ExpiredBadge : ").append(SignalStore.donationsValues().getExpiredBadge() != null).append("\n") - .append("LastKeepAliveLaunchTime : ").append(SignalStore.donationsValues().getLastKeepAliveLaunchTime()).append("\n") - .append("LastEndOfPeriod : ").append(SignalStore.donationsValues().getLastEndOfPeriod()).append("\n") - .append("SubscriptionEndOfPeriodConversionStarted: ").append(SignalStore.donationsValues().getSubscriptionEndOfPeriodConversionStarted()).append("\n") - .append("SubscriptionEndOfPeriodRedemptionStarted: ").append(SignalStore.donationsValues().getSubscriptionEndOfPeriodRedemptionStarted()).append("\n") - .append("SubscriptionEndOfPeriodRedeemed : ").append(SignalStore.donationsValues().getSubscriptionEndOfPeriodRedeemed()).append("\n") - .append("IsUserManuallyCancelled : ").append(SignalStore.donationsValues().isUserManuallyCancelled()).append("\n") - .append("DisplayBadgesOnProfile : ").append(SignalStore.donationsValues().getDisplayBadgesOnProfile()).append("\n") - .append("SubscriptionRedemptionFailed : ").append(SignalStore.donationsValues().getSubscriptionRedemptionFailed()).append("\n") - .append("ShouldCancelBeforeNextAttempt : ").append(SignalStore.donationsValues().getShouldCancelSubscriptionBeforeNextSubscribeAttempt()).append("\n") - .append("Has unconverted request context : ").append(SignalStore.donationsValues().getSubscriptionRequestCredential() != null).append("\n") - .append("Has unredeemed receipt presentation : ").append(SignalStore.donationsValues().getSubscriptionReceiptCredential() != null).append("\n"); + InAppPaymentTable.InAppPayment latestRecurringDonation = SignalDatabase.inAppPayments().getLatestInAppPaymentByType(InAppPaymentTable.Type.RECURRING_DONATION); + + if (latestRecurringDonation != null) { + return new StringBuilder().append("Badge Count : ").append(Recipient.self().getBadges().size()).append("\n") + .append("ExpiredBadge : ").append(SignalStore.donationsValues().getExpiredBadge() != null).append("\n") + .append("LastKeepAliveLaunchTime : ").append(SignalStore.donationsValues().getLastKeepAliveLaunchTime()).append("\n") + .append("LastEndOfPeriod : ").append(SignalStore.donationsValues().getLastEndOfPeriod()).append("\n") + .append("InAppPayment.State : ").append(latestRecurringDonation.getState()).append("\n") + .append("InAppPayment.EndOfPeriod : ").append(latestRecurringDonation.getEndOfPeriodSeconds()).append("\n") + .append("InAppPaymentData.RedemptionState: ").append(getRedemptionStage(latestRecurringDonation.getData())).append("\n") + .append("InAppPaymentData.Error : ").append(getError(latestRecurringDonation.getData())).append("\n") + .append("InAppPaymentData.Cancellation : ").append(getCancellation(latestRecurringDonation.getData())).append("\n") + .append("DisplayBadgesOnProfile : ").append(SignalStore.donationsValues().getDisplayBadgesOnProfile()).append("\n") + .append("ShouldCancelBeforeNextAttempt : ").append(InAppPaymentsRepository.getShouldCancelSubscriptionBeforeNextSubscribeAttempt(InAppPaymentSubscriberRecord.Type.DONATION)).append("\n"); + } else { + return new StringBuilder().append("Badge Count : ").append(Recipient.self().getBadges().size()).append("\n") + .append("ExpiredBadge : ").append(SignalStore.donationsValues().getExpiredBadge() != null).append("\n") + .append("LastKeepAliveLaunchTime : ").append(SignalStore.donationsValues().getLastKeepAliveLaunchTime()).append("\n") + .append("LastEndOfPeriod : ").append(SignalStore.donationsValues().getLastEndOfPeriod()).append("\n") + .append("SubscriptionEndOfPeriodConversionStarted: ").append(SignalStore.donationsValues().getSubscriptionEndOfPeriodConversionStarted()).append("\n") + .append("SubscriptionEndOfPeriodRedemptionStarted: ").append(SignalStore.donationsValues().getSubscriptionEndOfPeriodRedemptionStarted()).append("\n") + .append("SubscriptionEndOfPeriodRedeemed : ").append(SignalStore.donationsValues().getSubscriptionEndOfPeriodRedeemed()).append("\n") + .append("IsUserManuallyCancelled : ").append(SignalStore.donationsValues().isUserManuallyCancelled()).append("\n") + .append("DisplayBadgesOnProfile : ").append(SignalStore.donationsValues().getDisplayBadgesOnProfile()).append("\n") + .append("SubscriptionRedemptionFailed : ").append(SignalStore.donationsValues().getSubscriptionRedemptionFailed()).append("\n") + .append("ShouldCancelBeforeNextAttempt : ").append(SignalStore.donationsValues().getShouldCancelSubscriptionBeforeNextSubscribeAttempt()).append("\n") + .append("Has unconverted request context : ").append(SignalStore.donationsValues().getSubscriptionRequestCredential() != null).append("\n") + .append("Has unredeemed receipt presentation : ").append(SignalStore.donationsValues().getSubscriptionReceiptCredential() != null).append("\n"); + } + } + + private @NonNull String getRedemptionStage(@NonNull InAppPaymentData inAppPaymentData) { + if (inAppPaymentData.redemption == null) { + return "null"; + } else { + return inAppPaymentData.redemption.stage.name(); + } + } + + private @NonNull String getError(@NonNull InAppPaymentData inAppPaymentData) { + if (inAppPaymentData.error == null) { + return "none"; + } else { + return inAppPaymentData.error.toString(); + } + } + + private @NonNull String getCancellation(@NonNull InAppPaymentData inAppPaymentData) { + if (inAppPaymentData.cancellation == null) { + return "none"; + } else { + return inAppPaymentData.cancellation.reason.name(); + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/RemoteMegaphoneRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/megaphone/RemoteMegaphoneRepository.kt index d285c2d691..652f528905 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/RemoteMegaphoneRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/RemoteMegaphoneRepository.kt @@ -12,8 +12,8 @@ import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.badges.gifts.flow.GiftFlowActivity import org.thoughtcrime.securesms.badges.models.Badge 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.donate.DonateToSignalActivity -import org.thoughtcrime.securesms.components.settings.app.subscription.manage.DonationRedemptionJobWatcher import org.thoughtcrime.securesms.database.RemoteMegaphoneTable import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord @@ -131,7 +131,7 @@ object RemoteMegaphoneRepository { private fun shouldShowDonateMegaphone(): Boolean { return VersionTracker.getDaysSinceFirstInstalled(context) >= 7 && InAppDonations.hasAtLeastOnePaymentMethodAvailable() && - !DonationRedemptionJobWatcher.hasPendingRedemptionJob() && + !InAppPaymentsRepository.hasPendingDonation() && Recipient.self() .badges .stream() diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java index 8271eff2ec..61b3ea094d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java @@ -146,9 +146,10 @@ public class ApplicationMigrations { static final int PNP_LAUNCH = 102; static final int EMOJI_VERSION_10 = 103; static final int ATTACHMENT_HASH_BACKFILL = 104; + static final int SUBSCRIBER_ID = 105; } - public static final int CURRENT_VERSION = 104; + public static final int CURRENT_VERSION = 105; /** * This *must* be called after the {@link JobManager} has been instantiated, but *before* the call @@ -667,6 +668,10 @@ public class ApplicationMigrations { jobs.put(Version.ATTACHMENT_HASH_BACKFILL, new AttachmentHashBackfillMigrationJob()); } + if (lastSeenVersion < Version.SUBSCRIBER_ID) { + jobs.put(Version.SUBSCRIBER_ID, new SubscriberIdMigrationJob()); + } + return jobs; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/SubscriberIdMigrationJob.kt b/app/src/main/java/org/thoughtcrime/securesms/migrations/SubscriberIdMigrationJob.kt new file mode 100644 index 0000000000..2d92fe30f6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/SubscriberIdMigrationJob.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.migrations + +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentMethodType +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.keyvalue.SignalStore +import java.util.Currency + +/** + * Migrates all subscriber ids from the key value store into the database. + */ +internal class SubscriberIdMigrationJob( + parameters: Parameters = Parameters.Builder().build() +) : MigrationJob( + parameters +) { + + companion object { + const val KEY = "SubscriberIdMigrationJob" + } + + override fun getFactoryKey(): String = KEY + + override fun isUiBlocking(): Boolean = false + + override fun performMigration() { + Currency.getAvailableCurrencies().forEach { currency -> + val subscriber = SignalStore.donationsValues().getSubscriber(currency) + + if (subscriber != null) { + SignalDatabase.inAppPaymentSubscribers.insertOrReplace( + InAppPaymentSubscriberRecord( + subscriber.subscriberId, + subscriber.currencyCode, + InAppPaymentSubscriberRecord.Type.DONATION, + SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt, + SignalStore.donationsValues().getSubscriptionPaymentSourceType().toPaymentMethodType() + ) + ) + } + } + } + + override fun shouldRetry(e: Exception): Boolean = false + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): SubscriberIdMigrationJob { + return SubscriberIdMigrationJob(parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java index 979544e145..11f1792c79 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java @@ -9,10 +9,13 @@ import androidx.annotation.VisibleForTesting; import com.annimon.stream.Collectors; import com.annimon.stream.Stream; +import org.signal.core.util.Base64; import org.signal.core.util.SetUtil; import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository; import org.thoughtcrime.securesms.database.RecipientTable; 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.jobs.RetrieveProfileAvatarJob; @@ -22,8 +25,6 @@ import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberD import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.payments.Entropy; import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.subscription.Subscriber; -import org.signal.core.util.Base64; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.signalservice.api.push.UsernameLinkComponents; @@ -124,7 +125,7 @@ public final class StorageSyncHelper { if (self.getStorageId() == null || (record != null && record.getStorageId() == null)) { Log.w(TAG, "[buildAccountRecord] No storageId for self or record! Generating. (Self: " + (self.getStorageId() != null) + ", Record: " + (record != null && record.getStorageId() != null) + ")"); SignalDatabase.recipients().updateStorageId(self.getId(), generateKey()); - self = Recipient.self().fresh(); + self = Recipient.self().fresh(); record = recipientTable.getRecordForSync(self.getId()); } @@ -155,9 +156,9 @@ public final class StorageSyncHelper { .setPrimarySendsSms(false) .setUniversalExpireTimer(SignalStore.settings().getUniversalExpireTimer()) .setDefaultReactions(SignalStore.emojiValues().getReactions()) - .setSubscriber(StorageSyncModels.localToRemoteSubscriber(SignalStore.donationsValues().getSubscriber())) + .setSubscriber(StorageSyncModels.localToRemoteSubscriber(InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION))) .setDisplayBadgesOnProfile(SignalStore.donationsValues().getDisplayBadgesOnProfile()) - .setSubscriptionManuallyCancelled(SignalStore.donationsValues().isUserManuallyCancelled()) + .setSubscriptionManuallyCancelled(InAppPaymentsRepository.isUserManuallyCancelled(InAppPaymentSubscriberRecord.Type.DONATION)) .setKeepMutedChatsArchived(SignalStore.settings().shouldKeepMutedChatsArchived()) .setHasSetMyStoriesPrivacy(SignalStore.storyValues().getUserHasBeenNotifiedAboutStories()) .setHasViewedOnboardingStory(SignalStore.storyValues().getUserHasViewedOnboardingStory()) @@ -219,15 +220,13 @@ public final class StorageSyncHelper { SignalStore.storyValues().setViewedReceiptsEnabled(update.getNew().getStoryViewReceiptsState() == OptionalBool.ENABLED); } - if (update.getNew().isSubscriptionManuallyCancelled()) { - SignalStore.donationsValues().updateLocalStateForManualCancellation(); - } else { - SignalStore.donationsValues().clearUserManuallyCancelled(); + InAppPaymentSubscriberRecord remoteSubscriber = StorageSyncModels.remoteToLocalSubscriber(update.getNew().getSubscriber(), InAppPaymentSubscriberRecord.Type.DONATION); + if (remoteSubscriber != null) { + InAppPaymentsRepository.setSubscriber(remoteSubscriber); } - Subscriber subscriber = StorageSyncModels.remoteToLocalSubscriber(update.getNew().getSubscriber()); - if (subscriber != null) { - SignalStore.donationsValues().setSubscriber(subscriber); + if (update.getNew().isSubscriptionManuallyCancelled()) { + SignalStore.donationsValues().updateLocalStateForManualCancellation(InAppPaymentSubscriberRecord.Type.DONATION); } if (fetchProfile && update.getNew().getAvatarUrlPath().isPresent()) { @@ -242,10 +241,10 @@ public final class StorageSyncHelper { if (update.getNew().getUsernameLink() != null) { SignalStore.account().setUsernameLink( - new UsernameLinkComponents( - update.getNew().getUsernameLink().entropy.toByteArray(), - UuidUtil.parseOrThrow(update.getNew().getUsernameLink().serverId.toByteArray()) - ) + new UsernameLinkComponents( + update.getNew().getUsernameLink().entropy.toByteArray(), + UuidUtil.parseOrThrow(update.getNew().getUsernameLink().serverId.toByteArray()) + ) ); SignalStore.misc().setUsernameQrCodeColorScheme(StorageSyncModels.remoteToLocalUsernameColor(update.getNew().getUsernameLink().color)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java index bf510a9ff6..f3c9958bd2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java @@ -13,11 +13,12 @@ import org.thoughtcrime.securesms.database.RecipientTable; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.model.DistributionListId; import org.thoughtcrime.securesms.database.model.DistributionListRecord; +import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord; import org.thoughtcrime.securesms.database.model.RecipientRecord; +import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues; import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.subscription.Subscriber; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.storage.SignalAccountRecord; import org.whispersystems.signalservice.api.storage.SignalContactRecord; @@ -273,7 +274,10 @@ public final class StorageSyncModels { } } - public static @NonNull SignalAccountRecord.Subscriber localToRemoteSubscriber(@Nullable Subscriber subscriber) { + /** + * TODO - need to store the subscriber type + */ + public static @NonNull SignalAccountRecord.Subscriber localToRemoteSubscriber(@Nullable InAppPaymentSubscriberRecord subscriber) { if (subscriber == null) { return new SignalAccountRecord.Subscriber(null, null); } else { @@ -281,9 +285,20 @@ public final class StorageSyncModels { } } - public static @Nullable Subscriber remoteToLocalSubscriber(@NonNull SignalAccountRecord.Subscriber subscriber) { + /** + * TODO - We need to store the subscriber type. + */ + public static @Nullable InAppPaymentSubscriberRecord remoteToLocalSubscriber( + @NonNull SignalAccountRecord.Subscriber subscriber, + @NonNull InAppPaymentSubscriberRecord.Type type + ) { if (subscriber.getId().isPresent()) { - return new Subscriber(SubscriberId.fromBytes(subscriber.getId().get()), subscriber.getCurrencyCode().get()); + SubscriberId subscriberId = SubscriberId.fromBytes(subscriber.getId().get()); + InAppPaymentSubscriberRecord localSubscriberRecord = SignalDatabase.inAppPaymentSubscribers().getBySubscriberId(subscriberId); + boolean requiresCancel = localSubscriberRecord != null && localSubscriberRecord.getRequiresCancel(); + InAppPaymentData.PaymentMethodType paymentMethodType = localSubscriberRecord != null ? localSubscriberRecord.getPaymentMethodType() : InAppPaymentData.PaymentMethodType.UNKNOWN; + + return new InAppPaymentSubscriberRecord(subscriberId, subscriber.getCurrencyCode().get(), type, requiresCancel, paymentMethodType); } else { return null; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/subscription/Subscriber.kt b/app/src/main/java/org/thoughtcrime/securesms/subscription/Subscriber.kt deleted file mode 100644 index 184a88d862..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/subscription/Subscriber.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.thoughtcrime.securesms.subscription - -import org.whispersystems.signalservice.api.subscriptions.SubscriberId - -data class Subscriber( - val subscriberId: SubscriberId, - val currencyCode: String -) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/parcelers/MillisecondDurationParceler.kt b/app/src/main/java/org/thoughtcrime/securesms/util/parcelers/MillisecondDurationParceler.kt new file mode 100644 index 0000000000..87ff6c66b1 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/parcelers/MillisecondDurationParceler.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.util.parcelers + +import android.os.Parcel +import kotlinx.parcelize.Parceler +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * Parceler for non-null durations, storing them in milliseconds. + */ +object MillisecondDurationParceler : Parceler { + override fun create(parcel: Parcel): Duration { + return parcel.readLong().milliseconds + } + + override fun Duration.write(parcel: Parcel, flags: Int) { + parcel.writeLong(inWholeMilliseconds) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/parcelers/NullableSubscriberIdParceler.kt b/app/src/main/java/org/thoughtcrime/securesms/util/parcelers/NullableSubscriberIdParceler.kt new file mode 100644 index 0000000000..a773581ecc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/parcelers/NullableSubscriberIdParceler.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.util.parcelers + +import android.os.Parcel +import kotlinx.parcelize.Parceler +import org.whispersystems.signalservice.api.subscriptions.SubscriberId + +/** + * Parceler for nullable SubscriberIds + */ +object NullableSubscriberIdParceler : Parceler { + override fun create(parcel: Parcel): SubscriberId? { + return parcel.readString()?.let { SubscriberId.deserialize(it) } + } + + override fun SubscriberId?.write(parcel: Parcel, flags: Int) { + parcel.writeString(this?.serialize()) + } +} diff --git a/app/src/main/protowire/Database.proto b/app/src/main/protowire/Database.proto index c5800aeb4f..266bd5e895 100644 --- a/app/src/main/protowire/Database.proto +++ b/app/src/main/protowire/Database.proto @@ -292,6 +292,7 @@ message FiatValue { uint64 timestamp = 3; } +// DEPRECATED -- Replaced with InAppPaymentData.Error message. message DonationErrorValue { enum Type { PROCESSOR_CODE = 0; // Generic processor error (e.g. Stripe returned an error code) @@ -299,12 +300,115 @@ message DonationErrorValue { FAILURE_CODE = 2; // Stripe bank transfer failure code REDEMPTION = 3; // Generic redemption error (status is HTTP code) PAYMENT = 4; // Generic payment error (status is HTTP code) + VALIDATION = 5; // Failed while trying to validate the ReceiptCredential returned from the service. } Type type = 1; string code = 2; } +/** + * Contains all the extra information required to appropriately + * manage the lifecycle of transactions. + */ +message InAppPaymentData { + + enum PaymentMethodType { + UNKNOWN = 0; + GOOGLE_PAY = 1; + CARD = 2; + SEPA_DEBIT = 3; + IDEAL = 4; + PAYPAL = 5; + } + + /** + * This mirrors ActiveSubscription.ChargeFailure + */ + message ChargeFailure { + string code = 1; + string message = 2; + string outcomeNetworkStatus = 3; + string outcomeNetworkReason = 4; + string outcomeType = 5; + } + + message Cancellation { + enum Reason { + UNKNOWN = 0; + MANUAL = 1; + PAST_DUE = 2; + CANCELED = 3; + UNPAID = 4; + USER_WAS_INACTIVE = 5; + } + + Reason reason = 1; // Why the cancellation happened + optional ChargeFailure chargeFailure = 2; // A charge failure, if available. + } + + message WaitingForAuthorizationState { + string stripeIntentId = 1; + string stripeClientSecret = 2; + bool checkedVerification = 3; + } + + message RedemptionState { + enum Stage { + INIT = 0; + CONVERSION_STARTED = 1; + REDEMPTION_STARTED = 2; + REDEEMED = 3; + } + + Stage stage = 1; + optional string paymentIntentId = 2; // Only present for one-time donations. + optional bool keepAlive = 3; // Only present for recurring donations, specifies this redemption started from a keep-alive + optional bytes receiptCredentialRequestContext = 4; // Reusable context for retrieving a presentation + optional bytes receiptCredentialPresentation = 5; // Redeemable presentation + } + + message Error { + enum Type { + UNKNOWN = 0; // A generic, untyped error. Check log for details. + GOOGLE_PAY_REQUEST_TOKEN = 1; // Google pay failed to give us a request token. + INVALID_GIFT_RECIPIENT = 2; // Selected recipient for gift is invalid. + ONE_TIME_AMOUNT_TOO_SMALL = 3; // One-time payment amount is below minimum + ONE_TIME_AMOUNT_TOO_LARGE = 4; // One-time payment amount is too large + INVALID_CURRENCY = 5; // One-time payment currency is invalid + PAYMENT_SETUP = 6; // A generic payment setup error (prior to charging the user) + STRIPE_CODED_ERROR = 7; // Stripe error containing a stripe error code in data. + STRIPE_DECLINED_ERROR = 8; // Stripe error containing a decline error code in data. + STRIPE_FAILURE = 9; // Stripe error containing a failure error code in data. + PAYPAL_CODED_ERROR = 10; // PayPal error containing a paypal error code in data. + PAYPAL_DECLINED_ERROR = 11; // PayPal error containing a paypal decline code in data. + PAYMENT_PROCESSING = 12; // Generic payment error containing an HTTP status code in data. + CREDENTIAL_VALIDATION = 13; // Failed to validate credential returned from service. + REDEMPTION = 14; // Failed during badge redemption containing an HTTP status code in data if available. + } + + Type type = 1; + optional string data = 2; + } + + optional BadgeList.Badge badge = 1; // The badge. Not present for backups transactions + FiatValue amount = 2; // The amount the user paid for the transaction + optional Error error = 3; // An error, if present. + int64 level = 4; // The transaction "level" given to us by the server + optional Cancellation cancellation = 5; // The transaction was cancelled. This notes why. + string label = 6; // Descriptive text about the token + optional string recipientId = 7; // The target recipient the token is to be sent to (only used for gifts) + optional string additionalMessage = 8; // The additional message to send the target recipient (only used for gifts) + PaymentMethodType paymentMethodType = 9; // The method through which this in app payment was made + + oneof redemptionState { + WaitingForAuthorizationState waitForAuth = 10; // Waiting on user authorization from an external source (3DS, iDEAL) + RedemptionState redemption = 11; // Waiting on processing of token + } + +} + +// DEPRECATED -- Move to TokenTransactionData message PendingOneTimeDonation { enum PaymentMethodType { CARD = 0; @@ -328,6 +432,7 @@ message PendingOneTimeDonation { * the same way that it is used in Rx, where we simply mean that, regardless * of outcome, a donation has completed processing. */ +// DEPRECATED -- Move to TokenTransactionData message TerminalDonationQueue { message TerminalDonation { int64 level = 1; @@ -344,6 +449,7 @@ message TerminalDonationQueue { * scenarios where the application dies while the user is confirming * a transaction in their bank app. */ +// DEPRECATED -- Move to TokenTransactionData message ExternalLaunchTransactionState { message StripeIntentAccessor { @@ -390,4 +496,4 @@ message MessageExtras { message GV2UpdateDescription { optional DecryptedGroupV2Context gv2ChangeDescription = 1; backup.GroupChangeChatUpdate groupChangeUpdate = 2; -} \ No newline at end of file +} diff --git a/app/src/main/protowire/JobData.proto b/app/src/main/protowire/JobData.proto index 889a9799b8..85724869c8 100644 --- a/app/src/main/protowire/JobData.proto +++ b/app/src/main/protowire/JobData.proto @@ -62,3 +62,12 @@ message ArchiveAttachmentBackfillJobData { message ArchiveThumbnailUploadJobData { uint64 attachmentId = 1; } + +message InAppPaymentRedemptionJobData { + oneof id { + uint64 inAppPaymentId = 1; + uint64 giftMessageId = 2; + } + + bool makePrimary = 3; +} diff --git a/app/src/main/res/navigation/app_settings.xml b/app/src/main/res/navigation/app_settings.xml index 89c2902e48..cf0df6b26e 100644 --- a/app/src/main/res/navigation/app_settings.xml +++ b/app/src/main/res/navigation/app_settings.xml @@ -501,7 +501,7 @@ @@ -705,7 +705,7 @@ diff --git a/app/src/main/res/navigation/donate_to_signal.xml b/app/src/main/res/navigation/donate_to_signal.xml index a8801f6b40..989bef033c 100644 --- a/app/src/main/res/navigation/donate_to_signal.xml +++ b/app/src/main/res/navigation/donate_to_signal.xml @@ -13,7 +13,7 @@ @@ -74,8 +74,8 @@ tools:layout="@layout/dsl_settings_fragment"> + android:name="inAppPaymentType" + app:argType="org.thoughtcrime.securesms.database.InAppPaymentTable$Type" /> + android:name="in_app_payment" + app:argType="org.thoughtcrime.securesms.database.InAppPaymentTable$InAppPayment" + app:nullable="true" /> + @@ -190,9 +193,12 @@ app:nullable="false" /> + android:name="in_app_payment" + app:argType="org.thoughtcrime.securesms.database.InAppPaymentTable$InAppPayment" + app:nullable="true" /> + @@ -221,8 +227,8 @@ tools:layout="@layout/dsl_settings_bottom_sheet"> @@ -232,8 +238,8 @@ android:label="bank_transfer_mandate_fragment"> @@ -281,8 +287,8 @@ android:label="ideal_transfer_details_fragment"> + android:name="inAppPaymentType" + app:argType="org.thoughtcrime.securesms.database.InAppPaymentTable$Type" /> @@ -89,9 +89,12 @@ app:nullable="false" /> + android:name="in_app_payment" + app:argType="org.thoughtcrime.securesms.database.InAppPaymentTable$InAppPayment" + app:nullable="true" /> + @@ -161,9 +164,12 @@ app:nullable="false" /> + android:name="in_app_payment" + app:argType="org.thoughtcrime.securesms.database.InAppPaymentTable$InAppPayment" + app:nullable="true" /> + @@ -192,8 +198,8 @@ tools:layout="@layout/dsl_settings_bottom_sheet"> \ No newline at end of file diff --git a/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt b/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt index dd8d710567..8e20a72322 100644 --- a/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt +++ b/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt @@ -88,7 +88,7 @@ fun Cursor.readToSingleLong(defaultValue: Long = 0): Long { } } -fun Cursor.readToSingleObject(serializer: Serializer): T? { +fun Cursor.readToSingleObject(serializer: BaseSerializer): T? { return use { if (it.moveToFirst()) { serializer.deserialize(it) diff --git a/core-util/src/main/java/org/signal/core/util/Serializer.kt b/core-util/src/main/java/org/signal/core/util/Serializer.kt index 5bbddb2f15..6526c14189 100644 --- a/core-util/src/main/java/org/signal/core/util/Serializer.kt +++ b/core-util/src/main/java/org/signal/core/util/Serializer.kt @@ -1,12 +1,25 @@ package org.signal.core.util +import android.content.ContentValues +import android.database.Cursor + +/** + * Generalized serializer for finer control + */ +interface BaseSerializer { + fun serialize(data: Data): Output + fun deserialize(input: Input): Data +} + /** * Generic serialization interface for use with database and store operations. */ -interface Serializer { - fun serialize(data: T): R - fun deserialize(data: R): T -} +interface Serializer : BaseSerializer + +/** + * Serializer specifically for working with SQLite + */ +interface DatabaseSerializer : BaseSerializer interface StringSerializer : Serializer diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/subscriptions/SubscriberId.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/subscriptions/SubscriberId.java index 856fbcf7de..b6d75f0d41 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/subscriptions/SubscriberId.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/subscriptions/SubscriberId.java @@ -4,6 +4,7 @@ package org.whispersystems.signalservice.api.subscriptions; import org.signal.core.util.Base64; import org.whispersystems.signalservice.api.util.Preconditions; +import java.io.IOException; import java.security.SecureRandom; import java.util.Arrays; @@ -30,6 +31,12 @@ public final class SubscriberId { return Base64.encodeUrlSafeWithPadding(bytes); } + public static @NonNull SubscriberId deserialize(@NonNull String serialized) throws IOException { + byte[] bytes = Base64.decode(serialized); + + return fromBytes(bytes); + } + public static SubscriberId fromBytes(byte[] bytes) { Preconditions.checkArgument(bytes.length == SIZE); return new SubscriberId(bytes);