mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-23 20:48:43 +00:00
Add google play billing token conversion endpoint and job.
This commit is contained in:
committed by
Greyson Parrelli
parent
d23ef647d8
commit
12e25b0f40
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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<InAppPaymentPurchaseTokenJob> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): InAppPaymentPurchaseTokenJob {
|
||||
return InAppPaymentPurchaseTokenJob(
|
||||
InAppPaymentTable.InAppPaymentId(serializedData!!.decodeToString().toLong()),
|
||||
parameters
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -184,6 +184,16 @@ public class DonationsService {
|
||||
});
|
||||
}
|
||||
|
||||
public ServiceResponse<EmptyResponse> 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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -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 {
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user