Update remote backups settings to align with design.

This commit is contained in:
Alex Hart 2024-10-15 16:51:38 -03:00 committed by Greyson Parrelli
parent 6a77631b09
commit b073005ff9
7 changed files with 516 additions and 157 deletions

View file

@ -57,6 +57,7 @@ import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
import org.thoughtcrime.securesms.keyvalue.KeyValueStore
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.toMillis
import org.whispersystems.signalservice.api.NetworkResult
@ -73,6 +74,7 @@ import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
import org.whispersystems.signalservice.internal.push.AttachmentUploadForm
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
@ -113,9 +115,24 @@ object BackupRepository {
}
@WorkerThread
fun turnOffAndDeleteBackup() {
RecurringInAppPaymentRepository.cancelActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP)
SignalStore.backup.disableBackups()
fun turnOffAndDeleteBackup(): 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.")
}
}
Log.d(TAG, "Disabling backups.")
SignalStore.backup.disableBackups()
true
} catch (e: Exception) {
Log.w(TAG, "Failed to turn off backups.", e)
false
}
}
private fun createSignalDatabaseSnapshot(baseName: String): SignalDatabase {
@ -515,6 +532,25 @@ 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.
*/
fun getBackupTier(): NetworkResult<MessageBackupTier> {
return if (SignalStore.backup.backupsInitialized) {
getBackupTier(Recipient.self().requireAci())
} else if (SignalStore.backup.backupTier != null) {
NetworkResult.Success(SignalStore.backup.backupTier!!)
} else {
NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(404))
}
}
/**
* Grabs the backup tier for the given ACI. Note that this will set the user's backup
* tier to FREE if they are not on PAID, so avoid this method if you don't intend that
* to be the case.
*/
private fun getBackupTier(aci: ACI): NetworkResult<MessageBackupTier> {
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()

View file

@ -23,6 +23,7 @@ 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.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults
@ -66,6 +67,7 @@ import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.Snackbars
import org.signal.core.ui.Texts
import org.signal.core.ui.horizontalGutters
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
@ -119,6 +121,7 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
val callbacks = remember { Callbacks() }
RemoteBackupsSettingsContent(
backupsInitialized = state.backupsInitialized,
messageBackupsType = state.messageBackupsType,
lastBackupTimestamp = state.lastBackupTimestamp,
canBackUpUsingCellular = state.canBackUpUsingCellular,
@ -128,7 +131,8 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
contentCallbacks = callbacks,
backupProgress = backupProgress,
backupSize = state.backupSize,
renewalTime = state.renewalTime
renewalTime = state.renewalTime,
backupState = state.backupState
)
}
@ -145,6 +149,10 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
}
}
override fun onLaunchBackupsCheckoutFlow() {
checkoutLauncher.launch(null)
}
override fun onBackUpUsingCellularClick(canUseCellular: Boolean) {
viewModel.setCanBackUpUsingCellular(canUseCellular)
}
@ -245,6 +253,7 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
*/
private interface ContentCallbacks {
fun onNavigationClick() = Unit
fun onLaunchBackupsCheckoutFlow() = Unit
fun onBackupTypeActionClick(tier: MessageBackupTier) = Unit
fun onBackUpUsingCellularClick(canUseCellular: Boolean) = Unit
fun onBackupNowClick() = Unit
@ -259,7 +268,9 @@ private interface ContentCallbacks {
@Composable
private fun RemoteBackupsSettingsContent(
backupsInitialized: Boolean,
messageBackupsType: MessageBackupsType?,
backupState: RemoteBackupsSettingsState.BackupState,
renewalTime: Duration,
lastBackupTimestamp: Long,
canBackUpUsingCellular: Boolean,
@ -286,101 +297,65 @@ private fun RemoteBackupsSettingsContent(
modifier = Modifier
.padding(it)
) {
if (messageBackupsType != null) {
if (backupState == RemoteBackupsSettingsState.BackupState.LOADING) {
item {
BackupTypeRow(
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 {
Texts.SectionHeader(text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backup_details))
}
if (backupsInitialized) {
appendBackupDetailsItems(
backupProgress = backupProgress,
lastBackupTimestamp = lastBackupTimestamp,
backupSize = backupSize,
backupsFrequency = backupsFrequency,
canBackUpUsingCellular = canBackUpUsingCellular,
contentCallbacks = contentCallbacks
)
} else {
// TODO [backups] -- Download progress bar / state if required.
if (backupProgress == null || backupProgress.state == ArchiveUploadProgressState.State.None) {
item {
LastBackupRow(
lastBackupTimestamp = lastBackupTimestamp,
onBackupNowClick = contentCallbacks::onBackupNowClick
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__backups_have_been_turned_off),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 24.dp, bottom = 20.dp)
)
}
} else {
item {
InProgressBackupRow(progress = backupProgress.completedAttachments.toInt(), totalProgress = backupProgress.totalAttachments.toInt())
}
}
item {
Rows.TextRow(text = {
Column {
Text(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backup_size),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = Util.getPrettyFileSize(backupSize),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Buttons.LargePrimary(
onClick = { contentCallbacks.onBackupTypeActionClick(MessageBackupTier.FREE) },
modifier = Modifier.horizontalGutters()
) {
Text(text = stringResource(R.string.RemoteBackupsSettingsFragment__reenable_backups))
}
})
}
item {
Rows.TextRow(
text = {
Column {
Text(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backup_frequency),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = getTextForFrequency(backupsFrequency = backupsFrequency),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
onClick = contentCallbacks::onChangeBackupFrequencyClick
)
}
item {
Rows.ToggleRow(
checked = canBackUpUsingCellular,
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__back_up_using_cellular),
onCheckChanged = contentCallbacks::onBackUpUsingCellularClick
)
}
item {
Rows.TextRow(
text = stringResource(R.string.RemoteBackupsSettingsFragment__view_backup_key),
onClick = contentCallbacks::onViewBackupKeyClick
)
}
item {
Dividers.Default()
}
item {
Rows.TextRow(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__turn_off_and_delete_backup),
foregroundTint = MaterialTheme.colorScheme.error,
onClick = contentCallbacks::onTurnOffAndDeleteBackupsClick
)
}
}
}
}
when (requestedDialog) {
RemoteBackupsSettingsState.Dialog.NONE -> {}
RemoteBackupsSettingsState.Dialog.TURN_OFF_FAILED -> {
FailedToTurnOffBackupDialog(
onDismiss = contentCallbacks::onDialogDismissed
)
}
RemoteBackupsSettingsState.Dialog.TURN_OFF_AND_DELETE_BACKUPS -> {
TurnOffAndDeleteBackupsDialog(
onConfirm = contentCallbacks::onTurnOffAndDeleteBackupsConfirm,
@ -431,9 +406,104 @@ private fun RemoteBackupsSettingsContent(
}
}
private fun LazyListScope.appendBackupDetailsItems(
backupProgress: ArchiveUploadProgressState?,
lastBackupTimestamp: Long,
backupSize: Long,
backupsFrequency: BackupFrequency,
canBackUpUsingCellular: Boolean,
contentCallbacks: ContentCallbacks
) {
item {
Dividers.Default()
}
item {
Texts.SectionHeader(text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backup_details))
}
if (backupProgress == null || backupProgress.state == ArchiveUploadProgressState.State.None) {
item {
LastBackupRow(
lastBackupTimestamp = lastBackupTimestamp,
onBackupNowClick = contentCallbacks::onBackupNowClick
)
}
} else {
item {
InProgressBackupRow(progress = backupProgress.completedAttachments.toInt(), totalProgress = backupProgress.totalAttachments.toInt())
}
}
item {
Rows.TextRow(text = {
Column {
Text(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backup_size),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = Util.getPrettyFileSize(backupSize),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
})
}
item {
Rows.TextRow(
text = {
Column {
Text(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backup_frequency),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = getTextForFrequency(backupsFrequency = backupsFrequency),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
onClick = contentCallbacks::onChangeBackupFrequencyClick
)
}
item {
Rows.ToggleRow(
checked = canBackUpUsingCellular,
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__back_up_using_cellular),
onCheckChanged = contentCallbacks::onBackUpUsingCellularClick
)
}
item {
Rows.TextRow(
text = stringResource(R.string.RemoteBackupsSettingsFragment__view_backup_key),
onClick = contentCallbacks::onViewBackupKeyClick
)
}
item {
Dividers.Default()
}
item {
Rows.TextRow(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__turn_off_and_delete_backup),
foregroundTint = MaterialTheme.colorScheme.error,
onClick = contentCallbacks::onTurnOffAndDeleteBackupsClick
)
}
}
@Composable
private fun BackupTypeRow(
private fun BackupCard(
messageBackupsType: MessageBackupsType,
backupState: RemoteBackupsSettingsState.BackupState,
renewalTime: Duration,
onBackupTypeActionButtonClicked: (MessageBackupTier) -> Unit = {}
) {
@ -453,28 +523,63 @@ private fun BackupTypeRow(
Text(
text = buildAnnotatedString {
SignalSymbol(SignalSymbols.Weight.REGULAR, SignalSymbols.Glyph.CHECKMARK)
append(" ")
if (backupState == RemoteBackupsSettingsState.BackupState.ACTIVE) {
SignalSymbol(SignalSymbols.Weight.REGULAR, SignalSymbols.Glyph.CHECKMARK)
append(" ")
}
append(title)
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
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)
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)
}
Text(
text = cost,
modifier = Modifier.padding(top = 12.dp)
)
}
RemoteBackupsSettingsState.BackupState.INACTIVE -> {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__subscription_inactive),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 8.dp)
)
}
RemoteBackupsSettingsState.BackupState.CANCELED -> {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__subscription_cancelled),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(top = 8.dp)
)
}
else -> error("Not supported here.")
}
Text(
text = cost,
modifier = Modifier.padding(top = 12.dp)
)
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
else -> error("Not supported here.")
}
if (renewalTime > 0.seconds) {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__renews_s, DateUtils.formatDateWithYear(Locale.getDefault(), renewalTime.inWholeMilliseconds))
text = stringResource(resource, DateUtils.formatDateWithYear(Locale.getDefault(), renewalTime.inWholeMilliseconds))
)
}
}
@ -494,21 +599,52 @@ private fun BackupTypeRow(
is MessageBackupsType.Free -> stringResource(R.string.RemoteBackupsSettingsFragment__upgrade)
}
Buttons.LargeTonal(
onClick = { onBackupTypeActionButtonClicked(messageBackupsType.tier) },
colors = ButtonDefaults.filledTonalButtonColors().copy(
containerColor = SignalTheme.colors.colorTransparent5,
contentColor = colorResource(R.color.signal_light_colorOnSurface)
),
modifier = Modifier.padding(top = 12.dp)
) {
Text(
text = buttonText
)
if (backupState == RemoteBackupsSettingsState.BackupState.ACTIVE) {
Buttons.LargeTonal(
onClick = { onBackupTypeActionButtonClicked(messageBackupsType.tier) },
colors = ButtonDefaults.filledTonalButtonColors().copy(
containerColor = SignalTheme.colors.colorTransparent5,
contentColor = colorResource(R.color.signal_light_colorOnSurface)
),
modifier = Modifier.padding(top = 12.dp)
) {
Text(
text = buttonText
)
}
}
}
}
@Composable
private fun BoxCard(content: @Composable () -> Unit) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 150.dp)
.padding(horizontal = 16.dp, vertical = 12.dp)
.background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(12.dp))
.padding(24.dp)
) {
content()
}
}
@Composable
private fun LoadingCard() {
BoxCard {
CircularProgressIndicator()
}
}
@Composable
private fun ErrorCard() {
BoxCard {
Text(text = "Error") // TODO [alex] -- Finalized error card
}
}
@Composable
private fun InProgressBackupRow(
progress: Int?,
@ -590,6 +726,19 @@ private fun LastBackupRow(
}
}
@Composable
private fun FailedToTurnOffBackupDialog(
onDismiss: () -> Unit
) {
Dialogs.SimpleAlertDialog(
title = "TODO",
body = "TODO",
confirm = stringResource(id = android.R.string.ok),
onConfirm = {},
onDismiss = onDismiss
)
}
@Composable
private fun TurnOffAndDeleteBackupsDialog(
onConfirm: () -> Unit,
@ -733,6 +882,7 @@ private fun getTextForFrequency(backupsFrequency: BackupFrequency): String {
private fun RemoteBackupsSettingsContentPreview() {
Previews.Preview {
RemoteBackupsSettingsContent(
backupsInitialized = true,
messageBackupsType = MessageBackupsType.Free(mediaRetentionDays = 30),
lastBackupTimestamp = -1,
canBackUpUsingCellular = false,
@ -742,28 +892,65 @@ private fun RemoteBackupsSettingsContentPreview() {
contentCallbacks = object : ContentCallbacks {},
backupProgress = null,
renewalTime = 1727193018.seconds,
backupSize = 2300000
backupSize = 2300000,
backupState = RemoteBackupsSettingsState.BackupState.ACTIVE
)
}
}
@SignalPreview
@Composable
private fun BackupTypeRowPreview() {
private fun LoadingCardPreview() {
Previews.Preview {
LoadingCard()
}
}
@SignalPreview
@Composable
private fun ErrorCardPreview() {
Previews.Preview {
ErrorCard()
}
}
@SignalPreview
@Composable
private fun BackupCardPreview() {
Previews.Preview {
Column {
BackupTypeRow(
BackupCard(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000
),
backupState = RemoteBackupsSettingsState.BackupState.ACTIVE,
renewalTime = 1727193018.seconds
)
BackupTypeRow(
BackupCard(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000
),
backupState = RemoteBackupsSettingsState.BackupState.CANCELED,
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
)
BackupCard(
messageBackupsType = MessageBackupsType.Free(
mediaRetentionDays = 30
),
backupState = RemoteBackupsSettingsState.BackupState.ACTIVE,
renewalTime = 0.seconds
)
}
@ -789,6 +976,16 @@ private fun InProgressRowPreview() {
}
}
@SignalPreview
@Composable
private fun FailedToTurnOffBackupDialogPreview() {
Previews.Preview {
FailedToTurnOffBackupDialog(
onDismiss = {}
)
}
}
@SignalPreview
@Composable
private fun TurnOffAndDeleteBackupsDialogPreview() {

View file

@ -11,8 +11,10 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
data class RemoteBackupsSettingsState(
val backupsInitialized: Boolean,
val messageBackupsType: MessageBackupsType? = null,
val canBackUpUsingCellular: Boolean = false,
val backupState: BackupState = BackupState.LOADING,
val backupSize: Long = 0,
val backupsFrequency: BackupFrequency = BackupFrequency.DAILY,
val lastBackupTimestamp: Long = 0,
@ -20,12 +22,43 @@ data class RemoteBackupsSettingsState(
val dialog: Dialog = Dialog.NONE,
val snackbar: Snackbar = Snackbar.NONE
) {
/**
* Describes the state of the user's selected backup tier.
*/
enum class BackupState {
/**
* The exact backup state is being loaded from the network.
*/
LOADING,
/**
* User has an active backup
*/
ACTIVE,
/**
* User has an inactive paid tier backup
*/
INACTIVE,
/**
* User has a canceled paid tier backup
*/
CANCELED,
/**
* An error occurred retrieving the network state
*/
ERROR
}
enum class Dialog {
NONE,
TURN_OFF_AND_DELETE_BACKUPS,
BACKUP_FREQUENCY,
DELETING_BACKUP,
BACKUP_DELETED
BACKUP_DELETED,
TURN_OFF_FAILED
}
enum class Snackbar {

View file

@ -15,8 +15,10 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.logging.Log
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.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
@ -30,34 +32,24 @@ import kotlin.time.Duration.Companion.seconds
* ViewModel for state management of RemoteBackupsSettingsFragment
*/
class RemoteBackupsSettingsViewModel : ViewModel() {
companion object {
private val TAG = Log.tag(RemoteBackupsSettingsFragment::class)
}
private val _state = MutableStateFlow(
RemoteBackupsSettingsState(
backupsInitialized = SignalStore.backup.backupsInitialized,
messageBackupsType = null,
lastBackupTimestamp = SignalStore.backup.lastBackupTime,
backupSize = SignalStore.backup.totalBackupSize,
backupsFrequency = SignalStore.backup.backupFrequency
backupsFrequency = SignalStore.backup.backupFrequency,
canBackUpUsingCellular = SignalStore.backup.backupWithCellular
)
)
val state: StateFlow<RemoteBackupsSettingsState> = _state
init {
refresh()
viewModelScope.launch {
val activeSubscription = withContext(Dispatchers.IO) {
RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP)
}
if (activeSubscription.isSuccess) {
val subscription = activeSubscription.getOrThrow().activeSubscription
if (subscription != null) {
_state.update { it.copy(renewalTime = subscription.endOfCurrentPeriod.seconds) }
}
}
}
}
fun setCanBackUpUsingCellular(canBackUpUsingCellular: Boolean) {
SignalStore.backup.backupWithCellular = canBackUpUsingCellular
_state.update { it.copy(canBackUpUsingCellular = canBackUpUsingCellular) }
@ -80,17 +72,85 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
fun refresh() {
viewModelScope.launch {
Log.d(TAG, "Attempting to synchronize backup tier from archive service.")
val backupTier = withContext(Dispatchers.IO) {
BackupRepository.getBackupTier()
}
backupTier.runIfSuccessful {
Log.d(TAG, "Setting backup tier to $it")
SignalStore.backup.backupTier = it
}
val tier = SignalStore.backup.backupTier
val backupType = if (tier != null) BackupRepository.getBackupsType(tier) else null
_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
backupsFrequency = SignalStore.backup.backupFrequency,
canBackUpUsingCellular = SignalStore.backup.backupWithCellular
)
}
when (tier) {
MessageBackupTier.PAID -> {
Log.d(TAG, "Attempting to retrieve subscription details for active PAID backup.")
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 = 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.")
_state.update {
it.copy(
renewalTime = 0.seconds,
backupState = RemoteBackupsSettingsState.BackupState.ERROR
)
}
}
}
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) }
}
}
}
}
@ -98,28 +158,23 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
viewModelScope.launch {
requestDialog(RemoteBackupsSettingsState.Dialog.DELETING_BACKUP)
withContext(Dispatchers.IO) {
val succeeded = withContext(Dispatchers.IO) {
BackupRepository.turnOffAndDeleteBackup()
}
if (isActive) {
requestDialog(RemoteBackupsSettingsState.Dialog.BACKUP_DELETED)
delay(2000.milliseconds)
requestDialog(RemoteBackupsSettingsState.Dialog.NONE)
refresh()
if (succeeded) {
requestDialog(RemoteBackupsSettingsState.Dialog.BACKUP_DELETED)
delay(2000.milliseconds)
requestDialog(RemoteBackupsSettingsState.Dialog.NONE)
refresh()
} else {
requestDialog(RemoteBackupsSettingsState.Dialog.TURN_OFF_FAILED)
}
}
}
}
private fun refreshBackupState() {
_state.update {
it.copy(
lastBackupTimestamp = SignalStore.backup.lastBackupTime,
backupSize = SignalStore.backup.totalBackupSize
)
}
}
fun onBackupNowClick() {
BackupMessagesJob.enqueue()
}

View file

@ -9,6 +9,7 @@ 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
@ -55,65 +56,70 @@ class BackupSubscriptionCheckJob private constructor(parameters: Parameters) : C
}
override suspend fun doRun(): Result {
if (!SignalStore.account.isRegistered) {
Log.i(TAG, "User is not registered. Exiting.")
return Result.success()
}
if (!RemoteConfig.messageBackups) {
Log.i(TAG, "Message backups are not enabled. Exiting.")
return Result.success()
}
if (!SignalStore.backup.backupsInitialized) {
Log.i(TAG, "Backups are not initialized on this device. Exiting.")
return Result.success()
}
if (!AppDependencies.billingApi.isApiAvailable()) {
Log.i(TAG, "Google Play Billing API is not available on this device. Exiting.")
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()
val subscriberId = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP)
if (subscriberId == null && hasActivePurchase) {
Log.w(TAG, "User has active Google Play Billing purchase but no subscriber id! User should cancel backup and resubscribe.")
updateLocalState(null)
// TODO [message-backups] Set UI flag hint here to launch sheet (designs pending)
return Result.success()
}
val tier = SignalStore.backup.backupTier
if (subscriberId == null && tier == MessageBackupTier.PAID) {
Log.w(TAG, "User has no subscriber id but PAID backup tier. Reverting to no backup tier and informing the user.")
updateLocalState(null)
Log.w(TAG, "User has no subscriber id but PAID backup tier. User will need to cancel and resubscribe.")
// TODO [message-backups] Set UI flag hint here to launch sheet (designs pending)
return Result.success()
}
val activeSubscription = RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP).getOrNull()
if (activeSubscription?.isActive == true && tier != MessageBackupTier.PAID) {
Log.w(TAG, "User has an active subscription but no backup tier. Setting to PAID and enabling backups.")
updateLocalState(MessageBackupTier.PAID)
Log.w(TAG, "User has an active subscription but no backup tier.")
// TODO [message-backups] Set UI flag hint here to launch error sheet?
return Result.success()
}
if (activeSubscription?.isActive != true && tier == MessageBackupTier.PAID) {
Log.w(TAG, "User subscription is inactive or does not exist. Clearing backup tier.")
Log.w(TAG, "User subscription is inactive or does not exist. User will need to cancel and resubscribe.")
// TODO [message-backups] Set UI hint?
updateLocalState(null)
return Result.success()
}
if (activeSubscription?.isActive != true && hasActivePurchase) {
Log.w(TAG, "User subscription is inactive but user has a recent purchase. Clearing backup tier.")
Log.w(TAG, "User subscription is inactive but user has a recent purchase. User will need to cancel and resubscribe.")
// TODO [message-backups] Set UI hint?
updateLocalState(null)
return Result.success()
}
return Result.success()
}
private fun updateLocalState(backupTier: MessageBackupTier?) {
synchronized(InAppPaymentSubscriberRecord.Type.BACKUP) {
SignalStore.backup.backupTier = backupTier
}
}
override fun serialize(): ByteArray? = null
override fun getFactoryKey(): String = KEY

View file

@ -7550,11 +7550,13 @@
<!-- Snackbar text displayed when backup type is downgraded -->
<string name="RemoteBackupsSettingsFragment__backup_type_changed_and_subcription_deleted">Backup type changed and subscription cancelled</string>
<!-- Snackbar text displayed when backup subscription is cancelled -->
<string name="RemoteBackupsSettingsFragment__subscription_cancelled">Subscription cancelled</string>
<string name="RemoteBackupsSettingsFragment__subscription_cancelled">Subscription canceled</string>
<!-- Snackbar text displayed when backup is successfully downloaded -->
<string name="RemoteBackupsSettingsFragment__download_complete">Download complete</string>
<!-- Snackbar text displayed when backup will be created overnight -->
<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>
<!-- 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 -->
@ -7563,8 +7565,12 @@
<string name="RemoteBackupsSettingsFragment__s_per_month">%1$s/month</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 -->
<!-- Displays the date the subscription will renew. Placeholder is the date. -->
<string name="RemoteBackupsSettingsFragment__renews_s">Renews %1$s</string>
<!-- Displays the date the subscription will expire. Placeholder is the date. -->
<string name="RemoteBackupsSettingsFragment__expires_on_s">Expires on %1$s</string>
<!-- Displays the date the subscription expired. Placeholder is the date. -->
<string name="RemoteBackupsSettingsFragment__expired_on_s">Expired on %1$s</string>
<!-- Displayed at the top of the screen when no backup is currently enabled -->
<string name="RemoteBackupsSettingsFragment__back_up_your_message_history">Back up your message history so you never lose data when you get a new phone or reinstall Signal.</string>
<!-- Section header for other ways to back up -->
@ -7609,6 +7615,10 @@
<string name="RemoteBackupsSettingsFragment__manually_back_up">Manually back up</string>
<!-- 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>
<!-- Button label to re-enable backups (launches into checkout flow) -->
<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>
<!-- MessageBackupsEducationScreen -->
<!-- Screen subtitle underneath large headline title -->

View file

@ -0,0 +1,22 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.unit.Dp
/**
* Applies sensible horizontal padding to the given component.
*/
@Composable
fun Modifier.horizontalGutters(
gutterSize: Dp = dimensionResource(R.dimen.core_ui__gutter)
): Modifier {
return padding(horizontal = gutterSize)
}