Start re-work of play billing checkout flow.

This commit is contained in:
Alex Hart
2024-09-18 12:28:11 -03:00
committed by Greyson Parrelli
parent b340097f9c
commit 48bd57c56a
37 changed files with 807 additions and 1111 deletions

View File

@@ -1121,6 +1121,12 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity android:name=".backup.v2.ui.subscription.MessageBackupsCheckoutActivity"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="adjustResize"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<service
android:enabled="true"
android:name=".service.webrtc.WebRtcCallService"

View File

@@ -15,7 +15,6 @@ import org.signal.core.util.concurrent.LimitedWorker
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.fullWalCheckpoint
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.core.util.stream.NonClosingOutputStream
import org.signal.core.util.withinTransaction
import org.signal.libsignal.messagebackup.MessageBackup
@@ -82,7 +81,6 @@ import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.time.ZonedDateTime
import java.util.Currency
import java.util.Locale
import java.util.concurrent.atomic.AtomicLong
import kotlin.time.Duration.Companion.milliseconds
@@ -883,10 +881,9 @@ object BackupRepository {
}
suspend fun getBackupsType(tier: MessageBackupTier): MessageBackupsType {
val backupCurrency = SignalStore.inAppPayments.getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.BACKUP)
return when (tier) {
MessageBackupTier.FREE -> getFreeType()
MessageBackupTier.PAID -> getPaidType(backupCurrency)
MessageBackupTier.PAID -> getPaidType()
}
}
@@ -898,11 +895,12 @@ object BackupRepository {
)
}
private suspend fun getPaidType(currency: Currency): MessageBackupsType {
private suspend fun getPaidType(): MessageBackupsType {
val config = getSubscriptionsConfiguration()
val product = AppDependencies.billingApi.queryProduct()
return MessageBackupsType.Paid(
pricePerMonth = FiatMoney(config.currencies[currency.currencyCode.lowercase()]!!.backupSubscription[SubscriptionsConfiguration.BACKUPS_LEVEL]!!, currency),
pricePerMonth = product!!.price,
storageAllowanceBytes = config.backupConfiguration.backupLevelConfigurationMap[SubscriptionsConfiguration.BACKUPS_LEVEL]!!.storageAllowanceBytes
)
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import android.content.Context
import android.content.Intent
import androidx.activity.result.contract.ActivityResultContract
import androidx.fragment.app.Fragment
import org.signal.core.util.getParcelableExtraCompat
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.CheckoutFlowActivity.Result
/**
* Self-contained activity for message backups checkout, which utilizes Google Play Billing
* instead of the normal donations routes.
*/
class MessageBackupsCheckoutActivity : FragmentWrapperActivity() {
companion object {
private const val RESULT_DATA = "result_data"
}
override fun getFragment(): Fragment = MessageBackupsFlowFragment()
class Contract : ActivityResultContract<Unit, Result?>() {
override fun createIntent(context: Context, input: Unit): Intent {
return Intent(context, MessageBackupsCheckoutActivity::class.java)
}
override fun parseResult(resultCode: Int, intent: Intent?): Result? {
return intent?.getParcelableExtraCompat(RESULT_DATA, Result::class.java)
}
}
}

View File

@@ -1,254 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import android.view.LayoutInflater
import android.view.ViewGroup
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
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
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 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.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(
messageBackupsType: MessageBackupsType.Paid,
availablePaymentMethods: List<InAppPaymentData.PaymentMethodType>,
sheetState: SheetState,
onDismissRequest: () -> Unit,
onPaymentMethodSelected: (InAppPaymentData.PaymentMethodType) -> Unit
) {
ModalBottomSheet(
onDismissRequest = onDismissRequest,
sheetState = sheetState,
dragHandle = { BottomSheets.Handle() },
modifier = Modifier.padding()
) {
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(
messageBackupsType: MessageBackupsType.Paid,
availablePaymentGateways: List<InAppPaymentData.PaymentMethodType>,
onPaymentGatewaySelected: (InAppPaymentData.PaymentMethodType) -> Unit
) {
val resources = LocalContext.current.resources
val formattedPrice = remember(messageBackupsType.pricePerMonth) {
FiatMoneyUtil.format(resources, messageBackupsType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
}
Text(
text = stringResource(id = R.string.MessageBackupsCheckoutSheet__pay_s_per_month, formattedPrice),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(top = 48.dp)
)
Text(
text = stringResource(id = R.string.MessageBackupsCheckoutSheet__youll_get),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 5.dp)
)
MessageBackupsTypeBlock(
messageBackupsType = messageBackupsType,
isCurrent = false,
isSelected = false,
onSelected = {},
enabled = false,
modifier = Modifier.padding(top = 24.dp)
)
Column(
verticalArrangement = spacedBy(12.dp),
modifier = Modifier.padding(top = 48.dp, bottom = 24.dp)
) {
availablePaymentGateways.forEach {
when (it) {
InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> GooglePayButton {
onPaymentGatewaySelected(InAppPaymentData.PaymentMethodType.GOOGLE_PAY)
}
InAppPaymentData.PaymentMethodType.PAYPAL -> PayPalButton {
onPaymentGatewaySelected(InAppPaymentData.PaymentMethodType.PAYPAL)
}
InAppPaymentData.PaymentMethodType.CARD -> CreditOrDebitCardButton {
onPaymentGatewaySelected(InAppPaymentData.PaymentMethodType.CARD)
}
InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> SepaButton {
onPaymentGatewaySelected(InAppPaymentData.PaymentMethodType.SEPA_DEBIT)
}
InAppPaymentData.PaymentMethodType.IDEAL -> IdealButton {
onPaymentGatewaySelected(InAppPaymentData.PaymentMethodType.IDEAL)
}
InAppPaymentData.PaymentMethodType.UNKNOWN -> error("Unsupported payment method type $it")
}
}
}
}
@Composable
private fun PayPalButton(
onClick: () -> Unit
) {
AndroidView(factory = {
val view = LayoutInflater.from(it).inflate(R.layout.paypal_button, null)
view.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
view
}) {
val binding = PaypalButtonBinding.bind(it)
binding.paypalButton.updateLayoutParams<ViewGroup.MarginLayoutParams> {
marginStart = 0
marginEnd = 0
}
binding.paypalButton.setOnClickListener {
onClick()
}
}
}
@Composable
private fun GooglePayButton(
onClick: () -> Unit
) {
val model = GooglePayButton.Model(onClick, true)
AndroidView(factory = {
LayoutInflater.from(it).inflate(R.layout.google_pay_button_pref, null)
}) {
val holder = GooglePayButton.ViewHolder(it)
holder.bind(model)
}
}
@Composable
private fun SepaButton(
onClick: () -> Unit
) {
Buttons.LargeTonal(
onClick = onClick,
modifier = Modifier.fillMaxWidth()
) {
Icon(
painter = painterResource(id = R.drawable.bank_transfer),
contentDescription = null,
modifier = Modifier.padding(end = 8.dp)
)
Text(text = stringResource(id = R.string.GatewaySelectorBottomSheet__bank_transfer))
}
}
@Composable
private fun IdealButton(
onClick: () -> Unit
) {
Buttons.LargeTonal(
onClick = onClick,
modifier = Modifier.fillMaxWidth()
) {
Image(
painter = painterResource(id = R.drawable.logo_ideal),
contentDescription = null,
modifier = Modifier
.size(32.dp)
.padding(end = 8.dp)
)
Text(text = stringResource(id = R.string.GatewaySelectorBottomSheet__ideal))
}
}
@Composable
private fun CreditOrDebitCardButton(
onClick: () -> Unit
) {
Buttons.LargePrimary(
onClick = onClick,
modifier = Modifier.fillMaxWidth()
) {
Icon(
painter = painterResource(id = R.drawable.credit_card),
contentDescription = null,
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = stringResource(id = R.string.GatewaySelectorBottomSheet__credit_or_debit_card)
)
}
}
@Preview
@Composable
private fun MessageBackupsCheckoutSheetPreview() {
val availablePaymentGateways = InAppPaymentData.PaymentMethodType.values().toList() - InAppPaymentData.PaymentMethodType.UNKNOWN
Previews.Preview {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
) {
SheetContent(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.ZERO, Currency.getInstance("USD")),
storageAllowanceBytes = 107374182400
),
availablePaymentGateways = availablePaymentGateways,
onPaymentGatewaySelected = {}
)
}
}
}

View File

@@ -49,7 +49,7 @@ fun MessageBackupsEducationScreen(
Scaffolds.Settings(
onNavigationClick = onNavigationClick,
navigationIconPainter = painterResource(id = R.drawable.symbol_x_24),
title = stringResource(id = R.string.RemoteBackupsSettingsFragment__signal_backups)
title = ""
) {
Column(
modifier = Modifier

View File

@@ -5,63 +5,36 @@
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import android.app.Activity
import androidx.activity.OnBackPressedCallback
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LocalLifecycleOwner
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.R
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentCheckoutDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorAction
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.compose.Nav
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.viewModel
/**
* Handles the selection, payment, and changing of a user's backup tier.
*/
class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelegate.Callback {
class MessageBackupsFlowFragment : ComposeFragment() {
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.stateFlow.collectAsState()
val pin by viewModel.pinState
val navController = rememberNavController()
val checkoutDelegate = remember {
InAppPaymentCheckoutDelegate(this, this, inAppPaymentIdProcessor)
}
LaunchedEffect(state.inAppPayment?.id) {
val inAppPaymentId = state.inAppPayment?.id
if (inAppPaymentId != null) {
inAppPaymentIdProcessor.onNext(inAppPaymentId)
}
}
val checkoutSheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(Unit) {
navController.setLifecycleOwner(this@MessageBackupsFlowFragment)
@@ -69,7 +42,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
lifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
viewModel.goToPreviousScreen()
viewModel.goToPreviousStage()
}
}
)
@@ -79,36 +52,35 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
navController = navController,
startDestination = state.startScreen.name
) {
composable(route = MessageBackupsScreen.EDUCATION.name) {
composable(route = MessageBackupsStage.Route.EDUCATION.name) {
MessageBackupsEducationScreen(
onNavigationClick = viewModel::goToPreviousScreen,
onEnableBackups = viewModel::goToNextScreen,
onNavigationClick = viewModel::goToPreviousStage,
onEnableBackups = viewModel::goToNextStage,
onLearnMore = {}
)
}
composable(route = MessageBackupsScreen.PIN_EDUCATION.name) {
MessageBackupsPinEducationScreen(
onNavigationClick = viewModel::goToPreviousScreen,
onCreatePinClick = {},
onUseCurrentPinClick = viewModel::goToNextScreen,
recommendedPinSize = 16 // TODO [message-backups] This value should come from some kind of config
composable(route = MessageBackupsStage.Route.BACKUP_KEY_EDUCATION.name) {
MessageBackupsKeyEducationScreen(
onNavigationClick = viewModel::goToPreviousStage,
onNextClick = viewModel::goToNextStage
)
}
composable(route = MessageBackupsScreen.PIN_CONFIRMATION.name) {
MessageBackupsPinConfirmationScreen(
pin = pin,
isPinIncorrect = state.displayIncorrectPinError,
onPinChanged = viewModel::onPinEntryUpdated,
pinKeyboardType = state.pinKeyboardType,
onPinKeyboardTypeSelected = viewModel::onPinKeyboardTypeUpdated,
onNextClick = viewModel::goToNextScreen,
onCreateNewPinClick = this@MessageBackupsFlowFragment::createANewPin
composable(route = MessageBackupsStage.Route.BACKUP_KEY_RECORD.name) {
val context = LocalContext.current
MessageBackupsKeyRecordScreen(
backupKey = state.backupKey,
onNavigationClick = viewModel::goToPreviousStage,
onNextClick = viewModel::goToNextStage,
onCopyToClipboardClick = {
Util.copyToClipboard(context, it)
}
)
}
composable(route = MessageBackupsScreen.TYPE_SELECTION.name) {
composable(route = MessageBackupsStage.Route.TYPE_SELECTION.name) {
MessageBackupsTypeSelectionScreen(
currentBackupTier = state.currentMessageBackupTier,
selectedBackupTier = state.selectedMessageBackupTier,
@@ -122,174 +94,32 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
viewModel.onMessageBackupTierUpdated(tier, label)
},
onNavigationClick = viewModel::goToPreviousScreen,
onNavigationClick = viewModel::goToPreviousStage,
onReadMoreClicked = {},
onCancelSubscriptionClicked = viewModel::displayCancellationDialog,
onNextClicked = viewModel::goToNextScreen
onNextClicked = viewModel::goToNextStage
)
}
}
if (state.screen == MessageBackupsScreen.CHECKOUT_SHEET) {
MessageBackupsCheckoutSheet(
messageBackupsType = state.availableBackupTypes.filterIsInstance<MessageBackupsType.Paid>().first { it.tier == state.selectedMessageBackupTier!! },
availablePaymentMethods = state.availablePaymentMethods,
sheetState = checkoutSheetState,
onDismissRequest = {
viewModel.goToPreviousScreen()
},
onPaymentMethodSelected = {
viewModel.onPaymentMethodUpdated(it)
viewModel.goToNextScreen()
}
)
}
if (state.screen == MessageBackupsScreen.CANCELLATION_DIALOG) {
ConfirmBackupCancellationDialog(
onConfirmAndDownloadNow = {
// TODO [message-backups] Set appropriate state to handle post-cancellation action.
viewModel.goToNextScreen()
},
onConfirmAndDownloadLater = {
// TODO [message-backups] Set appropriate state to handle post-cancellation action.
viewModel.goToNextScreen()
},
onKeepSubscriptionClick = viewModel::goToPreviousScreen
)
LaunchedEffect(state.stage) {
val newRoute = state.stage.route.name
val currentRoute = navController.currentDestination?.route
if (currentRoute != newRoute) {
if (currentRoute != null && MessageBackupsStage.Route.valueOf(currentRoute).isAfter(state.stage.route)) {
navController.popBackStack()
} else {
navController.navigate(newRoute)
}
}
}
LaunchedEffect(state.screen) {
val route = navController.currentDestination?.route ?: return@LaunchedEffect
if (route == state.screen.name) {
return@LaunchedEffect
if (state.stage == MessageBackupsStage.CHECKOUT_SHEET) {
AppDependencies.billingApi.launchBillingFlow(requireActivity())
}
if (state.screen == MessageBackupsScreen.COMPLETED) {
if (!findNavController().popBackStack()) {
requireActivity().finishAfterTransition()
}
return@LaunchedEffect
}
if (state.screen == MessageBackupsScreen.CREATING_IN_APP_PAYMENT) {
return@LaunchedEffect
}
if (state.screen == MessageBackupsScreen.PROCESS_PAYMENT) {
checkoutDelegate.handleGatewaySelectionResponse(state.inAppPayment!!)
viewModel.goToPreviousScreen()
return@LaunchedEffect
}
if (state.screen == MessageBackupsScreen.PROCESS_CANCELLATION) {
cancelSubscription()
viewModel.goToPreviousScreen()
return@LaunchedEffect
}
if (state.screen == MessageBackupsScreen.CHECKOUT_SHEET) {
return@LaunchedEffect
}
if (state.screen == MessageBackupsScreen.CANCELLATION_DIALOG) {
return@LaunchedEffect
}
if (state.screen == MessageBackupsScreen.PROCESS_FREE) {
checkoutDelegate.setActivityResult(InAppPaymentProcessorAction.UPDATE_SUBSCRIPTION, InAppPaymentType.RECURRING_BACKUP)
viewModel.goToNextScreen()
return@LaunchedEffect
}
val routeScreen = MessageBackupsScreen.valueOf(route)
if (routeScreen.isAfter(state.screen)) {
navController.popBackStack()
} else {
navController.navigate(state.screen.name)
if (state.stage == MessageBackupsStage.COMPLETED) {
requireActivity().setResult(Activity.RESULT_OK)
requireActivity().finishAfterTransition()
}
}
}
private fun createANewPin() {
viewModel.onPinEntryUpdated("")
startActivity(CreateSvrPinActivity.getIntentForPinChangeFromSettings(requireContext()))
}
private fun cancelSubscription() {
findNavController().safeNavigate(
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
InAppPaymentProcessorAction.CANCEL_SUBSCRIPTION,
null,
InAppPaymentType.RECURRING_BACKUP
)
)
}
override fun navigateToStripePaymentInProgress(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
InAppPaymentProcessorAction.PROCESS_NEW_IN_APP_PAYMENT,
inAppPayment,
inAppPayment.type
)
)
}
override fun navigateToPayPalPaymentInProgress(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToPaypalPaymentInProgressFragment(
InAppPaymentProcessorAction.PROCESS_NEW_IN_APP_PAYMENT,
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) {
viewModel.onCancellationComplete()
if (!findNavController().popBackStack()) {
requireActivity().finishAfterTransition()
}
}
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?
}
override fun exitCheckoutFlow() {
requireActivity().finishAfterTransition()
}
}

View File

@@ -7,20 +7,16 @@ 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
import org.whispersystems.signalservice.api.backup.BackupKey
data class MessageBackupsFlowState(
val selectedMessageBackupTierLabel: String? = null,
val selectedMessageBackupTier: MessageBackupTier? = SignalStore.backup.backupTier,
val currentMessageBackupTier: MessageBackupTier? = SignalStore.backup.backupTier,
val availableBackupTypes: List<MessageBackupsType> = emptyList(),
val selectedPaymentMethod: InAppPaymentData.PaymentMethodType? = null,
val availablePaymentMethods: List<InAppPaymentData.PaymentMethodType> = emptyList(),
val pinKeyboardType: PinKeyboardType = SignalStore.pin.keyboardType,
val inAppPayment: InAppPaymentTable.InAppPayment? = null,
val startScreen: MessageBackupsScreen,
val screen: MessageBackupsScreen = startScreen,
val displayIncorrectPinError: Boolean = false
val startScreen: MessageBackupsStage,
val stage: MessageBackupsStage = startScreen,
val backupKey: BackupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
)

View File

@@ -5,9 +5,6 @@
package org.thoughtcrime.securesms.backup.v2.ui.subscription
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
@@ -16,44 +13,37 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.billing.BillingPurchaseResult
import org.signal.core.util.money.FiatMoney
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.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.dependencies.AppDependencies
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
import java.math.BigDecimal
class MessageBackupsFlowViewModel : ViewModel() {
private val internalStateFlow = MutableStateFlow(
MessageBackupsFlowState(
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
startScreen = if (SignalStore.backup.backupTier == null) MessageBackupsStage.EDUCATION else MessageBackupsStage.TYPE_SELECTION
)
)
private val internalPinState = mutableStateOf("")
private var isDowngrading = false
val stateFlow: StateFlow<MessageBackupsFlowState> = internalStateFlow
val pinState: State<String> = internalPinState
init {
check(SignalStore.backup.backupTier != MessageBackupTier.PAID) { "This screen does not support cancellation or downgrades." }
viewModelScope.launch {
internalStateFlow.update {
it.copy(
@@ -63,71 +53,63 @@ class MessageBackupsFlowViewModel : ViewModel() {
)
}
}
}
fun goToNextScreen() {
val pinSnapshot = pinState.value
viewModelScope.launch {
AppDependencies.billingApi.getBillingPurchaseResults().collect {
when (it) {
is BillingPurchaseResult.Success -> {
// 1. Copy the purchaseToken into our inAppPaymentData
// 2. Enqueue the redemption chain
goToNextStage()
}
internalStateFlow.update {
when (it.screen) {
MessageBackupsScreen.EDUCATION -> it.copy(screen = MessageBackupsScreen.PIN_EDUCATION)
MessageBackupsScreen.PIN_EDUCATION -> it.copy(screen = MessageBackupsScreen.PIN_CONFIRMATION)
MessageBackupsScreen.PIN_CONFIRMATION -> validatePinAndUpdateState(it, pinSnapshot)
MessageBackupsScreen.TYPE_SELECTION -> validateTypeAndUpdateState(it)
MessageBackupsScreen.CHECKOUT_SHEET -> validateGatewayAndUpdateState(it)
MessageBackupsScreen.CREATING_IN_APP_PAYMENT -> error("This is driven by an async coroutine.")
MessageBackupsScreen.CANCELLATION_DIALOG -> it.copy(screen = MessageBackupsScreen.PROCESS_CANCELLATION)
MessageBackupsScreen.PROCESS_PAYMENT -> it.copy(screen = MessageBackupsScreen.COMPLETED)
MessageBackupsScreen.PROCESS_CANCELLATION -> it.copy(screen = MessageBackupsScreen.COMPLETED)
MessageBackupsScreen.PROCESS_FREE -> it.copy(screen = MessageBackupsScreen.COMPLETED)
MessageBackupsScreen.COMPLETED -> error("Unsupported state transition from terminal state COMPLETED")
else -> goToPreviousStage()
}
}
}
}
fun goToPreviousScreen() {
/**
* Go to the next stage of the pipeline, based off of the current stage and state data.
*/
fun goToNextStage() {
internalStateFlow.update {
if (it.screen == it.startScreen) {
it.copy(screen = MessageBackupsScreen.COMPLETED)
when (it.stage) {
MessageBackupsStage.EDUCATION -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_EDUCATION)
MessageBackupsStage.BACKUP_KEY_EDUCATION -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_RECORD)
MessageBackupsStage.BACKUP_KEY_RECORD -> it.copy(stage = MessageBackupsStage.TYPE_SELECTION)
MessageBackupsStage.TYPE_SELECTION -> validateTypeAndUpdateState(it)
MessageBackupsStage.CHECKOUT_SHEET -> validateGatewayAndUpdateState(it)
MessageBackupsStage.CREATING_IN_APP_PAYMENT -> error("This is driven by an async coroutine.")
MessageBackupsStage.PROCESS_PAYMENT -> it.copy(stage = MessageBackupsStage.COMPLETED)
MessageBackupsStage.PROCESS_FREE -> it.copy(stage = MessageBackupsStage.COMPLETED)
MessageBackupsStage.COMPLETED -> error("Unsupported state transition from terminal state COMPLETED")
}
}
}
fun goToPreviousStage() {
internalStateFlow.update {
if (it.stage == it.startScreen) {
it.copy(stage = MessageBackupsStage.COMPLETED)
} else {
val previousScreen = when (it.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.CREATING_IN_APP_PAYMENT -> MessageBackupsScreen.TYPE_SELECTION
MessageBackupsScreen.PROCESS_PAYMENT -> MessageBackupsScreen.TYPE_SELECTION
MessageBackupsScreen.PROCESS_CANCELLATION -> MessageBackupsScreen.TYPE_SELECTION
MessageBackupsScreen.PROCESS_FREE -> MessageBackupsScreen.TYPE_SELECTION
MessageBackupsScreen.CANCELLATION_DIALOG -> MessageBackupsScreen.TYPE_SELECTION
MessageBackupsScreen.COMPLETED -> error("Unsupported state transition from terminal state COMPLETED")
val previousScreen = when (it.stage) {
MessageBackupsStage.EDUCATION -> MessageBackupsStage.COMPLETED
MessageBackupsStage.BACKUP_KEY_EDUCATION -> MessageBackupsStage.EDUCATION
MessageBackupsStage.BACKUP_KEY_RECORD -> MessageBackupsStage.BACKUP_KEY_EDUCATION
MessageBackupsStage.TYPE_SELECTION -> MessageBackupsStage.BACKUP_KEY_RECORD
MessageBackupsStage.CHECKOUT_SHEET -> MessageBackupsStage.TYPE_SELECTION
MessageBackupsStage.CREATING_IN_APP_PAYMENT -> MessageBackupsStage.CREATING_IN_APP_PAYMENT
MessageBackupsStage.PROCESS_PAYMENT -> MessageBackupsStage.PROCESS_PAYMENT
MessageBackupsStage.PROCESS_FREE -> MessageBackupsStage.PROCESS_FREE
MessageBackupsStage.COMPLETED -> error("Unsupported state transition from terminal state COMPLETED")
}
it.copy(screen = previousScreen)
it.copy(stage = previousScreen)
}
}
}
fun displayCancellationDialog() {
internalStateFlow.update {
check(it.screen == MessageBackupsScreen.TYPE_SELECTION)
it.copy(screen = MessageBackupsScreen.CANCELLATION_DIALOG)
}
}
fun onPinEntryUpdated(pin: String) {
internalPinState.value = pin
}
fun onPinKeyboardTypeUpdated(pinKeyboardType: PinKeyboardType) {
internalStateFlow.update { it.copy(pinKeyboardType = pinKeyboardType) }
}
fun onPaymentMethodUpdated(paymentMethod: InAppPaymentData.PaymentMethodType) {
internalStateFlow.update { it.copy(selectedPaymentMethod = paymentMethod) }
}
fun onMessageBackupTierUpdated(messageBackupTier: MessageBackupTier, messageBackupTierLabel: String) {
internalStateFlow.update {
it.copy(
@@ -137,53 +119,16 @@ class MessageBackupsFlowViewModel : ViewModel() {
}
}
fun onCancellationComplete() {
if (isDowngrading) {
SignalStore.backup.areBackupsEnabled = true
SignalStore.backup.backupTier = MessageBackupTier.FREE
// TODO [message-backups] -- Trigger backup now?
}
}
private fun validatePinAndUpdateState(state: MessageBackupsFlowState, pin: String): MessageBackupsFlowState {
val pinHash = SignalStore.svr.localPinHash
if (pinHash == null || TextUtils.isEmpty(pin) || pin.length < SvrConstants.MINIMUM_PIN_LENGTH) {
return state.copy(
screen = MessageBackupsScreen.PIN_CONFIRMATION,
displayIncorrectPinError = true
)
}
if (!verifyLocalPinHash(pinHash, pin)) {
return state.copy(
screen = MessageBackupsScreen.PIN_CONFIRMATION,
displayIncorrectPinError = true
)
}
internalPinState.value = ""
return state.copy(
screen = MessageBackupsScreen.TYPE_SELECTION,
displayIncorrectPinError = false
)
}
private fun validateTypeAndUpdateState(state: MessageBackupsFlowState): MessageBackupsFlowState {
return when (state.selectedMessageBackupTier!!) {
MessageBackupTier.FREE -> {
if (SignalStore.backup.backupTier == MessageBackupTier.PAID) {
isDowngrading = true
state.copy(screen = MessageBackupsScreen.PROCESS_CANCELLATION)
} else {
SignalStore.backup.areBackupsEnabled = true
SignalStore.backup.backupTier = MessageBackupTier.FREE
SignalStore.backup.areBackupsEnabled = true
SignalStore.backup.backupTier = MessageBackupTier.FREE
state.copy(screen = MessageBackupsScreen.PROCESS_FREE)
}
state.copy(stage = MessageBackupsStage.PROCESS_FREE)
}
MessageBackupTier.PAID -> state.copy(screen = MessageBackupsScreen.CHECKOUT_SHEET)
MessageBackupTier.PAID -> state.copy(stage = MessageBackupsStage.CHECKOUT_SHEET)
}
}
@@ -195,7 +140,7 @@ class MessageBackupsFlowViewModel : ViewModel() {
internalStateFlow.update { it.copy(inAppPayment = null) }
}
val currency = SignalStore.inAppPayments.getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.BACKUP)
val paidFiat = AppDependencies.billingApi.queryProduct()!!.price
SignalDatabase.inAppPayments.clearCreated()
val id = SignalDatabase.inAppPayments.insert(
@@ -206,10 +151,10 @@ class MessageBackupsFlowViewModel : ViewModel() {
inAppPaymentData = InAppPaymentData(
badge = null,
label = state.selectedMessageBackupTierLabel!!,
amount = if (backupsType is MessageBackupsType.Paid) backupsType.pricePerMonth.toFiatValue() else FiatMoney(BigDecimal.ZERO, currency).toFiatValue(),
amount = if (backupsType is MessageBackupsType.Paid) paidFiat.toFiatValue() else FiatMoney(BigDecimal.ZERO, paidFiat.currency).toFiatValue(),
level = SubscriptionsConfiguration.BACKUPS_LEVEL.toLong(),
recipientId = Recipient.self().id.serialize(),
paymentMethodType = state.selectedPaymentMethod!!,
paymentMethodType = InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING,
redemption = InAppPaymentData.RedemptionState(
stage = InAppPaymentData.RedemptionState.Stage.INIT
)
@@ -219,10 +164,10 @@ class MessageBackupsFlowViewModel : ViewModel() {
val inAppPayment = SignalDatabase.inAppPayments.getById(id)!!
withContext(Dispatchers.Main) {
internalStateFlow.update { it.copy(inAppPayment = inAppPayment, screen = MessageBackupsScreen.PROCESS_PAYMENT) }
internalStateFlow.update { it.copy(inAppPayment = inAppPayment, stage = MessageBackupsStage.PROCESS_PAYMENT) }
}
}
return state.copy(screen = MessageBackupsScreen.CREATING_IN_APP_PAYMENT)
return state.copy(stage = MessageBackupsStage.CREATING_IN_APP_PAYMENT)
}
}

View File

@@ -0,0 +1,114 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview
import org.thoughtcrime.securesms.R
/**
* Screen detailing how a backups key is used to restore a backup
*/
@Composable
fun MessageBackupsKeyEducationScreen(
onNavigationClick: () -> Unit = {},
onNextClick: () -> Unit = {}
) {
Scaffolds.Settings(
title = "",
navigationIconPainter = painterResource(R.drawable.symbol_arrow_left_24),
onNavigationClick = onNavigationClick
) {
Column(
modifier = Modifier
.padding(it)
.padding(horizontal = dimensionResource(R.dimen.core_ui__gutter))
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
painter = painterResource(R.drawable.symbol_key_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = 24.dp)
.size(80.dp)
.background(
color = MaterialTheme.colorScheme.primaryContainer,
shape = CircleShape
)
.padding(16.dp)
)
Text(
text = stringResource(R.string.MessageBackupsKeyEducationScreen__your_backup_key),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 16.dp)
)
Text(
text = stringResource(R.string.MessageBackupsKeyEducationScreen__your_backup_key_is_a),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp)
)
Text(
text = stringResource(R.string.MessageBackupsKeyEducationScreen__if_you_forget_your_key),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 16.dp)
)
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(bottom = 24.dp)
) {
Buttons.LargeTonal(
onClick = onNextClick,
modifier = Modifier.align(Alignment.BottomEnd)
) {
Text(
text = stringResource(R.string.MessageBackupsKeyEducationScreen__next)
)
}
}
}
}
}
@SignalPreview
@Composable
private fun MessageBackupsKeyEducationScreenPreview() {
Previews.Preview {
MessageBackupsKeyEducationScreen()
}
}

View File

@@ -0,0 +1,260 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.Hex
import org.thoughtcrime.securesms.R
import org.whispersystems.signalservice.api.backup.BackupKey
import kotlin.random.Random
/**
* Screen displaying the backup key allowing the user to write it down
* or copy it.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MessageBackupsKeyRecordScreen(
backupKey: BackupKey,
onNavigationClick: () -> Unit = {},
onCopyToClipboardClick: (String) -> Unit = {},
onNextClick: () -> Unit = {}
) {
val coroutineScope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
Scaffolds.Settings(
title = "",
navigationIconPainter = painterResource(R.drawable.symbol_arrow_left_24),
onNavigationClick = onNavigationClick
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.padding(horizontal = dimensionResource(R.dimen.core_ui__gutter))
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
painter = painterResource(R.drawable.symbol_lock_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.padding(top = 24.dp)
.size(80.dp)
.background(
color = MaterialTheme.colorScheme.primaryContainer,
shape = CircleShape
)
.padding(16.dp)
)
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__record_your_backup_key),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 16.dp)
)
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__this_key_is_required_to_recover),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp)
)
val backupKeyString = remember(backupKey) {
backupKey.value.toList().chunked(2).map { Hex.toStringCondensed(it.toByteArray()) }.joinToString(" ")
}
Box(
modifier = Modifier
.padding(top = 24.dp, bottom = 16.dp)
.background(
color = SignalTheme.colors.colorSurface1,
shape = RoundedCornerShape(10.dp)
)
.padding(24.dp)
) {
Text(
text = backupKeyString,
style = MaterialTheme.typography.bodyLarge
.copy(
fontSize = 18.sp,
fontWeight = FontWeight(400),
letterSpacing = 1.44.sp,
lineHeight = 36.sp,
textAlign = TextAlign.Center,
fontFamily = FontFamily.Monospace
)
)
}
Buttons.Small(
onClick = { onCopyToClipboardClick(backupKeyString) }
) {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__copy_to_clipboard)
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(bottom = 24.dp)
) {
Buttons.LargeTonal(
onClick = {
coroutineScope.launch {
sheetState.show()
}
},
modifier = Modifier.align(Alignment.BottomEnd)
) {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__next)
)
}
}
}
if (sheetState.isVisible) {
ModalBottomSheet(
dragHandle = null,
onDismissRequest = {
coroutineScope.launch {
sheetState.hide()
}
}
) {
BottomSheetContent(
onContinueClick = onNextClick,
onSeeKeyAgainClick = {
coroutineScope.launch {
sheetState.hide()
}
}
)
}
}
}
}
@Composable
private fun BottomSheetContent(
onContinueClick: () -> Unit,
onSeeKeyAgainClick: () -> Unit
) {
var checked by remember { mutableStateOf(false) }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(R.dimen.core_ui__gutter))
) {
BottomSheets.Handle()
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__keep_your_key_safe),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 30.dp)
)
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__signal_will_not),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 12.dp)
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 24.dp)
) {
Checkbox(
checked = checked,
onCheckedChange = { checked = it }
)
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__ive_recorded_my_key),
style = MaterialTheme.typography.bodyLarge
)
}
Buttons.LargeTonal(
enabled = checked,
onClick = onContinueClick,
modifier = Modifier.padding(bottom = 16.dp)
) {
Text(text = stringResource(R.string.MessageBackupsKeyRecordScreen__continue))
}
TextButton(
onClick = onSeeKeyAgainClick,
modifier = Modifier.padding(bottom = 24.dp)
) {
Text(
text = stringResource(R.string.MessageBackupsKeyRecordScreen__see_key_again)
)
}
}
}
@SignalPreview
@Composable
private fun MessageBackupsKeyRecordScreenPreview() {
Previews.Preview {
MessageBackupsKeyRecordScreen(
backupKey = BackupKey(Random.nextBytes(32))
)
}
}

View File

@@ -1,231 +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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
/**
* Screen which requires the user to enter their pin before enabling backups.
*/
@Composable
fun MessageBackupsPinConfirmationScreen(
pin: String,
isPinIncorrect: Boolean,
onPinChanged: (String) -> Unit,
pinKeyboardType: PinKeyboardType,
onPinKeyboardTypeSelected: (PinKeyboardType) -> Unit,
onNextClick: () -> Unit,
onCreateNewPinClick: () -> Unit
) {
val focusRequester = remember { FocusRequester() }
Surface {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
item {
Text(
text = stringResource(id = R.string.MessageBackupsPinConfirmationScreen__enter_your_pin),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 40.dp)
)
}
item {
Text(
text = stringResource(id = R.string.MessageBackupsPinConfirmationScreen__enter_your_signal_pin_to_enable_backups),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 16.dp)
)
}
item {
val keyboardType = remember(pinKeyboardType) {
when (pinKeyboardType) {
PinKeyboardType.NUMERIC -> KeyboardType.NumberPassword
PinKeyboardType.ALPHA_NUMERIC -> KeyboardType.Password
}
}
TextField(
value = pin,
onValueChange = onPinChanged,
textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center),
keyboardActions = KeyboardActions(
onDone = { onNextClick() }
),
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType,
imeAction = ImeAction.Done
),
modifier = Modifier
.padding(top = 72.dp)
.fillMaxWidth()
.focusRequester(focusRequester),
visualTransformation = PasswordVisualTransformation(),
isError = isPinIncorrect,
supportingText = {
if (isPinIncorrect) {
Text(
text = stringResource(id = R.string.PinRestoreEntryFragment_incorrect_pin),
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp)
)
}
}
)
}
item {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 48.dp)
) {
PinKeyboardTypeToggle(
pinKeyboardType = pinKeyboardType,
onPinKeyboardTypeSelected = onPinKeyboardTypeSelected
)
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
if (isPinIncorrect) {
TextButton(onClick = onCreateNewPinClick) {
Text(
text = stringResource(id = R.string.MessageBackupsPinConfirmationScreen__create_new_pin)
)
}
}
Spacer(modifier = Modifier.weight(1f))
Buttons.LargeTonal(
onClick = onNextClick
) {
Text(
text = stringResource(id = R.string.MessageBackupsPinConfirmationScreen__next)
)
}
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}
}
@Preview
@Composable
private fun MessageBackupsPinConfirmationScreenPreview() {
Previews.Preview {
MessageBackupsPinConfirmationScreen(
pin = "",
isPinIncorrect = true,
onPinChanged = {},
pinKeyboardType = PinKeyboardType.ALPHA_NUMERIC,
onPinKeyboardTypeSelected = {},
onNextClick = {},
onCreateNewPinClick = {}
)
}
}
@Preview
@Composable
private fun PinKeyboardTypeTogglePreview() {
Previews.Preview {
var type by remember { mutableStateOf(PinKeyboardType.ALPHA_NUMERIC) }
PinKeyboardTypeToggle(
pinKeyboardType = type,
onPinKeyboardTypeSelected = { type = it }
)
}
}
@Composable
private fun PinKeyboardTypeToggle(
pinKeyboardType: PinKeyboardType,
onPinKeyboardTypeSelected: (PinKeyboardType) -> Unit
) {
val callback = remember(pinKeyboardType) {
{ onPinKeyboardTypeSelected(pinKeyboardType.other) }
}
val iconRes = remember(pinKeyboardType) {
when (pinKeyboardType) {
PinKeyboardType.NUMERIC -> R.drawable.symbol_keyboard_24
PinKeyboardType.ALPHA_NUMERIC -> R.drawable.symbol_number_pad_24
}
}
TextButton(onClick = callback) {
Icon(
painter = painterResource(id = iconRes),
tint = MaterialTheme.colorScheme.primary,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp)
)
Text(
text = stringResource(id = R.string.MessageBackupsPinConfirmationScreen__switch_keyboard)
)
}
}

View File

@@ -1,133 +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.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
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.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds
import org.thoughtcrime.securesms.R
/**
* Explanation screen that details how the user's pin is utilized with backups,
* and how long they should make their pin.
*/
@Composable
fun MessageBackupsPinEducationScreen(
onNavigationClick: () -> Unit,
onCreatePinClick: () -> Unit,
onUseCurrentPinClick: () -> Unit,
recommendedPinSize: Int
) {
Scaffolds.Settings(
title = stringResource(id = R.string.RemoteBackupsSettingsFragment__signal_backups),
onNavigationClick = onNavigationClick,
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(it)
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
) {
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
item {
Image(
painter = painterResource(id = R.drawable.ic_signal_logo_large), // TODO [message-backups] Finalized image
contentDescription = null,
modifier = Modifier
.padding(top = 48.dp)
.size(88.dp)
)
}
item {
Text(
text = stringResource(id = R.string.MessageBackupsPinEducationScreen__pins_protect_your_backup),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 16.dp)
)
}
item {
Text(
text = stringResource(id = R.string.MessageBackupsPinEducationScreen__your_signal_pin_lets_you),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(top = 16.dp)
)
}
item {
Text(
text = stringResource(id = R.string.MessageBackupsPinEducationScreen__if_you_forget_your_pin),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(top = 16.dp)
)
}
}
Buttons.LargePrimary(
onClick = onUseCurrentPinClick,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(id = R.string.MessageBackupsPinEducationScreen__use_current_signal_pin)
)
}
TextButton(
onClick = onCreatePinClick,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
) {
Text(
text = stringResource(id = R.string.MessageBackupsPinEducationScreen__create_new_pin)
)
}
}
}
}
@Preview
@Composable
private fun MessageBackupsPinScreenPreview() {
Previews.Preview {
MessageBackupsPinEducationScreen(
onNavigationClick = {},
onCreatePinClick = {},
onUseCurrentPinClick = {},
recommendedPinSize = 16
)
}
}

