Add subscription states to the remote backups settings.

This commit is contained in:
Alex Hart 2024-10-21 13:22:45 -03:00 committed by Greyson Parrelli
parent a66c7058b1
commit b519bf6772
12 changed files with 522 additions and 227 deletions

View file

@ -119,16 +119,20 @@ object BackupRepository {
}
}
/**
* If the user is on a paid tier, this method will unsubscribe them from that tier.
* It will then disable backups.
*
* Returns true if we were successful, false otherwise.
*/
@WorkerThread
fun turnOffAndDeleteBackup(): Boolean {
fun turnOffAndDisableBackups(): Boolean {
return try {
Log.d(TAG, "Attempting to disable backups.")
getBackupTier().runIfSuccessful { tier ->
if (tier == MessageBackupTier.PAID) {
Log.d(TAG, "User is currently on a paid tier. Canceling.")
RecurringInAppPaymentRepository.cancelActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP)
Log.d(TAG, "Successfully canceled paid tier.")
}
if (SignalStore.backup.backupTier == MessageBackupTier.PAID) {
Log.d(TAG, "User is currently on a paid tier. Canceling.")
RecurringInAppPaymentRepository.cancelActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP)
Log.d(TAG, "Successfully canceled paid tier.")
}
Log.d(TAG, "Disabling backups.")
@ -636,14 +640,11 @@ object BackupRepository {
}
/**
* If backups are initialized, this method will query the server for the current backup level.
* If backups are not initialized, this method will return either the stored tier or a 404 result.
* If backups are enabled, sync with the network. Otherwise, return a 404.
*/
fun getBackupTier(): NetworkResult<MessageBackupTier> {
return if (SignalStore.backup.backupsInitialized) {
return if (SignalStore.backup.areBackupsEnabled) {
getBackupTier(Recipient.self().requireAci())
} else if (SignalStore.backup.backupTier != null) {
NetworkResult.Success(SignalStore.backup.backupTier!!)
} else {
NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(404))
}

View file

@ -118,11 +118,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
currentBackupTier = state.currentMessageBackupTier,
selectedBackupTier = state.selectedMessageBackupTier,
availableBackupTypes = state.availableBackupTypes.filter { it.tier == MessageBackupTier.FREE || state.hasBackupSubscriberAvailable },
onMessageBackupsTierSelected = { tier ->
val type = state.availableBackupTypes.first { it.tier == tier }
viewModel.onMessageBackupTierUpdated(tier)
},
onMessageBackupsTierSelected = viewModel::onMessageBackupTierUpdated,
onNavigationClick = viewModel::goToPreviousStage,
onReadMoreClicked = {},
onNextClicked = viewModel::goToNextStage

View file

@ -97,6 +97,13 @@ class MessageBackupsFlowViewModel(
try {
Log.d(TAG, "Attempting to handle successful purchase.")
internalStateFlow.update {
it.copy(
stage = MessageBackupsStage.PROCESS_PAYMENT
)
}
handleSuccess(result, id)
internalStateFlow.update {

View file

@ -37,7 +37,7 @@ import org.thoughtcrime.securesms.util.safeUnregisterReceiver
import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalCoroutinesApi::class)
class MediaRestoreProgressBanner(private val listener: RestoreProgressBannerListener) : Banner<BackupStatusData>() {
class MediaRestoreProgressBanner(private val listener: RestoreProgressBannerListener = EmptyListener) : Banner<BackupStatusData>() {
private var totalRestoredSize: Long = 0
@ -127,4 +127,9 @@ class MediaRestoreProgressBanner(private val listener: RestoreProgressBannerList
fun onSkip()
fun onDismissComplete()
}
private object EmptyListener : RestoreProgressBannerListener {
override fun onSkip() = Unit
override fun onDismissComplete() = Unit
}
}

View file

@ -0,0 +1,16 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.backups.remote
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatusData
/**
* State container for BackupStatusData, including the enabled state.
*/
data class BackupRestoreState(
val enabled: Boolean,
val backupStatusData: BackupStatusData
)

View file

@ -11,6 +11,7 @@ import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -18,6 +19,7 @@ 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.aspectRatio
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@ -31,7 +33,6 @@ import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
@ -46,7 +47,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.dimensionResource
@ -76,6 +76,8 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
import org.thoughtcrime.securesms.backup.v2.BackupFrequency
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatusData
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatusRow
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.billing.launchManageBackupsSubscription
import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBackupsCheckoutLauncher.createBackupsCheckoutLauncher
@ -92,7 +94,6 @@ import org.thoughtcrime.securesms.util.viewModel
import java.math.BigDecimal
import java.util.Currency
import java.util.Locale
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
@ -118,11 +119,11 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
override fun FragmentContent() {
val state by viewModel.state.collectAsState()
val backupProgress by ArchiveUploadProgress.progress.collectAsState(initial = null)
val restoreState by viewModel.restoreState.collectAsState()
val callbacks = remember { Callbacks() }
RemoteBackupsSettingsContent(
backupsInitialized = state.backupsInitialized,
messageBackupsType = state.messageBackupsType,
backupsEnabled = state.backupsEnabled,
lastBackupTimestamp = state.lastBackupTimestamp,
canBackUpUsingCellular = state.canBackUpUsingCellular,
backupsFrequency = state.backupsFrequency,
@ -131,8 +132,8 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
contentCallbacks = callbacks,
backupProgress = backupProgress,
backupSize = state.backupSize,
renewalTime = state.renewalTime,
backupState = state.backupState
backupState = state.backupState,
backupRestoreState = restoreState
)
}
@ -190,6 +191,14 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
displayBackupKey()
}
}
override fun onCancelMediaRestore() {
// TODO - [backups] Cancel media restoration
}
override fun onSkipMediaRestore() {
// TODO - [backups] Skip media restoration
}
}
private fun displayBackupKey() {
@ -264,14 +273,15 @@ private interface ContentCallbacks {
fun onSelectBackupsFrequencyChange(newFrequency: BackupFrequency) = Unit
fun onTurnOffAndDeleteBackupsConfirm() = Unit
fun onViewBackupKeyClick() = Unit
fun onSkipMediaRestore() = Unit
fun onCancelMediaRestore() = Unit
}
@Composable
private fun RemoteBackupsSettingsContent(
backupsInitialized: Boolean,
messageBackupsType: MessageBackupsType?,
backupsEnabled: Boolean,
backupState: RemoteBackupsSettingsState.BackupState,
renewalTime: Duration,
backupRestoreState: BackupRestoreState,
lastBackupTimestamp: Long,
canBackUpUsingCellular: Boolean,
backupsFrequency: BackupFrequency,
@ -297,26 +307,32 @@ private fun RemoteBackupsSettingsContent(
modifier = Modifier
.padding(it)
) {
if (backupState == RemoteBackupsSettingsState.BackupState.LOADING) {
item {
LoadingCard()
}
} else if (backupState == RemoteBackupsSettingsState.BackupState.ERROR) {
item {
ErrorCard()
}
} else if (messageBackupsType != null) {
item {
BackupCard(
messageBackupsType = messageBackupsType,
renewalTime = renewalTime,
backupState = backupState,
onBackupTypeActionButtonClicked = contentCallbacks::onBackupTypeActionClick
)
item {
AnimatedContent(backupState, label = "backup-state-block") { state ->
when (state) {
is RemoteBackupsSettingsState.BackupState.Loading -> {
LoadingCard()
}
is RemoteBackupsSettingsState.BackupState.Error -> {
ErrorCard()
}
is RemoteBackupsSettingsState.BackupState.Pending -> {
PendingCard(state.price)
}
RemoteBackupsSettingsState.BackupState.None -> Unit
is RemoteBackupsSettingsState.BackupState.WithTypeAndRenewalTime -> {
BackupCard(
backupState = state,
onBackupTypeActionButtonClicked = contentCallbacks::onBackupTypeActionClick
)
}
}
}
}
if (backupsInitialized) {
if (backupsEnabled) {
appendBackupDetailsItems(
backupProgress = backupProgress,
lastBackupTimestamp = lastBackupTimestamp,
@ -326,13 +342,23 @@ private fun RemoteBackupsSettingsContent(
contentCallbacks = contentCallbacks
)
} else {
// TODO [backups] -- Download progress bar / state if required.
if (backupRestoreState.enabled) {
item {
BackupStatusRow(
backupStatusData = backupRestoreState.backupStatusData,
onCancelClick = contentCallbacks::onCancelMediaRestore,
onSkipClick = contentCallbacks::onSkipMediaRestore
)
}
}
item {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__backups_have_been_turned_off),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 24.dp, bottom = 20.dp)
modifier = Modifier
.padding(top = 24.dp, bottom = 20.dp)
.horizontalGutters()
)
}
@ -371,11 +397,12 @@ private fun RemoteBackupsSettingsContent(
)
}
RemoteBackupsSettingsState.Dialog.DELETING_BACKUP, RemoteBackupsSettingsState.Dialog.BACKUP_DELETED -> {
DeletingBackupDialog(
backupDeleted = requestedDialog == RemoteBackupsSettingsState.Dialog.BACKUP_DELETED,
onDismiss = contentCallbacks::onDialogDismissed
)
RemoteBackupsSettingsState.Dialog.PROGRESS_SPINNER -> {
CircularProgressDialog(onDismiss = contentCallbacks::onDialogDismissed)
}
RemoteBackupsSettingsState.Dialog.DOWNLOADING_YOUR_BACKUP -> {
DownloadingYourBackupDialog(onDismiss = contentCallbacks::onDialogDismissed)
}
}
@ -502,11 +529,11 @@ private fun LazyListScope.appendBackupDetailsItems(
@Composable
private fun BackupCard(
messageBackupsType: MessageBackupsType,
backupState: RemoteBackupsSettingsState.BackupState,
renewalTime: Duration,
backupState: RemoteBackupsSettingsState.BackupState.WithTypeAndRenewalTime,
onBackupTypeActionButtonClicked: (MessageBackupTier) -> Unit = {}
) {
val messageBackupsType = backupState.messageBackupsType
Column(
modifier = Modifier
.fillMaxWidth()
@ -523,7 +550,7 @@ private fun BackupCard(
Text(
text = buildAnnotatedString {
if (backupState == RemoteBackupsSettingsState.BackupState.ACTIVE) {
if (backupState.isActive()) {
SignalSymbol(SignalSymbols.Weight.REGULAR, SignalSymbols.Glyph.CHECKMARK)
append(" ")
}
@ -535,28 +562,35 @@ private fun BackupCard(
)
when (backupState) {
RemoteBackupsSettingsState.BackupState.ACTIVE -> {
val cost = when (messageBackupsType) {
is MessageBackupsType.Paid -> stringResource(R.string.RemoteBackupsSettingsFragment__s_per_month, FiatMoneyUtil.format(LocalContext.current.resources, messageBackupsType.pricePerMonth))
is MessageBackupsType.Free -> stringResource(R.string.RemoteBackupsSettingsFragment__your_backup_plan_is_free)
}
is RemoteBackupsSettingsState.BackupState.ActivePaid -> {
Text(
text = cost,
text = stringResource(R.string.RemoteBackupsSettingsFragment__s_per_month, FiatMoneyUtil.format(LocalContext.current.resources, backupState.price)),
modifier = Modifier.padding(top = 12.dp)
)
}
RemoteBackupsSettingsState.BackupState.INACTIVE -> {
is RemoteBackupsSettingsState.BackupState.ActiveFree -> {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__subscription_inactive),
text = stringResource(R.string.RemoteBackupsSettingsFragment__your_backup_plan_is_free),
modifier = Modifier.padding(top = 12.dp)
)
}
is RemoteBackupsSettingsState.BackupState.Inactive -> {
val text = when (messageBackupsType) {
is MessageBackupsType.Paid -> stringResource(R.string.RemoteBackupsSettingsFragment__subscription_inactive)
is MessageBackupsType.Free -> stringResource(R.string.RemoteBackupsSettingsFragment__you_turned_off_backups)
}
Text(
text = text,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 8.dp)
)
}
RemoteBackupsSettingsState.BackupState.CANCELED -> {
is RemoteBackupsSettingsState.BackupState.Canceled -> {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__subscription_cancelled),
color = MaterialTheme.colorScheme.error,
@ -565,21 +599,20 @@ private fun BackupCard(
)
}
else -> error("Not supported here.")
else -> error("Not supported here: $backupState")
}
if (messageBackupsType is MessageBackupsType.Paid) {
@Suppress("KotlinConstantConditions")
val resource = when (backupState) {
RemoteBackupsSettingsState.BackupState.ACTIVE -> R.string.RemoteBackupsSettingsFragment__renews_s
RemoteBackupsSettingsState.BackupState.INACTIVE -> R.string.RemoteBackupsSettingsFragment__expired_on_s
RemoteBackupsSettingsState.BackupState.CANCELED -> R.string.RemoteBackupsSettingsFragment__expires_on_s
is RemoteBackupsSettingsState.BackupState.ActivePaid -> R.string.RemoteBackupsSettingsFragment__renews_s
is RemoteBackupsSettingsState.BackupState.Inactive -> R.string.RemoteBackupsSettingsFragment__expired_on_s
is RemoteBackupsSettingsState.BackupState.Canceled -> R.string.RemoteBackupsSettingsFragment__expires_on_s
else -> error("Not supported here.")
}
if (renewalTime > 0.seconds) {
if (backupState.renewalTime > 0.seconds) {
Text(
text = stringResource(resource, DateUtils.formatDateWithYear(Locale.getDefault(), renewalTime.inWholeMilliseconds))
text = stringResource(resource, DateUtils.formatDateWithYear(Locale.getDefault(), backupState.renewalTime.inWholeMilliseconds))
)
}
}
@ -599,7 +632,7 @@ private fun BackupCard(
is MessageBackupsType.Free -> stringResource(R.string.RemoteBackupsSettingsFragment__upgrade)
}
if (backupState == RemoteBackupsSettingsState.BackupState.ACTIVE) {
if (backupState.isActive()) {
Buttons.LargeTonal(
onClick = { onBackupTypeActionButtonClicked(messageBackupsType.tier) },
colors = ButtonDefaults.filledTonalButtonColors().copy(
@ -641,7 +674,56 @@ private fun LoadingCard() {
@Composable
private fun ErrorCard() {
BoxCard {
Text(text = "Error") // TODO [alex] -- Finalized error card
Column {
CircularProgressIndicator(
strokeWidth = 3.5.dp,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(32.dp)
)
Text(text = stringResource(R.string.RemoteBackupsSettingsFragment__waiting_for_network))
}
}
}
@Composable
private fun PendingCard(
price: FiatMoney
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
.background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(12.dp))
.padding(24.dp)
) {
Text(
text = stringResource(R.string.MessageBackupsTypeSelectionScreen__text_plus_all_your_media),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 12.dp)
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__s_per_month, FiatMoneyUtil.format(LocalContext.current.resources, price))
)
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__payment_pending)
)
}
CircularProgressIndicator(
strokeWidth = 3.5.dp,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(32.dp)
)
}
}
}
@ -755,10 +837,22 @@ private fun TurnOffAndDeleteBackupsDialog(
)
}
@Composable
private fun DownloadingYourBackupDialog(
onDismiss: () -> Unit
) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.RemoteBackupsSettingsFragment__downloading_your_backup),
body = stringResource(R.string.RemoteBackupsSettingsFragment__depending_on_the_size),
confirm = stringResource(android.R.string.ok),
onConfirm = {},
onDismiss = onDismiss
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DeletingBackupDialog(
backupDeleted: Boolean,
private fun CircularProgressDialog(
onDismiss: () -> Unit
) {
BasicAlertDialog(
@ -772,38 +866,14 @@ private fun DeletingBackupDialog(
shape = AlertDialogDefaults.shape,
color = AlertDialogDefaults.containerColor
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.defaultMinSize(minWidth = 232.dp)
.padding(bottom = 60.dp)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.aspectRatio(1f)
) {
if (backupDeleted) {
Icon(
painter = painterResource(id = R.drawable.symbol_check_light_24),
contentDescription = null,
tint = Color(0xFF09B37B),
modifier = Modifier
.padding(top = 58.dp, bottom = 9.dp)
.size(48.dp)
)
Text(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backup_deleted),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
CircularProgressIndicator(
modifier = Modifier
.padding(top = 64.dp, bottom = 20.dp)
.size(48.dp)
)
Text(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__deleting_backup),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
CircularProgressIndicator(
modifier = Modifier
.size(48.dp)
)
}
}
}
@ -882,8 +952,7 @@ private fun getTextForFrequency(backupsFrequency: BackupFrequency): String {
private fun RemoteBackupsSettingsContentPreview() {
Previews.Preview {
RemoteBackupsSettingsContent(
backupsInitialized = true,
messageBackupsType = MessageBackupsType.Free(mediaRetentionDays = 30),
backupsEnabled = true,
lastBackupTimestamp = -1,
canBackUpUsingCellular = false,
backupsFrequency = BackupFrequency.MANUAL,
@ -891,9 +960,11 @@ private fun RemoteBackupsSettingsContentPreview() {
requestedSnackbar = RemoteBackupsSettingsState.Snackbar.NONE,
contentCallbacks = object : ContentCallbacks {},
backupProgress = null,
renewalTime = 1727193018.seconds,
backupSize = 2300000,
backupState = RemoteBackupsSettingsState.BackupState.ACTIVE
backupState = RemoteBackupsSettingsState.BackupState.ActiveFree(
messageBackupsType = MessageBackupsType.Free(mediaRetentionDays = 30)
),
backupRestoreState = BackupRestoreState(false, BackupStatusData.CouldNotCompleteBackup)
)
}
}
@ -914,44 +985,69 @@ private fun ErrorCardPreview() {
}
}
@SignalPreview
@Composable
private fun PendingCardPreview() {
Previews.Preview {
PendingCard(
price = FiatMoney(BigDecimal.TEN, Currency.getInstance(Locale.getDefault()))
)
}
}
@SignalPreview
@Composable
private fun BackupCardPreview() {
Previews.Preview {
Column {
BackupCard(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000
),
backupState = RemoteBackupsSettingsState.BackupState.ACTIVE,
renewalTime = 1727193018.seconds
backupState = RemoteBackupsSettingsState.BackupState.ActivePaid(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000
),
renewalTime = 1727193018.seconds,
price = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD"))
)
)
BackupCard(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000
),
backupState = RemoteBackupsSettingsState.BackupState.CANCELED,
renewalTime = 1727193018.seconds
backupState = RemoteBackupsSettingsState.BackupState.Canceled(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000
),
renewalTime = 1727193018.seconds
)
)
BackupCard(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000
),
backupState = RemoteBackupsSettingsState.BackupState.INACTIVE,
renewalTime = 1727193018.seconds
backupState = RemoteBackupsSettingsState.BackupState.Inactive(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000
),
renewalTime = 1727193018.seconds
)
)
BackupCard(
messageBackupsType = MessageBackupsType.Free(
mediaRetentionDays = 30
),
backupState = RemoteBackupsSettingsState.BackupState.ACTIVE,
renewalTime = 0.seconds
backupState = RemoteBackupsSettingsState.BackupState.ActivePaid(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000
),
renewalTime = 1727193018.seconds,
price = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD"))
)
)
BackupCard(
backupState = RemoteBackupsSettingsState.BackupState.ActiveFree(
messageBackupsType = MessageBackupsType.Free(
mediaRetentionDays = 30
)
)
)
}
}
@ -999,10 +1095,19 @@ private fun TurnOffAndDeleteBackupsDialogPreview() {
@SignalPreview
@Composable
private fun DeleteBackupDialogPreview() {
private fun DownloadingYourBackupDialogPreview() {
Previews.Preview {
DeletingBackupDialog(
backupDeleted = true,
DownloadingYourBackupDialog(
onDismiss = {}
)
}
}
@SignalPreview
@Composable
private fun CircularProgressDialogPreview() {
Previews.Preview {
CircularProgressDialog(
onDismiss = {}
)
}

View file

@ -5,59 +5,104 @@
package org.thoughtcrime.securesms.components.settings.app.backups.remote
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.backup.v2.BackupFrequency
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
data class RemoteBackupsSettingsState(
val backupsInitialized: Boolean,
val messageBackupsType: MessageBackupsType? = null,
val backupsEnabled: Boolean,
val canBackUpUsingCellular: Boolean = false,
val backupState: BackupState = BackupState.LOADING,
val backupState: BackupState = BackupState.Loading,
val backupSize: Long = 0,
val backupsFrequency: BackupFrequency = BackupFrequency.DAILY,
val lastBackupTimestamp: Long = 0,
val renewalTime: Duration = 0.seconds,
val dialog: Dialog = Dialog.NONE,
val snackbar: Snackbar = Snackbar.NONE
) {
/**
* Describes the state of the user's selected backup tier.
*/
enum class BackupState {
sealed interface BackupState {
/**
* User has no active backup tier, no tier history
*/
data object None : BackupState
/**
* The exact backup state is being loaded from the network.
*/
LOADING,
data object Loading : BackupState
/**
* User has an active backup
* User has a paid backup subscription pending redemption
*/
ACTIVE,
data class Pending(
val price: FiatMoney
) : BackupState
/**
* User has an inactive paid tier backup
* A backup state with a type and renewal time
*/
INACTIVE,
sealed interface WithTypeAndRenewalTime : BackupState {
val messageBackupsType: MessageBackupsType
val renewalTime: Duration
fun isActive(): Boolean = false
}
/**
* User has an active paid backup. Pricing comes from the subscription object.
*/
data class ActivePaid(
override val messageBackupsType: MessageBackupsType.Paid,
val price: FiatMoney,
override val renewalTime: Duration
) : WithTypeAndRenewalTime {
override fun isActive(): Boolean = true
}
/**
* User has an active free backup.
*/
data class ActiveFree(
override val messageBackupsType: MessageBackupsType.Free,
override val renewalTime: Duration = 0.seconds
) : WithTypeAndRenewalTime {
override fun isActive(): Boolean = true
}
/**
* User has an inactive backup
*/
data class Inactive(
override val messageBackupsType: MessageBackupsType,
override val renewalTime: Duration = 0.seconds
) : WithTypeAndRenewalTime
/**
* User has a canceled paid tier backup
*/
CANCELED,
data class Canceled(
override val messageBackupsType: MessageBackupsType,
override val renewalTime: Duration
) : WithTypeAndRenewalTime
/**
* An error occurred retrieving the network state
*/
ERROR
data object Error : BackupState
}
enum class Dialog {
NONE,
TURN_OFF_AND_DELETE_BACKUPS,
BACKUP_FREQUENCY,
DELETING_BACKUP,
BACKUP_DELETED,
PROGRESS_SPINNER,
DOWNLOADING_YOUR_BACKUP,
TURN_OFF_FAILED
}

View file

@ -8,29 +8,45 @@ package org.thoughtcrime.securesms.components.settings.app.backups.remote
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.reactive.asFlow
import kotlinx.coroutines.withContext
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.backup.v2.BackupFrequency
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatusData
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.banner.banners.MediaRestoreProgressBanner
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.service.MessageBackupListener
import kotlin.time.Duration.Companion.milliseconds
import java.util.Currency
import kotlin.time.Duration.Companion.seconds
/**
* ViewModel for state management of RemoteBackupsSettingsFragment
*/
@OptIn(ExperimentalCoroutinesApi::class)
class RemoteBackupsSettingsViewModel : ViewModel() {
companion object {
@ -39,8 +55,7 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
private val _state = MutableStateFlow(
RemoteBackupsSettingsState(
backupsInitialized = SignalStore.backup.backupsInitialized,
messageBackupsType = null,
backupsEnabled = SignalStore.backup.areBackupsEnabled,
lastBackupTimestamp = SignalStore.backup.lastBackupTime,
backupSize = SignalStore.backup.totalBackupSize,
backupsFrequency = SignalStore.backup.backupFrequency,
@ -48,7 +63,36 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
)
)
private val _restoreState: MutableStateFlow<BackupRestoreState> = MutableStateFlow(BackupRestoreState(false, BackupStatusData.RestoringMedia()))
private val latestPurchaseId = MutableSharedFlow<InAppPaymentTable.InAppPaymentId>()
val state: StateFlow<RemoteBackupsSettingsState> = _state
val restoreState: StateFlow<BackupRestoreState> = _restoreState
init {
viewModelScope.launch(Dispatchers.IO) {
latestPurchaseId
.flatMapLatest { id -> InAppPaymentsRepository.observeUpdates(id).asFlow() }
.collectLatest { purchase ->
refreshState(purchase)
}
}
viewModelScope.launch(Dispatchers.Default) {
val restoreProgress = MediaRestoreProgressBanner()
while (isActive) {
if (restoreProgress.enabled) {
Log.d(TAG, "Backup is being restored. Collecting updates.")
restoreProgress.dataFlow.collectLatest { latest ->
_restoreState.update { BackupRestoreState(restoreProgress.enabled, latest) }
}
}
delay(1.seconds)
}
}
}
fun setCanBackUpUsingCellular(canBackUpUsingCellular: Boolean) {
SignalStore.backup.backupWithCellular = canBackUpUsingCellular
@ -71,104 +115,147 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
}
fun refresh() {
viewModelScope.launch {
Log.d(TAG, "Attempting to synchronize backup tier from archive service.")
viewModelScope.launch(Dispatchers.IO) {
val id = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_BACKUP)?.id
val backupTier = withContext(Dispatchers.IO) {
BackupRepository.getBackupTier()
if (id != null) {
latestPurchaseId.emit(id)
} else {
refreshState(null)
}
}
}
backupTier.runIfSuccessful {
Log.d(TAG, "Setting backup tier to $it")
SignalStore.backup.backupTier = it
}
private suspend fun refreshState(lastPurchase: InAppPaymentTable.InAppPayment?) {
val tier = SignalStore.backup.latestBackupTier
val tier = SignalStore.backup.backupTier
val backupType = if (tier != null) BackupRepository.getBackupsType(tier) else null
_state.update {
it.copy(
backupsEnabled = SignalStore.backup.areBackupsEnabled,
backupState = RemoteBackupsSettingsState.BackupState.Loading,
lastBackupTimestamp = SignalStore.backup.lastBackupTime,
backupSize = SignalStore.backup.totalBackupSize,
backupsFrequency = SignalStore.backup.backupFrequency,
canBackUpUsingCellular = SignalStore.backup.backupWithCellular
)
}
if (lastPurchase?.state == InAppPaymentTable.State.PENDING) {
Log.d(TAG, "We have a pending subscription.")
_state.update {
it.copy(
backupsInitialized = SignalStore.backup.backupsInitialized,
messageBackupsType = backupType,
backupState = RemoteBackupsSettingsState.BackupState.LOADING,
lastBackupTimestamp = SignalStore.backup.lastBackupTime,
backupSize = SignalStore.backup.totalBackupSize,
backupsFrequency = SignalStore.backup.backupFrequency,
canBackUpUsingCellular = SignalStore.backup.backupWithCellular
backupState = RemoteBackupsSettingsState.BackupState.Pending(
price = lastPurchase.data.amount!!.toFiatMoney()
)
)
}
when (tier) {
MessageBackupTier.PAID -> {
Log.d(TAG, "Attempting to retrieve subscription details for active PAID backup.")
return
}
val activeSubscription = withContext(Dispatchers.IO) {
RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP)
}
when (tier) {
MessageBackupTier.PAID -> {
Log.d(TAG, "Attempting to retrieve subscription details for active PAID backup.")
if (activeSubscription.isSuccess) {
Log.d(TAG, "Retrieved subscription details.")
val type = withContext(Dispatchers.IO) {
BackupRepository.getBackupsType(tier) as MessageBackupsType.Paid
}
val subscription = activeSubscription.getOrThrow().activeSubscription
if (subscription != null) {
Log.d(TAG, "Subscription found. Updating UI state with subscription details.")
_state.update {
it.copy(
renewalTime = subscription.endOfCurrentPeriod.seconds,
backupState = when {
subscription.isActive -> RemoteBackupsSettingsState.BackupState.ACTIVE
subscription.isCanceled -> RemoteBackupsSettingsState.BackupState.CANCELED
else -> RemoteBackupsSettingsState.BackupState.INACTIVE
}
)
}
} else {
Log.d(TAG, "ActiveSubscription had null subscription object. Updating UI state with INACTIVE subscription.")
_state.update {
it.copy(
renewalTime = 0.seconds,
backupState = RemoteBackupsSettingsState.BackupState.INACTIVE
)
}
}
} else {
Log.d(TAG, "Failed to load ActiveSubscription data. Updating UI state with error.")
val activeSubscription = withContext(Dispatchers.IO) {
RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP)
}
if (activeSubscription.isSuccess) {
Log.d(TAG, "Retrieved subscription details.")
val subscription = activeSubscription.getOrThrow().activeSubscription
if (subscription != null) {
Log.d(TAG, "Subscription found. Updating UI state with subscription details.")
_state.update {
it.copy(
renewalTime = 0.seconds,
backupState = RemoteBackupsSettingsState.BackupState.ERROR
backupState = when {
subscription.isActive -> RemoteBackupsSettingsState.BackupState.ActivePaid(
messageBackupsType = type,
price = FiatMoney.fromSignalNetworkAmount(subscription.amount, Currency.getInstance(subscription.currency)),
renewalTime = subscription.endOfCurrentPeriod.seconds
)
subscription.isCanceled -> RemoteBackupsSettingsState.BackupState.Canceled(
messageBackupsType = type,
renewalTime = subscription.endOfCurrentPeriod.seconds
)
else -> RemoteBackupsSettingsState.BackupState.Inactive(
messageBackupsType = type,
renewalTime = subscription.endOfCurrentPeriod.seconds
)
}
)
}
} else {
Log.d(TAG, "ActiveSubscription had null subscription object. Updating UI state with INACTIVE subscription.")
_state.update {
it.copy(
backupState = RemoteBackupsSettingsState.BackupState.Inactive(type)
)
}
}
} else {
Log.d(TAG, "Failed to load ActiveSubscription data. Updating UI state with error.")
_state.update {
it.copy(
backupState = RemoteBackupsSettingsState.BackupState.Error
)
}
}
}
MessageBackupTier.FREE -> {
val type = withContext(Dispatchers.IO) {
BackupRepository.getBackupsType(tier) as MessageBackupsType.Free
}
MessageBackupTier.FREE -> {
Log.d(TAG, "Updating UI state with ACTIVE FREE tier.")
_state.update { it.copy(renewalTime = 0.seconds, backupState = RemoteBackupsSettingsState.BackupState.ACTIVE) }
}
null -> {
Log.d(TAG, "Updating UI state with INACTIVE null tier.")
_state.update { it.copy(renewalTime = 0.seconds, backupState = RemoteBackupsSettingsState.BackupState.INACTIVE) }
val backupState = if (SignalStore.backup.areBackupsEnabled) {
RemoteBackupsSettingsState.BackupState.ActiveFree(type)
} else {
RemoteBackupsSettingsState.BackupState.Inactive(type)
}
Log.d(TAG, "Updating UI state with $backupState FREE tier.")
_state.update { it.copy(backupState = backupState) }
}
null -> {
Log.d(TAG, "Updating UI state with NONE null tier.")
_state.update { it.copy(backupState = RemoteBackupsSettingsState.BackupState.None) }
}
}
}
fun turnOffAndDeleteBackups() {
viewModelScope.launch {
requestDialog(RemoteBackupsSettingsState.Dialog.DELETING_BACKUP)
Log.d(TAG, "Beginning to turn off and delete backup.")
requestDialog(RemoteBackupsSettingsState.Dialog.PROGRESS_SPINNER)
val hasMediaBackupUploaded = SignalStore.backup.backsUpMedia && SignalStore.backup.hasBackupBeenUploaded
val succeeded = withContext(Dispatchers.IO) {
BackupRepository.turnOffAndDeleteBackup()
BackupRepository.turnOffAndDisableBackups()
}
if (isActive) {
if (succeeded) {
requestDialog(RemoteBackupsSettingsState.Dialog.BACKUP_DELETED)
delay(2000.milliseconds)
requestDialog(RemoteBackupsSettingsState.Dialog.NONE)
if (hasMediaBackupUploaded && SignalStore.backup.optimizeStorage) {
Log.d(TAG, "User has optimized storage, downloading.")
requestDialog(RemoteBackupsSettingsState.Dialog.DOWNLOADING_YOUR_BACKUP)
SignalStore.backup.optimizeStorage = false
RestoreOptimizedMediaJob.enqueue()
} else {
Log.d(TAG, "User does not have optimized storage, finished.")
requestDialog(RemoteBackupsSettingsState.Dialog.NONE)
}
refresh()
} else {
Log.d(TAG, "Failed to disable backups.")
requestDialog(RemoteBackupsSettingsState.Dialog.TURN_OFF_FAILED)
}
}

View file

@ -99,6 +99,9 @@ object InAppPaymentsRepository {
val observer = InAppPaymentObserver {
refresh()
}
refresh()
AppDependencies.databaseObserver.registerInAppPaymentObserver(observer)
awaitClose {
AppDependencies.databaseObserver.unregisterObserver(observer)

View file

@ -9,7 +9,6 @@ import androidx.annotation.VisibleForTesting
import org.signal.core.util.billing.BillingPurchaseResult
import org.signal.core.util.logging.Log
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.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
@ -66,8 +65,8 @@ class BackupSubscriptionCheckJob private constructor(parameters: Parameters) : C
return Result.success()
}
if (!SignalStore.backup.backupsInitialized) {
Log.i(TAG, "Backups are not initialized on this device. Exiting.")
if (!SignalStore.backup.areBackupsEnabled) {
Log.i(TAG, "Backups are not enabled on this device. Exiting.")
return Result.success()
}
@ -76,11 +75,6 @@ class BackupSubscriptionCheckJob private constructor(parameters: Parameters) : C
return Result.success()
}
BackupRepository.getBackupTier().runIfSuccessful {
Log.i(TAG, "Successfully retrieved backup tier $it. Applying.")
SignalStore.backup.backupTier = it
}
val purchase: BillingPurchaseResult = AppDependencies.billingApi.queryPurchases()
val hasActivePurchase = purchase is BillingPurchaseResult.Success && purchase.isAcknowledged && purchase.isWithinTheLastMonth()

View file

@ -25,6 +25,7 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
private const val KEY_BACKUP_USED_MEDIA_SPACE = "backup.usedMediaSpace"
private const val KEY_BACKUP_LAST_PROTO_SIZE = "backup.lastProtoSize"
private const val KEY_BACKUP_TIER = "backup.backupTier"
private const val KEY_LATEST_BACKUP_TIER = "backup.latestBackupTier"
private const val KEY_NEXT_BACKUP_TIME = "backup.nextBackupTime"
private const val KEY_LAST_BACKUP_TIME = "backup.lastBackupTime"
@ -64,7 +65,32 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
var lastBackupTime: Long by longValue(KEY_LAST_BACKUP_TIME, -1)
var lastMediaSyncTime: Long by longValue(KEY_LAST_BACKUP_MEDIA_SYNC_TIME, -1)
var backupFrequency: BackupFrequency by enumValue(KEY_BACKUP_FREQUENCY, BackupFrequency.MANUAL, BackupFrequency.Serializer)
var backupTier: MessageBackupTier? by enumValue(KEY_BACKUP_TIER, null, MessageBackupTier.Serializer)
/**
* This is the 'latest' backup tier. This isn't necessarily the user's current backup tier, so this should only ever
* be used to display backup tier information to the user in the settings fragments, not to check whether the user
* currently has backups enabled.
*/
val latestBackupTier: MessageBackupTier? by enumValue(KEY_LATEST_BACKUP_TIER, null, MessageBackupTier.Serializer)
/**
* When seting the backup tier, we also want to write to the latestBackupTier, as long as
* the value is non-null. This gives us a 1-deep history of the selected backup tier for
* use in the UI
*/
var backupTier: MessageBackupTier?
get() = MessageBackupTier.deserialize(getLong(KEY_BACKUP_TIER, -1))
set(value) {
val serializedValue = MessageBackupTier.serialize(value)
if (value != null) {
store.beginWrite()
.putLong(KEY_BACKUP_TIER, serializedValue)
.putLong(KEY_LATEST_BACKUP_TIER, serializedValue)
.apply()
} else {
putLong(KEY_BACKUP_TIER, serializedValue)
}
}
/**
* When uploading a backup, we store the progress state here so that I can remain across app restarts.

View file

@ -7587,12 +7587,18 @@
<string name="RemoteBackupsSettingsFragment__backup_will_be_created_overnight">Backup will be created overnight.</string>
<!-- Text displayed in card when subscription is inactive -->
<string name="RemoteBackupsSettingsFragment__subscription_inactive">Subscription inactive</string>
<!-- Text displayed in card when free tier is inactive -->
<string name="RemoteBackupsSettingsFragment__you_turned_off_backups">You turned off backups</string>
<!-- Title text in row detailing selected backup type -->
<string name="RemoteBackupsSettingsFragment__backup_plan">Backup plan</string>
<!-- Subtitle text in row detailing selected backup type displayed when backups are disabled -->
<string name="RemoteBackupsSettingsFragment__backups_disabled">Backups disabled</string>
<!-- Format string for backup and cost. First placeholder is cost per month. -->
<string name="RemoteBackupsSettingsFragment__s_per_month">%1$s/month</string>
<!-- Displayed in card at top of screen while payment processing is pending -->
<string name="RemoteBackupsSettingsFragment__payment_pending">Payment pending…</string>
<!-- Displayed in card while waiting for network -->
<string name="RemoteBackupsSettingsFragment__waiting_for_network">Waiting for network…</string>
<!-- String for free backups -->
<string name="RemoteBackupsSettingsFragment__your_backup_plan_is_free">Your backup plan is free</string>
<!-- Displays the date the subscription will renew. Placeholder is the date. -->
@ -7649,6 +7655,10 @@
<string name="RemoteBackupsSettingsFragment__reenable_backups">Re-enable backups</string>
<!-- Notice displayed when backups have been disabled and deleted -->
<string name="RemoteBackupsSettingsFragment__backups_have_been_turned_off">Backups have been turned off and your data has been deleted from Signal\'s secure storage service.</string>
<!-- Dialog title for dialog which alerts user their optimized media will be downloaded -->
<string name="RemoteBackupsSettingsFragment__downloading_your_backup">Downloading your backup</string>
<!-- Dialog message for dialog which alerts user their optimized media will be downloaded -->
<string name="RemoteBackupsSettingsFragment__depending_on_the_size">Depending on the size of your backup, this could take a long time. You can use your phone as you normally do while the download takes place.</string>
<!-- MessageBackupsEducationScreen -->
<!-- Screen subtitle underneath large headline title -->