Add google play billing token conversion endpoint and job.

This commit is contained in:
Alex Hart
2024-09-20 09:55:08 -03:00
committed by Greyson Parrelli
parent d23ef647d8
commit 12e25b0f40
17 changed files with 366 additions and 38 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.
*/

View File

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

View File

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

View File

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

View File

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

View File

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