Implement happy path for backups subscriptions.

This commit is contained in:
Alex Hart 2024-09-25 16:14:41 -03:00 committed by Greyson Parrelli
parent c80ebd5658
commit 81d99c9d30
12 changed files with 132 additions and 155 deletions

View file

@ -1,104 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.thoughtcrime.securesms.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConfirmBackupCancellationDialog(
onConfirmAndDownloadNow: () -> Unit,
onConfirmAndDownloadLater: () -> Unit,
onKeepSubscriptionClick: () -> Unit
) {
BasicAlertDialog(onDismissRequest = onKeepSubscriptionClick) {
Surface(
shape = AlertDialogDefaults.shape,
color = AlertDialogDefaults.containerColor
) {
Column {
Text(
text = stringResource(id = R.string.ConfirmBackupCancellationDialog__confirm_cancellation),
color = AlertDialogDefaults.titleContentColor,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier
.padding(top = 24.dp)
.padding(horizontal = 24.dp)
)
Text(
text = stringResource(id = R.string.ConfirmBackupCancellationDialog__you_wont_be_charged_again),
color = AlertDialogDefaults.textContentColor,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.padding(top = 16.dp)
.padding(horizontal = 24.dp)
)
TextButton(
onClick = onConfirmAndDownloadNow,
modifier = Modifier
.align(Alignment.End)
.padding(end = 12.dp)
) {
Text(
text = stringResource(id = R.string.ConfirmBackupCancellationDialog__confirm_and_download_now)
)
}
TextButton(
onClick = onConfirmAndDownloadLater,
modifier = Modifier
.align(Alignment.End)
.padding(end = 12.dp)
) {
Text(
text = stringResource(id = R.string.ConfirmBackupCancellationDialog__confirm_and_download_later)
)
}
TextButton(
onClick = onKeepSubscriptionClick,
modifier = Modifier
.align(Alignment.End)
.padding(end = 12.dp, bottom = 12.dp)
) {
Text(
text = stringResource(id = R.string.ConfirmBackupCancellationDialog__keep_subscription)
)
}
}
}
}
}
@SignalPreview
@Composable
private fun ConfirmCancellationDialogPreview() {
Previews.Preview {
ConfirmBackupCancellationDialog(
onKeepSubscriptionClick = {},
onConfirmAndDownloadNow = {},
onConfirmAndDownloadLater = {}
)
}
}

View file

