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 e773119970..5cc7aa476d 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 @@ -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 { + 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 { val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey() 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 668d46ac7b..967d7474ed 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 @@ -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() { 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 b70706b578..446cfd20db 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 @@ -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 { 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 0ae28905e2..815f3e24fe 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 @@ -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 = _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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt index ef96c1235b..758128420b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt @@ -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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d888c51a07..ba2ce78cdc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7550,11 +7550,13 @@ Backup type changed and subscription cancelled - Subscription cancelled + Subscription canceled Download complete Backup will be created overnight. + + Subscription inactive Backup plan @@ -7563,8 +7565,12 @@ %1$s/month Your backup plan is free - + Renews %1$s + + Expires on %1$s + + Expired on %1$s Back up your message history so you never lose data when you get a new phone or reinstall Signal. @@ -7609,6 +7615,10 @@ Manually back up Please enter your device pin, password or pattern. + + Re-enable backups + + Backups have been turned off and your data has been deleted from Signal\'s secure storage service. diff --git a/core-ui/src/main/java/org/signal/core/ui/ModifierExtensions.kt b/core-ui/src/main/java/org/signal/core/ui/ModifierExtensions.kt new file mode 100644 index 0000000000..507ede5cf2 --- /dev/null +++ b/core-ui/src/main/java/org/signal/core/ui/ModifierExtensions.kt @@ -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) +}