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