mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-24 10:51:27 +01:00
Implement start of backups payment integration work.
This commit is contained in:
committed by
Greyson Parrelli
parent
680223c4b6
commit
6b50be78c0
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,7 @@ enum class MessageBackupsScreen {
|
||||
TYPE_SELECTION,
|
||||
CHECKOUT_SHEET,
|
||||
PROCESS_PAYMENT,
|
||||
COMPLETED
|
||||
COMPLETED;
|
||||
|
||||
fun isAfter(other: MessageBackupsScreen): Boolean = ordinal > other.ordinal
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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!"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user