mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-24 04:58:45 +00:00
Start re-work of play billing checkout flow.
This commit is contained in:
committed by
Greyson Parrelli
parent
b340097f9c
commit
48bd57c56a
@@ -1121,6 +1121,12 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".backup.v2.ui.subscription.MessageBackupsCheckoutActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<service
|
||||
android:enabled="true"
|
||||
android:name=".service.webrtc.WebRtcCallService"
|
||||
|
||||
@@ -15,7 +15,6 @@ import org.signal.core.util.concurrent.LimitedWorker
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.fullWalCheckpoint
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.core.util.stream.NonClosingOutputStream
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.signal.libsignal.messagebackup.MessageBackup
|
||||
@@ -82,7 +81,6 @@ import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.time.ZonedDateTime
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
@@ -883,10 +881,9 @@ object BackupRepository {
|
||||
}
|
||||
|
||||
suspend fun getBackupsType(tier: MessageBackupTier): MessageBackupsType {
|
||||
val backupCurrency = SignalStore.inAppPayments.getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.BACKUP)
|
||||
return when (tier) {
|
||||
MessageBackupTier.FREE -> getFreeType()
|
||||
MessageBackupTier.PAID -> getPaidType(backupCurrency)
|
||||
MessageBackupTier.PAID -> getPaidType()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -898,11 +895,12 @@ object BackupRepository {
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getPaidType(currency: Currency): MessageBackupsType {
|
||||
private suspend fun getPaidType(): MessageBackupsType {
|
||||
val config = getSubscriptionsConfiguration()
|
||||
val product = AppDependencies.billingApi.queryProduct()
|
||||
|
||||
return MessageBackupsType.Paid(
|
||||
pricePerMonth = FiatMoney(config.currencies[currency.currencyCode.lowercase()]!!.backupSubscription[SubscriptionsConfiguration.BACKUPS_LEVEL]!!, currency),
|
||||
pricePerMonth = product!!.price,
|
||||
storageAllowanceBytes = config.backupConfiguration.backupLevelConfigurationMap[SubscriptionsConfiguration.BACKUPS_LEVEL]!!.storageAllowanceBytes
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.fragment.app.Fragment
|
||||
import org.signal.core.util.getParcelableExtraCompat
|
||||
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.CheckoutFlowActivity.Result
|
||||
|
||||
/**
|
||||
* Self-contained activity for message backups checkout, which utilizes Google Play Billing
|
||||
* instead of the normal donations routes.
|
||||
*/
|
||||
class MessageBackupsCheckoutActivity : FragmentWrapperActivity() {
|
||||
|
||||
companion object {
|
||||
private const val RESULT_DATA = "result_data"
|
||||
}
|
||||
|
||||
override fun getFragment(): Fragment = MessageBackupsFlowFragment()
|
||||
|
||||
class Contract : ActivityResultContract<Unit, Result?>() {
|
||||
|
||||
override fun createIntent(context: Context, input: Unit): Intent {
|
||||
return Intent(context, MessageBackupsCheckoutActivity::class.java)
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Result? {
|
||||
return intent?.getParcelableExtraCompat(RESULT_DATA, Result::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import org.signal.core.ui.BottomSheets
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.databinding.PaypalButtonBinding
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MessageBackupsCheckoutSheet(
|
||||
messageBackupsType: MessageBackupsType.Paid,
|
||||
availablePaymentMethods: List<InAppPaymentData.PaymentMethodType>,
|
||||
sheetState: SheetState,
|
||||
onDismissRequest: () -> Unit,
|
||||
onPaymentMethodSelected: (InAppPaymentData.PaymentMethodType) -> Unit
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismissRequest,
|
||||
sheetState = sheetState,
|
||||
dragHandle = { BottomSheets.Handle() },
|
||||
modifier = Modifier.padding()
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
SheetContent(
|
||||
messageBackupsType = messageBackupsType,
|
||||
availablePaymentGateways = availablePaymentMethods,
|
||||
onPaymentGatewaySelected = onPaymentMethodSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SheetContent(
|
||||
messageBackupsType: MessageBackupsType.Paid,
|
||||
availablePaymentGateways: List<InAppPaymentData.PaymentMethodType>,
|
||||
onPaymentGatewaySelected: (InAppPaymentData.PaymentMethodType) -> Unit
|
||||
) {
|
||||
val resources = LocalContext.current.resources
|
||||
val formattedPrice = remember(messageBackupsType.pricePerMonth) {
|
||||
FiatMoneyUtil.format(resources, messageBackupsType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.MessageBackupsCheckoutSheet__pay_s_per_month, formattedPrice),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(top = 48.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.MessageBackupsCheckoutSheet__youll_get),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 5.dp)
|
||||
)
|
||||
|
||||
MessageBackupsTypeBlock(
|
||||
messageBackupsType = messageBackupsType,
|
||||
isCurrent = false,
|
||||
isSelected = false,
|
||||
onSelected = {},
|
||||
enabled = false,
|
||||
modifier = Modifier.padding(top = 24.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
verticalArrangement = spacedBy(12.dp),
|
||||
modifier = Modifier.padding(top = 48.dp, bottom = 24.dp)
|
||||
) {
|
||||
availablePaymentGateways.forEach {
|
||||
when (it) {
|
||||
InAppPaymentData.PaymentMethodType.GOOGLE_PAY -> GooglePayButton {
|
||||
onPaymentGatewaySelected(InAppPaymentData.PaymentMethodType.GOOGLE_PAY)
|
||||
}
|
||||
|
||||
InAppPaymentData.PaymentMethodType.PAYPAL -> PayPalButton {
|
||||
onPaymentGatewaySelected(InAppPaymentData.PaymentMethodType.PAYPAL)
|
||||
}
|
||||
|
||||
InAppPaymentData.PaymentMethodType.CARD -> CreditOrDebitCardButton {
|
||||
onPaymentGatewaySelected(InAppPaymentData.PaymentMethodType.CARD)
|
||||
}
|
||||
|
||||
InAppPaymentData.PaymentMethodType.SEPA_DEBIT -> SepaButton {
|
||||
onPaymentGatewaySelected(InAppPaymentData.PaymentMethodType.SEPA_DEBIT)
|
||||
}
|
||||
|
||||
InAppPaymentData.PaymentMethodType.IDEAL -> IdealButton {
|
||||
onPaymentGatewaySelected(InAppPaymentData.PaymentMethodType.IDEAL)
|
||||
}
|
||||
|
||||
InAppPaymentData.PaymentMethodType.UNKNOWN -> error("Unsupported payment method type $it")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PayPalButton(
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
AndroidView(factory = {
|
||||
val view = LayoutInflater.from(it).inflate(R.layout.paypal_button, null)
|
||||
view.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
view
|
||||
}) {
|
||||
val binding = PaypalButtonBinding.bind(it)
|
||||
binding.paypalButton.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
marginStart = 0
|
||||
marginEnd = 0
|
||||
}
|
||||
|
||||
binding.paypalButton.setOnClickListener {
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GooglePayButton(
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val model = GooglePayButton.Model(onClick, true)
|
||||
|
||||
AndroidView(factory = {
|
||||
LayoutInflater.from(it).inflate(R.layout.google_pay_button_pref, null)
|
||||
}) {
|
||||
val holder = GooglePayButton.ViewHolder(it)
|
||||
holder.bind(model)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SepaButton(
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Buttons.LargeTonal(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.bank_transfer),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
|
||||
Text(text = stringResource(id = R.string.GatewaySelectorBottomSheet__bank_transfer))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IdealButton(
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Buttons.LargeTonal(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.logo_ideal),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.padding(end = 8.dp)
|
||||
)
|
||||
|
||||
Text(text = stringResource(id = R.string.GatewaySelectorBottomSheet__ideal))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreditOrDebitCardButton(
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Buttons.LargePrimary(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.credit_card),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.GatewaySelectorBottomSheet__credit_or_debit_card)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun MessageBackupsCheckoutSheetPreview() {
|
||||
val availablePaymentGateways = InAppPaymentData.PaymentMethodType.values().toList() - InAppPaymentData.PaymentMethodType.UNKNOWN
|
||||
|
||||
Previews.Preview {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
|
||||
) {
|
||||
SheetContent(
|
||||
messageBackupsType = MessageBackupsType.Paid(
|
||||
pricePerMonth = FiatMoney(BigDecimal.ZERO, Currency.getInstance("USD")),
|
||||
storageAllowanceBytes = 107374182400
|
||||
),
|
||||
availablePaymentGateways = availablePaymentGateways,
|
||||
onPaymentGatewaySelected = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -5,63 +5,36 @@
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import io.reactivex.rxjava3.processors.PublishProcessor
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentCheckoutDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorAction
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.compose.Nav
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
|
||||
/**
|
||||
* Handles the selection, payment, and changing of a user's backup tier.
|
||||
*/
|
||||
class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelegate.Callback {
|
||||
class MessageBackupsFlowFragment : ComposeFragment() {
|
||||
|
||||
private val viewModel: MessageBackupsFlowViewModel by viewModel { MessageBackupsFlowViewModel() }
|
||||
|
||||
private val inAppPaymentIdProcessor = PublishProcessor.create<InAppPaymentTable.InAppPaymentId>()
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.stateFlow.collectAsState()
|
||||
val pin by viewModel.pinState
|
||||
val navController = rememberNavController()
|
||||
|
||||
val checkoutDelegate = remember {
|
||||
InAppPaymentCheckoutDelegate(this, this, inAppPaymentIdProcessor)
|
||||
}
|
||||
|
||||
LaunchedEffect(state.inAppPayment?.id) {
|
||||
val inAppPaymentId = state.inAppPayment?.id
|
||||
if (inAppPaymentId != null) {
|
||||
inAppPaymentIdProcessor.onNext(inAppPaymentId)
|
||||
}
|
||||
}
|
||||
|
||||
val checkoutSheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
)
|
||||
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
navController.setLifecycleOwner(this@MessageBackupsFlowFragment)
|
||||
|
||||
@@ -69,7 +42,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
|
||||
lifecycleOwner,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
viewModel.goToPreviousScreen()
|
||||
viewModel.goToPreviousStage()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -79,36 +52,35 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
|
||||
navController = navController,
|
||||
startDestination = state.startScreen.name
|
||||
) {
|
||||
composable(route = MessageBackupsScreen.EDUCATION.name) {
|
||||
composable(route = MessageBackupsStage.Route.EDUCATION.name) {
|
||||
MessageBackupsEducationScreen(
|
||||
onNavigationClick = viewModel::goToPreviousScreen,
|
||||
onEnableBackups = viewModel::goToNextScreen,
|
||||
onNavigationClick = viewModel::goToPreviousStage,
|
||||
onEnableBackups = viewModel::goToNextStage,
|
||||
onLearnMore = {}
|
||||
)
|
||||
}
|
||||
|
||||
composable(route = MessageBackupsScreen.PIN_EDUCATION.name) {
|
||||
MessageBackupsPinEducationScreen(
|
||||
onNavigationClick = viewModel::goToPreviousScreen,
|
||||
onCreatePinClick = {},
|
||||
onUseCurrentPinClick = viewModel::goToNextScreen,
|
||||
recommendedPinSize = 16 // TODO [message-backups] This value should come from some kind of config
|
||||
composable(route = MessageBackupsStage.Route.BACKUP_KEY_EDUCATION.name) {
|
||||
MessageBackupsKeyEducationScreen(
|
||||
onNavigationClick = viewModel::goToPreviousStage,
|
||||
onNextClick = viewModel::goToNextStage
|
||||
)
|
||||
}
|
||||
|
||||
composable(route = MessageBackupsScreen.PIN_CONFIRMATION.name) {
|
||||
MessageBackupsPinConfirmationScreen(
|
||||
pin = pin,
|
||||
isPinIncorrect = state.displayIncorrectPinError,
|
||||
onPinChanged = viewModel::onPinEntryUpdated,
|
||||
pinKeyboardType = state.pinKeyboardType,
|
||||
onPinKeyboardTypeSelected = viewModel::onPinKeyboardTypeUpdated,
|
||||
onNextClick = viewModel::goToNextScreen,
|
||||
onCreateNewPinClick = this@MessageBackupsFlowFragment::createANewPin
|
||||
composable(route = MessageBackupsStage.Route.BACKUP_KEY_RECORD.name) {
|
||||
val context = LocalContext.current
|
||||
|
||||
MessageBackupsKeyRecordScreen(
|
||||
backupKey = state.backupKey,
|
||||
onNavigationClick = viewModel::goToPreviousStage,
|
||||
onNextClick = viewModel::goToNextStage,
|
||||
onCopyToClipboardClick = {
|
||||
Util.copyToClipboard(context, it)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
composable(route = MessageBackupsScreen.TYPE_SELECTION.name) {
|
||||
composable(route = MessageBackupsStage.Route.TYPE_SELECTION.name) {
|
||||
MessageBackupsTypeSelectionScreen(
|
||||
currentBackupTier = state.currentMessageBackupTier,
|
||||
selectedBackupTier = state.selectedMessageBackupTier,
|
||||
@@ -122,174 +94,32 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
|
||||
|
||||
viewModel.onMessageBackupTierUpdated(tier, label)
|
||||
},
|
||||
onNavigationClick = viewModel::goToPreviousScreen,
|
||||
onNavigationClick = viewModel::goToPreviousStage,
|
||||
onReadMoreClicked = {},
|
||||
onCancelSubscriptionClicked = viewModel::displayCancellationDialog,
|
||||
onNextClicked = viewModel::goToNextScreen
|
||||
onNextClicked = viewModel::goToNextStage
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.screen == MessageBackupsScreen.CHECKOUT_SHEET) {
|
||||
MessageBackupsCheckoutSheet(
|
||||
messageBackupsType = state.availableBackupTypes.filterIsInstance<MessageBackupsType.Paid>().first { it.tier == state.selectedMessageBackupTier!! },
|
||||
availablePaymentMethods = state.availablePaymentMethods,
|
||||
sheetState = checkoutSheetState,
|
||||
onDismissRequest = {
|
||||
viewModel.goToPreviousScreen()
|
||||
},
|
||||
onPaymentMethodSelected = {
|
||||
viewModel.onPaymentMethodUpdated(it)
|
||||
viewModel.goToNextScreen()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (state.screen == MessageBackupsScreen.CANCELLATION_DIALOG) {
|
||||
ConfirmBackupCancellationDialog(
|
||||
onConfirmAndDownloadNow = {
|
||||
// TODO [message-backups] Set appropriate state to handle post-cancellation action.
|
||||
viewModel.goToNextScreen()
|
||||
},
|
||||
onConfirmAndDownloadLater = {
|
||||
// TODO [message-backups] Set appropriate state to handle post-cancellation action.
|
||||
viewModel.goToNextScreen()
|
||||
},
|
||||
onKeepSubscriptionClick = viewModel::goToPreviousScreen
|
||||
)
|
||||
LaunchedEffect(state.stage) {
|
||||
val newRoute = state.stage.route.name
|
||||
val currentRoute = navController.currentDestination?.route
|
||||
if (currentRoute != newRoute) {
|
||||
if (currentRoute != null && MessageBackupsStage.Route.valueOf(currentRoute).isAfter(state.stage.route)) {
|
||||
navController.popBackStack()
|
||||
} else {
|
||||
navController.navigate(newRoute)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.screen) {
|
||||
val route = navController.currentDestination?.route ?: return@LaunchedEffect
|
||||
if (route == state.screen.name) {
|
||||
return@LaunchedEffect
|
||||
if (state.stage == MessageBackupsStage.CHECKOUT_SHEET) {
|
||||
AppDependencies.billingApi.launchBillingFlow(requireActivity())
|
||||
}
|
||||
|
||||
if (state.screen == MessageBackupsScreen.COMPLETED) {
|
||||
if (!findNavController().popBackStack()) {
|
||||
requireActivity().finishAfterTransition()
|
||||
}
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (state.screen == MessageBackupsScreen.CREATING_IN_APP_PAYMENT) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (state.screen == MessageBackupsScreen.PROCESS_PAYMENT) {
|
||||
checkoutDelegate.handleGatewaySelectionResponse(state.inAppPayment!!)
|
||||
viewModel.goToPreviousScreen()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (state.screen == MessageBackupsScreen.PROCESS_CANCELLATION) {
|
||||
cancelSubscription()
|
||||
viewModel.goToPreviousScreen()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (state.screen == MessageBackupsScreen.CHECKOUT_SHEET) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (state.screen == MessageBackupsScreen.CANCELLATION_DIALOG) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (state.screen == MessageBackupsScreen.PROCESS_FREE) {
|
||||
checkoutDelegate.setActivityResult(InAppPaymentProcessorAction.UPDATE_SUBSCRIPTION, InAppPaymentType.RECURRING_BACKUP)
|
||||
viewModel.goToNextScreen()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val routeScreen = MessageBackupsScreen.valueOf(route)
|
||||
if (routeScreen.isAfter(state.screen)) {
|
||||
navController.popBackStack()
|
||||
} else {
|
||||
navController.navigate(state.screen.name)
|
||||
if (state.stage == MessageBackupsStage.COMPLETED) {
|
||||
requireActivity().setResult(Activity.RESULT_OK)
|
||||
requireActivity().finishAfterTransition()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createANewPin() {
|
||||
viewModel.onPinEntryUpdated("")
|
||||
startActivity(CreateSvrPinActivity.getIntentForPinChangeFromSettings(requireContext()))
|
||||
}
|
||||
|
||||
private fun cancelSubscription() {
|
||||
findNavController().safeNavigate(
|
||||
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
|
||||
InAppPaymentProcessorAction.CANCEL_SUBSCRIPTION,
|
||||
null,
|
||||
InAppPaymentType.RECURRING_BACKUP
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun navigateToStripePaymentInProgress(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
findNavController().safeNavigate(
|
||||
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
|
||||
InAppPaymentProcessorAction.PROCESS_NEW_IN_APP_PAYMENT,
|
||||
inAppPayment,
|
||||
inAppPayment.type
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun navigateToPayPalPaymentInProgress(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
findNavController().safeNavigate(
|
||||
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToPaypalPaymentInProgressFragment(
|
||||
InAppPaymentProcessorAction.PROCESS_NEW_IN_APP_PAYMENT,
|
||||
inAppPayment,
|
||||
inAppPayment.type
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun navigateToCreditCardForm(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
findNavController().safeNavigate(
|
||||
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(inAppPayment)
|
||||
)
|
||||
}
|
||||
|
||||
override fun navigateToIdealDetailsFragment(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
findNavController().safeNavigate(
|
||||
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToIdealTransferDetailsFragment(inAppPayment)
|
||||
)
|
||||
}
|
||||
|
||||
override fun navigateToBankTransferMandate(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
findNavController().safeNavigate(
|
||||
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToBankTransferMandateFragment(inAppPayment)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onPaymentComplete(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
// TODO [message-backups] What do? probably some kind of success thing?
|
||||
if (!findNavController().popBackStack()) {
|
||||
requireActivity().finishAfterTransition()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSubscriptionCancelled(inAppPaymentType: InAppPaymentType) {
|
||||
viewModel.onCancellationComplete()
|
||||
|
||||
if (!findNavController().popBackStack()) {
|
||||
requireActivity().finishAfterTransition()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onProcessorActionProcessed() = Unit
|
||||
|
||||
override fun onUserLaunchedAnExternalApplication() {
|
||||
// TODO [message-backups] What do? Are we even supporting bank transfers?
|
||||
}
|
||||
|
||||
override fun navigateToDonationPending(inAppPayment: InAppPaymentTable.InAppPayment) {
|
||||
// TODO [message-backups] What do? Are we even supporting bank transfers?
|
||||
}
|
||||
|
||||
override fun exitCheckoutFlow() {
|
||||
requireActivity().finishAfterTransition()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,20 +7,16 @@ package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
|
||||
import org.whispersystems.signalservice.api.backup.BackupKey
|
||||
|
||||
data class MessageBackupsFlowState(
|
||||
val selectedMessageBackupTierLabel: String? = null,
|
||||
val selectedMessageBackupTier: MessageBackupTier? = SignalStore.backup.backupTier,
|
||||
val currentMessageBackupTier: MessageBackupTier? = SignalStore.backup.backupTier,
|
||||
val availableBackupTypes: List<MessageBackupsType> = emptyList(),
|
||||
val selectedPaymentMethod: InAppPaymentData.PaymentMethodType? = null,
|
||||
val availablePaymentMethods: List<InAppPaymentData.PaymentMethodType> = emptyList(),
|
||||
val pinKeyboardType: PinKeyboardType = SignalStore.pin.keyboardType,
|
||||
val inAppPayment: InAppPaymentTable.InAppPayment? = null,
|
||||
val startScreen: MessageBackupsScreen,
|
||||
val screen: MessageBackupsScreen = startScreen,
|
||||
val displayIncorrectPinError: Boolean = false
|
||||
val startScreen: MessageBackupsStage,
|
||||
val stage: MessageBackupsStage = startScreen,
|
||||
val backupKey: BackupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
|
||||
)
|
||||
|
||||
@@ -5,9 +5,6 @@
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.ui.subscription
|
||||
|
||||
import android.text.TextUtils
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -16,44 +13,37 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.core.util.billing.BillingPurchaseResult
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatValue
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayOrderStrategy
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
|
||||
import org.thoughtcrime.securesms.lock.v2.SvrConstants
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.whispersystems.signalservice.api.kbs.PinHashUtil.verifyLocalPinHash
|
||||
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
|
||||
import java.math.BigDecimal
|
||||
|
||||
class MessageBackupsFlowViewModel : ViewModel() {
|
||||
|
||||
private val internalStateFlow = MutableStateFlow(
|
||||
MessageBackupsFlowState(
|
||||
availableBackupTypes = emptyList(),
|
||||
selectedMessageBackupTier = SignalStore.backup.backupTier,
|
||||
availablePaymentMethods = GatewayOrderStrategy.getStrategy().orderedGateways.filter { InAppDonations.isPaymentSourceAvailable(it.toPaymentSourceType(), InAppPaymentType.RECURRING_BACKUP) },
|
||||
startScreen = if (SignalStore.backup.backupTier == null) MessageBackupsScreen.EDUCATION else MessageBackupsScreen.TYPE_SELECTION
|
||||
startScreen = if (SignalStore.backup.backupTier == null) MessageBackupsStage.EDUCATION else MessageBackupsStage.TYPE_SELECTION
|
||||
)
|
||||
)
|
||||
|
||||
private val internalPinState = mutableStateOf("")
|
||||
private var isDowngrading = false
|
||||
|
||||
val stateFlow: StateFlow<MessageBackupsFlowState> = internalStateFlow
|
||||
val pinState: State<String> = internalPinState
|
||||
|
||||
init {
|
||||
check(SignalStore.backup.backupTier != MessageBackupTier.PAID) { "This screen does not support cancellation or downgrades." }
|
||||
|
||||
viewModelScope.launch {
|
||||
internalStateFlow.update {
|
||||
it.copy(
|
||||
@@ -63,71 +53,63 @@ class MessageBackupsFlowViewModel : ViewModel() {
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun goToNextScreen() {
|
||||
val pinSnapshot = pinState.value
|
||||
viewModelScope.launch {
|
||||
AppDependencies.billingApi.getBillingPurchaseResults().collect {
|
||||
when (it) {
|
||||
is BillingPurchaseResult.Success -> {
|
||||
// 1. Copy the purchaseToken into our inAppPaymentData
|
||||
// 2. Enqueue the redemption chain
|
||||
goToNextStage()
|
||||
}
|
||||
|
||||
internalStateFlow.update {
|
||||
when (it.screen) {
|
||||
MessageBackupsScreen.EDUCATION -> it.copy(screen = MessageBackupsScreen.PIN_EDUCATION)
|
||||
MessageBackupsScreen.PIN_EDUCATION -> it.copy(screen = MessageBackupsScreen.PIN_CONFIRMATION)
|
||||
MessageBackupsScreen.PIN_CONFIRMATION -> validatePinAndUpdateState(it, pinSnapshot)
|
||||
MessageBackupsScreen.TYPE_SELECTION -> validateTypeAndUpdateState(it)
|
||||
MessageBackupsScreen.CHECKOUT_SHEET -> validateGatewayAndUpdateState(it)
|
||||
MessageBackupsScreen.CREATING_IN_APP_PAYMENT -> error("This is driven by an async coroutine.")
|
||||
MessageBackupsScreen.CANCELLATION_DIALOG -> it.copy(screen = MessageBackupsScreen.PROCESS_CANCELLATION)
|
||||
MessageBackupsScreen.PROCESS_PAYMENT -> it.copy(screen = MessageBackupsScreen.COMPLETED)
|
||||
MessageBackupsScreen.PROCESS_CANCELLATION -> it.copy(screen = MessageBackupsScreen.COMPLETED)
|
||||
MessageBackupsScreen.PROCESS_FREE -> it.copy(screen = MessageBackupsScreen.COMPLETED)
|
||||
MessageBackupsScreen.COMPLETED -> error("Unsupported state transition from terminal state COMPLETED")
|
||||
else -> goToPreviousStage()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun goToPreviousScreen() {
|
||||
/**
|
||||
* Go to the next stage of the pipeline, based off of the current stage and state data.
|
||||
*/
|
||||
fun goToNextStage() {
|
||||
internalStateFlow.update {
|
||||
if (it.screen == it.startScreen) {
|
||||
it.copy(screen = MessageBackupsScreen.COMPLETED)
|
||||
when (it.stage) {
|
||||
MessageBackupsStage.EDUCATION -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_EDUCATION)
|
||||
MessageBackupsStage.BACKUP_KEY_EDUCATION -> it.copy(stage = MessageBackupsStage.BACKUP_KEY_RECORD)
|
||||
MessageBackupsStage.BACKUP_KEY_RECORD -> it.copy(stage = MessageBackupsStage.TYPE_SELECTION)
|
||||
MessageBackupsStage.TYPE_SELECTION -> validateTypeAndUpdateState(it)
|
||||
MessageBackupsStage.CHECKOUT_SHEET -> validateGatewayAndUpdateState(it)
|
||||
MessageBackupsStage.CREATING_IN_APP_PAYMENT -> error("This is driven by an async coroutine.")
|
||||
MessageBackupsStage.PROCESS_PAYMENT -> it.copy(stage = MessageBackupsStage.COMPLETED)
|
||||
MessageBackupsStage.PROCESS_FREE -> it.copy(stage = MessageBackupsStage.COMPLETED)
|
||||
MessageBackupsStage.COMPLETED -> error("Unsupported state transition from terminal state COMPLETED")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun goToPreviousStage() {
|
||||
internalStateFlow.update {
|
||||
if (it.stage == it.startScreen) {
|
||||
it.copy(stage = MessageBackupsStage.COMPLETED)
|
||||
} else {
|
||||
val previousScreen = when (it.screen) {
|
||||
MessageBackupsScreen.EDUCATION -> MessageBackupsScreen.COMPLETED
|
||||
MessageBackupsScreen.PIN_EDUCATION -> MessageBackupsScreen.EDUCATION
|
||||
MessageBackupsScreen.PIN_CONFIRMATION -> MessageBackupsScreen.PIN_EDUCATION
|
||||
MessageBackupsScreen.TYPE_SELECTION -> MessageBackupsScreen.PIN_CONFIRMATION
|
||||
MessageBackupsScreen.CHECKOUT_SHEET -> MessageBackupsScreen.TYPE_SELECTION
|
||||
MessageBackupsScreen.CREATING_IN_APP_PAYMENT -> MessageBackupsScreen.TYPE_SELECTION
|
||||
MessageBackupsScreen.PROCESS_PAYMENT -> MessageBackupsScreen.TYPE_SELECTION
|
||||
MessageBackupsScreen.PROCESS_CANCELLATION -> MessageBackupsScreen.TYPE_SELECTION
|
||||
MessageBackupsScreen.PROCESS_FREE -> MessageBackupsScreen.TYPE_SELECTION
|
||||
MessageBackupsScreen.CANCELLATION_DIALOG -> MessageBackupsScreen.TYPE_SELECTION
|
||||
MessageBackupsScreen.COMPLETED -> error("Unsupported state transition from terminal state COMPLETED")
|
||||
val previousScreen = when (it.stage) {
|
||||
MessageBackupsStage.EDUCATION -> MessageBackupsStage.COMPLETED
|
||||
MessageBackupsStage.BACKUP_KEY_EDUCATION -> MessageBackupsStage.EDUCATION
|
||||
MessageBackupsStage.BACKUP_KEY_RECORD -> MessageBackupsStage.BACKUP_KEY_EDUCATION
|
||||
MessageBackupsStage.TYPE_SELECTION -> MessageBackupsStage.BACKUP_KEY_RECORD
|
||||
MessageBackupsStage.CHECKOUT_SHEET -> MessageBackupsStage.TYPE_SELECTION
|
||||
MessageBackupsStage.CREATING_IN_APP_PAYMENT -> MessageBackupsStage.CREATING_IN_APP_PAYMENT
|
||||
MessageBackupsStage.PROCESS_PAYMENT -> MessageBackupsStage.PROCESS_PAYMENT
|
||||
MessageBackupsStage.PROCESS_FREE -> MessageBackupsStage.PROCESS_FREE
|
||||
MessageBackupsStage.COMPLETED -> error("Unsupported state transition from terminal state COMPLETED")
|
||||
}
|
||||
|
||||
it.copy(screen = previousScreen)
|
||||
it.copy(stage = previousScreen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun displayCancellationDialog() {
|
||||
internalStateFlow.update {
|
||||
check(it.screen == MessageBackupsScreen.TYPE_SELECTION)
|
||||
it.copy(screen = MessageBackupsScreen.CANCELLATION_DIALOG)
|
||||
}
|
||||
}
|
||||
|
||||
fun onPinEntryUpdated(pin: String) {
|
||||
internalPinState.value = pin
|
||||
}
|
||||
|
||||
fun onPinKeyboardTypeUpdated(pinKeyboardType: PinKeyboardType) {
|
||||
internalStateFlow.update { it.copy(pinKeyboardType = pinKeyboardType) }
|
||||
}
|
||||
|
||||
fun onPaymentMethodUpdated(paymentMethod: InAppPaymentData.PaymentMethodType) {
|
||||
internalStateFlow.update { it.copy(selectedPaymentMethod = paymentMethod) }
|
||||
}
|
||||
|
||||
fun onMessageBackupTierUpdated(messageBackupTier: MessageBackupTier, messageBackupTierLabel: String) {
|
||||
internalStateFlow.update {
|
||||
it.copy(
|
||||
@@ -137,53 +119,16 @@ class MessageBackupsFlowViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun onCancellationComplete() {
|
||||
if (isDowngrading) {
|
||||
SignalStore.backup.areBackupsEnabled = true
|
||||
SignalStore.backup.backupTier = MessageBackupTier.FREE
|
||||
|
||||
// TODO [message-backups] -- Trigger backup now?
|
||||
}
|
||||
}
|
||||
|
||||
private fun validatePinAndUpdateState(state: MessageBackupsFlowState, pin: String): MessageBackupsFlowState {
|
||||
val pinHash = SignalStore.svr.localPinHash
|
||||
|
||||
if (pinHash == null || TextUtils.isEmpty(pin) || pin.length < SvrConstants.MINIMUM_PIN_LENGTH) {
|
||||
return state.copy(
|
||||
screen = MessageBackupsScreen.PIN_CONFIRMATION,
|
||||
displayIncorrectPinError = true
|
||||
)
|
||||
}
|
||||
|
||||
if (!verifyLocalPinHash(pinHash, pin)) {
|
||||
return state.copy(
|
||||
screen = MessageBackupsScreen.PIN_CONFIRMATION,
|
||||
displayIncorrectPinError = true
|
||||
)
|
||||
}
|
||||
|
||||
internalPinState.value = ""
|
||||
return state.copy(
|
||||
screen = MessageBackupsScreen.TYPE_SELECTION,
|
||||
displayIncorrectPinError = false
|
||||
)
|
||||
}
|
||||
|
||||
private fun validateTypeAndUpdateState(state: MessageBackupsFlowState): MessageBackupsFlowState {
|
||||
return when (state.selectedMessageBackupTier!!) {
|
||||
MessageBackupTier.FREE -> {
|
||||
if (SignalStore.backup.backupTier == MessageBackupTier.PAID) {
|
||||
isDowngrading = true
|
||||
state.copy(screen = MessageBackupsScreen.PROCESS_CANCELLATION)
|
||||
} else {
|
||||
SignalStore.backup.areBackupsEnabled = true
|
||||
SignalStore.backup.backupTier = MessageBackupTier.FREE
|
||||
SignalStore.backup.areBackupsEnabled = true
|
||||
SignalStore.backup.backupTier = MessageBackupTier.FREE
|
||||
|
||||
state.copy(screen = MessageBackupsScreen.PROCESS_FREE)
|
||||
}
|
||||
state.copy(stage = MessageBackupsStage.PROCESS_FREE)
|
||||
}
|
||||
MessageBackupTier.PAID -> state.copy(screen = MessageBackupsScreen.CHECKOUT_SHEET)
|
||||
|
||||
MessageBackupTier.PAID -> state.copy(stage = MessageBackupsStage.CHECKOUT_SHEET)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +140,7 @@ class MessageBackupsFlowViewModel : ViewModel() {
|
||||
internalStateFlow.update { it.copy(inAppPayment = null) }
|
||||
}
|
||||
|
||||
val currency = SignalStore.inAppPayments.getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.BACKUP)
|
||||
val paidFiat = AppDependencies.billingApi.queryProduct()!!.price
|
||||
|
||||
SignalDatabase.inAppPayments.clearCreated()
|
||||
val id = SignalDatabase.inAppPayments.insert(
|
||||
@@ -206,10 +151,10 @@ class MessageBackupsFlowViewModel : ViewModel() {
|
||||
inAppPaymentData = InAppPaymentData(
|
||||
badge = null,
|
||||
label = state.selectedMessageBackupTierLabel!!,
|
||||
amount = if (backupsType is MessageBackupsType.Paid) backupsType.pricePerMonth.toFiatValue() else FiatMoney(BigDecimal.ZERO, currency).toFiatValue(),
|
||||
amount = if (backupsType is MessageBackupsType.Paid) paidFiat.toFiatValue() else FiatMoney(BigDecimal.ZERO, paidFiat.currency).toFiatValue(),
|
||||
level = SubscriptionsConfiguration.BACKUPS_LEVEL.toLong(),
|
||||
recipientId = Recipient.self().id.serialize(),
|
||||
paymentMethodType = state.selectedPaymentMethod!!,
|
||||
paymentMethodType = InAppPaymentData.PaymentMethodType.GOOGLE_PLAY_BILLING,
|
||||
redemption = InAppPaymentData.RedemptionState(
|
||||
stage = InAppPaymentData.RedemptionState.Stage.INIT
|
||||
)
|
||||
@@ -219,10 +164,10 @@ class MessageBackupsFlowViewModel : ViewModel() {
|
||||
val inAppPayment = SignalDatabase.inAppPayments.getById(id)!!
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
internalStateFlow.update { it.copy(inAppPayment = inAppPayment, screen = MessageBackupsScreen.PROCESS_PAYMENT) }
|
||||
internalStateFlow.update { it.copy(inAppPayment = inAppPayment, stage = MessageBackupsStage.PROCESS_PAYMENT) }
|
||||
}
|
||||
}
|
||||
|
||||
return state.copy(screen = MessageBackupsScreen.CREATING_IN_APP_PAYMENT)
|
||||
return state.copy(stage = MessageBackupsStage.CREATING_IN_APP_PAYMENT)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,12 +4,11 @@ import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.Navigation
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentCheckoutLauncher.createBackupsCheckoutLauncher
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBackupsCheckoutLauncher.createBackupsCheckoutLauncher
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
@@ -18,7 +17,7 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
class ChatsSettingsFragment : DSLSettingsFragment(R.string.preferences_chats__chats) {
|
||||
|
||||
private lateinit var viewModel: ChatsSettingsViewModel
|
||||
private lateinit var checkoutLauncher: ActivityResultLauncher<InAppPaymentType>
|
||||
private lateinit var checkoutLauncher: ActivityResultLauncher<Unit>
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
@@ -99,7 +98,7 @@ class ChatsSettingsFragment : DSLSettingsFragment(R.string.preferences_chats__ch
|
||||
if (state.canAccessRemoteBackupsSettings) {
|
||||
Navigation.findNavController(requireView()).safeNavigate(R.id.action_chatsSettingsFragment_to_remoteBackupsSettingsFragment)
|
||||
} else {
|
||||
checkoutLauncher.launch(InAppPaymentType.RECURRING_BACKUP)
|
||||
checkoutLauncher.launch(Unit)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -62,13 +62,12 @@ import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.ui.Snackbars
|
||||
import org.signal.core.ui.Texts
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupFrequency
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupV2Event
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.backups.type.BackupsTypeSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentCheckoutLauncher.createBackupsCheckoutLauncher
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBackupsCheckoutLauncher.createBackupsCheckoutLauncher
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
@@ -92,7 +91,7 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
|
||||
|
||||
private val args: RemoteBackupsSettingsFragmentArgs by navArgs()
|
||||
|
||||
private lateinit var checkoutLauncher: ActivityResultLauncher<InAppPaymentType>
|
||||
private lateinit var checkoutLauncher: ActivityResultLauncher<Unit>
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
@@ -119,7 +118,7 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
|
||||
}
|
||||
|
||||
override fun onEnableBackupsClick() {
|
||||
checkoutLauncher.launch(InAppPaymentType.RECURRING_BACKUP)
|
||||
checkoutLauncher.launch(Unit)
|
||||
}
|
||||
|
||||
override fun onBackUpUsingCellularClick(canUseCellular: Boolean) {
|
||||
|
||||
@@ -30,11 +30,10 @@ import org.signal.core.ui.Rows
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentCheckoutLauncher.createBackupsCheckoutLauncher
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBackupsCheckoutLauncher.createBackupsCheckoutLauncher
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -58,7 +57,7 @@ class BackupsTypeSettingsFragment : ComposeFragment() {
|
||||
BackupsTypeSettingsViewModel()
|
||||
}
|
||||
|
||||
private lateinit var checkoutLauncher: ActivityResultLauncher<InAppPaymentType>
|
||||
private lateinit var checkoutLauncher: ActivityResultLauncher<Unit>
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
@@ -92,7 +91,7 @@ class BackupsTypeSettingsFragment : ComposeFragment() {
|
||||
}
|
||||
|
||||
override fun onChangeOrCancelSubscriptionClick() {
|
||||
checkoutLauncher.launch(InAppPaymentType.RECURRING_BACKUP)
|
||||
checkoutLauncher.launch(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +194,7 @@ private fun BackupsTypeRow(
|
||||
private fun PaymentSourceRow(paymentSourceType: PaymentSourceType) {
|
||||
val paymentSourceTextResId = remember(paymentSourceType) {
|
||||
when (paymentSourceType) {
|
||||
is PaymentSourceType.GooglePlayBilling -> R.string.BackupsTypeSettingsFragment__google_play
|
||||
is PaymentSourceType.Stripe.CreditCard -> R.string.BackupsTypeSettingsFragment__credit_or_debit_card
|
||||
is PaymentSourceType.Stripe.IDEAL -> R.string.BackupsTypeSettingsFragment__iDEAL
|
||||
is PaymentSourceType.Stripe.GooglePay -> R.string.BackupsTypeSettingsFragment__google_pay
|
||||
|
||||
@@ -32,13 +32,12 @@ import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Icons
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.BackupsIconColors
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeBlock
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.testBackupTypes
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentCheckoutLauncher.createBackupsCheckoutLauncher
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBackupsCheckoutLauncher.createBackupsCheckoutLauncher
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
|
||||
/**
|
||||
@@ -50,7 +49,7 @@ class UpgradeToEnableOptimizedStorageSheet : ComposeBottomSheetDialogFragment()
|
||||
|
||||
private val viewModel: UpgradeToEnableOptimizedStorageViewModel by viewModels()
|
||||
|
||||
private lateinit var checkoutLauncher: ActivityResultLauncher<InAppPaymentType>
|
||||
private lateinit var checkoutLauncher: ActivityResultLauncher<Unit>
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
@@ -63,7 +62,7 @@ class UpgradeToEnableOptimizedStorageSheet : ComposeBottomSheetDialogFragment()
|
||||
UpgradeToEnableOptimizedStorageSheetContent(
|
||||
messageBackupsType = type,
|
||||
onUpgradeNowClick = {
|
||||
checkoutLauncher.launch(InAppPaymentType.RECURRING_BACKUP)
|
||||
checkoutLauncher.launch(Unit)
|
||||
dismissAllowingStateLoss()
|
||||
},
|
||||
onCancelClick = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,17 +8,16 @@ package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.fragment.app.Fragment
|
||||
import org.signal.core.util.getSerializableCompat
|
||||
import org.signal.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.CreateBackupBottomSheet
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.CheckoutFlowActivity
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsCheckoutActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorAction
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
|
||||
object InAppPaymentCheckoutLauncher {
|
||||
object MessageBackupsCheckoutLauncher {
|
||||
|
||||
fun Fragment.createBackupsCheckoutLauncher(
|
||||
onCreateBackupBottomSheetResultListener: OnCreateBackupBottomSheetResultListener = {} as OnCreateBackupBottomSheetResultListener
|
||||
): ActivityResultLauncher<InAppPaymentType> {
|
||||
): ActivityResultLauncher<Unit> {
|
||||
childFragmentManager.setFragmentResultListener(CreateBackupBottomSheet.REQUEST_KEY, viewLifecycleOwner) { requestKey, bundle ->
|
||||
if (requestKey == CreateBackupBottomSheet.REQUEST_KEY) {
|
||||
val result = bundle.getSerializableCompat(CreateBackupBottomSheet.REQUEST_KEY, CreateBackupBottomSheet.Result::class.java)
|
||||
@@ -26,7 +25,7 @@ object InAppPaymentCheckoutLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
return registerForActivityResult(CheckoutFlowActivity.Contract()) { result ->
|
||||
return registerForActivityResult(MessageBackupsCheckoutActivity.Contract()) { result ->
|
||||
if (result?.action == InAppPaymentProcessorAction.PROCESS_NEW_IN_APP_PAYMENT || result?.action == InAppPaymentProcessorAction.UPDATE_SUBSCRIPTION) {
|
||||
CreateBackupBottomSheet().show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -380,33 +380,4 @@
|
||||
</fragment>
|
||||
|
||||
<!-- endregion -->
|
||||
|
||||
<!-- region Backups -->
|
||||
|
||||
<fragment
|
||||
android:id="@+id/messageBackupsFlowFragment"
|
||||
android:name="org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsFlowFragment"
|
||||
android:label="message_backups_flow_fragment">
|
||||
|
||||
<action
|
||||
android:id="@+id/action_donateToSignalFragment_to_creditCardFragment"
|
||||
app:destination="@id/creditCardFragment" />
|
||||
<action
|
||||
android:id="@+id/action_donateToSignalFragment_to_stripePaymentInProgressFragment"
|
||||
app:destination="@id/stripePaymentInProgressFragment" />
|
||||
<action
|
||||
android:id="@+id/action_donateToSignalFragment_to_paypalPaymentInProgressFragment"
|
||||
app:destination="@id/paypalPaymentInProgressFragment" />
|
||||
<action
|
||||
android:id="@+id/action_donateToSignalFragment_to_bankTransferMandateFragment"
|
||||
app:destination="@id/bankTransferMandateFragment" />
|
||||
<action
|
||||
android:id="@+id/action_donateToSignalFragment_to_donationPendingBottomSheet"
|
||||
app:destination="@id/donationPendingBottomSheet" />
|
||||
<action
|
||||
android:id="@+id/action_donateToSignalFragment_to_idealTransferDetailsFragment"
|
||||
app:destination="@id/idealTransferDetailsFragment" />
|
||||
</fragment>
|
||||
|
||||
<!-- endregion -->
|
||||
</navigation>
|
||||
10
app/src/main/res/navigation/checkout_backups.xml
Normal file
10
app/src/main/res/navigation/checkout_backups.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ Copyright 2024 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/checkout_backups">
|
||||
|
||||
</navigation>
|
||||
@@ -7274,6 +7274,8 @@
|
||||
|
||||
<!-- BackupsTypeSettingsFragment -->
|
||||
<!-- Displayed as the user\'s payment method as a label in a preference row -->
|
||||
<string name="BackupsTypeSettingsFragment__google_play">Google Play</string>
|
||||
<!-- Displayed as the user\'s payment method as a label in a preference row -->
|
||||
<string name="BackupsTypeSettingsFragment__credit_or_debit_card">Credit or debit card</string>
|
||||
<!-- Displayed as the user\'s payment method as a label in a preference row -->
|
||||
<string name="BackupsTypeSettingsFragment__iDEAL">iDEAL</string>
|
||||
@@ -7411,12 +7413,6 @@
|
||||
<!-- The body of an alert dialog shown when we detect the user may be confused by the lock screen -->
|
||||
<string name="PassphrasePromptActivity_help_prompt_body">Please enter your device pin, password or pattern.</string>
|
||||
|
||||
<!-- MessageBackupsCheckoutSheet -->
|
||||
<!-- Sheet title. Placeholder is formatted price. -->
|
||||
<string name="MessageBackupsCheckoutSheet__pay_s_per_month">Pay %1$s/month to Signal</string>
|
||||
<!-- Sheet subtitle -->
|
||||
<string name="MessageBackupsCheckoutSheet__youll_get">You\'ll get:</string>
|
||||
|
||||
<!-- MessageBackupsEducationScreen -->
|
||||
<!-- Screen subtitle underneath large headline title -->
|
||||
<string name="MessageBackupsEducationScreen__backup_your_messages_and_media">Back up your messages and media and using Signal\'s secure, end-to-end encrypted storage service. Never lose a message when you get a new phone or reinstall Signal.</string>
|
||||
@@ -7431,29 +7427,35 @@
|
||||
<!-- Action button label to learn more -->
|
||||
<string name="MessageBackupsEducationScreen__learn_more">Learn more</string>
|
||||
|
||||
<!-- MessageBackupsPinConfirmationScreen -->
|
||||
<!-- Screen title -->
|
||||
<string name="MessageBackupsPinConfirmationScreen__enter_your_pin">Enter your PIN</string>
|
||||
<!-- Screen subtitle -->
|
||||
<string name="MessageBackupsPinConfirmationScreen__enter_your_signal_pin_to_enable_backups">Enter your Signal PIN to enable backups</string>
|
||||
<!-- Action button to confirm pin entry and continue -->
|
||||
<string name="MessageBackupsPinConfirmationScreen__next">Next</string>
|
||||
<!-- Action button to switch keyboard type -->
|
||||
<string name="MessageBackupsPinConfirmationScreen__switch_keyboard">Switch keyboard</string>
|
||||
<!-- Action button to create a new pin if we type the incorrect one -->
|
||||
<string name="MessageBackupsPinConfirmationScreen__create_new_pin">Create new pin</string>
|
||||
|
||||
<!-- MessageBackupsPinEducationScreen -->
|
||||
<!-- MessageBackupsKeyEducationScreen -->
|
||||
<!-- Screen headline -->
|
||||
<string name="MessageBackupsPinEducationScreen__pins_protect_your_backup">PINs protect your backup</string>
|
||||
<!-- Notice about pin length. Placeholder is recommended pin length -->
|
||||
<string name="MessageBackupsPinEducationScreen__your_signal_pin_lets_you">Your Signal PIN lets you restore your backup when you re-install Signal. We recommend using a PIN that\'s at least %1$d digits.</string>
|
||||
<!-- Notice about changing your pin. -->
|
||||
<string name="MessageBackupsPinEducationScreen__if_you_forget_your_pin">If you forget your PIN, you will not be able to restore your backup. You can change your PIN at any time in settings.</string>
|
||||
<!-- Action button label to utilize current pin -->
|
||||
<string name="MessageBackupsPinEducationScreen__use_current_signal_pin">Use current Signal PIN</string>
|
||||
<!-- Action button to create a new pin -->
|
||||
<string name="MessageBackupsPinEducationScreen__create_new_pin">Create new PIN</string>
|
||||
<string name="MessageBackupsKeyEducationScreen__your_backup_key">Your backup key</string>
|
||||
<!-- Screen body part 1 -->
|
||||
<string name="MessageBackupsKeyEducationScreen__your_backup_key_is_a">Your backup key is a 64-digit code that lets you restore your backup when you re-install Signal.</string>
|
||||
<!-- Screen body part 2 -->
|
||||
<string name="MessageBackupsKeyEducationScreen__if_you_forget_your_key">If you forget your key, you will not be able to restore your backup. Signal cannot help you recover your backup.</string>
|
||||
<!-- Action button label -->
|
||||
<string name="MessageBackupsKeyEducationScreen__next">Next</string>
|
||||
|
||||
<!-- MessageBackupsKeyRecordScreen -->
|
||||
<!-- Screen headline -->
|
||||
<string name="MessageBackupsKeyRecordScreen__record_your_backup_key">Record your backup key</string>
|
||||
<!-- Screen subhead -->
|
||||
<string name="MessageBackupsKeyRecordScreen__this_key_is_required_to_recover">This key is required to recover your account and data. Store this key somewhere safe. If you lose it, you won’t be able to recover your account.</string>
|
||||
<!-- Copy to clipboard button label -->
|
||||
<string name="MessageBackupsKeyRecordScreen__copy_to_clipboard">Copy to clipboard</string>
|
||||
<!-- Action button label -->
|
||||
<string name="MessageBackupsKeyRecordScreen__next">Next</string>
|
||||
<!-- Bottom sheet title -->
|
||||
<string name="MessageBackupsKeyRecordScreen__keep_your_key_safe">Keep your key safe</string>
|
||||
<!-- Bottom sheet subtitle -->
|
||||
<string name="MessageBackupsKeyRecordScreen__signal_will_not">Signal will not be able to help you restore your backup if you lose your key. Store it somewhere safe and secure, and do not share it with others.</string>
|
||||
<!-- Checkbox label -->
|
||||
<string name="MessageBackupsKeyRecordScreen__ive_recorded_my_key">I\'ve recorded my key</string>
|
||||
<!-- Sheet action button label -->
|
||||
<string name="MessageBackupsKeyRecordScreen__continue">Continue</string>
|
||||
<!-- Sheet secondary action button label -->
|
||||
<string name="MessageBackupsKeyRecordScreen__see_key_again">See key again</string>
|
||||
|
||||
<!-- MessagesBackupsTypeSelectionScreen -->
|
||||
<!-- Screen headline -->
|
||||
|
||||
@@ -27,6 +27,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
@@ -37,7 +38,12 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.core.util.billing.BillingApi
|
||||
import org.signal.core.util.billing.BillingDependencies
|
||||
import org.signal.core.util.billing.BillingProduct
|
||||
import org.signal.core.util.billing.BillingPurchaseResult
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
|
||||
/**
|
||||
* BillingApi serves as the core location for interacting with the Google Billing API. Use of this API is required
|
||||
@@ -56,22 +62,78 @@ internal class BillingApiImpl(
|
||||
private val connectionState = MutableStateFlow<State>(State.Init)
|
||||
private val coroutineScope = CoroutineScope(Dispatchers.Default)
|
||||
|
||||
private val internalResults = MutableSharedFlow<BillingPurchaseResult>()
|
||||
|
||||
private val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases ->
|
||||
when {
|
||||
billingResult.responseCode == BillingResponseCode.OK && purchases != null -> {
|
||||
Log.d(TAG, "purchasesUpdatedListener: ${purchases.size} purchases.")
|
||||
purchases.forEach {
|
||||
// Handle purchases.
|
||||
val result = when (billingResult.responseCode) {
|
||||
BillingResponseCode.OK -> {
|
||||
if (purchases == null) {
|
||||
Log.d(TAG, "purchasesUpdatedListener: No purchases.")
|
||||
BillingPurchaseResult.None
|
||||
} else {
|
||||
Log.d(TAG, "purchasesUpdatedListener: ${purchases.size} purchases.")
|
||||
val newestPurchase = purchases.maxByOrNull { it.purchaseTime }
|
||||
if (newestPurchase == null) {
|
||||
BillingPurchaseResult.None
|
||||
} else {
|
||||
BillingPurchaseResult.Success(
|
||||
purchaseToken = newestPurchase.purchaseToken,
|
||||
isAcknowledged = newestPurchase.isAcknowledged,
|
||||
purchaseTime = newestPurchase.purchaseTime
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
billingResult.responseCode == BillingResponseCode.USER_CANCELED -> {
|
||||
// Handle user cancelled
|
||||
BillingResponseCode.BILLING_UNAVAILABLE -> {
|
||||
Log.d(TAG, "purchasesUpdatedListener: Billing unavailable.")
|
||||
BillingPurchaseResult.BillingUnavailable
|
||||
}
|
||||
BillingResponseCode.USER_CANCELED -> {
|
||||
Log.d(TAG, "purchasesUpdatedListener: User cancelled.")
|
||||
BillingPurchaseResult.UserCancelled
|
||||
}
|
||||
BillingResponseCode.ERROR -> {
|
||||
Log.d(TAG, "purchasesUpdatedListener: error.")
|
||||
BillingPurchaseResult.GenericError
|
||||
}
|
||||
BillingResponseCode.NETWORK_ERROR -> {
|
||||
Log.d(TAG, "purchasesUpdatedListener: Network error.")
|
||||
BillingPurchaseResult.NetworkError
|
||||
}
|
||||
BillingResponseCode.DEVELOPER_ERROR -> {
|
||||
Log.d(TAG, "purchasesUpdatedListener: Developer error.")
|
||||
BillingPurchaseResult.GenericError
|
||||
}
|
||||
BillingResponseCode.FEATURE_NOT_SUPPORTED -> {
|
||||
Log.d(TAG, "purchasesUpdatedListener: Feature not supported.")
|
||||
BillingPurchaseResult.FeatureNotSupported
|
||||
}
|
||||
BillingResponseCode.ITEM_ALREADY_OWNED -> {
|
||||
Log.d(TAG, "purchasesUpdatedListener: Already owned.")
|
||||
BillingPurchaseResult.AlreadySubscribed
|
||||
}
|
||||
BillingResponseCode.ITEM_NOT_OWNED -> {
|
||||
error("This shouldn't happen during the purchase process")
|
||||
}
|
||||
BillingResponseCode.ITEM_UNAVAILABLE -> {
|
||||
Log.d(TAG, "purchasesUpdatedListener: Item is unavailable")
|
||||
BillingPurchaseResult.TryAgainLater
|
||||
}
|
||||
BillingResponseCode.SERVICE_UNAVAILABLE -> {
|
||||
Log.d(TAG, "purchasesUpdatedListener: Service is unavailable.")
|
||||
BillingPurchaseResult.TryAgainLater
|
||||
}
|
||||
BillingResponseCode.SERVICE_DISCONNECTED -> {
|
||||
Log.d(TAG, "purchasesUpdatedListener: Service is disconnected.")
|
||||
BillingPurchaseResult.TryAgainLater
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "purchasesUpdatedListener: No purchases.")
|
||||
BillingPurchaseResult.None
|
||||
}
|
||||
}
|
||||
|
||||
coroutineScope.launch { internalResults.emit(result) }
|
||||
}
|
||||
|
||||
private val billingClient: BillingClient = BillingClient.newBuilder(billingDependencies.context)
|
||||
@@ -96,9 +158,24 @@ internal class BillingApiImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun queryProducts() {
|
||||
override fun getBillingPurchaseResults(): Flow<BillingPurchaseResult> {
|
||||
return internalResults
|
||||
}
|
||||
|
||||
override suspend fun queryProduct(): BillingProduct? {
|
||||
val products = queryProductsInternal()
|
||||
Log.d(TAG, "Retrieved products with result: $products")
|
||||
|
||||
val details: ProductDetails? = products.productDetailsList?.firstOrNull { it.productId == billingDependencies.getProductId() }
|
||||
val pricing: ProductDetails.PricingPhase? = details?.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()
|
||||
|
||||
if (pricing == null) {
|
||||
Log.d(TAG, "No pricing available.")
|
||||
return null
|
||||
}
|
||||
|
||||
return BillingProduct(
|
||||
price = FiatMoney(BigDecimal.valueOf(pricing.priceAmountMicros, 6), Currency.getInstance(pricing.priceCurrencyCode))
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun queryPurchases() {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -6,13 +6,22 @@
|
||||
package org.signal.core.util.billing
|
||||
|
||||
import android.app.Activity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
|
||||
/**
|
||||
* Variant interface for the BillingApi.
|
||||
*/
|
||||
interface BillingApi {
|
||||
/**
|
||||
* Listenable stream of billing purchase results. It's up to the user
|
||||
* to call queryPurchases after subscription.
|
||||
*/
|
||||
fun getBillingPurchaseResults(): Flow<BillingPurchaseResult> = emptyFlow()
|
||||
|
||||
fun isApiAvailable(): Boolean = false
|
||||
suspend fun queryProducts() = Unit
|
||||
|
||||
suspend fun queryProduct(): BillingProduct? = null
|
||||
|
||||
/**
|
||||
* Queries the user's current purchases. This enqueues a check and will
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user