diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9b62bf9f65..ba24593718 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1121,6 +1121,12 @@ android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize" android:exported="false"/> + + 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 ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsCheckoutActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsCheckoutActivity.kt new file mode 100644 index 0000000000..13ef1a4e52 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsCheckoutActivity.kt @@ -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() { + + 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) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsCheckoutSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsCheckoutSheet.kt deleted file mode 100644 index 14c61d12d6..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsCheckoutSheet.kt +++ /dev/null @@ -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, - 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, - 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 { - 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 = {} - ) - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsEducationScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsEducationScreen.kt index d384d92189..c8d3603fd2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsEducationScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsEducationScreen.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt index e0f888feaa..15be083462 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt @@ -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() - - @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().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() - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt index ffa0f48d4c..5b98b4b7eb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt @@ -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 = emptyList(), - val selectedPaymentMethod: InAppPaymentData.PaymentMethodType? = null, - val availablePaymentMethods: List = 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() ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt index 811e3088f7..a226777971 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt @@ -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 = internalStateFlow - val pinState: State = 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) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyEducationScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyEducationScreen.kt new file mode 100644 index 0000000000..72024c0ac7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyEducationScreen.kt @@ -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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt new file mode 100644 index 0000000000..b59ba7d878 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsKeyRecordScreen.kt @@ -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)) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsPinConfirmationScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsPinConfirmationScreen.kt deleted file mode 100644 index 108fff4a92..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsPinConfirmationScreen.kt +++ /dev/null @@ -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) - ) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsPinEducationScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsPinEducationScreen.kt deleted file mode 100644 index 09b618a7e8..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsPinEducationScreen.kt +++ /dev/null @@ -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 - ) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsScreen.kt deleted file mode 100644 index 0a8ec4cb84..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsScreen.kt +++ /dev/null @@ -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 -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsStage.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsStage.kt new file mode 100644 index 0000000000..ae64515016 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsStage.kt @@ -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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt index b1358fc888..da73ced2d1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt @@ -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 ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsFragment.kt index 80094ad07c..7e34176914 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsFragment.kt @@ -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 + private lateinit var checkoutLauncher: ActivityResultLauncher 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) } } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/RemoteBackupsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/RemoteBackupsSettingsFragment.kt index f1b0dd332e..6c3b546c6e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/RemoteBackupsSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/RemoteBackupsSettingsFragment.kt @@ -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 + private lateinit var checkoutLauncher: ActivityResultLauncher @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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/type/BackupsTypeSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/type/BackupsTypeSettingsFragment.kt index 9c00a21228..e6f5b3404f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/type/BackupsTypeSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/type/BackupsTypeSettingsFragment.kt @@ -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 + private lateinit var checkoutLauncher: ActivityResultLauncher 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/storage/UpgradeToEnableOptimizedStorageSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/storage/UpgradeToEnableOptimizedStorageSheet.kt index 6497fca202..bd2deba6d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/storage/UpgradeToEnableOptimizedStorageSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/storage/UpgradeToEnableOptimizedStorageSheet.kt @@ -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 + private lateinit var checkoutLauncher: ActivityResultLauncher 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 = { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationSerializationHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationSerializationHelper.kt index 516b228fd1..51d75c230e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationSerializationHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationSerializationHelper.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppDonations.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppDonations.kt index c1ce4e8d31..a1b6b4fe11 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppDonations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppDonations.kt @@ -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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsRepository.kt index 3050d6a353..04c722496d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsRepository.kt @@ -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.") } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentCheckoutLauncher.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/MessageBackupsCheckoutLauncher.kt similarity index 83% rename from app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentCheckoutLauncher.kt rename to app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/MessageBackupsCheckoutLauncher.kt index a0c3b3bdef..70ae6cb962 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentCheckoutLauncher.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/MessageBackupsCheckoutLauncher.kt @@ -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 { + ): ActivityResultLauncher { 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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutNavHostFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutNavHostFragment.kt index 07a9825c17..16f713a586 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutNavHostFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutNavHostFragment.kt @@ -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") } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt index a6614e38c4..ee049e361a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/gateway/GatewaySelectorBottomSheet.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt index 28b5bf81e1..4658e937bf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt @@ -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)) diff --git a/app/src/main/protowire/Database.proto b/app/src/main/protowire/Database.proto index 9394c85d5c..d4ba3bd416 100644 --- a/app/src/main/protowire/Database.proto +++ b/app/src/main/protowire/Database.proto @@ -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 { diff --git a/app/src/main/res/navigation/checkout.xml b/app/src/main/res/navigation/checkout.xml index 21001da5a4..2735b72b4c 100644 --- a/app/src/main/res/navigation/checkout.xml +++ b/app/src/main/res/navigation/checkout.xml @@ -380,33 +380,4 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/navigation/checkout_backups.xml b/app/src/main/res/navigation/checkout_backups.xml new file mode 100644 index 0000000000..08bd1d43dc --- /dev/null +++ b/app/src/main/res/navigation/checkout_backups.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ae44e89b38..dd34fc2ddb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7274,6 +7274,8 @@ + Google Play + Credit or debit card iDEAL @@ -7411,12 +7413,6 @@ Please enter your device pin, password or pattern. - - - Pay %1$s/month to Signal - - You\'ll get: - 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. @@ -7431,29 +7427,35 @@ Learn more - - - Enter your PIN - - Enter your Signal PIN to enable backups - - Next - - Switch keyboard - - Create new pin - - + - PINs protect your backup - - 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. - - If you forget your PIN, you will not be able to restore your backup. You can change your PIN at any time in settings. - - Use current Signal PIN - - Create new PIN + Your backup key + + Your backup key is a 64-digit code that lets you restore your backup when you re-install Signal. + + If you forget your key, you will not be able to restore your backup. Signal cannot help you recover your backup. + + Next + + + + Record your backup key + + This key is required to recover your account and data. Store this key somewhere safe. If you lose it, you won’t be able to recover your account. + + Copy to clipboard + + Next + + Keep your key safe + + 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. + + I\'ve recorded my key + + Continue + + See key again diff --git a/billing/src/main/java/org/signal/billing/BillingApiImpl.kt b/billing/src/main/java/org/signal/billing/BillingApiImpl.kt index 05f26e0387..b58fccbd8e 100644 --- a/billing/src/main/java/org/signal/billing/BillingApiImpl.kt +++ b/billing/src/main/java/org/signal/billing/BillingApiImpl.kt @@ -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.Init) private val coroutineScope = CoroutineScope(Dispatchers.Default) + private val internalResults = MutableSharedFlow() + 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 { + 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() { diff --git a/core-ui/src/main/java/org/signal/core/ui/Scaffolds.kt b/core-ui/src/main/java/org/signal/core/ui/Scaffolds.kt index 1baf583e57..609c688316 100644 --- a/core-ui/src/main/java/org/signal/core/ui/Scaffolds.kt +++ b/core-ui/src/main/java/org/signal/core/ui/Scaffolds.kt @@ -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 = { diff --git a/core-util/src/main/java/org/signal/core/util/billing/BillingApi.kt b/core-util/src/main/java/org/signal/core/util/billing/BillingApi.kt index 51d25f0931..d92d815af9 100644 --- a/core-util/src/main/java/org/signal/core/util/billing/BillingApi.kt +++ b/core-util/src/main/java/org/signal/core/util/billing/BillingApi.kt @@ -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 = 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 diff --git a/core-util/src/main/java/org/signal/core/util/billing/BillingProduct.kt b/core-util/src/main/java/org/signal/core/util/billing/BillingProduct.kt new file mode 100644 index 0000000000..74c3ae66bf --- /dev/null +++ b/core-util/src/main/java/org/signal/core/util/billing/BillingProduct.kt @@ -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 +) diff --git a/core-util/src/main/java/org/signal/core/util/billing/BillingPurchaseResult.kt b/core-util/src/main/java/org/signal/core/util/billing/BillingPurchaseResult.kt new file mode 100644 index 0000000000..d8bf0d2dfb --- /dev/null +++ b/core-util/src/main/java/org/signal/core/util/billing/BillingPurchaseResult.kt @@ -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 +} diff --git a/dependencies.gradle.kts b/dependencies.gradle.kts index 7ff3fd87c4..c8cfed571d 100644 --- a/dependencies.gradle.kts +++ b/dependencies.gradle.kts @@ -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() diff --git a/donations/lib/src/main/java/org/signal/donations/PaymentSourceType.kt b/donations/lib/src/main/java/org/signal/donations/PaymentSourceType.kt index 5e8072f74b..e8ba5b55b2 100644 --- a/donations/lib/src/main/java/org/signal/donations/PaymentSourceType.kt +++ b/donations/lib/src/main/java/org/signal/donations/PaymentSourceType.kt @@ -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 } } }