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
}
}
}