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 5b98b4b7eb..85a884a20c 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 @@ -18,5 +18,6 @@ data class MessageBackupsFlowState( val inAppPayment: InAppPaymentTable.InAppPayment? = null, val startScreen: MessageBackupsStage, val stage: MessageBackupsStage = startScreen, - val backupKey: BackupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey() + val backupKey: BackupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey(), + val failure: Throwable? = null ) 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 a226777971..dd64bc64ff 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 @@ -8,10 +8,16 @@ package org.thoughtcrime.securesms.backup.v2.ui.subscription import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.timeout import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.reactive.asFlow import kotlinx.coroutines.withContext import org.signal.core.util.billing.BillingPurchaseResult import org.signal.core.util.money.FiatMoney @@ -19,15 +25,21 @@ import org.signal.donations.InAppPaymentType import org.thoughtcrime.securesms.backup.v2.BackupRepository import org.thoughtcrime.securesms.backup.v2.MessageBackupTier 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.RecurringInAppPaymentRepository +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentError 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.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobs.InAppPaymentPurchaseTokenJob import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.util.RemoteConfig import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration import java.math.BigDecimal +import kotlin.time.Duration.Companion.seconds class MessageBackupsFlowViewModel : ViewModel() { @@ -45,6 +57,17 @@ class MessageBackupsFlowViewModel : ViewModel() { check(SignalStore.backup.backupTier != MessageBackupTier.PAID) { "This screen does not support cancellation or downgrades." } viewModelScope.launch { + try { + ensureSubscriberIdForBackups() + } catch (e: Exception) { + internalStateFlow.update { + it.copy( + stage = MessageBackupsStage.FAILURE, + failure = e + ) + } + } + internalStateFlow.update { it.copy( availableBackupTypes = BackupRepository.getAvailableBackupsTypes( @@ -55,12 +78,30 @@ class MessageBackupsFlowViewModel : ViewModel() { } viewModelScope.launch { - AppDependencies.billingApi.getBillingPurchaseResults().collect { - when (it) { + AppDependencies.billingApi.getBillingPurchaseResults().collect { result -> + when (result) { is BillingPurchaseResult.Success -> { - // 1. Copy the purchaseToken into our inAppPaymentData - // 2. Enqueue the redemption chain - goToNextStage() + internalStateFlow.update { it.copy(stage = MessageBackupsStage.PROCESS_PAYMENT) } + + try { + handleSuccess( + result, + internalStateFlow.value.inAppPayment!!.id + ) + + internalStateFlow.update { + it.copy( + stage = MessageBackupsStage.COMPLETED + ) + } + } catch (e: Exception) { + internalStateFlow.update { + it.copy( + stage = MessageBackupsStage.FAILURE, + failure = e + ) + } + } } else -> goToPreviousStage() @@ -81,9 +122,10 @@ class MessageBackupsFlowViewModel : ViewModel() { MessageBackupsStage.TYPE_SELECTION -> validateTypeAndUpdateState(it) MessageBackupsStage.CHECKOUT_SHEET -> validateGatewayAndUpdateState(it) MessageBackupsStage.CREATING_IN_APP_PAYMENT -> error("This is driven by an async coroutine.") - MessageBackupsStage.PROCESS_PAYMENT -> it.copy(stage = MessageBackupsStage.COMPLETED) - MessageBackupsStage.PROCESS_FREE -> it.copy(stage = MessageBackupsStage.COMPLETED) + MessageBackupsStage.PROCESS_PAYMENT -> error("This is driven by an async coroutine.") + MessageBackupsStage.PROCESS_FREE -> error("This is driven by an async coroutine.") MessageBackupsStage.COMPLETED -> error("Unsupported state transition from terminal state COMPLETED") + MessageBackupsStage.FAILURE -> error("Unsupported state transition from terminal state FAILURE") } } } @@ -103,6 +145,7 @@ class MessageBackupsFlowViewModel : ViewModel() { MessageBackupsStage.PROCESS_PAYMENT -> MessageBackupsStage.PROCESS_PAYMENT MessageBackupsStage.PROCESS_FREE -> MessageBackupsStage.PROCESS_FREE MessageBackupsStage.COMPLETED -> error("Unsupported state transition from terminal state COMPLETED") + MessageBackupsStage.FAILURE -> error("Unsupported state transition from terminal state FAILURE") } it.copy(stage = previousScreen) @@ -146,7 +189,7 @@ class MessageBackupsFlowViewModel : ViewModel() { val id = SignalDatabase.inAppPayments.insert( type = InAppPaymentType.RECURRING_BACKUP, state = InAppPaymentTable.State.CREATED, - subscriberId = null, + subscriberId = InAppPaymentsRepository.requireSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP).subscriberId, endOfPeriod = null, inAppPaymentData = InAppPaymentData( badge = null, @@ -170,4 +213,51 @@ class MessageBackupsFlowViewModel : ViewModel() { return state.copy(stage = MessageBackupsStage.CREATING_IN_APP_PAYMENT) } + + /** + * Ensures we have a SubscriberId created and available for use. This is considered safe because + * the screen this is called in is assumed to only be accessible if the user does not currently have + * a subscription. + */ + private suspend fun ensureSubscriberIdForBackups() { + val product = AppDependencies.billingApi.queryProduct() ?: error("No product available.") + SignalStore.inAppPayments.setSubscriberCurrency(product.price.currency, InAppPaymentSubscriberRecord.Type.BACKUP) + RecurringInAppPaymentRepository.ensureSubscriberId(InAppPaymentSubscriberRecord.Type.BACKUP).blockingAwait() + } + + /** + * Handles a successful BillingPurchaseResult. Updates the in app payment, enqueues the appropriate job chain, + * and handles any resulting error. Like donations, we will wait up to 10s for the completion of the job chain. + */ + @OptIn(FlowPreview::class) + private suspend fun handleSuccess(result: BillingPurchaseResult.Success, inAppPaymentId: InAppPaymentTable.InAppPaymentId) { + withContext(Dispatchers.IO) { + val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!! + SignalDatabase.inAppPayments.update( + inAppPayment.copy( + data = inAppPayment.data.copy( + redemption = inAppPayment.data.redemption!!.copy( + googlePlayBillingPurchaseToken = result.purchaseToken + ) + ) + ) + ) + + InAppPaymentPurchaseTokenJob.createJobChain(inAppPayment).enqueue() + } + + val terminalInAppPayment = withContext(Dispatchers.IO) { + InAppPaymentsRepository.observeUpdates(inAppPaymentId).asFlow() + .filter { it.state == InAppPaymentTable.State.END } + .take(1) + .timeout(10.seconds) + .first() + } + + if (terminalInAppPayment.data.error != null) { + throw InAppPaymentError(terminalInAppPayment.data.error) + } else { + return + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsStage.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsStage.kt index ae64515016..ec9d00b809 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsStage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsStage.kt @@ -19,7 +19,8 @@ enum class MessageBackupsStage( CREATING_IN_APP_PAYMENT(route = Route.TYPE_SELECTION), PROCESS_PAYMENT(route = Route.TYPE_SELECTION), PROCESS_FREE(route = Route.TYPE_SELECTION), - COMPLETED(route = Route.TYPE_SELECTION); + COMPLETED(route = Route.TYPE_SELECTION), + FAILURE(route = Route.TYPE_SELECTION); /** * Compose navigation route to display while in a given stage. 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 index 04c722496d..67c23c08bc 100644 --- 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 @@ -46,7 +46,7 @@ import org.thoughtcrime.securesms.util.Util import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription import org.whispersystems.signalservice.api.subscriptions.SubscriberId import org.whispersystems.signalservice.internal.push.DonationProcessor -import org.whispersystems.signalservice.internal.push.exceptions.DonationProcessorError +import org.whispersystems.signalservice.internal.push.exceptions.InAppPaymentProcessorError import java.security.SecureRandom import java.util.Currency import java.util.Optional @@ -95,7 +95,7 @@ object InAppPaymentsRepository { val donationError: DonationError = when (error) { is DonationError -> error - is DonationProcessorError -> error.toDonationError(donationErrorSource, paymentSourceType) + is InAppPaymentProcessorError -> error.toDonationError(donationErrorSource, paymentSourceType) else -> DonationError.genericBadgeRedemptionFailure(donationErrorSource) } 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 2f91a565fc..df04d46848 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 @@ -37,7 +37,7 @@ 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.exceptions.DonationProcessorError +import org.whispersystems.signalservice.internal.push.exceptions.InAppPaymentProcessorError class StripePaymentInProgressViewModel( private val stripeRepository: StripeRepository, @@ -175,7 +175,7 @@ class StripePaymentInProgressViewModel( .onErrorResumeNext { when (it) { is DonationError -> Completable.error(it) - is DonationProcessorError -> Completable.error(it.toDonationError(DonationErrorSource.MONTHLY, paymentSourceProvider.paymentSourceType)) + is InAppPaymentProcessorError -> Completable.error(it.toDonationError(DonationErrorSource.MONTHLY, paymentSourceProvider.paymentSourceType)) else -> Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.MONTHLY, it, paymentSourceProvider.paymentSourceType)) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationProcessorErrorExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationProcessorErrorExtensions.kt index 3a0ae18141..0614ae49b9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationProcessorErrorExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/errors/DonationProcessorErrorExtensions.kt @@ -9,9 +9,9 @@ import org.signal.donations.PaymentSourceType import org.signal.donations.StripeDeclineCode import org.signal.donations.StripeFailureCode import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription -import org.whispersystems.signalservice.internal.push.exceptions.DonationProcessorError +import org.whispersystems.signalservice.internal.push.exceptions.InAppPaymentProcessorError -fun DonationProcessorError.toDonationError( +fun InAppPaymentProcessorError.toDonationError( source: DonationErrorSource, method: PaymentSourceType ): DonationError { @@ -44,5 +44,9 @@ fun DonationProcessorError.toDonationError( } } } + ActiveSubscription.Processor.GOOGLE_PLAY_BILLING -> { + check(method is PaymentSourceType.GooglePlayBilling) + DonationError.PaymentSetupError.GenericError(source, this) + } } } 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 9ee2507b87..abef5c8b2b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BoostReceiptRequestResponseJob.java @@ -32,7 +32,7 @@ import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription; import org.whispersystems.signalservice.api.subscriptions.SubscriptionLevels; import org.whispersystems.signalservice.internal.ServiceResponse; import org.whispersystems.signalservice.internal.push.DonationProcessor; -import org.whispersystems.signalservice.internal.push.exceptions.DonationReceiptCredentialError; +import org.whispersystems.signalservice.internal.push.exceptions.InAppPaymentReceiptCredentialError; import java.io.IOException; import java.security.SecureRandom; @@ -325,8 +325,8 @@ public class BoostReceiptRequestResponseJob extends BaseJob { Log.w(TAG, "User payment failed.", applicationException, true); DonationError.routeBackgroundError(context, DonationError.genericPaymentFailure(donationErrorSource), terminalDonation.isLongRunningPaymentMethod); - if (applicationException instanceof DonationReceiptCredentialError) { - setPendingOneTimeDonationChargeFailureError(((DonationReceiptCredentialError) applicationException).getChargeFailure()); + if (applicationException instanceof InAppPaymentReceiptCredentialError) { + setPendingOneTimeDonationChargeFailureError(((InAppPaymentReceiptCredentialError) applicationException).getChargeFailure()); } else { setPendingOneTimeDonationGenericRedemptionError(response.getStatus()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentOneTimeContextJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentOneTimeContextJob.kt index 3237f6147f..b1443d1afd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentOneTimeContextJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentOneTimeContextJob.kt @@ -22,7 +22,7 @@ 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 org.whispersystems.signalservice.internal.push.exceptions.InAppPaymentReceiptCredentialError import java.io.IOException import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.milliseconds @@ -238,7 +238,7 @@ class InAppPaymentOneTimeContextJob private constructor( notified = false, state = InAppPaymentTable.State.END, data = inAppPayment.data.copy( - error = InAppPaymentsRepository.buildPaymentFailure(inAppPayment, (applicationError as? DonationReceiptCredentialError)?.chargeFailure) + error = InAppPaymentsRepository.buildPaymentFailure(inAppPayment, (applicationError as? InAppPaymentReceiptCredentialError)?.chargeFailure) ) ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentPurchaseTokenJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentPurchaseTokenJob.kt new file mode 100644 index 0000000000..d83a873462 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentPurchaseTokenJob.kt @@ -0,0 +1,201 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.jobs + +import okio.IOException +import org.signal.core.util.logging.Log +import org.signal.donations.InAppPaymentType +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.database.model.databaseprotos.InAppPaymentData +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.JobManager.Chain +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint + +/** + * Submits a purchase token to the server to link it with a subscriber id. + */ +class InAppPaymentPurchaseTokenJob private constructor( + private val inAppPaymentId: InAppPaymentTable.InAppPaymentId, + parameters: Parameters +) : BaseJob(parameters) { + + companion object { + private val TAG = Log.tag(InAppPaymentPurchaseTokenJob::class) + + const val KEY = "InAppPaymentPurchaseTokenJob" + + private fun create(inAppPayment: InAppPaymentTable.InAppPayment): Job { + return InAppPaymentPurchaseTokenJob( + 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): Chain { + return AppDependencies.jobManager + .startChain(create(inAppPayment)) + .then(InAppPaymentRecurringContextJob.create(inAppPayment)) + .then(InAppPaymentRedemptionJob.create(inAppPayment)) + } + } + + 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 onRun() { + synchronized(InAppPaymentsRepository.resolveMutex(inAppPaymentId)) { + doRun() + } + } + + private fun doRun() { + val inAppPayment = getAndValidateInAppPayment() + + val response = AppDependencies.donationsService.linkGooglePlayBillingPurchaseTokenToSubscriberId( + inAppPayment.subscriberId!!, + inAppPayment.data.redemption!!.googlePlayBillingPurchaseToken!!, + InAppPaymentSubscriberRecord.Type.BACKUP + ) + + if (response.applicationError.isPresent) { + handleApplicationError(response.applicationError.get(), response.status) + } else if (response.result.isPresent) { + info("Successfully linked purchase token to subscriber id.") + } else { + warning("Encountered a retryable exception.", response.executionError.get()) + throw InAppPaymentRetryException(response.executionError.get()) + } + } + + private fun getAndValidateInAppPayment(): InAppPaymentTable.InAppPayment { + 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 != InAppPaymentType.RECURRING_BACKUP) { + warning("Unexpected type. Got ${inAppPayment.type} but expected a recurring backup.") + 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.") + } + + if (inAppPayment.data.redemption.googlePlayBillingPurchaseToken == null) { + warning("No purchase token for linking!") + throw IOException("InAppPayment does not have a purchase token!") + } + + return inAppPayment + } + + private fun handleApplicationError(applicationError: Throwable, status: Int) { + when (status) { + 402 -> { + warning("The purchaseToken payment is incomplete or invalid.", applicationError) + // TODO [message-backups] -- Is this a recoverable failure? + throw IOException("TODO -- recoverable?") + } + + 403 -> { + warning("subscriberId authentication failure OR account authentication is present", applicationError) + throw IOException("subscriberId authentication failure OR account authentication is present") + } + + 404 -> { + warning("No such subscriberId exists or subscriberId is malformed or the purchaseToken does not exist", applicationError) + throw IOException("No such subscriberId exists or subscriberId is malformed or the purchaseToken does not exist") + } + + 409 -> { + warning("subscriberId is already linked to a processor that does not support Play Billing. Delete this subscriberId and use a new one.", applicationError) + + try { + info("Generating a new subscriber id.") + RecurringInAppPaymentRepository.ensureSubscriberId(InAppPaymentSubscriberRecord.Type.BACKUP, true).blockingAwait() + } catch (e: Exception) { + throw InAppPaymentRetryException(e) + } + + info("Writing the new subscriber id to the InAppPayment.") + val latest = SignalDatabase.inAppPayments.getById(inAppPaymentId)!! + SignalDatabase.inAppPayments.update( + latest.copy(subscriberId = InAppPaymentsRepository.requireSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP).subscriberId) + ) + + info("Scheduling retry.") + throw InAppPaymentRetryException() + } + } + } + + 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?): InAppPaymentPurchaseTokenJob { + return InAppPaymentPurchaseTokenJob( + 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 index 4658e937bf..e8e0a3bf32 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt @@ -48,7 +48,7 @@ class InAppPaymentRecurringContextJob private constructor( const val KEY = "InAppPurchaseRecurringContextJob" - private fun create(inAppPayment: InAppPaymentTable.InAppPayment): Job { + fun create(inAppPayment: InAppPaymentTable.InAppPayment): Job { return InAppPaymentRecurringContextJob( inAppPaymentId = inAppPayment.id, parameters = Parameters.Builder() 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 a0ffef57ff..cd807ca86a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -155,6 +155,7 @@ public final class JobManagerFactories { put(InAppPaymentAuthCheckJob.KEY, new InAppPaymentAuthCheckJob.Factory()); put(InAppPaymentGiftSendJob.KEY, new InAppPaymentGiftSendJob.Factory()); put(InAppPaymentKeepAliveJob.KEY, new InAppPaymentKeepAliveJob.Factory()); + put(InAppPaymentPurchaseTokenJob.KEY, new InAppPaymentPurchaseTokenJob.Factory()); put(InAppPaymentRecurringContextJob.KEY, new InAppPaymentRecurringContextJob.Factory()); put(InAppPaymentOneTimeContextJob.KEY, new InAppPaymentOneTimeContextJob.Factory()); put(InAppPaymentRedemptionJob.KEY, new InAppPaymentRedemptionJob.Factory()); diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java index 56c40a6bd8..0e4ee458d4 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java @@ -184,6 +184,16 @@ public class DonationsService { }); } + public ServiceResponse linkGooglePlayBillingPurchaseTokenToSubscriberId(SubscriberId subscriberId, String purchaseToken, Object mutex) { + return wrapInServiceResponse(() -> { + synchronized (mutex) { + pushServiceSocket.linkPlayBillingPurchaseToken(subscriberId.serialize(), purchaseToken); + } + + return new Pair<>(EmptyResponse.INSTANCE, 200); + }); + } + /** * Synchronously returns information about the current subscription if one exists. */ diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/subscriptions/ActiveSubscription.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/subscriptions/ActiveSubscription.java index 3bfb782431..3c042da561 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/subscriptions/ActiveSubscription.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/subscriptions/ActiveSubscription.java @@ -19,7 +19,8 @@ public final class ActiveSubscription { public enum Processor { STRIPE("STRIPE"), - BRAINTREE("BRAINTREE"); + BRAINTREE("BRAINTREE"), + GOOGLE_PLAY_BILLING("GOOGLE_PLAY_BILLING"); private final String code; diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index ff785f19b1..2bdf5b1b3f 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -131,8 +131,8 @@ import org.whispersystems.signalservice.internal.configuration.SignalServiceConf import org.whispersystems.signalservice.internal.configuration.SignalUrl; import org.whispersystems.signalservice.internal.crypto.AttachmentDigest; import org.whispersystems.signalservice.internal.push.exceptions.CaptchaRejectedException; -import org.whispersystems.signalservice.internal.push.exceptions.DonationProcessorError; -import org.whispersystems.signalservice.internal.push.exceptions.DonationReceiptCredentialError; +import org.whispersystems.signalservice.internal.push.exceptions.InAppPaymentProcessorError; +import org.whispersystems.signalservice.internal.push.exceptions.InAppPaymentReceiptCredentialError; import org.whispersystems.signalservice.internal.push.exceptions.ForbiddenException; import org.whispersystems.signalservice.internal.push.exceptions.GroupExistsException; import org.whispersystems.signalservice.internal.push.exceptions.GroupMismatchedDevicesException; @@ -306,6 +306,7 @@ public class PushServiceSocket { private static final String BOOST_RECEIPT_CREDENTIALS = "/v1/subscription/boost/receipt_credentials"; private static final String DONATIONS_CONFIGURATION = "/v1/subscription/configuration"; private static final String BANK_MANDATE = "/v1/subscription/bank_mandate/%s"; + private static final String LINK_PLAY_BILLING_PURCHASE_TOKEN = "/v1/subscription/%s/playbilling/%s"; private static final String VERIFICATION_SESSION_PATH = "/v1/verification/session"; private static final String VERIFICATION_CODE_PATH = "/v1/verification/session/%s/code"; @@ -1369,7 +1370,7 @@ public class PushServiceSocket { public PayPalConfirmPaymentIntentResponse confirmPayPalOneTimePaymentIntent(String currency, String amount, long level, String payerId, String paymentId, String paymentToken) throws IOException { String payload = JsonUtil.toJson(new PayPalConfirmOneTimePaymentIntentPayload(amount, currency, level, payerId, paymentId, paymentToken)); - String result = makeServiceRequestWithoutAuthentication(CONFIRM_PAYPAL_ONE_TIME_PAYMENT_INTENT, "POST", payload, NO_HEADERS, new DonationResponseHandler()); + String result = makeServiceRequestWithoutAuthentication(CONFIRM_PAYPAL_ONE_TIME_PAYMENT_INTENT, "POST", payload, NO_HEADERS, new InAppPaymentResponseCodeHandler()); return JsonUtil.fromJsonResponse(result, PayPalConfirmPaymentIntentResponse.class); } @@ -1390,14 +1391,14 @@ public class PushServiceSocket { (code, body) -> { if (code == 204) throw new NonSuccessfulResponseCodeException(204); if (code == 402) { - DonationReceiptCredentialError donationReceiptCredentialError; + InAppPaymentReceiptCredentialError inAppPaymentReceiptCredentialError; try { - donationReceiptCredentialError = JsonUtil.fromJson(body.string(), DonationReceiptCredentialError.class); + inAppPaymentReceiptCredentialError = JsonUtil.fromJson(body.string(), InAppPaymentReceiptCredentialError.class); } catch (IOException e) { throw new NonSuccessfulResponseCodeException(402); } - throw donationReceiptCredentialError; + throw inAppPaymentReceiptCredentialError; } }); @@ -1430,8 +1431,12 @@ public class PushServiceSocket { return JsonUtil.fromJson(result, BankMandate.class); } + public void linkPlayBillingPurchaseToken(String subscriberId, String purchaseToken) throws IOException { + makeServiceRequestWithoutAuthentication(String.format(LINK_PLAY_BILLING_PURCHASE_TOKEN, subscriberId, purchaseToken), "PUT", "", NO_HEADERS, new LinkGooglePlayBillingPurchaseTokenResponseCodeHandler()); + } + public void updateSubscriptionLevel(String subscriberId, String level, String currencyCode, String idempotencyKey) throws IOException { - makeServiceRequestWithoutAuthentication(String.format(UPDATE_SUBSCRIPTION_LEVEL, subscriberId, level, currencyCode, idempotencyKey), "PUT", "", NO_HEADERS, new DonationResponseHandler()); + makeServiceRequestWithoutAuthentication(String.format(UPDATE_SUBSCRIPTION_LEVEL, subscriberId, level, currencyCode, idempotencyKey), "PUT", "", NO_HEADERS, new InAppPaymentResponseCodeHandler()); } public ActiveSubscription getSubscription(String subscriberId) throws IOException { @@ -3001,9 +3006,23 @@ public class PushServiceSocket { } /** - * Handler for a couple donation endpoints. + * Handler for Google Play Billing purchase token linking */ - private static class DonationResponseHandler implements ResponseCodeHandler { + private static class LinkGooglePlayBillingPurchaseTokenResponseCodeHandler implements ResponseCodeHandler { + @Override + public void handle(int responseCode, ResponseBody body) throws NonSuccessfulResponseCodeException, PushNetworkException { + if (responseCode < 400) { + return; + } + + throw new NonSuccessfulResponseCodeException(responseCode); + } + } + + /** + * Handler for a couple in app payment endpoints. + */ + private static class InAppPaymentResponseCodeHandler implements ResponseCodeHandler { @Override public void handle(int responseCode, ResponseBody body) throws NonSuccessfulResponseCodeException, PushNetworkException { if (responseCode < 400) { @@ -3011,9 +3030,9 @@ public class PushServiceSocket { } if (responseCode == 440) { - DonationProcessorError exception; + InAppPaymentProcessorError exception; try { - exception = JsonUtil.fromJson(body.string(), DonationProcessorError.class); + exception = JsonUtil.fromJson(body.string(), InAppPaymentProcessorError.class); } catch (IOException e) { throw new NonSuccessfulResponseCodeException(440); } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/DonationProcessorError.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/InAppPaymentProcessorError.kt similarity index 93% rename from libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/DonationProcessorError.kt rename to libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/InAppPaymentProcessorError.kt index db07d9726e..987280e37c 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/DonationProcessorError.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/InAppPaymentProcessorError.kt @@ -14,7 +14,7 @@ import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription.Pro * HTTP 440 Exception when something bad happens while updating a user's subscription level or * confirming a PayPal intent. */ -class DonationProcessorError @JsonCreator constructor( +class InAppPaymentProcessorError @JsonCreator constructor( val processor: Processor, val chargeFailure: ChargeFailure ) : NonSuccessfulResponseCodeException(440) { diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/DonationReceiptCredentialError.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/InAppPaymentReceiptCredentialError.kt similarity index 91% rename from libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/DonationReceiptCredentialError.kt rename to libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/InAppPaymentReceiptCredentialError.kt index 88f30e641c..64b4cc2233 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/DonationReceiptCredentialError.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/exceptions/InAppPaymentReceiptCredentialError.kt @@ -13,7 +13,7 @@ import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription.Cha * HTTP 402 Exception when trying to submit credentials for a donation with * a failed payment. */ -class DonationReceiptCredentialError @JsonCreator constructor( +class InAppPaymentReceiptCredentialError @JsonCreator constructor( val chargeFailure: ChargeFailure ) : NonSuccessfulResponseCodeException(402) { override fun toString(): String { diff --git a/libsignal-service/src/test/java/org/whispersystems/signalservice/internal/push/exceptions/DonationProcessorErrorTest.kt b/libsignal-service/src/test/java/org/whispersystems/signalservice/internal/push/exceptions/InAppPaymentProcessorErrorTest.kt similarity index 92% rename from libsignal-service/src/test/java/org/whispersystems/signalservice/internal/push/exceptions/DonationProcessorErrorTest.kt rename to libsignal-service/src/test/java/org/whispersystems/signalservice/internal/push/exceptions/InAppPaymentProcessorErrorTest.kt index 598096d715..afb2eb3408 100644 --- a/libsignal-service/src/test/java/org/whispersystems/signalservice/internal/push/exceptions/DonationProcessorErrorTest.kt +++ b/libsignal-service/src/test/java/org/whispersystems/signalservice/internal/push/exceptions/InAppPaymentProcessorErrorTest.kt @@ -10,7 +10,7 @@ import org.junit.Test import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription import org.whispersystems.signalservice.internal.util.JsonUtil -class DonationProcessorErrorTest { +class InAppPaymentProcessorErrorTest { companion object { private val TEST_PROCESSOR = ActiveSubscription.Processor.STRIPE @@ -36,7 +36,7 @@ class DonationProcessorErrorTest { @Test fun givenTestJson_whenIFromJson_thenIExpectProperlyParsedError() { - val result = JsonUtil.fromJson(TEST_JSON, DonationProcessorError::class.java) + val result = JsonUtil.fromJson(TEST_JSON, InAppPaymentProcessorError::class.java) assertEquals(TEST_PROCESSOR, result.processor) assertEquals(TEST_CODE, result.chargeFailure.code)