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 package org.thoughtcrime.securesms.backup.v2.ui.subscription
import android.app.Activity import android.app.Activity
import android.os.Bundle
import android.view.View
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -15,10 +17,13 @@ import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.rx3.asFlowable
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier 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.ComposeFragment
import org.thoughtcrime.securesms.compose.Nav import org.thoughtcrime.securesms.compose.Nav
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.util.Util import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.viewModel 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. * 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 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 @Composable
override fun FragmentContent() { override fun FragmentContent() {
@ -83,6 +99,7 @@ class MessageBackupsFlowFragment : ComposeFragment() {
composable(route = MessageBackupsStage.Route.TYPE_SELECTION.name) { composable(route = MessageBackupsStage.Route.TYPE_SELECTION.name) {
MessageBackupsTypeSelectionScreen( MessageBackupsTypeSelectionScreen(
stage = state.stage,
currentBackupTier = state.currentMessageBackupTier, currentBackupTier = state.currentMessageBackupTier,
selectedBackupTier = state.selectedMessageBackupTier, selectedBackupTier = state.selectedMessageBackupTier,
availableBackupTypes = state.availableBackupTypes.filter { it.tier == MessageBackupTier.FREE || state.hasBackupSubscriberAvailable }, 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 androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.take 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.billing.BillingPurchaseResult
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.signal.donations.InAppPaymentType import org.signal.donations.InAppPaymentType
import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.backup.v2.BackupRepository import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier 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.DonationSerializationHelper.toFiatValue
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository 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.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentError 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.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
@ -84,13 +89,12 @@ class MessageBackupsFlowViewModel : ViewModel() {
AppDependencies.billingApi.getBillingPurchaseResults().collect { result -> AppDependencies.billingApi.getBillingPurchaseResults().collect { result ->
when (result) { when (result) {
is BillingPurchaseResult.Success -> { 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 { try {
handleSuccess( Log.d(TAG, "Attempting to handle successful purchase.")
result, handleSuccess(result, id)
internalStateFlow.value.inAppPayment!!.id
)
internalStateFlow.update { internalStateFlow.update {
it.copy( it.copy(
@ -98,6 +102,14 @@ class MessageBackupsFlowViewModel : ViewModel() {
) )
} }
} catch (e: Exception) { } 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 { internalStateFlow.update {
it.copy( it.copy(
stage = MessageBackupsStage.FAILURE, stage = MessageBackupsStage.FAILURE,
@ -123,7 +135,7 @@ class MessageBackupsFlowViewModel : ViewModel() {
MessageBackupsStage.BACKUP_KEY_EDUCATION -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_RECORD) MessageBackupsStage.BACKUP_KEY_EDUCATION -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_RECORD)
MessageBackupsStage.BACKUP_KEY_RECORD -> it.copy(stage = MessageBackupsStage.TYPE_SELECTION) MessageBackupsStage.BACKUP_KEY_RECORD -> it.copy(stage = MessageBackupsStage.TYPE_SELECTION)
MessageBackupsStage.TYPE_SELECTION -> validateTypeAndUpdateState(it) 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.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_PAYMENT -> error("This is driven by an async coroutine.")
MessageBackupsStage.PROCESS_FREE -> error("This is driven by an async coroutine.") MessageBackupsStage.PROCESS_FREE -> error("This is driven by an async coroutine.")
@ -174,19 +186,13 @@ class MessageBackupsFlowViewModel : ViewModel() {
state.copy(stage = MessageBackupsStage.COMPLETED) state.copy(stage = MessageBackupsStage.COMPLETED)
} }
MessageBackupTier.PAID -> state.copy(stage = MessageBackupsStage.CHECKOUT_SHEET) MessageBackupTier.PAID -> {
}
}
private fun validateGatewayAndUpdateState(state: MessageBackupsFlowState): MessageBackupsFlowState {
check(state.selectedMessageBackupTier == MessageBackupTier.PAID) check(state.selectedMessageBackupTier == MessageBackupTier.PAID)
check(state.availableBackupTypes.any { it.tier == state.selectedMessageBackupTier }) check(state.availableBackupTypes.any { it.tier == state.selectedMessageBackupTier })
check(state.hasBackupSubscriberAvailable) check(state.hasBackupSubscriberAvailable)
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
withContext(Dispatchers.Main) {
internalStateFlow.update { it.copy(inAppPayment = null) } internalStateFlow.update { it.copy(inAppPayment = null) }
}
val paidFiat = AppDependencies.billingApi.queryProduct()!!.price val paidFiat = AppDependencies.billingApi.queryProduct()!!.price
@ -210,13 +216,14 @@ class MessageBackupsFlowViewModel : ViewModel() {
) )
val inAppPayment = SignalDatabase.inAppPayments.getById(id)!! val inAppPayment = SignalDatabase.inAppPayments.getById(id)!!
internalStateFlow.update {
withContext(Dispatchers.Main) { it.copy(inAppPayment = inAppPayment, stage = MessageBackupsStage.CHECKOUT_SHEET)
internalStateFlow.update { it.copy(inAppPayment = inAppPayment, stage = MessageBackupsStage.PROCESS_PAYMENT) }
} }
} }
return state.copy(stage = MessageBackupsStage.CREATING_IN_APP_PAYMENT) state.copy(stage = MessageBackupsStage.CREATING_IN_APP_PAYMENT)
}
}
} }
/** /**
@ -237,6 +244,8 @@ class MessageBackupsFlowViewModel : ViewModel() {
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
private suspend fun handleSuccess(result: BillingPurchaseResult.Success, inAppPaymentId: InAppPaymentTable.InAppPaymentId) { private suspend fun handleSuccess(result: BillingPurchaseResult.Success, inAppPaymentId: InAppPaymentTable.InAppPaymentId) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
Log.d(TAG, "Setting purchase token data on InAppPayment.")
val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!! val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)!!
SignalDatabase.inAppPayments.update( SignalDatabase.inAppPayments.update(
inAppPayment.copy( inAppPayment.copy(
@ -248,20 +257,30 @@ class MessageBackupsFlowViewModel : ViewModel() {
) )
) )
Log.d(TAG, "Enqueueing InAppPaymentPurchaseTokenJob chain.")
InAppPaymentPurchaseTokenJob.createJobChain(inAppPayment).enqueue() InAppPaymentPurchaseTokenJob.createJobChain(inAppPayment).enqueue()
} }
val terminalInAppPayment = withContext(Dispatchers.IO) { val terminalInAppPayment = withContext(Dispatchers.IO) {
Log.d(TAG, "Awaiting completion of job chain for up to 10 seconds.")
InAppPaymentsRepository.observeUpdates(inAppPaymentId).asFlow() InAppPaymentsRepository.observeUpdates(inAppPaymentId).asFlow()
.filter { it.state == InAppPaymentTable.State.END } .filter { it.state == InAppPaymentTable.State.END }
.take(1) .take(1)
.timeout(10.seconds) .timeout(10.seconds)
.catch { exception ->
if (exception is TimeoutCancellationException) {
throw DonationError.BadgeRedemptionError.TimeoutWaitingForTokenError(DonationErrorSource.BACKUPS)
}
}
.first() .first()
} }
if (terminalInAppPayment.data.error != null) { 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 { } else {
Log.d(TAG, "Job chain completed successfully.")
return return
} }
} }

View file

@ -15,8 +15,8 @@ enum class MessageBackupsStage(
BACKUP_KEY_EDUCATION(route = Route.BACKUP_KEY_EDUCATION), BACKUP_KEY_EDUCATION(route = Route.BACKUP_KEY_EDUCATION),
BACKUP_KEY_RECORD(route = Route.BACKUP_KEY_RECORD), BACKUP_KEY_RECORD(route = Route.BACKUP_KEY_RECORD),
TYPE_SELECTION(route = Route.TYPE_SELECTION), TYPE_SELECTION(route = Route.TYPE_SELECTION),
CHECKOUT_SHEET(route = Route.TYPE_SELECTION),
CREATING_IN_APP_PAYMENT(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_PAYMENT(route = Route.TYPE_SELECTION),
PROCESS_FREE(route = Route.TYPE_SELECTION), PROCESS_FREE(route = Route.TYPE_SELECTION),
COMPLETED(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 androidx.compose.ui.unit.dp
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import org.signal.core.ui.Buttons import org.signal.core.ui.Buttons
import org.signal.core.ui.Dialogs
import org.signal.core.ui.Previews import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview import org.signal.core.ui.SignalPreview
@ -64,6 +65,7 @@ import java.util.Currency
@OptIn(ExperimentalTextApi::class) @OptIn(ExperimentalTextApi::class)
@Composable @Composable
fun MessageBackupsTypeSelectionScreen( fun MessageBackupsTypeSelectionScreen(
stage: MessageBackupsStage,
currentBackupTier: MessageBackupTier?, currentBackupTier: MessageBackupTier?,
selectedBackupTier: MessageBackupTier?, selectedBackupTier: MessageBackupTier?,
availableBackupTypes: List<MessageBackupsType>, 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 { Previews.Preview {
MessageBackupsTypeSelectionScreen( MessageBackupsTypeSelectionScreen(
stage = MessageBackupsStage.TYPE_SELECTION,
selectedBackupTier = MessageBackupTier.FREE, selectedBackupTier = MessageBackupTier.FREE,
availableBackupTypes = testBackupTypes(), availableBackupTypes = testBackupTypes(),
onMessageBackupsTierSelected = { selectedBackupsType = it }, onMessageBackupsTierSelected = { selectedBackupsType = it },
@ -197,6 +207,7 @@ private fun MessageBackupsTypeSelectionScreenWithCurrentTierPreview() {
Previews.Preview { Previews.Preview {
MessageBackupsTypeSelectionScreen( MessageBackupsTypeSelectionScreen(
stage = MessageBackupsStage.TYPE_SELECTION,
selectedBackupTier = MessageBackupTier.FREE, selectedBackupTier = MessageBackupTier.FREE,
availableBackupTypes = testBackupTypes(), availableBackupTypes = testBackupTypes(),
onMessageBackupsTierSelected = { selectedBackupsType = it }, onMessageBackupsTierSelected = { selectedBackupsType = it },

View file

@ -224,6 +224,7 @@ object InAppPaymentsRepository {
DonationErrorSource.ONE_TIME -> InAppPaymentType.ONE_TIME_DONATION DonationErrorSource.ONE_TIME -> InAppPaymentType.ONE_TIME_DONATION
DonationErrorSource.MONTHLY -> InAppPaymentType.RECURRING_DONATION DonationErrorSource.MONTHLY -> InAppPaymentType.RECURRING_DONATION
DonationErrorSource.GIFT -> InAppPaymentType.ONE_TIME_GIFT DonationErrorSource.GIFT -> InAppPaymentType.ONE_TIME_GIFT
DonationErrorSource.BACKUPS -> InAppPaymentType.RECURRING_BACKUP
DonationErrorSource.GIFT_REDEMPTION -> InAppPaymentType.UNKNOWN DonationErrorSource.GIFT_REDEMPTION -> InAppPaymentType.UNKNOWN
DonationErrorSource.KEEP_ALIVE -> InAppPaymentType.UNKNOWN DonationErrorSource.KEEP_ALIVE -> InAppPaymentType.UNKNOWN
DonationErrorSource.UNKNOWN -> InAppPaymentType.UNKNOWN DonationErrorSource.UNKNOWN -> InAppPaymentType.UNKNOWN
@ -266,7 +267,7 @@ object InAppPaymentsRepository {
InAppPaymentType.ONE_TIME_GIFT -> DonationErrorSource.GIFT InAppPaymentType.ONE_TIME_GIFT -> DonationErrorSource.GIFT
InAppPaymentType.ONE_TIME_DONATION -> DonationErrorSource.ONE_TIME InAppPaymentType.ONE_TIME_DONATION -> DonationErrorSource.ONE_TIME
InAppPaymentType.RECURRING_DONATION -> DonationErrorSource.MONTHLY 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"), KEEP_ALIVE("keep-alive"),
/**
* Refers to backup payments.
*/
BACKUPS("backups"),
UNKNOWN("unknown"); UNKNOWN("unknown");
fun serialize(): String = code fun serialize(): String = code

View file

@ -177,6 +177,11 @@ class InAppPaymentPurchaseTokenJob private constructor(
info("Scheduling retry.") info("Scheduling retry.")
throw InAppPaymentRetryException() 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 okio.ByteString.Companion.toByteString
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.signal.donations.InAppPaymentType
import org.signal.libsignal.zkgroup.VerificationFailedException import org.signal.libsignal.zkgroup.VerificationFailedException
import org.signal.libsignal.zkgroup.receipts.ReceiptCredential import org.signal.libsignal.zkgroup.receipts.ReceiptCredential
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
@ -65,13 +66,19 @@ class InAppPaymentRecurringContextJob private constructor(
* meaning the job will always load the freshest data it can about the payment. * meaning the job will always load the freshest data it can about the payment.
*/ */
fun createJobChain(inAppPayment: InAppPaymentTable.InAppPayment, makePrimary: Boolean = false): Chain { fun createJobChain(inAppPayment: InAppPaymentTable.InAppPayment, makePrimary: Boolean = false): Chain {
return AppDependencies.jobManager return if (inAppPayment.type == InAppPaymentType.RECURRING_BACKUP) {
AppDependencies.jobManager
.startChain(create(inAppPayment))
.then(InAppPaymentRedemptionJob.create(inAppPayment, makePrimary))
} else {
AppDependencies.jobManager
.startChain(create(inAppPayment)) .startChain(create(inAppPayment))
.then(InAppPaymentRedemptionJob.create(inAppPayment, makePrimary)) .then(InAppPaymentRedemptionJob.create(inAppPayment, makePrimary))
.then(RefreshOwnProfileJob()) .then(RefreshOwnProfileJob())
.then(MultiDeviceProfileContentUpdateJob()) .then(MultiDeviceProfileContentUpdateJob())
} }
} }
}
override fun onAdded() { override fun onAdded() {
val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId) val inAppPayment = SignalDatabase.inAppPayments.getById(inAppPaymentId)

View file

@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.jobs
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.signal.donations.InAppPaymentType import org.signal.donations.InAppPaymentType
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation 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
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.requireSubscriberType import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.requireSubscriberType
import org.thoughtcrime.securesms.database.InAppPaymentTable 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 = {}) { 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.") Log.d(TAG, "purchasesUpdatedListener: ${purchases.size} purchases.")
val newestPurchase = purchases.maxByOrNull { it.purchaseTime } val newestPurchase = purchases.maxByOrNull { it.purchaseTime }
if (newestPurchase == null) { if (newestPurchase == null) {
Log.d(TAG, "purchasesUpdatedListener: no purchase.")
BillingPurchaseResult.None BillingPurchaseResult.None
} else { } else {
Log.d(TAG, "purchasesUpdatedListener: successful purchase at ${newestPurchase.purchaseTime}")
BillingPurchaseResult.Success( BillingPurchaseResult.Success(
purchaseToken = newestPurchase.purchaseToken, purchaseToken = newestPurchase.purchaseToken,
isAcknowledged = newestPurchase.isAcknowledged, isAcknowledged = newestPurchase.isAcknowledged,

View file

@ -1432,7 +1432,7 @@ public class PushServiceSocket {
} }
public void linkPlayBillingPurchaseToken(String subscriberId, String purchaseToken) throws IOException { 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 { public void updateSubscriptionLevel(String subscriberId, String level, String currencyCode, String idempotencyKey) throws IOException {