Implement start of backups payment integration work.

This commit is contained in:
Alex Hart
2024-05-29 16:48:33 -03:00
committed by Greyson Parrelli
parent 680223c4b6
commit 6b50be78c0
81 changed files with 1492 additions and 1141 deletions

View File

@@ -5,11 +5,14 @@
package org.thoughtcrime.securesms.backup.v2
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.greenrobot.eventbus.EventBus
import org.signal.core.util.Base64
import org.signal.core.util.EventTimer
import org.signal.core.util.LongSerializer
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.core.util.withinTransaction
import org.signal.libsignal.messagebackup.MessageBackup
import org.signal.libsignal.messagebackup.MessageBackup.ValidationResult
@@ -17,6 +20,7 @@ import org.signal.libsignal.messagebackup.MessageBackupKey
import org.signal.libsignal.protocol.ServiceId.Aci
import org.signal.libsignal.zkgroup.backups.BackupLevel
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.Cdn
@@ -35,8 +39,11 @@ import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupReader
import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupWriter
import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupReader
import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupWriter
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeature
import org.thoughtcrime.securesms.database.DistributionListTables
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
@@ -58,13 +65,16 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.Pro
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.InputStream
import java.io.OutputStream
import java.lang.Exception
import java.math.BigDecimal
import java.time.ZonedDateTime
import java.util.Currency
import java.util.Locale
import kotlin.time.Duration.Companion.milliseconds
object BackupRepository {
@@ -673,6 +683,82 @@ object BackupRepository {
}
}
suspend fun getAvailableBackupsTypes(availableBackupTiers: List<MessageBackupTier>): List<MessageBackupsType> {
return availableBackupTiers.map { getBackupsType(it) }
}
suspend fun getBackupsType(tier: MessageBackupTier): MessageBackupsType {
val backupCurrency = SignalStore.donationsValues().getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.BACKUP)
return when (tier) {
MessageBackupTier.FREE -> getFreeType(backupCurrency)
MessageBackupTier.PAID -> getPaidType(backupCurrency)
}
}
private fun getFreeType(currency: Currency): MessageBackupsType {
return MessageBackupsType(
tier = MessageBackupTier.FREE,
pricePerMonth = FiatMoney(BigDecimal.ZERO, currency),
title = "Text + 30 days of media", // TODO [message-backups] Finalize text (does this come from server?)
features = persistentListOf(
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = "Full text message backup" // TODO [message-backups] Finalize text (does this come from server?)
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_album_compact_bold_16,
label = "Last 30 days of media" // TODO [message-backups] Finalize text (does this come from server?)
)
)
)
}
private suspend fun getPaidType(currency: Currency): MessageBackupsType {
val serviceResponse = withContext(Dispatchers.IO) {
AppDependencies
.donationsService
.getDonationsConfiguration(Locale.getDefault())
}
if (serviceResponse.result.isEmpty) {
if (serviceResponse.applicationError.isPresent) {
throw serviceResponse.applicationError.get()
}
if (serviceResponse.executionError.isPresent) {
throw serviceResponse.executionError.get()
}
error("Unhandled error occurred while downloading configuration.")
}
val config = serviceResponse.result.get()
return MessageBackupsType(
tier = MessageBackupTier.PAID,
pricePerMonth = FiatMoney(config.currencies[currency.currencyCode.lowercase()]!!.backupSubscription[SubscriptionsConfiguration.BACKUPS_LEVEL]!!, currency),
title = "Text + All your media", // TODO [message-backups] Finalize text (does this come from server?)
features = persistentListOf(
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = "Full text message backup" // TODO [message-backups] Finalize text (does this come from server?)
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_album_compact_bold_16,
label = "Full media backup" // TODO [message-backups] Finalize text (does this come from server?)
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = "1TB of storage (~250K photos)" // TODO [message-backups] Finalize text (does this come from server?)
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_heart_compact_bold_16,
label = "Thanks for supporting Signal!" // TODO [message-backups] Finalize text (does this come from server?)
)
)
)
}
/**
* Ensures that the backupId has been reserved and that your public key has been set, while also returning an auth credential.
* Should be the basis of all backup operations.
@@ -765,18 +851,3 @@ class BackupMetadata(
val usedSpace: Long,
val mediaCount: Long
)
enum class MessageBackupTier(val value: Int) {
FREE(0),
PAID(1);
companion object Serializer : LongSerializer<MessageBackupTier?> {
override fun serialize(data: MessageBackupTier?): Long {
return data?.value?.toLong() ?: -1
}
override fun deserialize(data: Long): MessageBackupTier? {
return values().firstOrNull { it.value == data.toInt() }
}
}
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2
import org.signal.core.util.LongSerializer
/**
* Serializable enum value for what we think a user's current backup tier is.
*
* We should not trust the stored value on its own, we should also verify it
* against what the server knows, but it is a useful flag that helps avoid a
* network call in some cases.
*/
enum class MessageBackupTier(val value: Int) {
FREE(0),
PAID(1);
companion object Serializer : LongSerializer<MessageBackupTier?> {
override fun serialize(data: MessageBackupTier?): Long {
return data?.value?.toLong() ?: -1
}
override fun deserialize(data: Long): MessageBackupTier? {
return entries.firstOrNull { it.value == data.toInt() }
}
}
}

