Always display paid tier but stick a dialog in front of it for non-GPS devices.

This commit is contained in:
Alex Hart
2025-09-10 15:12:35 -03:00
committed by GitHub
parent ea772cbf55
commit eeb8164c18
10 changed files with 200 additions and 74 deletions

View File

@@ -1820,7 +1820,7 @@ object BackupRepository {
}
}
suspend fun getAvailableBackupsTypes(availableBackupTiers: List<MessageBackupTier>): List<MessageBackupsType> {
suspend fun getBackupTypes(availableBackupTiers: List<MessageBackupTier>): List<MessageBackupsType> {
return availableBackupTiers.mapNotNull {
val type = getBackupsType(it)
@@ -1868,9 +1868,20 @@ object BackupRepository {
RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP).getOrNull()?.activeSubscription?.let {
FiatMoney.fromSignalNetworkAmount(it.amount, Currency.getInstance(it.currency))
}
} else {
} else if (AppDependencies.billingApi.isApiAvailable()) {
Log.d(TAG, "Accessing price via billing api.")
AppDependencies.billingApi.queryProduct()?.price
} else {
Log.d(TAG, "Billing API is not available on this device. Accessing price via subscription configuration.")
val configurationResult = AppDependencies.donationsService.getDonationsConfiguration(Locale.getDefault()).toNetworkResult()
val currency = Currency.getInstance(Locale.getDefault())
when (configurationResult) {
is NetworkResult.Success -> configurationResult.result.currencies[currency.currencyCode.lowercase()]?.backupSubscription[SubscriptionsConfiguration.BACKUPS_LEVEL]?.let {
FiatMoney(it, currency)
}
else -> null
}
}
if (productPrice == null) {

View File

@@ -170,7 +170,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
stage = state.stage,
currentBackupTier = state.currentMessageBackupTier,
selectedBackupTier = state.selectedMessageBackupTier,
availableBackupTypes = state.availableBackupTypes,
allBackupTypes = state.allBackupTypes,
isNextEnabled = state.isCheckoutButtonEnabled(),
onMessageBackupsTierSelected = viewModel::onMessageBackupTierUpdated,
onNavigationClick = viewModel::goToPreviousStage,
@@ -180,7 +180,14 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
getString(R.string.backup_support_url)
)
},
onNextClicked = viewModel::goToNextStage
onNextClicked = viewModel::goToNextStage,
isBillingApiAvailable = state.isBillingApiAvailable,
onLearnMoreAboutWhyUserCanNotUpgrade = {
CommunicationActions.openBrowserLink(
requireContext(),
getString(R.string.backup_support_url)
)
}
)
}
}

View File

