From eeb8164c18fb5b5b9202bd626196660c17bc3d11 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 10 Sep 2025 15:12:35 -0300 Subject: [PATCH] Always display paid tier but stick a dialog in front of it for non-GPS devices. --- .../securesms/backup/v2/BackupRepository.kt | 15 +- .../MessageBackupsFlowFragment.kt | 11 +- .../subscription/MessageBackupsFlowState.kt | 5 +- .../MessageBackupsFlowViewModel.kt | 13 +- .../MessageBackupsTypeSelectionScreen.kt | 73 ++++++++-- .../upgrade/UpgradeToPaidTierBottomSheet.kt | 4 +- .../remote/RemoteBackupsSettingsFragment.kt | 134 +++++++++++------- .../remote/RemoteBackupsSettingsState.kt | 1 + .../remote/RemoteBackupsSettingsViewModel.kt | 14 ++ app/src/main/res/values/strings.xml | 4 + 10 files changed, 200 insertions(+), 74 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt index 2c46d0ae7f..d2fec7c69a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -1820,7 +1820,7 @@ object BackupRepository { } } - suspend fun getAvailableBackupsTypes(availableBackupTiers: List): List { + suspend fun getBackupTypes(availableBackupTiers: List): List { 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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt index b45ab9ec81..95bc5c10f0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt @@ -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) + ) + } ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt index 9cca6c2585..6b86fafce4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt @@ -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 = emptyList(), + val allBackupTypes: List = 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 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt index a99149e361..50b001b49f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt @@ -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 = try { + val allBackupTypes: List = 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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt index e208d3cbc7..4ec80179ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt @@ -74,12 +74,14 @@ fun MessageBackupsTypeSelectionScreen( stage: MessageBackupsStage, currentBackupTier: MessageBackupTier?, selectedBackupTier: MessageBackupTier?, - availableBackupTypes: List, + allBackupTypes: List, + 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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/billing/upgrade/UpgradeToPaidTierBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/billing/upgrade/UpgradeToPaidTierBottomSheet.kt index 9e8232b826..35f583e3f1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/billing/upgrade/UpgradeToPaidTierBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/billing/upgrade/UpgradeToPaidTierBottomSheet.kt @@ -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( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt index b0fdc54536..14793e22ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt @@ -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 + ) + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt index c5e343f8b8..43f51bdbe9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt index f706ec41aa..beccdcdf1f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt @@ -83,6 +83,20 @@ class RemoteBackupsSettingsViewModel : ViewModel() { val restoreState: StateFlow = _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() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b2f90865d5..a904a5480c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8505,6 +8505,10 @@ Current plan + + Can\'t upgrade plan + + To subscribe to Signal Secure Backups, you must have Google Services enabled on your phone and be signed into the Google Play Store.