From df170dac321abe5a339d05bcfb305ea796816e91 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 30 May 2025 15:33:44 -0300 Subject: [PATCH] Polish deletion UX. --- .../securesms/backup/DeletionState.kt | 41 +++- .../securesms/backup/v2/BackupRepository.kt | 16 +- .../backup/v2/ui/status/BackupStatusRow.kt | 21 +- .../MessageBackupsFlowFragment.kt | 2 +- .../upgrade/UpgradeToPaidTierBottomSheet.kt | 2 +- .../remote/RemoteBackupsSettingsFragment.kt | 183 ++++++++++++------ .../remote/RemoteBackupsSettingsViewModel.kt | 28 ++- .../securesms/jobs/BackupDeleteJob.kt | 105 +++++++--- .../jobs/BackupSubscriptionCheckJob.kt | 9 +- .../securesms/keyvalue/BackupValues.kt | 2 + app/src/main/protowire/JobData.proto | 7 + app/src/main/res/values/strings.xml | 8 +- 12 files changed, 315 insertions(+), 109 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/DeletionState.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/DeletionState.kt index 16ec085f8a..308cd80bc0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/DeletionState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/DeletionState.kt @@ -10,10 +10,42 @@ import org.signal.core.util.LongSerializer /** * Denotes the deletion state for backups. */ -enum class DeletionState(val id: Int) { +enum class DeletionState(private val id: Int) { + /** + * Something bad happened, and the deletion could not be performed. + * User should see "backup failed" UX + */ FAILED(-1), + + /** + * No pending, running, failed, or completed deletion. + * User should not see UX specific to backup deletions. + */ NONE(0), - RUNNING(1); + + /** + * Clear local backup state and delete subscription. + * User should see a progress spinner. + */ + CLEAR_LOCAL_STATE(4), + + /** + * Waiting to download media before deletion. + * User should see the "restoring media" progress UX + */ + AWAITING_MEDIA_DOWNLOAD(1), + + /** + * Deleting the backups themselves. + * User should see the "deleting backups..." UX + */ + DELETE_BACKUPS(2), + + /** + * Completed deletion. + * User should see the "backups deleted" UX + */ + COMPLETE(3); companion object { val serializer: LongSerializer = Serializer() @@ -27,7 +59,10 @@ enum class DeletionState(val id: Int) { override fun deserialize(data: Long): DeletionState { return when (data.toInt()) { FAILED.id -> FAILED - RUNNING.id -> RUNNING + CLEAR_LOCAL_STATE.id -> CLEAR_LOCAL_STATE + AWAITING_MEDIA_DOWNLOAD.id -> AWAITING_MEDIA_DOWNLOAD + DELETE_BACKUPS.id -> DELETE_BACKUPS + COMPLETE.id -> COMPLETE else -> NONE } } 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 90b84a6246..cc665ec18d 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 @@ -423,14 +423,16 @@ object BackupRepository { */ suspend fun turnOffAndDisableBackups() { ArchiveUploadProgress.cancelAndBlock() + SignalStore.backup.userManuallySkippedMediaRestore = false + SignalStore.backup.deletionState = DeletionState.CLEAR_LOCAL_STATE + AppDependencies.jobManager.add(BackupDeleteJob()) + } - SignalStore.backup.deletionState = DeletionState.RUNNING - SignalStore.backup.optimizeStorage = false - - AppDependencies.jobManager - .startChain(RestoreOptimizedMediaJob()) - .then(BackupDeleteJob()) - .enqueue() + /** + * To be called if the user skips media restore during the deletion process. + */ + fun continueTurningOffAndDisablingBackups() { + AppDependencies.jobManager.add(BackupDeleteJob()) } private fun createSignalDatabaseSnapshot(baseName: String): SignalDatabase { diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/BackupStatusRow.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/BackupStatusRow.kt index 055856ebae..b6f688bc09 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/BackupStatusRow.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/BackupStatusRow.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -52,7 +53,7 @@ private val YELLOW_DOT = Color(0xFFFFCC00) fun BackupStatusRow( backupStatusData: BackupStatusData, onSkipClick: () -> Unit = {}, - onCancelClick: () -> Unit = {}, + onCancelClick: (() -> Unit)? = null, onLearnMoreClick: () -> Unit = {} ) { Column { @@ -61,7 +62,7 @@ fun BackupStatusRow( ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter)) + modifier = Modifier.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter)).defaultMinSize(minHeight = 48.dp) ) { LinearProgressIndicator( color = progressColor(backupStatusData), @@ -69,13 +70,15 @@ fun BackupStatusRow( modifier = Modifier.weight(1f) ) - IconButton( - onClick = onCancelClick - ) { - Icon( - painter = painterResource(R.drawable.symbol_x_24), - contentDescription = stringResource(R.string.BackupStatusRow__cancel_download) - ) + if (onCancelClick != null) { + IconButton( + onClick = onCancelClick + ) { + Icon( + painter = painterResource(R.drawable.symbol_x_24), + contentDescription = stringResource(R.string.BackupStatusRow__cancel_download) + ) + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt index 43fe6e6de6..b8ece27212 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt @@ -78,7 +78,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega viewLifecycleOwner.lifecycleScope.launch(SignalDispatchers.Main) { repeatOnLifecycle(Lifecycle.State.RESUMED) { viewModel.deletionState.collectLatest { - if (it == DeletionState.RUNNING) { + if (it == DeletionState.DELETE_BACKUPS) { Toast.makeText( requireContext(), R.string.MessageBackupsFlowFragment__a_backup_deletion_is_in_progress, diff --git a/app/src/main/java/org/thoughtcrime/securesms/billing/upgrade/UpgradeToPaidTierBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/billing/upgrade/UpgradeToPaidTierBottomSheet.kt index 0533dd3bd3..55e1de2819 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/billing/upgrade/UpgradeToPaidTierBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/billing/upgrade/UpgradeToPaidTierBottomSheet.kt @@ -80,7 +80,7 @@ abstract class UpgradeToPaidTierBottomSheet : ComposeBottomSheetDialogFragment() viewLifecycleOwner.lifecycleScope.launch(SignalDispatchers.Main) { repeatOnLifecycle(Lifecycle.State.RESUMED) { viewModel.deletionState.collectLatest { - if (it == DeletionState.RUNNING) { + if (it == DeletionState.DELETE_BACKUPS) { Toast.makeText( requireContext(), R.string.MessageBackupsFlowFragment__a_backup_deletion_is_in_progress, 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 0d2279e7b5..e4612e11ac 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 @@ -50,7 +50,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue @@ -275,6 +274,10 @@ class RemoteBackupsSettingsFragment : ComposeFragment() { override fun onRedemptionErrorDetailsClick() { BackupAlertBottomSheet.create(BackupAlert.CouldNotRedeemBackup).show(parentFragmentManager, null) } + + override fun onDisplayProgressDialog() { + viewModel.requestDialog(RemoteBackupsSettingsState.Dialog.PROGRESS_SPINNER) + } } private fun displayBackupKey() { @@ -361,6 +364,7 @@ private interface ContentCallbacks { fun onRestoreUsingCellularConfirm() = Unit fun onRestoreUsingCellularClick() = Unit fun onRedemptionErrorDetailsClick() = Unit + fun onDisplayProgressDialog() = Unit object Emtpy : ContentCallbacks } @@ -390,6 +394,12 @@ private fun RemoteBackupsSettingsContent( val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + LaunchedEffect(backupDeleteState) { + if (backupDeleteState != DeletionState.NONE && backupDeleteState != DeletionState.CLEAR_LOCAL_STATE) { + contentCallbacks.onDialogDismissed() + } + } + Scaffold( topBar = { Scaffolds.DefaultTopAppBar( @@ -449,7 +459,7 @@ private fun RemoteBackupsSettingsContent( state = backupState, onLearnMoreClick = contentCallbacks::onLearnMoreAboutLostSubscription, onRenewClick = contentCallbacks::onRenewLostSubscription, - isRenewEnabled = backupDeleteState != DeletionState.RUNNING + isRenewEnabled = backupDeleteState != DeletionState.DELETE_BACKUPS ) } @@ -459,7 +469,7 @@ private fun RemoteBackupsSettingsContent( BackupCard( backupState = backupState, onBackupTypeActionButtonClicked = contentCallbacks::onBackupTypeActionClick, - buttonsEnabled = backupDeleteState != DeletionState.RUNNING + buttonsEnabled = backupDeleteState != DeletionState.DELETE_BACKUPS ) } @@ -468,14 +478,19 @@ private fun RemoteBackupsSettingsContent( title = stringResource(R.string.RemoteBackupsSettingsFragment__your_subscription_was_not_found), onRenewClick = contentCallbacks::onRenewLostSubscription, onLearnMoreClick = contentCallbacks::onLearnMoreAboutLostSubscription, - isRenewEnabled = backupDeleteState != DeletionState.RUNNING + isRenewEnabled = backupDeleteState != DeletionState.DELETE_BACKUPS ) } } } - if (backupDeleteState != DeletionState.NONE) { - appendBackupDeletionState(backupDeleteState, contentCallbacks) + if (backupDeleteState != DeletionState.NONE && backupDeleteState != DeletionState.CLEAR_LOCAL_STATE) { + appendBackupDeletionItems( + backupDeleteState = backupDeleteState, + backupRestoreState = backupRestoreState, + canRestoreUsingCellular = canRestoreUsingCellular, + contentCallbacks = contentCallbacks + ) } else if (backupsEnabled) { appendBackupDetailsItems( backupState = backupState, @@ -619,22 +634,42 @@ private fun ReenableBackupsButton(contentCallbacks: ContentCallbacks) { } } -private fun LazyListScope.appendBackupDeletionState( +private fun LazyListScope.appendRestoreFromBackupStatusData( + backupRestoreState: BackupRestoreState.FromBackupStatusData, + canRestoreUsingCellular: Boolean, + contentCallbacks: ContentCallbacks +) { + item { + BackupStatusRow( + backupStatusData = backupRestoreState.backupStatusData, + onCancelClick = contentCallbacks::onCancelMediaRestore, + onSkipClick = contentCallbacks::onSkipMediaRestore, + onLearnMoreClick = contentCallbacks::onLearnMoreAboutBackupFailure + ) + } + + if (!canRestoreUsingCellular) { + item { + Rows.TextRow( + text = stringResource(R.string.RemoteBackupsSettingsFragment__resume_download), + icon = painterResource(R.drawable.symbol_arrow_circle_down_24), + onClick = contentCallbacks::onRestoreUsingCellularConfirm + ) + } + } +} + +private fun LazyListScope.appendBackupDeletionItems( backupDeleteState: DeletionState, + backupRestoreState: BackupRestoreState, + canRestoreUsingCellular: Boolean, contentCallbacks: ContentCallbacks ) { when (backupDeleteState) { DeletionState.NONE -> return DeletionState.FAILED -> { item { - Text( - text = stringResource(R.string.RemoteBackupsSettingsFragment__backups_have_been_turned_off_but_there_was_an_error), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(top = 16.dp) - ) + DescriptionText(text = stringResource(R.string.RemoteBackupsSettingsFragment__backups_have_been_turned_off_but_there_was_an_error)) } item { @@ -665,18 +700,33 @@ private fun LazyListScope.appendBackupDeletionState( } } - DeletionState.RUNNING -> { + DeletionState.AWAITING_MEDIA_DOWNLOAD -> { item { - Text( - text = stringResource(R.string.RemoteBackupsSettingsFragment__backups_have_been_turned_off_and_your_data), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(top = 16.dp) + DescriptionText( + text = stringResource(R.string.RemoteBackupsSettingsFragment__backups_have_been_turned_off_your_data_will_be) ) } + if (backupRestoreState is BackupRestoreState.FromBackupStatusData) { + appendRestoreFromBackupStatusData( + backupRestoreState = backupRestoreState, + canRestoreUsingCellular = canRestoreUsingCellular, + contentCallbacks = contentCallbacks + ) + } else { + item { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth() + ) + } + } + } + + DeletionState.DELETE_BACKUPS -> { + item { + DescriptionText(text = stringResource(R.string.RemoteBackupsSettingsFragment__backups_have_been_turned_off_and_your_data)) + } + item { Column( verticalArrangement = spacedBy(12.dp), @@ -696,9 +746,40 @@ private fun LazyListScope.appendBackupDeletionState( } } } + + DeletionState.COMPLETE -> { + item { + DescriptionText( + text = stringResource(R.string.RemoteBackupsSettingsFragment__backups_have_been_turned_off_and_your_data_has_been_deleted), + modifier = Modifier.padding(bottom = 12.dp) + ) + } + + item { + ReenableBackupsButton(contentCallbacks) + } + } + + DeletionState.CLEAR_LOCAL_STATE -> Unit } } +@Composable +private fun DescriptionText( + text: String, + modifier: Modifier = Modifier +) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 16.dp) + .then(modifier) + ) +} + private fun LazyListScope.appendBackupDetailsItems( backupState: RemoteBackupsSettingsState.BackupState, backupRestoreState: BackupRestoreState, @@ -720,24 +801,11 @@ private fun LazyListScope.appendBackupDetailsItems( if (backupRestoreState !is BackupRestoreState.None) { if (backupRestoreState is BackupRestoreState.FromBackupStatusData) { - item { - BackupStatusRow( - backupStatusData = backupRestoreState.backupStatusData, - onCancelClick = contentCallbacks::onCancelMediaRestore, - onSkipClick = contentCallbacks::onSkipMediaRestore, - onLearnMoreClick = contentCallbacks::onLearnMoreAboutBackupFailure - ) - } - - if (!canRestoreUsingCellular) { - item { - Rows.TextRow( - text = stringResource(R.string.RemoteBackupsSettingsFragment__resume_download), - icon = painterResource(R.drawable.symbol_arrow_circle_down_24), - onClick = contentCallbacks::onRestoreUsingCellularConfirm - ) - } - } + appendRestoreFromBackupStatusData( + backupRestoreState = backupRestoreState, + canRestoreUsingCellular = canRestoreUsingCellular, + contentCallbacks = contentCallbacks + ) } else if (backupRestoreState is BackupRestoreState.Ready) { item { BackupReadyToDownloadRow( @@ -1363,7 +1431,7 @@ private fun TurnOffAndDeleteBackupsDialog( ) { Dialogs.SimpleAlertDialog( title = stringResource(id = R.string.RemoteBackupsSettingsFragment__turn_off_and_delete_backups), - body = stringResource(id = R.string.RemoteBackupsSettingsFragment__you_will_not_be_charged_again), + body = stringResource(id = R.string.RemoteBackupsSettingsFragment__your_backup_will_be_deleted_and_no_new_backups_will_be_created), confirm = stringResource(id = R.string.RemoteBackupsSettingsFragment__turn_off_and_delete), dismiss = stringResource(id = android.R.string.cancel), confirmColor = MaterialTheme.colorScheme.error, @@ -1879,28 +1947,31 @@ private fun BackupFrequencyDialogPreview() { @SignalPreview @Composable private fun BackupDeletionCardPreview() { - var backupDeletionState by remember { mutableStateOf(DeletionState.NONE) } - Previews.Preview { LazyColumn { - item { - Buttons.MediumTonal( - onClick = { - backupDeletionState = when (backupDeletionState) { - DeletionState.FAILED -> DeletionState.NONE - DeletionState.NONE -> DeletionState.RUNNING - DeletionState.RUNNING -> DeletionState.FAILED - } - } - ) { - Text("Next Deletion State") + for (state in DeletionState.entries.filter { it.hasUx() }) { + appendBackupDeletionItems( + backupDeleteState = state, + backupRestoreState = BackupRestoreState.FromBackupStatusData( + backupStatusData = BackupStatusData.RestoringMedia( + bytesDownloaded = 80.mebiBytes, + bytesTotal = 3.gibiBytes + ) + ), + contentCallbacks = ContentCallbacks.Emtpy, + canRestoreUsingCellular = true + ) + + item { + Dividers.Default() } } - appendBackupDeletionState(backupDeletionState, contentCallbacks = ContentCallbacks.Emtpy) } } } +private fun DeletionState.hasUx() = this !in setOf(DeletionState.NONE, DeletionState.CLEAR_LOCAL_STATE) + private fun ArchiveUploadProgressState.frameExportProgress(): Float { return if (this.frameTotalCount == 0L) { 0f 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 04b406fc3a..aebe6ff8e4 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 @@ -14,7 +14,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.update @@ -47,6 +46,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState import org.thoughtcrime.securesms.service.MessageBackupListener import java.util.Currency +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds /** @@ -78,11 +78,9 @@ class RemoteBackupsSettingsViewModel : ViewModel() { init { viewModelScope.launch(Dispatchers.IO) { - SignalStore.backup.deletionStateFlow - .filter { it == DeletionState.NONE } - .collect { - refresh() - } + SignalStore.backup.deletionStateFlow.collectLatest { + refresh() + } } viewModelScope.launch(Dispatchers.IO) { @@ -164,6 +162,10 @@ class RemoteBackupsSettingsViewModel : ViewModel() { fun skipMediaRestore() { BackupRepository.skipMediaRestore() + + if (SignalStore.backup.deletionState == DeletionState.AWAITING_MEDIA_DOWNLOAD) { + BackupRepository.continueTurningOffAndDisablingBackups() + } } fun requestDialog(dialog: RemoteBackupsSettingsState.Dialog) { @@ -187,6 +189,8 @@ class RemoteBackupsSettingsViewModel : ViewModel() { } fun turnOffAndDeleteBackups() { + requestDialog(RemoteBackupsSettingsState.Dialog.PROGRESS_SPINNER) + viewModelScope.launch(Dispatchers.IO) { BackupRepository.turnOffAndDisableBackups() } @@ -202,6 +206,7 @@ class RemoteBackupsSettingsViewModel : ViewModel() { private suspend fun refreshState(lastPurchase: InAppPaymentTable.InAppPayment?) { try { + Log.i(TAG, "Performing a state refresh.") performStateRefresh(lastPurchase) } catch (e: Exception) { Log.w(TAG, "State refresh failed", e) @@ -241,7 +246,7 @@ class RemoteBackupsSettingsViewModel : ViewModel() { Log.d(TAG, "[subscriptionStateMismatchDetected] A mismatch was detected.") val hasActiveGooglePlayBillingSubscription = when (val purchaseResult = AppDependencies.billingApi.queryPurchases()) { - is BillingPurchaseResult.Success -> purchaseResult.isAcknowledged && purchaseResult.isWithinTheLastMonth() + is BillingPurchaseResult.Success -> purchaseResult.isAcknowledged && purchaseResult.isWithinTheLastMonth() && purchaseResult.isAutoRenewing else -> false } || SignalStore.backup.backupTierInternalOverride == MessageBackupTier.PAID @@ -339,6 +344,15 @@ class RemoteBackupsSettingsViewModel : ViewModel() { backupState = RemoteBackupsSettingsState.BackupState.NotFound ) } + } else if (lastPurchase != null && lastPurchase.endOfPeriod > System.currentTimeMillis().milliseconds) { + _state.update { + it.copy( + backupState = RemoteBackupsSettingsState.BackupState.Canceled( + messageBackupsType = type, + renewalTime = lastPurchase.endOfPeriod + ) + ) + } } else { _state.update { it.copy( diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupDeleteJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupDeleteJob.kt index d51d986c17..f219d11371 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupDeleteJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupDeleteJob.kt @@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaym import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord +import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.jobmanager.Job import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint import org.thoughtcrime.securesms.jobs.protos.BackupDeleteJobData @@ -32,8 +33,8 @@ class BackupDeleteJob private constructor( private val TAG = Log.tag(BackupDeleteJob::class) } - constructor() : this( - BackupDeleteJobData(), + constructor(backupDeleteJobData: BackupDeleteJobData = BackupDeleteJobData()) : this( + backupDeleteJobData, Parameters.Builder() .addConstraint(NetworkConstraint.KEY) .setMaxInstancesForFactory(1) @@ -44,25 +45,70 @@ class BackupDeleteJob private constructor( override fun getFactoryKey(): String = KEY - override fun onAdded() { - SignalStore.backup.deletionState = DeletionState.RUNNING - } - override fun run(): Result { + if (SignalStore.backup.deletionState == DeletionState.NONE || SignalStore.backup.deletionState == DeletionState.FAILED || SignalStore.backup.deletionState == DeletionState.COMPLETE) { + Log.w(TAG, "Invalid state ${SignalStore.backup.deletionState}. Exiting.") + return Result.failure() + } + + val clearLocalStateResult = if (SignalStore.backup.deletionState == DeletionState.CLEAR_LOCAL_STATE) { + val results = listOf( + deleteLocalState(), + cancelActiveSubscription() + ) + + checkResults(results) + } else { + Result.success() + } + + if (!clearLocalStateResult.isSuccess) { + Log.w(TAG, "Failed to clear local state and subscriber.") + return clearLocalStateResult + } + + if (isMediaRestoreRequired()) { + Log.i(TAG, "Moving to AWAITING_MEDIA_DOWNLOAD state") + SignalStore.backup.deletionState = DeletionState.AWAITING_MEDIA_DOWNLOAD + AppDependencies.jobManager + .startChain(RestoreOptimizedMediaJob()) + .then(BackupDeleteJob(backupDeleteJobData)) + + return Result.failure() + } + + Log.i(TAG, "Moving to DELETE_BACKUPS state") + SignalStore.backup.deletionState = DeletionState.DELETE_BACKUPS + val results = listOf( - cancelActiveSubscription(), deleteMessageBackup(), - deleteMediaBackup(), - deleteLocalState() + deleteMediaBackup() ) + val result = checkResults(results) + if (result.isSuccess) { + Log.i(TAG, "Backup deletion was successful.") + SignalStore.backup.deletionState = DeletionState.COMPLETE + } + + return result + } + + override fun onFailure() { + if (SignalStore.backup.deletionState == DeletionState.AWAITING_MEDIA_DOWNLOAD) { + Log.w(TAG, "BackupDeleteFailure occurred while awaiting media download, ignoring.") + } else { + SignalStore.backup.deletionState = DeletionState.FAILED + } + } + + private fun checkResults(results: List): Result { val isAllSuccess = results.all { it.isSuccess } val hasRetries = results.any { it.isRetry } return when { isAllSuccess -> { - Log.d(TAG, "All stages completed successfully.") - SignalStore.backup.deletionState = DeletionState.NONE + Log.d(TAG, "${results.size} stages completed successfully.") Result.success() } hasRetries -> { @@ -76,8 +122,15 @@ class BackupDeleteJob private constructor( } } - override fun onFailure() { - SignalStore.backup.deletionState = DeletionState.FAILED + private fun isMediaRestoreRequired(): Boolean { + val requiresMediaRestore = SignalDatabase.attachments.getRemainingRestorableAttachmentSize() > 0L + if (requiresMediaRestore && SignalStore.backup.userManuallySkippedMediaRestore) { + Log.i(TAG, "User has undownloaded media. Enqueuing download now.") + return true + } else { + Log.i(TAG, "User does not have undownloaded media or has opted to skip restoration.") + return false + } } private fun cancelActiveSubscription(): Result { @@ -129,16 +182,7 @@ class BackupDeleteJob private constructor( return Result.success() } - Log.d(TAG, "Loading backup tier from service.") - val backupTierResult: NetworkResult = BackupRepository.getBackupTier() - if (backupTierResult.getCause() != null) { - return handleNetworkError(backupTierResult) - } - - val backupTier: MessageBackupTier = backupTierResult.successOrThrow() - Log.d(TAG, "Network request returned $backupTier") - - if (backupTier == MessageBackupTier.PAID) { + if (backupDeleteJobData.tier == BackupDeleteJobData.Tier.PAID) { val deleteMediaBackupResult: NetworkResult = BackupRepository.deleteMediaBackup() if (deleteMediaBackupResult.getCause() != null) { Log.w(TAG, "Failed to delete media backup", deleteMediaBackupResult.getCause()) @@ -158,6 +202,21 @@ class BackupDeleteJob private constructor( return Result.success() } + Log.d(TAG, "Loading backup tier from service.") + val backupTierResult: NetworkResult = BackupRepository.getBackupTier() + if (backupTierResult.getCause() != null) { + return handleNetworkError(backupTierResult) + } + + val backupTier: MessageBackupTier = backupTierResult.successOrThrow() + Log.d(TAG, "Network request returned $backupTier") + backupDeleteJobData = backupDeleteJobData.newBuilder().tier( + when (backupTier) { + MessageBackupTier.FREE -> BackupDeleteJobData.Tier.FREE + MessageBackupTier.PAID -> BackupDeleteJobData.Tier.PAID + } + ).build() + Log.d(TAG, "Clearing local backup state.") SignalStore.backup.disableBackups() SignalDatabase.attachments.clearAllArchiveData() 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 55d1780e03..bec5f74493 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt @@ -11,6 +11,7 @@ import org.signal.core.util.billing.BillingPurchaseResult import org.signal.core.util.logging.Log import org.signal.core.util.money.FiatMoney import org.signal.donations.InAppPaymentType +import org.thoughtcrime.securesms.backup.DeletionState import org.thoughtcrime.securesms.backup.v2.BackupRepository import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatValue @@ -84,10 +85,16 @@ class BackupSubscriptionCheckJob private constructor(parameters: Parameters) : C return Result.success() } + if (SignalStore.backup.deletionState != DeletionState.NONE) { + Log.i(TAG, "User is in the process of or has delete their backup. Clearing mismatch value and exiting.") + SignalStore.backup.subscriptionStateMismatchDetected = false + return Result.success() + } + val purchase: BillingPurchaseResult = AppDependencies.billingApi.queryPurchases() Log.i(TAG, "Retrieved purchase result from Billing api: $purchase") - val hasActivePurchase = purchase is BillingPurchaseResult.Success && purchase.isAcknowledged && purchase.isWithinTheLastMonth() + val hasActivePurchase = purchase is BillingPurchaseResult.Success && purchase.isAcknowledged && purchase.isWithinTheLastMonth() && purchase.isAutoRenewing val product: BillingProduct? = AppDependencies.billingApi.queryProduct() if (product == null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt index d7f1c6fa45..a31bc1438e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt @@ -206,6 +206,8 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { .putLong(KEY_LATEST_BACKUP_TIER, serializedValue) .putBoolean(KEY_BACKUP_TIER_RESTORED, true) .apply() + + deletionState = DeletionState.NONE } else { putLong(KEY_BACKUP_TIER, serializedValue) } diff --git a/app/src/main/protowire/JobData.proto b/app/src/main/protowire/JobData.proto index af19c3f48b..2818a5c97b 100644 --- a/app/src/main/protowire/JobData.proto +++ b/app/src/main/protowire/JobData.proto @@ -229,5 +229,12 @@ message BackupDeleteJobData { CLEAR_LOCAL_STATE = 4; } + enum Tier { + UNKNOWN_TIER = 0; + FREE = 1; + PAID = 2; + } + repeated Stage completed = 1; + Tier tier = 2; } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6ff5ae68f4..ed481f8162 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8130,7 +8130,7 @@ Turn off and delete backups? - You will not be charged again. Your backup will be deleted and no new backups will be created. + Your backup will be deleted and no new backups will be created. Any media stored in your backup will be downloaded to your phone now. Turn off and delete @@ -8213,8 +8213,14 @@ Failed to delete backup An error occurred. Please contact support. + + Backups have been turned off, your data will download to your device and then be deleted from Signal\'s secure storage service. Backups have been turned off and your data will be deleted from Signal\'s secure storage service. + + Backups have been turned off and your data has been deleted from Signal’s secure storage service. + + Downloading: %1$s of %2$s (%3$d%%)