View File

@@ -11,12 +11,14 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@@ -30,49 +32,59 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.updateLayoutParams
import kotlinx.collections.immutable.persistentListOf
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.databinding.PaypalButtonBinding
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import java.math.BigDecimal
import java.util.Currency
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MessageBackupsCheckoutSheet(
messageBackupTier: MessageBackupTier,
messageBackupsType: MessageBackupsType,
availablePaymentMethods: List<InAppPaymentData.PaymentMethodType>,
sheetState: SheetState,
onDismissRequest: () -> Unit,
onPaymentMethodSelected: (InAppPaymentData.PaymentMethodType) -> Unit
) {
ModalBottomSheet(
onDismissRequest = onDismissRequest,
sheetState = sheetState,
dragHandle = { BottomSheets.Handle() },
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
modifier = Modifier.padding()
) {
SheetContent(
messageBackupTier = messageBackupTier,
availablePaymentGateways = availablePaymentMethods,
onPaymentGatewaySelected = onPaymentMethodSelected
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
.navigationBarsPadding()
) {
SheetContent(
messageBackupsType = messageBackupsType,
availablePaymentGateways = availablePaymentMethods,
onPaymentGatewaySelected = onPaymentMethodSelected
)
}
}
}
@Composable
private fun SheetContent(
messageBackupTier: MessageBackupTier,
messageBackupsType: MessageBackupsType,
availablePaymentGateways: List<InAppPaymentData.PaymentMethodType>,
onPaymentGatewaySelected: (InAppPaymentData.PaymentMethodType) -> Unit
) {
val resources = LocalContext.current.resources
val backupTypeDetails = remember(messageBackupTier) {
getTierDetails(messageBackupTier)
}
val formattedPrice = remember(backupTypeDetails.pricePerMonth) {
FiatMoneyUtil.format(resources, backupTypeDetails.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
val formattedPrice = remember(messageBackupsType.pricePerMonth) {
FiatMoneyUtil.format(resources, messageBackupsType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
}
Text(
@@ -88,7 +100,7 @@ private fun SheetContent(
)
MessageBackupsTypeBlock(
messageBackupsType = backupTypeDetails,
messageBackupsType = messageBackupsType,
isSelected = false,
onSelected = {},
enabled = false,
@@ -231,7 +243,12 @@ private fun MessageBackupsCheckoutSheetPreview() {
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
) {
SheetContent(
messageBackupTier = MessageBackupTier.PAID,
messageBackupsType = MessageBackupsType(
tier = MessageBackupTier.FREE,
title = "Free",
pricePerMonth = FiatMoney(BigDecimal.ZERO, Currency.getInstance("USD")),
features = persistentListOf()
),
availablePaymentGateways = availablePaymentGateways,
onPaymentGatewaySelected = {}
)

View File

@@ -1,119 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.dialog
import androidx.navigation.compose.rememberNavController
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.util.viewModel
class MessageBackupsFlowActivity : PassphraseRequiredActivity() {
private val viewModel: MessageBackupsFlowViewModel by viewModel { MessageBackupsFlowViewModel() }
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
setContent {
SignalTheme {
val state by viewModel.state
val navController = rememberNavController()
fun MessageBackupsScreen.next() {
val nextScreen = viewModel.goToNextScreen(this)
if (nextScreen == MessageBackupsScreen.COMPLETED) {
finishAfterTransition()
return
}
if (nextScreen != this) {
navController.navigate(nextScreen.name)
}
}
fun NavController.popOrFinish() {
if (popBackStack()) {
return
}
finishAfterTransition()
}
LaunchedEffect(Unit) {
navController.setLifecycleOwner(this@MessageBackupsFlowActivity)
navController.setOnBackPressedDispatcher(this@MessageBackupsFlowActivity.onBackPressedDispatcher)
navController.enableOnBackPressed(true)
}
NavHost(
navController = navController,
startDestination = if (state.currentMessageBackupTier == null) MessageBackupsScreen.EDUCATION.name else MessageBackupsScreen.TYPE_SELECTION.name,
enterTransition = { slideInHorizontally(initialOffsetX = { it }) },
exitTransition = { slideOutHorizontally(targetOffsetX = { -it }) },
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) }
) {
composable(route = MessageBackupsScreen.EDUCATION.name) {
MessageBackupsEducationScreen(
onNavigationClick = navController::popOrFinish,
onEnableBackups = { MessageBackupsScreen.EDUCATION.next() },
onLearnMore = {}
)
}
composable(route = MessageBackupsScreen.PIN_EDUCATION.name) {
MessageBackupsPinEducationScreen(
onNavigationClick = navController::popOrFinish,
onGeneratePinClick = {},
onUseCurrentPinClick = { MessageBackupsScreen.PIN_EDUCATION.next() },
recommendedPinSize = 16 // TODO [message-backups] This value should come from some kind of config
)
}
composable(route = MessageBackupsScreen.PIN_CONFIRMATION.name) {
MessageBackupsPinConfirmationScreen(
pin = state.pin,
onPinChanged = viewModel::onPinEntryUpdated,
pinKeyboardType = state.pinKeyboardType,
onPinKeyboardTypeSelected = viewModel::onPinKeyboardTypeUpdated,
onNextClick = { MessageBackupsScreen.PIN_CONFIRMATION.next() }
)
}
composable(route = MessageBackupsScreen.TYPE_SELECTION.name) {
MessageBackupsTypeSelectionScreen(
selectedBackupTier = state.selectedMessageBackupTier,
availableBackupTiers = state.availableBackupTiers,
onMessageBackupsTierSelected = viewModel::onMessageBackupTierUpdated,
onNavigationClick = navController::popOrFinish,
onReadMoreClicked = {},
onNextClicked = { MessageBackupsScreen.TYPE_SELECTION.next() }
)
}
dialog(route = MessageBackupsScreen.CHECKOUT_SHEET.name) {
MessageBackupsCheckoutSheet(
messageBackupTier = state.selectedMessageBackupTier!!,
availablePaymentMethods = state.availablePaymentMethods,
onDismissRequest = navController::popOrFinish,
onPaymentMethodSelected = {
viewModel.onPaymentMethodUpdated(it)
MessageBackupsScreen.CHECKOUT_SHEET.next()
}
)
}
}
}
}
}
}

View File

@@ -0,0 +1,215 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.fragment.findNavController
import io.reactivex.rxjava3.processors.PublishProcessor
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationCheckoutDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorAction
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.viewModel
/**
* Handles the selection, payment, and changing of a user's backup tier.
*/
class MessageBackupsFlowFragment : ComposeFragment(), DonationCheckoutDelegate.Callback {
private val viewModel: MessageBackupsFlowViewModel by viewModel { MessageBackupsFlowViewModel() }
private val inAppPaymentIdProcessor = PublishProcessor.create<InAppPaymentTable.InAppPaymentId>()
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun FragmentContent() {
val state by viewModel.state
val navController = rememberNavController()
val checkoutDelegate = remember {
DonationCheckoutDelegate(this, this, inAppPaymentIdProcessor)
}
LaunchedEffect(state.inAppPayment?.id) {
val inAppPaymentId = state.inAppPayment?.id
if (inAppPaymentId != null) {
inAppPaymentIdProcessor.onNext(inAppPaymentId)
}
}
val checkoutSheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
LaunchedEffect(Unit) {
navController.setLifecycleOwner(this@MessageBackupsFlowFragment)
navController.setOnBackPressedDispatcher(requireActivity().onBackPressedDispatcher)
navController.enableOnBackPressed(true)
}
NavHost(
navController = navController,
startDestination = state.startScreen.name,
enterTransition = { slideInHorizontally(initialOffsetX = { it }) },
exitTransition = { slideOutHorizontally(targetOffsetX = { -it }) },
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) }
) {
composable(route = MessageBackupsScreen.EDUCATION.name) {
MessageBackupsEducationScreen(
onNavigationClick = viewModel::goToPreviousScreen,
onEnableBackups = viewModel::goToNextScreen,
onLearnMore = {}
)
}
composable(route = MessageBackupsScreen.PIN_EDUCATION.name) {
MessageBackupsPinEducationScreen(
onNavigationClick = viewModel::goToPreviousScreen,
onGeneratePinClick = {},
onUseCurrentPinClick = viewModel::goToNextScreen,
recommendedPinSize = 16 // TODO [message-backups] This value should come from some kind of config
)
}
composable(route = MessageBackupsScreen.PIN_CONFIRMATION.name) {
MessageBackupsPinConfirmationScreen(
pin = state.pin,
onPinChanged = viewModel::onPinEntryUpdated,
pinKeyboardType = state.pinKeyboardType,
onPinKeyboardTypeSelected = viewModel::onPinKeyboardTypeUpdated,
onNextClick = viewModel::goToNextScreen
)
}
composable(route = MessageBackupsScreen.TYPE_SELECTION.name) {
MessageBackupsTypeSelectionScreen(
selectedBackupTier = state.selectedMessageBackupTier,
availableBackupTypes = state.availableBackupTypes,
onMessageBackupsTierSelected = viewModel::onMessageBackupTierUpdated,
onNavigationClick = viewModel::goToPreviousScreen,
onReadMoreClicked = {},
onNextClicked = viewModel::goToNextScreen
)
if (state.screen == MessageBackupsScreen.CHECKOUT_SHEET) {
MessageBackupsCheckoutSheet(
messageBackupsType = state.availableBackupTypes.first { it.tier == state.selectedMessageBackupTier!! },
availablePaymentMethods = state.availablePaymentMethods,
sheetState = checkoutSheetState,
onDismissRequest = {
viewModel.goToPreviousScreen()
},
onPaymentMethodSelected = {
viewModel.onPaymentMethodUpdated(it)
viewModel.goToNextScreen()
}
)
}
}
}
LaunchedEffect(state.screen) {
val route = navController.currentDestination?.route ?: return@LaunchedEffect
if (route == state.screen.name) {
return@LaunchedEffect
}
if (state.screen == MessageBackupsScreen.COMPLETED) {
if (!findNavController().popBackStack()) {
requireActivity().finishAfterTransition()
}
return@LaunchedEffect
}
if (state.screen == MessageBackupsScreen.PROCESS_PAYMENT) {
checkoutDelegate.handleGatewaySelectionResponse(state.inAppPayment!!)
viewModel.goToPreviousScreen()
return@LaunchedEffect
}
if (state.screen == MessageBackupsScreen.CHECKOUT_SHEET) {
return@LaunchedEffect
}
val routeScreen = MessageBackupsScreen.valueOf(route)
if (routeScreen.isAfter(state.screen)) {
navController.popBackStack()
} else {
navController.navigate(state.screen.name)
}
}
}
override fun navigateToStripePaymentInProgress(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
DonationProcessorAction.PROCESS_NEW_DONATION,
inAppPayment,
inAppPayment.type
)
)
}
override fun navigateToPayPalPaymentInProgress(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToPaypalPaymentInProgressFragment(
DonationProcessorAction.PROCESS_NEW_DONATION,
inAppPayment,
inAppPayment.type
)
)
}
override fun navigateToCreditCardForm(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(inAppPayment)
)
}
override fun navigateToIdealDetailsFragment(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToIdealTransferDetailsFragment(inAppPayment)
)
}
override fun navigateToBankTransferMandate(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToBankTransferMandateFragment(inAppPayment)
)
}
override fun onPaymentComplete(inAppPayment: InAppPaymentTable.InAppPayment) {
// TODO [message-backups] What do? probably some kind of success thing?
if (!findNavController().popBackStack()) {
requireActivity().finishAfterTransition()
}
}
override fun onSubscriptionCancelled(inAppPaymentType: InAppPaymentType) = error("This view doesn't support cancellation, that is done elsewhere.")
override fun onProcessorActionProcessed() = Unit
override fun onUserLaunchedAnExternalApplication() {
// TODO [message-backups] What do? Are we even supporting bank transfers?
}
override fun navigateToDonationPending(inAppPayment: InAppPaymentTable.InAppPayment) {
// TODO [message-backups] What do? Are we even supporting bank transfers?
}
}