View File

@@ -1,22 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.subscription
enum class MessageBackupsScreen {
EDUCATION,
PIN_EDUCATION,
PIN_CONFIRMATION,
TYPE_SELECTION,
CANCELLATION_DIALOG,
CHECKOUT_SHEET,
CREATING_IN_APP_PAYMENT,
PROCESS_PAYMENT,
PROCESS_CANCELLATION,
PROCESS_FREE,
COMPLETED;
fun isAfter(other: MessageBackupsScreen): Boolean = ordinal > other.ordinal
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.subscription
/**
* Pipeline for subscribing to message backups.
*/
enum class MessageBackupsStage(
val route: Route
) {
EDUCATION(route = Route.EDUCATION),
BACKUP_KEY_EDUCATION(route = Route.BACKUP_KEY_EDUCATION),
BACKUP_KEY_RECORD(route = Route.BACKUP_KEY_RECORD),
TYPE_SELECTION(route = Route.TYPE_SELECTION),
CHECKOUT_SHEET(route = Route.TYPE_SELECTION),
CREATING_IN_APP_PAYMENT(route = Route.TYPE_SELECTION),
PROCESS_PAYMENT(route = Route.TYPE_SELECTION),
PROCESS_FREE(route = Route.TYPE_SELECTION),
COMPLETED(route = Route.TYPE_SELECTION);
/**
* Compose navigation route to display while in a given stage.
*/
enum class Route {
EDUCATION,
BACKUP_KEY_EDUCATION,
BACKUP_KEY_RECORD,
TYPE_SELECTION;
fun isAfter(other: Route): Boolean = ordinal > other.ordinal
}
}

View File

@@ -22,7 +22,6 @@ import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -71,8 +70,7 @@ fun MessageBackupsTypeSelectionScreen(
onMessageBackupsTierSelected: (MessageBackupTier) -> Unit,
onNavigationClick: () -> Unit,
onReadMoreClicked: () -> Unit,
onNextClicked: () -> Unit,
onCancelSubscriptionClicked: () -> Unit
onNextClicked: () -> Unit
) {
Scaffolds.Settings(
title = "",
@@ -170,17 +168,6 @@ fun MessageBackupsTypeSelectionScreen(
)
)
}
if (hasCurrentBackupTier) {
TextButton(
onClick = onCancelSubscriptionClicked,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 14.dp)
) {
Text(text = stringResource(id = R.string.MessageBackupsTypeSelectionScreen__cancel_subscription))
}
}
}
}
}
@@ -198,7 +185,6 @@ private fun MessageBackupsTypeSelectionScreenPreview() {
onNavigationClick = {},
onReadMoreClicked = {},
onNextClicked = {},
onCancelSubscriptionClicked = {},
currentBackupTier = null
)
}
@@ -217,7 +203,6 @@ private fun MessageBackupsTypeSelectionScreenWithCurrentTierPreview() {
onNavigationClick = {},
onReadMoreClicked = {},
onNextClicked = {},
onCancelSubscriptionClicked = {},
currentBackupTier = MessageBackupTier.PAID
)
}