@ -6,6 +6,8 @@
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import android.app.Activity
import android.os.Bundle
import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -15,10 +17,13 @@ import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.rx3.asFlowable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentCheckoutDelegate
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.compose.Nav
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.viewModel
@ -26,9 +31,20 @@ import org.thoughtcrime.securesms.util.viewModel
/**
* Handles the selection, payment, and changing of a user's backup tier.
*/
class MessageBackupsFlowFragment : ComposeFragment() {
class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelegate.ErrorHandlerCallback {
private val viewModel: MessageBackupsFlowViewModel by viewModel { MessageBackupsFlowViewModel() }
private val errorHandler = InAppPaymentCheckoutDelegate.ErrorHandler()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
errorHandler.attach(
fragment = this,
errorHandlerCallback = this,
inAppPaymentIdSource = viewModel.stateFlow.asFlowable()
.filter { it.inAppPayment != null }
.map { it.inAppPayment!!.id }
)
}
@Composable
override fun FragmentContent() {
@ -83,6 +99,7 @@ class MessageBackupsFlowFragment : ComposeFragment() {
composable(route = MessageBackupsStage.Route.TYPE_SELECTION.name) {
MessageBackupsTypeSelectionScreen(
stage = state.stage,
currentBackupTier = state.currentMessageBackupTier,
selectedBackupTier = state.selectedMessageBackupTier,
availableBackupTypes = state.availableBackupTypes.filter { it.tier == MessageBackupTier.FREE || state.hasBackupSubscriberAvailable },
@ -123,4 +140,11 @@ class MessageBackupsFlowFragment : ComposeFragment() {
}
}
}
override fun onUserLaunchedAnExternalApplication() = error("Not supported by this fragment.")
override fun navigateToDonationPending(inAppPayment: InAppPaymentTable.InAppPayment) = error("Not supported by this fragment.")
override fun exitCheckoutFlow() {
requireActivity().finishAfterTransition()
}
}

View file

@ -9,8 +9,10 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.take
@ -22,12 +24,15 @@ import kotlinx.coroutines.withContext
import org.signal.core.util.billing.BillingPurchaseResult
import org.signal.core.util.logging.Log
import org.signal.donations.InAppPaymentType
import org.signal.donations.PaymentSourceType
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.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
@ -84,13 +89,12 @@ class MessageBackupsFlowViewModel : ViewModel() {
AppDependencies.billingApi.getBillingPurchaseResults().collect { result ->
when (result) {
is BillingPurchaseResult.Success -> {
internalStateFlow.update { it.copy(stage = MessageBackupsStage.PROCESS_PAYMENT) }
Log.d(TAG, "Got successful purchase result for purchase at ${result.purchaseTime}")
val id = internalStateFlow.value.inAppPayment!!.id
try {
handleSuccess(
result,
internalStateFlow.value.inAppPayment!!.id
)
Log.d(TAG, "Attempting to handle successful purchase.")
handleSuccess(result, id)
internalStateFlow.update {
it.copy(
@ -98,6 +102,14 @@ class MessageBackupsFlowViewModel : ViewModel() {
)
}
} catch (e: Exception) {
Log.d(TAG, "Failed to handle purchase.", e)
InAppPaymentsRepository.handlePipelineError(
inAppPaymentId = id,
donationErrorSource = DonationErrorSource.BACKUPS,
paymentSourceType = PaymentSourceType.GooglePlayBilling,
error = e
)
internalStateFlow.update {
it.copy(
stage = MessageBackupsStage.FAILURE,
@ -123,7 +135,7 @@ class MessageBackupsFlowViewModel : ViewModel() {
MessageBackupsStage.BACKUP_KEY_EDUCATION -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_RECORD)
MessageBackupsStage.BACKUP_KEY_RECORD -> it.copy(stage = MessageBackupsStage.TYPE_SELECTION)
MessageBackupsStage.TYPE_SELECTION -> validateTypeAndUpdateState(it)
MessageBackupsStage.CHECKOUT_SHEET -> validateGatewayAndUpdateState(it)
MessageBackupsStage.CHECKOUT_SHEET -> it.copy(stage = MessageBackupsStage.PROCESS_PAYMENT)
MessageBackupsStage.CREATING_IN_APP_PAYMENT -> error("This is driven by an async coroutine.")
MessageBackupsStage.PROCESS_PAYMENT -> error("This is driven by an async coroutine.")
MessageBackupsStage.PROCESS_FREE -> error("This is driven by an async coroutine.")
@ -174,49 +186,44 @@ class MessageBackupsFlowViewModel : ViewModel() {
state.copy(stage = MessageBackupsStage.COMPLETED)
}
MessageBackupTier.PAID -> state.copy(stage = MessageBackupsStage.CHECKOUT_SHEET)
}
}
MessageBackupTier.PAID -> {
check(state.selectedMessageBackupTier == MessageBackupTier.PAID)
check(state.availableBackupTypes.any { it.tier == state.selectedMessageBackupTier })
check(state.hasBackupSubscriberAvailable)
private fun validateGatewayAndUpdateState(state: MessageBackupsFlowState): MessageBackupsFlowState {
check(state.selectedMessageBackupTier == MessageBackupTier.PAID)
check(state.availableBackupTypes.any { it.tier == state.selectedMessageBackupTier })
check(state.hasBackupSubscriberAvailable)
viewModelScope.launch(Dispatchers.IO) {
internalStateFlow.update { it.copy(inAppPayment = null) }
viewModelScope.launch(Dispatchers.IO) {
withContext(Dispatchers.Main) {
internalStateFlow.update { it.copy(inAppPayment = null) }
}
val paidFiat = AppDependencies.billingApi.queryProduct()!!.price
val paidFiat = AppDependencies.billingApi.queryProduct()!!.price
SignalDatabase.inAppPayments.clearCreated()
val id = SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.RECURRING_BACKUP,
state = InAppPaymentTable.State.CREATED,
subscriberId = InAppPaymentsRepository.requireSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP).subscriberId,
endOfPeriod = null,
inAppPaymentData = InAppPaymentData(
badge = null,
label = state.selectedMessageBackupTierLabel!!,
amount = paidFiat.toFiatValue(),
level = SubscriptionsConfiguration.BACKUPS_LEVEL.toLong(),
recipientId = Recipient.self().id.serialize(),
paymentMethodType = InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING,
redemption = InAppPaymentData.RedemptionState(
stage = InAppPaymentData.RedemptionState.Stage.INIT
SignalDatabase.inAppPayments.clearCreated()
val id = SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.RECURRING_BACKUP,
state = InAppPaymentTable.State.CREATED,
subscriberId = InAppPaymentsRepository.requireSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP).subscriberId,
endOfPeriod = null,
inAppPaymentData = InAppPaymentData(
badge = null,
label = state.selectedMessageBackupTierLabel!!,
amount = paidFiat.toFiatValue(),
level = SubscriptionsConfiguration.BACKUPS_LEVEL.toLong(),
recipientId = Recipient.self().id.serialize(),
paymentMethodType = InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING,
redemption = InAppPaymentData.RedemptionState(
stage = InAppPaymentData.RedemptionState.Stage.INIT
)
)
)
)
)
val inAppPayment = SignalDatabase.inAppPayments.getById(id)!!
val inAppPayment = SignalDatabase.inAppPayments.getById(id)!!
internalStateFlow.update {
it.copy(inAppPayment = inAppPayment, stage = MessageBackupsStage.CHECKOUT_SHEET)
}
}
withContext(Dispatchers.Main) {
internalStateFlow.update { it.copy(inAppPayment = inAppPayment, stage = MessageBackupsStage.PROCESS_PAYMENT) }
state.copy(stage = MessageBackupsStage.CREATING_IN_APP_PAYMENT)
}
}
return state.copy(stage = MessageBackupsStage.CREATING_IN_APP_PAYMENT)
}
/**
@ -237,6 +244,8 @@ class MessageBackupsFlowViewModel : ViewModel() {
@OptIn(FlowPreview::class)
private suspend fun handleSuccess(result: BillingPurchaseResult.Success, inAppPaymentId: InAppPaymentTable.InAppPaymentId) {
withContext(Dispatchers.IO) {
Log.d(TAG, "Setting purchase token data on InAppPayment.")
val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
SignalDatabase.inAppPayments.update(
inAppPayment.copy(
@ -248,20 +257,30 @@ class MessageBackupsFlowViewModel : ViewModel() {
)
)
Log.d(TAG, "Enqueueing InAppPaymentPurchaseTokenJob chain.")
InAppPaymentPurchaseTokenJob.createJobChain(inAppPayment).enqueue()
}
val terminalInAppPayment = withContext(Dispatchers.IO) {
Log.d(TAG, "Awaiting completion of job chain for up to 10 seconds.")
InAppPaymentsRepository.observeUpdates(inAppPaymentId).asFlow()
.filter { it.state == InAppPaymentTable.State.END }
.take(1)
.timeout(10.seconds)
.catch { exception ->
if (exception is TimeoutCancellationException) {
throw DonationError.BadgeRedemptionError.TimeoutWaitingForTokenError(DonationErrorSource.BACKUPS)
}
}
.first()
}
if (terminalInAppPayment.data.error != null) {
throw InAppPaymentError(terminalInAppPayment.data.error)
val err = InAppPaymentError(terminalInAppPayment.data.error)
Log.d(TAG, "An error occurred during the job chain!", err)
throw err
} else {
Log.d(TAG, "Job chain completed successfully.")
return
}
}

View file

@ -15,8 +15,8 @@ enum class MessageBackupsStage(
BACKUP_KEY_EDUCATION(route = Route.BACKUP_KEY_EDUCATION),
BACKUP_KEY_RECORD(route = Route.BACKUP_KEY_RECORD),
TYPE_SELECTION(route = Route.TYPE_SELECTION),
CHECKOUT_SHEET(route = Route.TYPE_SELECTION),
CREATING_IN_APP_PAYMENT(route = Route.TYPE_SELECTION),
CHECKOUT_SHEET(route = Route.TYPE_SELECTION),
PROCESS_PAYMENT(route = Route.TYPE_SELECTION),
PROCESS_FREE(route = Route.TYPE_SELECTION),
COMPLETED(route = Route.TYPE_SELECTION),

View file

@ -45,6 +45,7 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import kotlinx.collections.immutable.persistentListOf
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dialogs
import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview
@ -64,6 +65,7 @@ import java.util.Currency
@OptIn(ExperimentalTextApi::class)
@Composable
fun MessageBackupsTypeSelectionScreen(
stage: MessageBackupsStage,
currentBackupTier: MessageBackupTier?,
selectedBackupTier: MessageBackupTier?,
availableBackupTypes: List<MessageBackupsType>,
@ -168,6 +170,13 @@ fun MessageBackupsTypeSelectionScreen(
)
)
}
when (stage) {
MessageBackupsStage.CREATING_IN_APP_PAYMENT -> Dialogs.IndeterminateProgressDialog()
MessageBackupsStage.PROCESS_PAYMENT -> Dialogs.IndeterminateProgressDialog()
MessageBackupsStage.PROCESS_FREE -> Dialogs.IndeterminateProgressDialog()
else -> Unit
}
}
}
}
@ -179,6 +188,7 @@ private fun MessageBackupsTypeSelectionScreenPreview() {
Previews.Preview {
MessageBackupsTypeSelectionScreen(
stage = MessageBackupsStage.TYPE_SELECTION,
selectedBackupTier = MessageBackupTier.FREE,
availableBackupTypes = testBackupTypes(),
onMessageBackupsTierSelected = { selectedBackupsType = it },
@ -197,6 +207,7 @@ private fun MessageBackupsTypeSelectionScreenWithCurrentTierPreview() {
Previews.Preview {
MessageBackupsTypeSelectionScreen(
stage = MessageBackupsStage.TYPE_SELECTION,
selectedBackupTier = MessageBackupTier.FREE,
availableBackupTypes = testBackupTypes(),
onMessageBackupsTierSelected = { selectedBackupsType = it },

View file

@ -224,6 +224,7 @@ object InAppPaymentsRepository {
DonationErrorSource.ONE_TIME -> InAppPaymentType.ONE_TIME_DONATION
DonationErrorSource.MONTHLY -> InAppPaymentType.RECURRING_DONATION
DonationErrorSource.GIFT -> InAppPaymentType.ONE_TIME_GIFT
DonationErrorSource.BACKUPS -> InAppPaymentType.RECURRING_BACKUP
DonationErrorSource.GIFT_REDEMPTION -> InAppPaymentType.UNKNOWN
DonationErrorSource.KEEP_ALIVE -> InAppPaymentType.UNKNOWN
DonationErrorSource.UNKNOWN -> InAppPaymentType.UNKNOWN
@ -266,7 +267,7 @@ object InAppPaymentsRepository {
InAppPaymentType.ONE_TIME_GIFT -> DonationErrorSource.GIFT
InAppPaymentType.ONE_TIME_DONATION -> DonationErrorSource.ONE_TIME
InAppPaymentType.RECURRING_DONATION -> DonationErrorSource.MONTHLY
InAppPaymentType.RECURRING_BACKUP -> DonationErrorSource.UNKNOWN // TODO [message-backups] error handling
InAppPaymentType.RECURRING_BACKUP -> DonationErrorSource.BACKUPS
}
}

View file

@ -31,6 +31,11 @@ enum class DonationErrorSource(private val code: String) {
*/
KEEP_ALIVE("keep-alive"),
/**
* Refers to backup payments.
*/
BACKUPS("backups"),
UNKNOWN("unknown");
fun serialize(): String = code

View file

@ -177,6 +177,11 @@ class InAppPaymentPurchaseTokenJob private constructor(
info("Scheduling retry.")
throw InAppPaymentRetryException()
}
else -> {
warning("An unknown error occurred.", applicationError)
throw IOException(applicationError)
}
}
}

View file

@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.jobs
import okio.ByteString.Companion.toByteString
import org.signal.core.util.logging.Log
import org.signal.donations.InAppPaymentType
import org.signal.libsignal.zkgroup.VerificationFailedException
import org.signal.libsignal.zkgroup.receipts.ReceiptCredential
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
@ -65,11 +66,17 @@ class InAppPaymentRecurringContextJob private constructor(
* meaning the job will always load the freshest data it can about the payment.
*/
fun createJobChain(inAppPayment: InAppPaymentTable.InAppPayment, makePrimary: Boolean = false): Chain {
return AppDependencies.jobManager
.startChain(create(inAppPayment))
.then(InAppPaymentRedemptionJob.create(inAppPayment, makePrimary))
.then(RefreshOwnProfileJob())
.then(MultiDeviceProfileContentUpdateJob())
return if (inAppPayment.type == InAppPaymentType.RECURRING_BACKUP) {
AppDependencies.jobManager
.startChain(create(inAppPayment))
.then(InAppPaymentRedemptionJob.create(inAppPayment, makePrimary))
} else {
AppDependencies.jobManager
.startChain(create(inAppPayment))
.then(InAppPaymentRedemptionJob.create(inAppPayment, makePrimary))
.then(RefreshOwnProfileJob())
.then(MultiDeviceProfileContentUpdateJob())
}
}
}

View file

@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.jobs
import org.signal.core.util.logging.Log
import org.signal.donations.InAppPaymentType
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.requireSubscriberType
import org.thoughtcrime.securesms.database.InAppPaymentTable
@ -252,6 +253,12 @@ class InAppPaymentRedemptionJob private constructor(
)
)
)
if (inAppPayment.type == InAppPaymentType.RECURRING_BACKUP) {
Log.i(TAG, "Enabling backups and setting backup tier to PAID", true)
SignalStore.backup.areBackupsEnabled = true
SignalStore.backup.backupTier = MessageBackupTier.PAID
}
}
private fun <T> verifyServiceResponse(serviceResponse: ServiceResponse<T>, onFatalError: (Int) -> Unit = {}) {

View file

@ -74,8 +74,10 @@ internal class BillingApiImpl(
Log.d(TAG, "purchasesUpdatedListener: ${purchases.size} purchases.")
val newestPurchase = purchases.maxByOrNull { it.purchaseTime }
if (newestPurchase == null) {
Log.d(TAG, "purchasesUpdatedListener: no purchase.")
BillingPurchaseResult.None
} else {
Log.d(TAG, "purchasesUpdatedListener: successful purchase at ${newestPurchase.purchaseTime}")
BillingPurchaseResult.Success(
purchaseToken = newestPurchase.purchaseToken,
isAcknowledged = newestPurchase.isAcknowledged,

View file

@ -1432,7 +1432,7 @@ public class PushServiceSocket {
}
public void linkPlayBillingPurchaseToken(String subscriberId, String purchaseToken) throws IOException {
makeServiceRequestWithoutAuthentication(String.format(LINK_PLAY_BILLING_PURCHASE_TOKEN, subscriberId, purchaseToken), "PUT", "", NO_HEADERS, new LinkGooglePlayBillingPurchaseTokenResponseCodeHandler());
makeServiceRequestWithoutAuthentication(String.format(LINK_PLAY_BILLING_PURCHASE_TOKEN, subscriberId, purchaseToken), "POST", "", NO_HEADERS, new LinkGooglePlayBillingPurchaseTokenResponseCodeHandler());
}
public void updateSubscriptionLevel(String subscriberId, String level, String currencyCode, String idempotencyKey) throws IOException {