View File

@@ -1,8 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.subscription
class MessageBackupsFlowRepository

View File

@@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
@@ -13,9 +14,12 @@ import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
data class MessageBackupsFlowState(
val selectedMessageBackupTier: MessageBackupTier? = SignalStore.backup().backupTier,
val currentMessageBackupTier: MessageBackupTier? = SignalStore.backup().backupTier,
val availableBackupTiers: List<MessageBackupTier> = emptyList(),
val availableBackupTypes: List<MessageBackupsType> = emptyList(),
val selectedPaymentMethod: InAppPaymentData.PaymentMethodType? = null,
val availablePaymentMethods: List<InAppPaymentData.PaymentMethodType> = emptyList(),
val pin: String = "",
val pinKeyboardType: PinKeyboardType = SignalStore.pinValues().keyboardType
val pinKeyboardType: PinKeyboardType = SignalStore.pinValues().keyboardType,
val inAppPayment: InAppPaymentTable.InAppPayment? = null,
val startScreen: MessageBackupsScreen,
val screen: MessageBackupsScreen = startScreen
)

View File

@@ -9,30 +9,52 @@ import android.text.TextUtils
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
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.InAppDonations
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayOrderStrategy
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
import org.thoughtcrime.securesms.lock.v2.SvrConstants
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.kbs.PinHashUtil.verifyLocalPinHash
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
class MessageBackupsFlowViewModel : ViewModel() {
private val internalState = mutableStateOf(
MessageBackupsFlowState(
availableBackupTiers = if (!RemoteConfig.messageBackups) {
emptyList()
} else {
listOf(MessageBackupTier.FREE, MessageBackupTier.PAID)
},
selectedMessageBackupTier = SignalStore.backup().backupTier
availableBackupTypes = emptyList(),
selectedMessageBackupTier = SignalStore.backup().backupTier,
availablePaymentMethods = GatewayOrderStrategy.getStrategy().orderedGateways.filter { InAppDonations.isPaymentSourceAvailable(it.toPaymentSourceType(), InAppPaymentType.RECURRING_BACKUP) },
startScreen = if (SignalStore.backup().backupTier == null) MessageBackupsScreen.EDUCATION else MessageBackupsScreen.TYPE_SELECTION
)
)
val state: State<MessageBackupsFlowState> = internalState
fun goToNextScreen(currentScreen: MessageBackupsScreen): MessageBackupsScreen {
return when (currentScreen) {
init {
viewModelScope.launch {
internalState.value = internalState.value.copy(
availableBackupTypes = BackupRepository.getAvailableBackupsTypes(
if (!RemoteConfig.messageBackups) emptyList() else listOf(MessageBackupTier.FREE, MessageBackupTier.PAID)
)
)
}
}
fun goToNextScreen() {
val nextScreen = when (internalState.value.screen) {
MessageBackupsScreen.EDUCATION -> MessageBackupsScreen.PIN_EDUCATION
MessageBackupsScreen.PIN_EDUCATION -> MessageBackupsScreen.PIN_CONFIRMATION
MessageBackupsScreen.PIN_CONFIRMATION -> validatePinAndUpdateState()
@@ -41,6 +63,27 @@ class MessageBackupsFlowViewModel : ViewModel() {
MessageBackupsScreen.PROCESS_PAYMENT -> MessageBackupsScreen.COMPLETED
MessageBackupsScreen.COMPLETED -> error("Unsupported state transition from terminal state COMPLETED")
}
internalState.value = state.value.copy(screen = nextScreen)
}
fun goToPreviousScreen() {
if (internalState.value.screen == internalState.value.startScreen) {
internalState.value = state.value.copy(screen = MessageBackupsScreen.COMPLETED)
return
}
val previousScreen = when (internalState.value.screen) {
MessageBackupsScreen.EDUCATION -> MessageBackupsScreen.COMPLETED
MessageBackupsScreen.PIN_EDUCATION -> MessageBackupsScreen.EDUCATION
MessageBackupsScreen.PIN_CONFIRMATION -> MessageBackupsScreen.PIN_EDUCATION
MessageBackupsScreen.TYPE_SELECTION -> MessageBackupsScreen.PIN_CONFIRMATION
MessageBackupsScreen.CHECKOUT_SHEET -> MessageBackupsScreen.TYPE_SELECTION
MessageBackupsScreen.PROCESS_PAYMENT -> MessageBackupsScreen.TYPE_SELECTION
MessageBackupsScreen.COMPLETED -> error("Unsupported state transition from terminal state COMPLETED")
}
internalState.value = state.value.copy(screen = previousScreen)
}
fun onPinEntryUpdated(pin: String) {
@@ -74,11 +117,48 @@ class MessageBackupsFlowViewModel : ViewModel() {
private fun validateTypeAndUpdateState(): MessageBackupsScreen {
SignalStore.backup().areBackupsEnabled = true
SignalStore.backup().backupTier = state.value.selectedMessageBackupTier!!
return MessageBackupsScreen.COMPLETED
// return MessageBackupsScreen.CHECKOUT_SHEET TODO [message-backups] Switch back to payment flow
// TODO [message-backups] - Does anything need to be kicked off?
return when (state.value.selectedMessageBackupTier!!) {
MessageBackupTier.FREE -> MessageBackupsScreen.COMPLETED
MessageBackupTier.PAID -> MessageBackupsScreen.CHECKOUT_SHEET
}
}
private fun validateGatewayAndUpdateState(): MessageBackupsScreen {
val stateSnapshot = state.value
val backupsType = stateSnapshot.availableBackupTypes.first { it.tier == stateSnapshot.selectedMessageBackupTier }
internalState.value = state.value.copy(inAppPayment = null)
viewModelScope.launch(Dispatchers.IO) {
SignalDatabase.inAppPayments.clearCreated()
val id = SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.RECURRING_BACKUP,
state = InAppPaymentTable.State.CREATED,
subscriberId = null,
endOfPeriod = null,
inAppPaymentData = InAppPaymentData(
badge = null,
label = backupsType.title,
amount = backupsType.pricePerMonth.toFiatValue(),
level = SubscriptionsConfiguration.BACKUPS_LEVEL.toLong(),
recipientId = Recipient.self().id.serialize(),
paymentMethodType = stateSnapshot.selectedPaymentMethod!!,
redemption = InAppPaymentData.RedemptionState(
stage = InAppPaymentData.RedemptionState.Stage.INIT
)
)
)
val inAppPayment = SignalDatabase.inAppPayments.getById(id)!!
withContext(Dispatchers.Main) {
internalState.value = state.value.copy(inAppPayment = inAppPayment)
}
}
return MessageBackupsScreen.PROCESS_PAYMENT
}
}

View File

@@ -12,5 +12,7 @@ enum class MessageBackupsScreen {
TYPE_SELECTION,
CHECKOUT_SHEET,
PROCESS_PAYMENT,
COMPLETED
COMPLETED;
fun isAfter(other: MessageBackupsScreen): Boolean = ordinal > other.ordinal
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.runtime.Stable
import kotlinx.collections.immutable.ImmutableList
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
/**
* Represents a type of backup a user can select.
*/
@Stable
data class MessageBackupsType(
val tier: MessageBackupTier,
val pricePerMonth: FiatMoney,
val title: String,
val features: ImmutableList<MessageBackupsTypeFeature>
)

View File

@@ -21,7 +21,6 @@ import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -40,8 +39,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withAnnotation
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds
@@ -52,7 +49,6 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import java.math.BigDecimal
import java.util.Currency
/**
* Screen which allows the user to select their preferred backup type.
@@ -61,7 +57,7 @@ import java.util.Currency
@Composable
fun MessageBackupsTypeSelectionScreen(
selectedBackupTier: MessageBackupTier?,
availableBackupTiers: List<MessageBackupTier>,
availableBackupTypes: List<MessageBackupsType>,
onMessageBackupsTierSelected: (MessageBackupTier) -> Unit,
onNavigationClick: () -> Unit,
onReadMoreClicked: () -> Unit,
@@ -129,16 +125,13 @@ fun MessageBackupsTypeSelectionScreen(
}
itemsIndexed(
availableBackupTiers,
{ _, item -> item }
availableBackupTypes,
{ _, item -> item.tier }
) { index, item ->
val type = remember(item) {
getTierDetails(item)
}
MessageBackupsTypeBlock(
messageBackupsType = type,
isSelected = item == selectedBackupTier,
onSelected = { onMessageBackupsTierSelected(item) },
messageBackupsType = item,
isSelected = item.tier == selectedBackupTier,
onSelected = { onMessageBackupsTierSelected(item.tier) },
modifier = Modifier.padding(top = if (index == 0) 20.dp else 18.dp)
)
}
@@ -167,7 +160,7 @@ private fun MessageBackupsTypeSelectionScreenPreview() {
Previews.Preview {
MessageBackupsTypeSelectionScreen(
selectedBackupTier = MessageBackupTier.FREE,
availableBackupTiers = listOf(MessageBackupTier.FREE, MessageBackupTier.PAID),
availableBackupTypes = emptyList(),
onMessageBackupsTierSelected = { selectedBackupsType = it },
onNavigationClick = {},
onReadMoreClicked = {},
@@ -236,54 +229,3 @@ private fun formatCostPerMonth(pricePerMonth: FiatMoney): String {
"${FiatMoneyUtil.format(LocalContext.current.resources, pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())}/month"
}
}
@Stable
data class MessageBackupsType(
val tier: MessageBackupTier,
val pricePerMonth: FiatMoney,
val title: String,
val features: ImmutableList<MessageBackupsTypeFeature>
)
fun getTierDetails(tier: MessageBackupTier): MessageBackupsType {
return when (tier) {
MessageBackupTier.FREE -> MessageBackupsType(
tier = MessageBackupTier.FREE,
pricePerMonth = FiatMoney(BigDecimal.ZERO, Currency.getInstance("USD")),
title = "Text + 30 days of media",
features = persistentListOf(
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = "Full text message backup"
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_album_compact_bold_16,
label = "Last 30 days of media"
)
)
)
MessageBackupTier.PAID -> MessageBackupsType(
tier = MessageBackupTier.PAID,
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("USD")),
title = "Text + All your media",
features = persistentListOf(
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = "Full text message backup"
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_album_compact_bold_16,
label = "Full media backup"
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = "1TB of storage (~250K photos)"
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_heart_compact_bold_16,
label = "Thanks for supporting Signal!"
)
)
)
}
}