View File

@@ -4,12 +4,11 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.Navigation
import androidx.navigation.fragment.findNavController
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentCheckoutLauncher.createBackupsCheckoutLauncher
import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBackupsCheckoutLauncher.createBackupsCheckoutLauncher
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@@ -18,7 +17,7 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
class ChatsSettingsFragment : DSLSettingsFragment(R.string.preferences_chats__chats) {
private lateinit var viewModel: ChatsSettingsViewModel
private lateinit var checkoutLauncher: ActivityResultLauncher<InAppPaymentType>
private lateinit var checkoutLauncher: ActivityResultLauncher<Unit>
override fun onResume() {
super.onResume()
@@ -99,7 +98,7 @@ class ChatsSettingsFragment : DSLSettingsFragment(R.string.preferences_chats__ch
if (state.canAccessRemoteBackupsSettings) {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_chatsSettingsFragment_to_remoteBackupsSettingsFragment)
} else {
checkoutLauncher.launch(InAppPaymentType.RECURRING_BACKUP)
checkoutLauncher.launch(Unit)
}
}
)

View File

@@ -62,13 +62,12 @@ import org.signal.core.ui.SignalPreview
import org.signal.core.ui.Snackbars
import org.signal.core.ui.Texts
import org.signal.core.util.money.FiatMoney
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.BackupFrequency
import org.thoughtcrime.securesms.backup.v2.BackupV2Event
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.components.settings.app.chats.backups.type.BackupsTypeSettingsFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentCheckoutLauncher.createBackupsCheckoutLauncher
import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBackupsCheckoutLauncher.createBackupsCheckoutLauncher
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
@@ -92,7 +91,7 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
private val args: RemoteBackupsSettingsFragmentArgs by navArgs()
private lateinit var checkoutLauncher: ActivityResultLauncher<InAppPaymentType>
private lateinit var checkoutLauncher: ActivityResultLauncher<Unit>
@Composable
override fun FragmentContent() {
@@ -119,7 +118,7 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
}
override fun onEnableBackupsClick() {
checkoutLauncher.launch(InAppPaymentType.RECURRING_BACKUP)
checkoutLauncher.launch(Unit)
}
override fun onBackUpUsingCellularClick(canUseCellular: Boolean) {

View File

@@ -30,11 +30,10 @@ import org.signal.core.ui.Rows
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview
import org.signal.core.util.money.FiatMoney
import org.signal.donations.InAppPaymentType
import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentCheckoutLauncher.createBackupsCheckoutLauncher
import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBackupsCheckoutLauncher.createBackupsCheckoutLauncher
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -58,7 +57,7 @@ class BackupsTypeSettingsFragment : ComposeFragment() {
BackupsTypeSettingsViewModel()
}
private lateinit var checkoutLauncher: ActivityResultLauncher<InAppPaymentType>
private lateinit var checkoutLauncher: ActivityResultLauncher<Unit>
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -92,7 +91,7 @@ class BackupsTypeSettingsFragment : ComposeFragment() {
}
override fun onChangeOrCancelSubscriptionClick() {
checkoutLauncher.launch(InAppPaymentType.RECURRING_BACKUP)
checkoutLauncher.launch(Unit)
}
}
@@ -195,6 +194,7 @@ private fun BackupsTypeRow(
private fun PaymentSourceRow(paymentSourceType: PaymentSourceType) {
val paymentSourceTextResId = remember(paymentSourceType) {
when (paymentSourceType) {
is PaymentSourceType.GooglePlayBilling -> R.string.BackupsTypeSettingsFragment__google_play
is PaymentSourceType.Stripe.CreditCard -> R.string.BackupsTypeSettingsFragment__credit_or_debit_card
is PaymentSourceType.Stripe.IDEAL -> R.string.BackupsTypeSettingsFragment__iDEAL
is PaymentSourceType.Stripe.GooglePay -> R.string.BackupsTypeSettingsFragment__google_pay

View File

@@ -32,13 +32,12 @@ import org.signal.core.ui.Buttons
import org.signal.core.ui.Icons
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.BackupsIconColors
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeBlock
import org.thoughtcrime.securesms.backup.v2.ui.subscription.testBackupTypes
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentCheckoutLauncher.createBackupsCheckoutLauncher
import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBackupsCheckoutLauncher.createBackupsCheckoutLauncher
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
/**
@@ -50,7 +49,7 @@ class UpgradeToEnableOptimizedStorageSheet : ComposeBottomSheetDialogFragment()
private val viewModel: UpgradeToEnableOptimizedStorageViewModel by viewModels()
private lateinit var checkoutLauncher: ActivityResultLauncher<InAppPaymentType>
private lateinit var checkoutLauncher: ActivityResultLauncher<Unit>
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
@@ -63,7 +62,7 @@ class UpgradeToEnableOptimizedStorageSheet : ComposeBottomSheetDialogFragment()
UpgradeToEnableOptimizedStorageSheetContent(
messageBackupsType = type,
onUpgradeNowClick = {
checkoutLauncher.launch(InAppPaymentType.RECURRING_BACKUP)
checkoutLauncher.launch(Unit)
dismissAllowingStateLoss()
},
onCancelClick = {

View File

@@ -27,6 +27,7 @@ object DonationSerializationHelper {
return PendingOneTimeDonation(
badge = Badges.toDatabaseBadge(badge),
paymentMethodType = when (paymentSourceType) {
PaymentSourceType.GooglePlayBilling -> error("Unsupported payment source.")
PaymentSourceType.PayPal -> PendingOneTimeDonation.PaymentMethodType.PAYPAL
PaymentSourceType.Stripe.CreditCard, PaymentSourceType.Stripe.GooglePay, PaymentSourceType.Unknown -> PendingOneTimeDonation.PaymentMethodType.CARD
PaymentSourceType.Stripe.SEPADebit -> PendingOneTimeDonation.PaymentMethodType.SEPA_DEBIT

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription
import org.signal.donations.InAppPaymentType
import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.LocaleRemoteConfig
@@ -25,12 +26,17 @@ object InAppDonations {
}
fun isPaymentSourceAvailable(paymentSourceType: PaymentSourceType, inAppPaymentType: InAppPaymentType): Boolean {
if (inAppPaymentType == InAppPaymentType.RECURRING_BACKUP) {
return paymentSourceType == PaymentSourceType.GooglePlayBilling && AppDependencies.billingApi.isApiAvailable()
}
return when (paymentSourceType) {
PaymentSourceType.PayPal -> isPayPalAvailableForDonateToSignalType(inAppPaymentType)
PaymentSourceType.Stripe.CreditCard -> isCreditCardAvailable()
PaymentSourceType.Stripe.GooglePay -> isGooglePayAvailable()
PaymentSourceType.Stripe.SEPADebit -> isSEPADebitAvailableForDonateToSignalType(inAppPaymentType)
PaymentSourceType.Stripe.IDEAL -> isIDEALAvailbleForDonateToSignalType(inAppPaymentType)
PaymentSourceType.GooglePlayBilling -> false
PaymentSourceType.Unknown -> false
}
}
@@ -40,7 +46,7 @@ object InAppDonations {
InAppPaymentType.UNKNOWN -> error("Unsupported type UNKNOWN")
InAppPaymentType.ONE_TIME_DONATION, InAppPaymentType.ONE_TIME_GIFT -> RemoteConfig.paypalOneTimeDonations
InAppPaymentType.RECURRING_DONATION -> RemoteConfig.paypalRecurringDonations
InAppPaymentType.RECURRING_BACKUP -> RemoteConfig.messageBackups && RemoteConfig.paypalRecurringDonations
InAppPaymentType.RECURRING_BACKUP -> false
} && !LocaleRemoteConfig.isPayPalDisabled()
}

View File

@@ -235,6 +235,7 @@ object InAppPaymentsRepository {
*/
fun PaymentSourceType.toPaymentMethodType(): InAppPaymentData.PaymentMethodType {
return when (this) {
PaymentSourceType.GooglePlayBilling -> InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING
PaymentSourceType.PayPal -> InAppPaymentData.PaymentMethodType.PAYPAL
PaymentSourceType.Stripe.CreditCard -> InAppPaymentData.PaymentMethodType.CARD
PaymentSourceType.Stripe.GooglePay -> InAppPaymentData.PaymentMethodType.GOOGLE_PAY
@@ -255,6 +256,7 @@ object InAppPaymentsRepository {
InAppPaymentData.PaymentMethodType.IDEAL -> PaymentSourceType.Stripe.IDEAL
InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> PaymentSourceType.Stripe.SEPADebit
InAppPaymentData.PaymentMethodType.UNKNOWN -> PaymentSourceType.Unknown
InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING -> PaymentSourceType.GooglePlayBilling
}
}
@@ -571,6 +573,7 @@ object InAppPaymentsRepository {
InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> PendingOneTimeDonation.PaymentMethodType.SEPA_DEBIT
InAppPaymentData.PaymentMethodType.IDEAL -> PendingOneTimeDonation.PaymentMethodType.IDEAL
InAppPaymentData.PaymentMethodType.PAYPAL -> PendingOneTimeDonation.PaymentMethodType.PAYPAL
InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING -> error("One-time donation do not support purchase via Google Play Billing.")
},
amount = data.amount!!,
badge = data.badge!!,
@@ -661,6 +664,7 @@ object InAppPaymentsRepository {
InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> DonationProcessor.STRIPE
InAppPaymentData.PaymentMethodType.IDEAL -> DonationProcessor.STRIPE
InAppPaymentData.PaymentMethodType.PAYPAL -> DonationProcessor.PAYPAL
InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING -> error("Google Play Billing does not support donation payments.")
}
}

View File

@@ -8,17 +8,16 @@ package org.thoughtcrime.securesms.components.settings.app.subscription
import androidx.activity.result.ActivityResultLauncher
import androidx.fragment.app.Fragment
import org.signal.core.util.getSerializableCompat
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.backup.v2.ui.CreateBackupBottomSheet
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.CheckoutFlowActivity
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsCheckoutActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorAction
import org.thoughtcrime.securesms.util.BottomSheetUtil
object InAppPaymentCheckoutLauncher {
object MessageBackupsCheckoutLauncher {
fun Fragment.createBackupsCheckoutLauncher(
onCreateBackupBottomSheetResultListener: OnCreateBackupBottomSheetResultListener = {} as OnCreateBackupBottomSheetResultListener
): ActivityResultLauncher<InAppPaymentType> {
): ActivityResultLauncher<Unit> {
childFragmentManager.setFragmentResultListener(CreateBackupBottomSheet.REQUEST_KEY, viewLifecycleOwner) { requestKey, bundle ->
if (requestKey == CreateBackupBottomSheet.REQUEST_KEY) {
val result = bundle.getSerializableCompat(CreateBackupBottomSheet.REQUEST_KEY, CreateBackupBottomSheet.Result::class.java)
@@ -26,7 +25,7 @@ object InAppPaymentCheckoutLauncher {
}
}
return registerForActivityResult(CheckoutFlowActivity.Contract()) { result ->
return registerForActivityResult(MessageBackupsCheckoutActivity.Contract()) { result ->
if (result?.action == InAppPaymentProcessorAction.PROCESS_NEW_IN_APP_PAYMENT || result?.action == InAppPaymentProcessorAction.UPDATE_SUBSCRIPTION) {
CreateBackupBottomSheet().show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}

View File

@@ -37,7 +37,7 @@ class CheckoutNavHostFragment : NavHostFragment() {
InAppPaymentType.UNKNOWN -> error("Unsupported start destination")
InAppPaymentType.ONE_TIME_GIFT -> R.id.giftFlowStartFragment
InAppPaymentType.ONE_TIME_DONATION, InAppPaymentType.RECURRING_DONATION -> R.id.donateToSignalFragment
InAppPaymentType.RECURRING_BACKUP -> R.id.messageBackupsFlowFragment
InAppPaymentType.RECURRING_BACKUP -> error("Unsupported start destination")
}
)

View File

@@ -83,6 +83,7 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
state.gatewayOrderStrategy.orderedGateways.forEach { gateway ->
when (gateway) {
InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING -> error("Unsupported payment method.")
InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> renderGooglePayButton(state)
InAppPaymentData.PaymentMethodType.PAYPAL -> renderPayPalButton(state)
InAppPaymentData.PaymentMethodType.CARD -> renderCreditCardButton(state)

View File

@@ -60,6 +60,10 @@ class InAppPaymentRecurringContextJob private constructor(
)
}
/**
* Creates a job chain using data from the given InAppPayment. This object is passed by ID to the job,
* meaning the job will always load the freshest data it can about the payment.
*/
fun createJobChain(inAppPayment: InAppPaymentTable.InAppPayment, makePrimary: Boolean = false): Chain {
return AppDependencies.jobManager
.startChain(create(inAppPayment))

View File

@@ -323,12 +323,13 @@ message DonationErrorValue {
message InAppPaymentData {
enum PaymentMethodType {
UNKNOWN = 0;
GOOGLE_PAY = 1;
CARD = 2;
SEPA_DEBIT = 3;
IDEAL = 4;
PAYPAL = 5;
UNKNOWN = 0;
GOOGLE_PAY = 1;
CARD = 2;
SEPA_DEBIT = 3;
IDEAL = 4;
PAYPAL = 5;
GOOGLE_PLAY_BILLING = 6;
}
/**
@@ -375,6 +376,7 @@ message InAppPaymentData {
optional bool keepAlive = 3; // Only present for recurring donations, specifies this redemption started from a keep-alive
optional bytes receiptCredentialRequestContext = 4; // Reusable context for retrieving a presentation
optional bytes receiptCredentialPresentation = 5; // Redeemable presentation
optional string googlePlayBillingPurchaseToken = 6; // Only present for backups
}
message Error {

View File

@@ -380,33 +380,4 @@
</fragment>
<!-- endregion -->
<!-- region Backups -->
<fragment
android:id="@+id/messageBackupsFlowFragment"
android:name="org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsFlowFragment"
android:label="message_backups_flow_fragment">
<action
android:id="@+id/action_donateToSignalFragment_to_creditCardFragment"
app:destination="@id/creditCardFragment" />
<action
android:id="@+id/action_donateToSignalFragment_to_stripePaymentInProgressFragment"
app:destination="@id/stripePaymentInProgressFragment" />
<action
android:id="@+id/action_donateToSignalFragment_to_paypalPaymentInProgressFragment"
app:destination="@id/paypalPaymentInProgressFragment" />
<action
android:id="@+id/action_donateToSignalFragment_to_bankTransferMandateFragment"
app:destination="@id/bankTransferMandateFragment" />
<action
android:id="@+id/action_donateToSignalFragment_to_donationPendingBottomSheet"
app:destination="@id/donationPendingBottomSheet" />
<action
android:id="@+id/action_donateToSignalFragment_to_idealTransferDetailsFragment"
app:destination="@id/idealTransferDetailsFragment" />
</fragment>
<!-- endregion -->
</navigation>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2024 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/checkout_backups">
</navigation>

View File

@@ -7274,6 +7274,8 @@
<!-- BackupsTypeSettingsFragment -->
<!-- Displayed as the user\'s payment method as a label in a preference row -->
<string name="BackupsTypeSettingsFragment__google_play">Google Play</string>
<!-- Displayed as the user\'s payment method as a label in a preference row -->
<string name="BackupsTypeSettingsFragment__credit_or_debit_card">Credit or debit card</string>
<!-- Displayed as the user\'s payment method as a label in a preference row -->
<string name="BackupsTypeSettingsFragment__iDEAL">iDEAL</string>
@@ -7411,12 +7413,6 @@
<!-- The body of an alert dialog shown when we detect the user may be confused by the lock screen -->
<string name="PassphrasePromptActivity_help_prompt_body">Please enter your device pin, password or pattern.</string>
<!-- MessageBackupsCheckoutSheet -->
<!-- Sheet title. Placeholder is formatted price. -->
<string name="MessageBackupsCheckoutSheet__pay_s_per_month">Pay %1$s/month to Signal</string>
<!-- Sheet subtitle -->
<string name="MessageBackupsCheckoutSheet__youll_get">You\'ll get:</string>
<!-- MessageBackupsEducationScreen -->
<!-- Screen subtitle underneath large headline title -->
<string name="MessageBackupsEducationScreen__backup_your_messages_and_media">Back up your messages and media and using Signal\'s secure, end-to-end encrypted storage service. Never lose a message when you get a new phone or reinstall Signal.</string>
@@ -7431,29 +7427,35 @@
<!-- Action button label to learn more -->
<string name="MessageBackupsEducationScreen__learn_more">Learn more</string>
<!-- MessageBackupsPinConfirmationScreen -->
<!-- Screen title -->
<string name="MessageBackupsPinConfirmationScreen__enter_your_pin">Enter your PIN</string>
<!-- Screen subtitle -->
<string name="MessageBackupsPinConfirmationScreen__enter_your_signal_pin_to_enable_backups">Enter your Signal PIN to enable backups</string>
<!-- Action button to confirm pin entry and continue -->
<string name="MessageBackupsPinConfirmationScreen__next">Next</string>
<!-- Action button to switch keyboard type -->
<string name="MessageBackupsPinConfirmationScreen__switch_keyboard">Switch keyboard</string>
<!-- Action button to create a new pin if we type the incorrect one -->
<string name="MessageBackupsPinConfirmationScreen__create_new_pin">Create new pin</string>
<!-- MessageBackupsPinEducationScreen -->
<!-- MessageBackupsKeyEducationScreen -->
<!-- Screen headline -->
<string name="MessageBackupsPinEducationScreen__pins_protect_your_backup">PINs protect your backup</string>
<!-- Notice about pin length. Placeholder is recommended pin length -->
<string name="MessageBackupsPinEducationScreen__your_signal_pin_lets_you">Your Signal PIN lets you restore your backup when you re-install Signal. We recommend using a PIN that\'s at least %1$d digits.</string>
<!-- Notice about changing your pin. -->
<string name="MessageBackupsPinEducationScreen__if_you_forget_your_pin">If you forget your PIN, you will not be able to restore your backup. You can change your PIN at any time in settings.</string>
<!-- Action button label to utilize current pin -->
<string name="MessageBackupsPinEducationScreen__use_current_signal_pin">Use current Signal PIN</string>
<!-- Action button to create a new pin -->
<string name="MessageBackupsPinEducationScreen__create_new_pin">Create new PIN</string>
<string name="MessageBackupsKeyEducationScreen__your_backup_key">Your backup key</string>
<!-- Screen body part 1 -->
<string name="MessageBackupsKeyEducationScreen__your_backup_key_is_a">Your backup key is a 64-digit code that lets you restore your backup when you re-install Signal.</string>
<!-- Screen body part 2 -->
<string name="MessageBackupsKeyEducationScreen__if_you_forget_your_key">If you forget your key, you will not be able to restore your backup. Signal cannot help you recover your backup.</string>
<!-- Action button label -->
<string name="MessageBackupsKeyEducationScreen__next">Next</string>
<!-- MessageBackupsKeyRecordScreen -->
<!-- Screen headline -->
<string name="MessageBackupsKeyRecordScreen__record_your_backup_key">Record your backup key</string>
<!-- Screen subhead -->
<string name="MessageBackupsKeyRecordScreen__this_key_is_required_to_recover">This key is required to recover your account and data. Store this key somewhere safe. If you lose it, you wont be able to recover your account.</string>
<!-- Copy to clipboard button label -->
<string name="MessageBackupsKeyRecordScreen__copy_to_clipboard">Copy to clipboard</string>
<!-- Action button label -->
<string name="MessageBackupsKeyRecordScreen__next">Next</string>
<!-- Bottom sheet title -->
<string name="MessageBackupsKeyRecordScreen__keep_your_key_safe">Keep your key safe</string>
<!-- Bottom sheet subtitle -->
<string name="MessageBackupsKeyRecordScreen__signal_will_not">Signal will not be able to help you restore your backup if you lose your key. Store it somewhere safe and secure, and do not share it with others.</string>
<!-- Checkbox label -->
<string name="MessageBackupsKeyRecordScreen__ive_recorded_my_key">I\'ve recorded my key</string>
<!-- Sheet action button label -->
<string name="MessageBackupsKeyRecordScreen__continue">Continue</string>
<!-- Sheet secondary action button label -->
<string name="MessageBackupsKeyRecordScreen__see_key_again">See key again</string>
<!-- MessagesBackupsTypeSelectionScreen -->
<!-- Screen headline -->

View File

@@ -27,6 +27,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.filter
@@ -37,7 +38,12 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.billing.BillingApi
import org.signal.core.util.billing.BillingDependencies
import org.signal.core.util.billing.BillingProduct
import org.signal.core.util.billing.BillingPurchaseResult
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import java.math.BigDecimal
import java.util.Currency
/**
* BillingApi serves as the core location for interacting with the Google Billing API. Use of this API is required
@@ -56,22 +62,78 @@ internal class BillingApiImpl(
private val connectionState = MutableStateFlow<State>(State.Init)
private val coroutineScope = CoroutineScope(Dispatchers.Default)
private val internalResults = MutableSharedFlow<BillingPurchaseResult>()
private val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases ->
when {
billingResult.responseCode == BillingResponseCode.OK && purchases != null -> {
Log.d(TAG, "purchasesUpdatedListener: ${purchases.size} purchases.")
purchases.forEach {
// Handle purchases.
val result = when (billingResult.responseCode) {
BillingResponseCode.OK -> {
if (purchases == null) {
Log.d(TAG, "purchasesUpdatedListener: No purchases.")
BillingPurchaseResult.None
} else {
Log.d(TAG, "purchasesUpdatedListener: ${purchases.size} purchases.")
val newestPurchase = purchases.maxByOrNull { it.purchaseTime }
if (newestPurchase == null) {
BillingPurchaseResult.None
} else {
BillingPurchaseResult.Success(
purchaseToken = newestPurchase.purchaseToken,
isAcknowledged = newestPurchase.isAcknowledged,
purchaseTime = newestPurchase.purchaseTime
)
}
}
}
billingResult.responseCode == BillingResponseCode.USER_CANCELED -> {
// Handle user cancelled
BillingResponseCode.BILLING_UNAVAILABLE -> {
Log.d(TAG, "purchasesUpdatedListener: Billing unavailable.")
BillingPurchaseResult.BillingUnavailable
}
BillingResponseCode.USER_CANCELED -> {
Log.d(TAG, "purchasesUpdatedListener: User cancelled.")
BillingPurchaseResult.UserCancelled
}
BillingResponseCode.ERROR -> {
Log.d(TAG, "purchasesUpdatedListener: error.")
BillingPurchaseResult.GenericError
}
BillingResponseCode.NETWORK_ERROR -> {
Log.d(TAG, "purchasesUpdatedListener: Network error.")
BillingPurchaseResult.NetworkError
}
BillingResponseCode.DEVELOPER_ERROR -> {
Log.d(TAG, "purchasesUpdatedListener: Developer error.")
BillingPurchaseResult.GenericError
}
BillingResponseCode.FEATURE_NOT_SUPPORTED -> {
Log.d(TAG, "purchasesUpdatedListener: Feature not supported.")
BillingPurchaseResult.FeatureNotSupported
}
BillingResponseCode.ITEM_ALREADY_OWNED -> {
Log.d(TAG, "purchasesUpdatedListener: Already owned.")
BillingPurchaseResult.AlreadySubscribed
}
BillingResponseCode.ITEM_NOT_OWNED -> {
error("This shouldn't happen during the purchase process")
}
BillingResponseCode.ITEM_UNAVAILABLE -> {
Log.d(TAG, "purchasesUpdatedListener: Item is unavailable")
BillingPurchaseResult.TryAgainLater
}
BillingResponseCode.SERVICE_UNAVAILABLE -> {
Log.d(TAG, "purchasesUpdatedListener: Service is unavailable.")
BillingPurchaseResult.TryAgainLater
}
BillingResponseCode.SERVICE_DISCONNECTED -> {
Log.d(TAG, "purchasesUpdatedListener: Service is disconnected.")
BillingPurchaseResult.TryAgainLater
}
else -> {
Log.d(TAG, "purchasesUpdatedListener: No purchases.")
BillingPurchaseResult.None
}
}
coroutineScope.launch { internalResults.emit(result) }
}
private val billingClient: BillingClient = BillingClient.newBuilder(billingDependencies.context)
@@ -96,9 +158,24 @@ internal class BillingApiImpl(
}
}
override suspend fun queryProducts() {
override fun getBillingPurchaseResults(): Flow<BillingPurchaseResult> {
return internalResults
}
override suspend fun queryProduct(): BillingProduct? {
val products = queryProductsInternal()
Log.d(TAG, "Retrieved products with result: $products")
val details: ProductDetails? = products.productDetailsList?.firstOrNull { it.productId == billingDependencies.getProductId() }
val pricing: ProductDetails.PricingPhase? = details?.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()
if (pricing == null) {
Log.d(TAG, "No pricing available.")
return null
}
return BillingProduct(
price = FiatMoney(BigDecimal.valueOf(pricing.priceAmountMicros, 6), Currency.getInstance(pricing.priceCurrencyCode))
)
}
override suspend fun queryPurchases() {

View File

@@ -52,13 +52,13 @@ object Scaffolds {
snackbarHost = snackbarHost,
topBar = {
DefaultTopAppBar(
title,
titleContent,
scrollBehavior,
onNavigationClick,
navigationIconPainter,
navigationContentDescription,
actions
title = title,
titleContent = titleContent,
onNavigationClick = onNavigationClick,
navigationIconPainter = navigationIconPainter,
navigationContentDescription = navigationContentDescription,
actions = actions,
scrollBehavior = scrollBehavior
)
},
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
@@ -67,14 +67,14 @@ object Scaffolds {
}
@Composable
private fun DefaultTopAppBar(
fun DefaultTopAppBar(
title: String,
titleContent: @Composable (Float, String) -> Unit,
scrollBehavior: TopAppBarScrollBehavior,
onNavigationClick: () -> Unit,
navigationIconPainter: Painter,
navigationContentDescription: String?,
actions: @Composable RowScope.() -> Unit
navigationContentDescription: String? = null,
actions: @Composable RowScope.() -> Unit = {},
scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
) {
TopAppBar(
title = {

View File

@@ -6,13 +6,22 @@
package org.signal.core.util.billing
import android.app.Activity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
/**
* Variant interface for the BillingApi.
*/
interface BillingApi {
/**
* Listenable stream of billing purchase results. It's up to the user
* to call queryPurchases after subscription.
*/
fun getBillingPurchaseResults(): Flow<BillingPurchaseResult> = emptyFlow()
fun isApiAvailable(): Boolean = false
suspend fun queryProducts() = Unit
suspend fun queryProduct(): BillingProduct? = null
/**
* Queries the user's current purchases. This enqueues a check and will

View File

@@ -0,0 +1,15 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.util.billing
import org.signal.core.util.money.FiatMoney
/**
* Represents a purchasable product from the Google Play Billing API
*/
data class BillingProduct(
val price: FiatMoney
)

View File

@@ -0,0 +1,26 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.util.billing
/**
* Sealed class hierarchy representing the different success
* and error states of google play billing purchases.
*/
sealed interface BillingPurchaseResult {
data class Success(
val purchaseToken: String,
val isAcknowledged: Boolean,
val purchaseTime: Long
) : BillingPurchaseResult
data object UserCancelled : BillingPurchaseResult
data object None : BillingPurchaseResult
data object TryAgainLater : BillingPurchaseResult
data object AlreadySubscribed : BillingPurchaseResult
data object FeatureNotSupported : BillingPurchaseResult
data object GenericError : BillingPurchaseResult
data object NetworkError : BillingPurchaseResult
data object BillingUnavailable : BillingPurchaseResult
}

View File

@@ -28,7 +28,6 @@ dependencyResolutionManagement {
// Compose
library("androidx-compose-bom", "androidx.compose:compose-bom:2024.09.00")
library("androidx-compose-material3", "androidx.compose.material3", "material3").withoutVersion()
library("androidx-compose-material-navigation", "androidx.compose.material", "material-navigation").withoutVersion()
library("androidx-compose-ui-tooling-preview", "androidx.compose.ui", "ui-tooling-preview").withoutVersion()
library("androidx-compose-ui-tooling-core", "androidx.compose.ui", "ui-tooling").withoutVersion()
library("androidx-compose-ui-test-manifest", "androidx.compose.ui", "ui-test-manifest").withoutVersion()

View File

@@ -4,11 +4,15 @@ sealed class PaymentSourceType {
abstract val code: String
open val isBankTransfer: Boolean = false
object Unknown : PaymentSourceType() {
data object Unknown : PaymentSourceType() {
override val code: String = Codes.UNKNOWN.code
}
object PayPal : PaymentSourceType() {
data object GooglePlayBilling : PaymentSourceType() {
override val code: String = Codes.GOOGLE_PLAY_BILLING.code
}
data object PayPal : PaymentSourceType() {
override val code: String = Codes.PAY_PAL.code
}
@@ -20,23 +24,23 @@ sealed class PaymentSourceType {
/**
* Credit card should happen instantaneously but can take up to 1 day to process.
*/
object CreditCard : Stripe(Codes.CREDIT_CARD.code, "CARD", false)
data object CreditCard : Stripe(Codes.CREDIT_CARD.code, "CARD", false)
/**
* Google Pay should happen instantaneously but can take up to 1 day to process.
*/
object GooglePay : Stripe(Codes.GOOGLE_PAY.code, "CARD", false)
data object GooglePay : Stripe(Codes.GOOGLE_PAY.code, "CARD", false)
/**
* SEPA Debits can take up to 14 bank days to process.
*/
object SEPADebit : Stripe(Codes.SEPA_DEBIT.code, "SEPA_DEBIT", true)
data object SEPADebit : Stripe(Codes.SEPA_DEBIT.code, "SEPA_DEBIT", true)
/**
* iDEAL Bank transfers happen instantaneously for 1:1 transactions, but do not do so for subscriptions, as Stripe
* will utilize SEPA under the hood.
*/
object IDEAL : Stripe(Codes.IDEAL.code, "IDEAL", true)
data object IDEAL : Stripe(Codes.IDEAL.code, "IDEAL", true)
fun hasDeclineCodeSupport(): Boolean = !this.isBankTransfer
fun hasFailureCodeSupport(): Boolean = this.isBankTransfer
@@ -48,7 +52,8 @@ sealed class PaymentSourceType {
CREDIT_CARD("credit_card"),
GOOGLE_PAY("google_pay"),
SEPA_DEBIT("sepa_debit"),
IDEAL("ideal")
IDEAL("ideal"),
GOOGLE_PLAY_BILLING("google_play_billing")
}
companion object {
@@ -60,6 +65,7 @@ sealed class PaymentSourceType {
Codes.GOOGLE_PAY -> Stripe.GooglePay
Codes.SEPA_DEBIT -> Stripe.SEPADebit
Codes.IDEAL -> Stripe.IDEAL
Codes.GOOGLE_PLAY_BILLING -> GooglePlayBilling
}
}
}