@@ -16,7 +16,8 @@ import org.whispersystems.signalservice.api.AccountEntropyPool
data class MessageBackupsFlowState(
val selectedMessageBackupTier: MessageBackupTier? = SignalStore.backup.backupTier,
val currentMessageBackupTier: MessageBackupTier? = null,
val availableBackupTypes: List<MessageBackupsType> = emptyList(),
val allBackupTypes: List<MessageBackupsType> = emptyList(),
val isBillingApiAvailable: Boolean = false,
val inAppPayment: InAppPaymentTable.InAppPayment? = null,
val startScreen: MessageBackupsStage,
val stage: MessageBackupsStage = startScreen,
@@ -35,7 +36,7 @@ data class MessageBackupsFlowState(
* Whether or not the 'next' button on the type selection screen is enabled.
*/
fun isCheckoutButtonEnabled(): Boolean {
return selectedMessageBackupTier in availableBackupTypes.map { it.tier } &&
return selectedMessageBackupTier in allBackupTypes.map { it.tier } &&
selectedMessageBackupTier != currentMessageBackupTier &&
paymentReadyState == PaymentReadyState.READY
}

View File

@@ -63,7 +63,7 @@ class MessageBackupsFlowViewModel(
private val internalStateFlow = MutableStateFlow(
MessageBackupsFlowState(
availableBackupTypes = emptyList(),
allBackupTypes = emptyList(),
currentMessageBackupTier = SignalStore.backup.backupTier,
selectedMessageBackupTier = resolveSelectedTier(initialTierSelection, SignalStore.backup.backupTier),
startScreen = startScreen
@@ -91,9 +91,9 @@ class MessageBackupsFlowViewModel(
}
viewModelScope.launch {
val availableBackupTypes: List<MessageBackupsType> = try {
val allBackupTypes: List<MessageBackupsType> = try {
withContext(SignalDispatchers.IO) {
BackupRepository.getAvailableBackupsTypes(
BackupRepository.getBackupTypes(
if (!RemoteConfig.messageBackups) emptyList() else listOf(MessageBackupTier.FREE, MessageBackupTier.PAID)
)
}
@@ -104,8 +104,9 @@ class MessageBackupsFlowViewModel(
internalStateFlow.update { state ->
state.copy(
availableBackupTypes = availableBackupTypes,
selectedMessageBackupTier = if (state.selectedMessageBackupTier in availableBackupTypes.map { it.tier }) state.selectedMessageBackupTier else availableBackupTypes.firstOrNull()?.tier
allBackupTypes = allBackupTypes,
isBillingApiAvailable = AppDependencies.billingApi.isApiAvailable(),
selectedMessageBackupTier = if (state.selectedMessageBackupTier in allBackupTypes.map { it.tier }) state.selectedMessageBackupTier else allBackupTypes.firstOrNull()?.tier
)
}
}
@@ -285,7 +286,7 @@ class MessageBackupsFlowViewModel(
MessageBackupTier.PAID -> {
check(state.selectedMessageBackupTier == MessageBackupTier.PAID)
check(state.availableBackupTypes.any { it.tier == state.selectedMessageBackupTier })
check(state.allBackupTypes.any { it.tier == state.selectedMessageBackupTier })
viewModelScope.launch(SignalDispatchers.IO) {
internalStateFlow.update { it.copy(inAppPayment = null) }

View File

@@ -74,12 +74,14 @@ fun MessageBackupsTypeSelectionScreen(
stage: MessageBackupsStage,
currentBackupTier: MessageBackupTier?,
selectedBackupTier: MessageBackupTier?,
availableBackupTypes: List<MessageBackupsType>,
allBackupTypes: List<MessageBackupsType>,
isBillingApiAvailable: Boolean,
isNextEnabled: Boolean,
onMessageBackupsTierSelected: (MessageBackupTier) -> Unit,
onNavigationClick: () -> Unit,
onReadMoreClicked: () -> Unit,
onNextClicked: () -> Unit
onNextClicked: () -> Unit,
onLearnMoreAboutWhyUserCanNotUpgrade: () -> Unit
) {
Scaffolds.Settings(
title = "",
@@ -144,7 +146,7 @@ fun MessageBackupsTypeSelectionScreen(
}
itemsIndexed(
availableBackupTypes,
allBackupTypes,
{ _, item -> item.tier }
) { index, item ->
MessageBackupsTypeBlock(
@@ -159,9 +161,31 @@ fun MessageBackupsTypeSelectionScreen(
}
val hasCurrentBackupTier = currentBackupTier != null
var displayNotAvailableDialog by remember { mutableStateOf(false) }
val onSubscribeButtonClick = remember(isBillingApiAvailable, selectedBackupTier) {
{
if (selectedBackupTier == MessageBackupTier.PAID && !isBillingApiAvailable) {
displayNotAvailableDialog = true
} else {
onNextClicked()
}
}
}
if (displayNotAvailableDialog) {
UpgradeNotAvailableDialog(
onConfirm = {
displayNotAvailableDialog = false
},
onDismiss = onLearnMoreAboutWhyUserCanNotUpgrade,
onDismissRequest = {
displayNotAvailableDialog = false
}
)
}
Buttons.LargeTonal(
onClick = onNextClicked,
onClick = onSubscribeButtonClick,
enabled = isNextEnabled,
modifier = Modifier
.testTag("subscribe-button")
@@ -169,8 +193,8 @@ fun MessageBackupsTypeSelectionScreen(
.padding(vertical = if (hasCurrentBackupTier) 10.dp else 16.dp)
) {
val text: String = if (currentBackupTier == null) {
if (selectedBackupTier == MessageBackupTier.PAID && availableBackupTypes.map { it.tier }.contains(selectedBackupTier)) {
val paidTier = availableBackupTypes.first { it.tier == MessageBackupTier.PAID } as MessageBackupsType.Paid
if (selectedBackupTier == MessageBackupTier.PAID && allBackupTypes.map { it.tier }.contains(selectedBackupTier)) {
val paidTier = allBackupTypes.first { it.tier == MessageBackupTier.PAID } as MessageBackupsType.Paid
val context = LocalContext.current
val price = remember(paidTier) {
@@ -200,6 +224,23 @@ fun MessageBackupsTypeSelectionScreen(
}
}
@Composable
private fun UpgradeNotAvailableDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit,
onDismissRequest: () -> Unit
) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.MessageBackupsTypeSelectionScreen__cant_upgrade_plan),
body = stringResource(R.string.MessageBackupsTypeSelectionScreen__to_subscribe_to_signal_secure_backups),
confirm = stringResource(android.R.string.ok),
dismiss = stringResource(R.string.MessageBackupsTypeSelectionScreen__learn_more),
onConfirm = onConfirm,
onDismiss = onDismiss,
onDismissRequest = onDismissRequest
)
}
@SignalPreview
@Composable
private fun MessageBackupsTypeSelectionScreenPreview() {
@@ -209,12 +250,14 @@ private fun MessageBackupsTypeSelectionScreenPreview() {
MessageBackupsTypeSelectionScreen(
stage = MessageBackupsStage.TYPE_SELECTION,
selectedBackupTier = selectedBackupsType,
availableBackupTypes = testBackupTypes(),
allBackupTypes = testBackupTypes(),
onMessageBackupsTierSelected = { selectedBackupsType = it },
onNavigationClick = {},
onReadMoreClicked = {},
onNextClicked = {},
onLearnMoreAboutWhyUserCanNotUpgrade = {},
currentBackupTier = null,
isBillingApiAvailable = true,
isNextEnabled = true
)
}
@@ -229,17 +272,31 @@ private fun MessageBackupsTypeSelectionScreenWithCurrentTierPreview() {
MessageBackupsTypeSelectionScreen(
stage = MessageBackupsStage.TYPE_SELECTION,
selectedBackupTier = selectedBackupsType,
availableBackupTypes = testBackupTypes(),
allBackupTypes = testBackupTypes(),
onMessageBackupsTierSelected = { selectedBackupsType = it },
onNavigationClick = {},
onReadMoreClicked = {},
onNextClicked = {},
onLearnMoreAboutWhyUserCanNotUpgrade = {},
currentBackupTier = MessageBackupTier.PAID,
isBillingApiAvailable = true,
isNextEnabled = true
)
}
}
@SignalPreview
@Composable
private fun UpgradeNotAvailableDialogPreview() {
Previews.Preview {
UpgradeNotAvailableDialog(
onConfirm = {},
onDismiss = {},
onDismissRequest = {}
)
}
}
@Composable
fun MessageBackupsTypeBlock(
messageBackupsType: MessageBackupsType,

View File

@@ -97,8 +97,8 @@ abstract class UpgradeToPaidTierBottomSheet : ComposeBottomSheetDialogFragment()
override fun SheetContent() {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val paidBackupType = state.availableBackupTypes.firstOrNull { it.tier == MessageBackupTier.PAID } as? MessageBackupsType.Paid
val freeBackupType = state.availableBackupTypes.firstOrNull { it.tier == MessageBackupTier.FREE } as? MessageBackupsType.Free
val paidBackupType = state.allBackupTypes.firstOrNull { it.tier == MessageBackupTier.PAID } as? MessageBackupsType.Paid
val freeBackupType = state.allBackupTypes.firstOrNull { it.tier == MessageBackupTier.FREE } as? MessageBackupsType.Free
if (paidBackupType != null && freeBackupType != null) {
UpgradeSheetContent(

View File

@@ -495,6 +495,7 @@ private fun RemoteBackupsSettingsContent(
BackupCard(
backupState = state.backupState,
onBackupTypeActionButtonClicked = contentCallbacks::onBackupTypeActionClick,
isPaidTierPricingAvailable = state.isPaidTierPricingAvailable,
buttonsEnabled = backupDeleteState.isIdle()
)
}
@@ -983,6 +984,7 @@ private fun LazyListScope.appendBackupDetailsItems(
@Composable
private fun BackupCard(
backupState: BackupState.WithTypeAndRenewalTime,
isPaidTierPricingAvailable: Boolean,
buttonsEnabled: Boolean,
onBackupTypeActionButtonClicked: (MessageBackupTier) -> Unit = {}
) {
@@ -1074,7 +1076,7 @@ private fun BackupCard(
)
}
if (backupState.isActive()) {
if (backupState.isActive() && isPaidTierPricingAvailable) {
val buttonText = when (messageBackupsType) {
is MessageBackupsType.Paid -> stringResource(R.string.RemoteBackupsSettingsFragment__manage_or_cancel)
is MessageBackupsType.Free -> stringResource(R.string.RemoteBackupsSettingsFragment__upgrade)
@@ -1464,6 +1466,7 @@ private fun getBackupExportPhaseProgressString(state: ArchiveUploadProgressState
stringResource(R.string.RemoteBackupsSettingsFragment__Waiting_for_Wifi)
}
}
ArchiveUploadProgressState.BackupPhase.Message -> {
pluralStringResource(
R.plurals.RemoteBackupsSettingsFragment__processing_messages_progress_text,
@@ -1835,65 +1838,92 @@ private fun SubscriptionMismatchMissingGooglePlayCardPreview() {
@Composable
private fun BackupCardPreview() {
Previews.Preview {
Column {
BackupCard(
backupState = BackupState.ActivePaid(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000,
mediaTtl = 30.days
LazyColumn {
item {
BackupCard(
backupState = BackupState.ActivePaid(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000,
mediaTtl = 30.days
),
renewalTime = 1727193018.seconds,
price = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD"))
),
renewalTime = 1727193018.seconds,
price = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD"))
),
buttonsEnabled = true
)
isPaidTierPricingAvailable = true,
buttonsEnabled = true
)
}
BackupCard(
backupState = BackupState.Canceled(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000,
mediaTtl = 30.days
item {
BackupCard(
backupState = BackupState.Canceled(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000,
mediaTtl = 30.days
),
renewalTime = 1727193018.seconds
),
renewalTime = 1727193018.seconds
),
buttonsEnabled = true
)
isPaidTierPricingAvailable = true,
buttonsEnabled = true
)
}
BackupCard(
backupState = BackupState.Inactive(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000,
mediaTtl = 30.days
item {
BackupCard(
backupState = BackupState.Inactive(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000,
mediaTtl = 30.days
),
renewalTime = 1727193018.seconds
),
renewalTime = 1727193018.seconds
),
buttonsEnabled = true
)
isPaidTierPricingAvailable = true,
buttonsEnabled = true
)
}
BackupCard(
backupState = BackupState.ActivePaid(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000,
mediaTtl = 30.days
item {
BackupCard(
backupState = BackupState.ActivePaid(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000,
mediaTtl = 30.days
),
renewalTime = 1727193018.seconds,
price = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD"))
),
renewalTime = 1727193018.seconds,
price = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD"))
),
buttonsEnabled = true
)
isPaidTierPricingAvailable = true,
buttonsEnabled = true
)
}
BackupCard(
backupState = BackupState.ActiveFree(
messageBackupsType = MessageBackupsType.Free(
mediaRetentionDays = 30
)
),
buttonsEnabled = true
)
item {
BackupCard(
backupState = BackupState.ActiveFree(
messageBackupsType = MessageBackupsType.Free(
mediaRetentionDays = 30
)
),
isPaidTierPricingAvailable = true,
buttonsEnabled = true
)
}
item {
BackupCard(
backupState = BackupState.ActiveFree(
messageBackupsType = MessageBackupsType.Free(
mediaRetentionDays = 30
)
),
isPaidTierPricingAvailable = false,
buttonsEnabled = true
)
}
}
}
}

View File

@@ -20,6 +20,7 @@ data class RemoteBackupsSettingsState(
val canRestoreUsingCellular: Boolean = false,
val hasRedemptionError: Boolean = false,
val isOutOfStorageSpace: Boolean = false,
val isPaidTierPricingAvailable: Boolean = false,
val totalAllowedStorageSpace: String = "",
val backupState: BackupState,
val backupMediaSize: Long = -1L,

View File

@@ -83,6 +83,20 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
val restoreState: StateFlow<BackupRestoreState> = _restoreState
init {
viewModelScope.launch(Dispatchers.IO) {
val isBillingApiAvailable = AppDependencies.billingApi.isApiAvailable()
if (isBillingApiAvailable) {
_state.update {
it.copy(isPaidTierPricingAvailable = true)
}
} else {
val paidType = BackupRepository.getPaidType()
_state.update {
it.copy(isPaidTierPricingAvailable = paidType is NetworkResult.Success)
}
}
}
viewModelScope.launch(Dispatchers.IO) {
refreshBackupMediaSizeState()
}