From 6a40f4a4f448c137c64c3fe55c45e2db13fe5b92 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 28 May 2025 13:07:09 -0300 Subject: [PATCH] Perform backup deletion in a durable job. --- .../securesms/backup/ArchiveUploadProgress.kt | 21 ++ .../securesms/backup/DeletionState.kt | 35 +++ .../securesms/backup/v2/BackupRepository.kt | 104 ++++++--- .../MessageBackupsFlowFragment.kt | 26 ++- .../subscription/MessageBackupsFlowState.kt | 11 + .../MessageBackupsFlowViewModel.kt | 3 + .../MessageBackupsTypeSelectionScreen.kt | 8 +- .../upgrade/UpgradeToPaidTierBottomSheet.kt | 25 +++ .../remote/RemoteBackupsSettingsFragment.kt | 184 ++++++++++++++-- .../remote/RemoteBackupsSettingsViewModel.kt | 41 ++-- .../securesms/jobs/BackupDeleteJob.kt | 201 ++++++++++++++++++ .../securesms/jobs/JobManagerFactories.java | 1 + .../jobs/RestoreOptimizedMediaJob.kt | 2 +- .../securesms/keyvalue/BackupValues.kt | 6 + app/src/main/protowire/JobData.proto | 12 ++ app/src/main/res/values/strings.xml | 10 + .../signalservice/api/archive/ArchiveApi.kt | 4 +- 17 files changed, 603 insertions(+), 91 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/backup/DeletionState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/BackupDeleteJob.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/ArchiveUploadProgress.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/ArchiveUploadProgress.kt index f097425336..298418d7cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/ArchiveUploadProgress.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/ArchiveUploadProgress.kt @@ -5,12 +5,15 @@ package org.thoughtcrime.securesms.backup +import androidx.annotation.WorkerThread import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext import org.signal.core.util.logging.Log import org.signal.core.util.throttleLatest import org.thoughtcrime.securesms.attachments.AttachmentId @@ -25,6 +28,7 @@ import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState import java.util.concurrent.ConcurrentHashMap import kotlin.math.max import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds /** * Tracks the progress of uploading your message archive and provides an observable stream of results. @@ -110,6 +114,23 @@ object ArchiveUploadProgress { AppDependencies.jobManager.cancelAllInQueue(ArchiveThumbnailUploadJob.KEY) } + @WorkerThread + suspend fun cancelAndBlock() { + Log.d(TAG, "Canceling upload.") + cancel() + + withContext(Dispatchers.IO) { + Log.d(TAG, "Flushing job manager queue...") + AppDependencies.jobManager.flush() + + val queues = setOf(BackfillDigestJob.QUEUE, ArchiveThumbnailUploadJob.KEY) + UploadAttachmentToArchiveJob.getAllQueueKeys() + Log.d(TAG, "Waiting for cancelations to occur...") + while (!AppDependencies.jobManager.areQueuesEmpty(queues)) { + delay(1.seconds) + } + } + } + fun onMessageBackupCreated(backupFileSize: Long) { updateState { it.copy( diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/DeletionState.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/DeletionState.kt new file mode 100644 index 0000000000..16ec085f8a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/DeletionState.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup + +import org.signal.core.util.LongSerializer + +/** + * Denotes the deletion state for backups. + */ +enum class DeletionState(val id: Int) { + FAILED(-1), + NONE(0), + RUNNING(1); + + companion object { + val serializer: LongSerializer = Serializer() + } + + class Serializer : LongSerializer { + override fun serialize(data: DeletionState): Long { + return data.id.toLong() + } + + override fun deserialize(data: Long): DeletionState { + return when (data.toInt()) { + FAILED.id -> FAILED + RUNNING.id -> RUNNING + 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 57888e8541..c6d28a1472 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 @@ -38,6 +38,8 @@ import org.thoughtcrime.securesms.attachments.Attachment import org.thoughtcrime.securesms.attachments.AttachmentId import org.thoughtcrime.securesms.attachments.Cdn import org.thoughtcrime.securesms.attachments.DatabaseAttachment +import org.thoughtcrime.securesms.backup.ArchiveUploadProgress +import org.thoughtcrime.securesms.backup.DeletionState import org.thoughtcrime.securesms.backup.v2.BackupRepository.copyAttachmentToArchive import org.thoughtcrime.securesms.backup.v2.importer.ChatItemArchiveImporter import org.thoughtcrime.securesms.backup.v2.processor.AccountDataArchiveProcessor @@ -56,8 +58,6 @@ import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupWriter import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupReader import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupWriter import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType -import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository -import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider import org.thoughtcrime.securesms.database.AttachmentTable @@ -68,11 +68,11 @@ import org.thoughtcrime.securesms.database.OneTimePreKeyTable import org.thoughtcrime.securesms.database.SearchTable import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.SignedPreKeyTable -import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.jobmanager.Job import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob +import org.thoughtcrime.securesms.jobs.BackupDeleteJob import org.thoughtcrime.securesms.jobs.CheckRestoreMediaLeftJob import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob import org.thoughtcrime.securesms.jobs.RestoreAttachmentJob @@ -166,13 +166,57 @@ object BackupRepository { * Refreshes backup via server */ fun refreshBackup(): NetworkResult { - return initBackupAndFetchAuth() - .then { accessPair -> - AppDependencies.archiveApi.refreshBackup( - aci = SignalStore.account.requireAci(), - archiveServiceAccess = accessPair.messageBackupAccess - ) + Log.d(TAG, "Refreshing backup...") + + Log.d(TAG, "Fetching backup auth credential.") + val credentialResult = initBackupAndFetchAuth() + if (credentialResult.getCause() != null) { + Log.w(TAG, "Failed to access backup auth.", credentialResult.getCause()) + return credentialResult.map { Unit } + } + + val credential = credentialResult.successOrThrow() + + Log.d(TAG, "Fetched backup auth credential. Fetching backup tier.") + + val backupTierResult = getBackupTier() + if (backupTierResult.getCause() != null) { + Log.w(TAG, "Failed to access backup tier.", backupTierResult.getCause()) + return backupTierResult.map { Unit } + } + + val backupTier = backupTierResult.successOrThrow() + + Log.d(TAG, "Fetched backup tier. Refreshing message backup access.") + val messageBackupAccessResult = AppDependencies.archiveApi.refreshBackup( + aci = SignalStore.account.requireAci(), + archiveServiceAccess = credential.messageBackupAccess + ) + + if (messageBackupAccessResult.getCause() != null) { + Log.d(TAG, "Failed to refresh message backup access.", messageBackupAccessResult.getCause()) + return messageBackupAccessResult + } + + Log.d(TAG, "Refreshed message backup access.") + if (backupTier == MessageBackupTier.PAID) { + Log.d(TAG, "Refreshing media backup access.") + + val mediaBackupAccessResult = AppDependencies.archiveApi.refreshBackup( + aci = SignalStore.account.requireAci(), + archiveServiceAccess = credential.mediaBackupAccess + ) + + if (mediaBackupAccessResult.getCause() != null) { + Log.d(TAG, "Failed to refresh media backup access.", mediaBackupAccessResult.getCause()) } + + Log.d(TAG, "Refreshed media backup access.") + + return mediaBackupAccessResult + } else { + return messageBackupAccessResult + } } /** @@ -377,35 +421,18 @@ object BackupRepository { } /** - * If the user is on a paid tier, this method will unsubscribe them from that tier. - * It will then disable backups. - * - * Returns true if we were successful, false otherwise. + * Initiates backup disable via [BackupDeleteJob] */ - @WorkerThread - fun turnOffAndDisableBackups(): Boolean { - return try { - Log.d(TAG, "Attempting to disable backups.") + suspend fun turnOffAndDisableBackups() { + ArchiveUploadProgress.cancelAndBlock() - val backupsSubscriber = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP) - if (SignalStore.backup.backupTier == MessageBackupTier.PAID && backupsSubscriber != null) { - Log.d(TAG, "User is currently on a paid tier. Canceling.") - RecurringInAppPaymentRepository.cancelActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) - Log.d(TAG, "Successfully canceled paid tier.") - } + SignalStore.backup.deletionState = DeletionState.RUNNING + SignalStore.backup.optimizeStorage = false - if (backupsSubscriber == null) { - Log.w(TAG, "No backup subscriber in the database. Proceeding with disabling backups anyway.") - } - - Log.d(TAG, "Disabling backups.") - SignalStore.backup.disableBackups() - SignalDatabase.attachments.clearAllArchiveData() - true - } catch (e: Exception) { - Log.w(TAG, "Failed to turn off backups.", e) - false - } + AppDependencies.jobManager + .startChain(RestoreOptimizedMediaJob()) + .then(BackupDeleteJob()) + .enqueue() } private fun createSignalDatabaseSnapshot(baseName: String): SignalDatabase { @@ -1281,6 +1308,13 @@ object BackupRepository { } } + fun deleteMediaBackup(): NetworkResult { + return initBackupAndFetchAuth() + .then { credential -> + SignalNetwork.archive.deleteBackup(SignalStore.account.requireAci(), credential.mediaBackupAccess) + } + } + fun debugDeleteAllArchivedMedia(): NetworkResult { val itemLimit = 1000 return debugGetArchivedMediaState() 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 f551af155d..43fe6e6de6 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 @@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.backup.v2.ui.subscription import android.app.Activity import android.os.Bundle import android.view.View +import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable @@ -16,14 +17,21 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.core.os.bundleOf +import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import kotlinx.coroutines.rx3.asFlowable import org.signal.core.ui.compose.Dialogs +import org.signal.core.util.concurrent.SignalDispatchers import org.signal.core.util.getSerializableCompat import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.backup.DeletionState import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentCheckoutDelegate import org.thoughtcrime.securesms.compose.ComposeFragment @@ -66,6 +74,22 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega .filter { it.inAppPayment != null } .map { it.inAppPayment!!.id } ) + + viewLifecycleOwner.lifecycleScope.launch(SignalDispatchers.Main) { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + viewModel.deletionState.collectLatest { + if (it == DeletionState.RUNNING) { + Toast.makeText( + requireContext(), + R.string.MessageBackupsFlowFragment__a_backup_deletion_is_in_progress, + Toast.LENGTH_SHORT + ).show() + + requireActivity().supportFinishAfterTransition() + } + } + } + } } override fun onResume() { @@ -137,10 +161,10 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega composable(route = MessageBackupsStage.Route.TYPE_SELECTION.name) { MessageBackupsTypeSelectionScreen( stage = state.stage, - paymentReadyState = state.paymentReadyState, currentBackupTier = state.currentMessageBackupTier, selectedBackupTier = state.selectedMessageBackupTier, availableBackupTypes = state.availableBackupTypes, + isNextEnabled = state.isCheckoutButtonEnabled(), onMessageBackupsTierSelected = viewModel::onMessageBackupTierUpdated, onNavigationClick = viewModel::goToPreviousStage, onReadMoreClicked = {}, diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt index 4f289e1b00..155e64d2a7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt @@ -5,11 +5,13 @@ package org.thoughtcrime.securesms.backup.v2.ui.subscription +import androidx.compose.runtime.Immutable import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.keyvalue.SignalStore import org.whispersystems.signalservice.api.AccountEntropyPool +@Immutable data class MessageBackupsFlowState( val selectedMessageBackupTier: MessageBackupTier? = SignalStore.backup.backupTier, val currentMessageBackupTier: MessageBackupTier? = null, @@ -26,4 +28,13 @@ data class MessageBackupsFlowState( READY, FAILED } + + /** + * Whether or not the 'next' button on the type selection screen is enabled. + */ + fun isCheckoutButtonEnabled(): Boolean { + return selectedMessageBackupTier in availableBackupTypes.map { it.tier } && + selectedMessageBackupTier != currentMessageBackupTier && + paymentReadyState == PaymentReadyState.READY + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt index 1454252d93..d54f897918 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt @@ -9,6 +9,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch @@ -24,6 +25,7 @@ import org.signal.core.util.billing.BillingPurchaseResult import org.signal.core.util.concurrent.SignalDispatchers import org.signal.core.util.logging.Log 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 @@ -63,6 +65,7 @@ class MessageBackupsFlowViewModel( ) val stateFlow: StateFlow = internalStateFlow + val deletionState: Flow = SignalStore.backup.deletionStateFlow init { viewModelScope.launch { diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt index 93d547f8bb..ad20be87ca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt @@ -70,10 +70,10 @@ import org.signal.core.ui.R as CoreUiR @Composable fun MessageBackupsTypeSelectionScreen( stage: MessageBackupsStage, - paymentReadyState: MessageBackupsFlowState.PaymentReadyState, currentBackupTier: MessageBackupTier?, selectedBackupTier: MessageBackupTier?, availableBackupTypes: List, + isNextEnabled: Boolean, onMessageBackupsTierSelected: (MessageBackupTier) -> Unit, onNavigationClick: () -> Unit, onReadMoreClicked: () -> Unit, @@ -161,7 +161,7 @@ fun MessageBackupsTypeSelectionScreen( Buttons.LargePrimary( onClick = onNextClicked, - enabled = selectedBackupTier != currentBackupTier && selectedBackupTier != null && paymentReadyState == MessageBackupsFlowState.PaymentReadyState.READY, + enabled = isNextEnabled, modifier = Modifier .fillMaxWidth() .padding(vertical = if (hasCurrentBackupTier) 10.dp else 16.dp) @@ -202,7 +202,7 @@ private fun MessageBackupsTypeSelectionScreenPreview() { onReadMoreClicked = {}, onNextClicked = {}, currentBackupTier = null, - paymentReadyState = MessageBackupsFlowState.PaymentReadyState.READY + isNextEnabled = true ) } } @@ -222,7 +222,7 @@ private fun MessageBackupsTypeSelectionScreenWithCurrentTierPreview() { onReadMoreClicked = {}, onNextClicked = {}, currentBackupTier = MessageBackupTier.PAID, - paymentReadyState = MessageBackupsFlowState.PaymentReadyState.READY + isNextEnabled = true ) } } 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 de661b03da..0533dd3bd3 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 @@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.billing.upgrade import android.os.Bundle import android.view.View +import android.widget.Toast import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -14,9 +15,17 @@ import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.fragment.app.setFragmentResult import androidx.fragment.app.setFragmentResultListener +import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import kotlinx.coroutines.rx3.asFlowable import org.signal.core.ui.compose.Dialogs +import org.signal.core.util.concurrent.SignalDispatchers +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.backup.DeletionState import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsFlowViewModel import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsStage @@ -67,6 +76,22 @@ abstract class UpgradeToPaidTierBottomSheet : ComposeBottomSheetDialogFragment() .filter { it.inAppPayment != null } .map { it.inAppPayment!!.id } ) + + viewLifecycleOwner.lifecycleScope.launch(SignalDispatchers.Main) { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + viewModel.deletionState.collectLatest { + if (it == DeletionState.RUNNING) { + Toast.makeText( + requireContext(), + R.string.MessageBackupsFlowFragment__a_backup_deletion_is_in_progress, + Toast.LENGTH_SHORT + ).show() + + dismissAllowingStateLoss() + } + } + } + } } @Composable 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 5c750fd555..0d2279e7b5 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,8 +50,10 @@ 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 import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -67,6 +69,7 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -91,6 +94,7 @@ import org.signal.core.util.money.FiatMoney import org.thoughtcrime.securesms.BiometricDeviceAuthentication import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.backup.ArchiveUploadProgress +import org.thoughtcrime.securesms.backup.DeletionState import org.thoughtcrime.securesms.backup.v2.BackupFrequency import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.backup.v2.ui.BackupAlert @@ -106,6 +110,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBa import org.thoughtcrime.securesms.compose.ComposeFragment import org.thoughtcrime.securesms.compose.StatusBarColorNestedScrollConnection import org.thoughtcrime.securesms.help.HelpFragment +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState import org.thoughtcrime.securesms.payments.FiatMoneyUtil import org.thoughtcrime.securesms.util.DateUtils @@ -145,6 +150,7 @@ class RemoteBackupsSettingsFragment : ComposeFragment() { val state by viewModel.state.collectAsState() val backupProgress by ArchiveUploadProgress.progress.collectAsStateWithLifecycle(initialValue = null) val restoreState by viewModel.restoreState.collectAsState() + val deleteState by SignalStore.backup.deletionStateFlow.collectAsStateWithLifecycle(initialValue = SignalStore.backup.deletionState) val callbacks = remember { Callbacks() } RemoteBackupsSettingsContent( @@ -160,6 +166,7 @@ class RemoteBackupsSettingsFragment : ComposeFragment() { backupMediaSize = state.backupMediaSize, backupState = state.backupState, backupRestoreState = restoreState, + backupDeleteState = deleteState, hasRedemptionError = state.hasRedemptionError, statusBarColorNestedScrollConnection = remember { StatusBarColorNestedScrollConnection(requireActivity()) } ) @@ -354,6 +361,8 @@ private interface ContentCallbacks { fun onRestoreUsingCellularConfirm() = Unit fun onRestoreUsingCellularClick() = Unit fun onRedemptionErrorDetailsClick() = Unit + + object Emtpy : ContentCallbacks } @OptIn(ExperimentalMaterial3Api::class) @@ -362,6 +371,7 @@ private fun RemoteBackupsSettingsContent( backupsEnabled: Boolean, backupState: RemoteBackupsSettingsState.BackupState, backupRestoreState: BackupRestoreState, + backupDeleteState: DeletionState, lastBackupTimestamp: Long, canBackUpUsingCellular: Boolean, canRestoreUsingCellular: Boolean, @@ -438,7 +448,8 @@ private fun RemoteBackupsSettingsContent( SubscriptionMismatchMissingGooglePlayCard( state = backupState, onLearnMoreClick = contentCallbacks::onLearnMoreAboutLostSubscription, - onRenewClick = contentCallbacks::onRenewLostSubscription + onRenewClick = contentCallbacks::onRenewLostSubscription, + isRenewEnabled = backupDeleteState != DeletionState.RUNNING ) } @@ -447,7 +458,8 @@ private fun RemoteBackupsSettingsContent( is RemoteBackupsSettingsState.BackupState.WithTypeAndRenewalTime -> { BackupCard( backupState = backupState, - onBackupTypeActionButtonClicked = contentCallbacks::onBackupTypeActionClick + onBackupTypeActionButtonClicked = contentCallbacks::onBackupTypeActionClick, + buttonsEnabled = backupDeleteState != DeletionState.RUNNING ) } @@ -455,13 +467,16 @@ private fun RemoteBackupsSettingsContent( SubscriptionNotFoundCard( title = stringResource(R.string.RemoteBackupsSettingsFragment__your_subscription_was_not_found), onRenewClick = contentCallbacks::onRenewLostSubscription, - onLearnMoreClick = contentCallbacks::onLearnMoreAboutLostSubscription + onLearnMoreClick = contentCallbacks::onLearnMoreAboutLostSubscription, + isRenewEnabled = backupDeleteState != DeletionState.RUNNING ) } } } - if (backupsEnabled) { + if (backupDeleteState != DeletionState.NONE) { + appendBackupDeletionState(backupDeleteState, contentCallbacks) + } else if (backupsEnabled) { appendBackupDetailsItems( backupState = backupState, backupRestoreState = backupRestoreState, @@ -496,12 +511,7 @@ private fun RemoteBackupsSettingsContent( } item { - Buttons.LargePrimary( - onClick = { contentCallbacks.onBackupTypeActionClick(MessageBackupTier.FREE) }, - modifier = Modifier.horizontalGutters() - ) { - Text(text = stringResource(R.string.RemoteBackupsSettingsFragment__reenable_backups)) - } + ReenableBackupsButton(contentCallbacks) } } } @@ -599,6 +609,96 @@ private fun RemoteBackupsSettingsContent( } } +@Composable +private fun ReenableBackupsButton(contentCallbacks: ContentCallbacks) { + Buttons.LargePrimary( + onClick = { contentCallbacks.onBackupTypeActionClick(MessageBackupTier.FREE) }, + modifier = Modifier.horizontalGutters() + ) { + Text(text = stringResource(R.string.RemoteBackupsSettingsFragment__reenable_backups)) + } +} + +private fun LazyListScope.appendBackupDeletionState( + backupDeleteState: DeletionState, + 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) + ) + } + + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(12.dp)) + .padding(24.dp) + ) { + Text( + text = stringResource(R.string.RemoteBackupsSettingsFragment__failed_to_delete_backup), + style = MaterialTheme.typography.titleSmall, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = stringResource(R.string.RemoteBackupsSettingsFragment__an_error_occurred_please_contact_support), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + } + + item { + ReenableBackupsButton(contentCallbacks) + } + } + + DeletionState.RUNNING -> { + 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) + ) + } + + item { + Column( + verticalArrangement = spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(12.dp)) + .padding(24.dp) + ) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth() + ) + Text( + text = stringResource(R.string.RemoteBackupsSettingsFragment__deleting_backup), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + private fun LazyListScope.appendBackupDetailsItems( backupState: RemoteBackupsSettingsState.BackupState, backupRestoreState: BackupRestoreState, @@ -735,6 +835,7 @@ private fun LazyListScope.appendBackupDetailsItems( @Composable private fun BackupCard( backupState: RemoteBackupsSettingsState.BackupState.WithTypeAndRenewalTime, + buttonsEnabled: Boolean, onBackupTypeActionButtonClicked: (MessageBackupTier) -> Unit = {} ) { val messageBackupsType = backupState.messageBackupsType @@ -833,11 +934,13 @@ private fun BackupCard( CallToActionButton( text = buttonText, + enabled = buttonsEnabled, onClick = { onBackupTypeActionButtonClicked(messageBackupsType.tier) } ) } else if (backupState is RemoteBackupsSettingsState.BackupState.Canceled) { CallToActionButton( text = stringResource(R.string.RemoteBackupsSettingsFragment__renew), + enabled = buttonsEnabled, onClick = { onBackupTypeActionButtonClicked(MessageBackupTier.FREE) } ) } @@ -847,10 +950,12 @@ private fun BackupCard( @Composable private fun CallToActionButton( text: String, + enabled: Boolean, onClick: () -> Unit ) { Buttons.MediumTonal( onClick = onClick, + enabled = enabled, colors = ButtonDefaults.filledTonalButtonColors().copy( containerColor = SignalTheme.colors.colorTransparent5, contentColor = colorResource(R.color.signal_light_colorOnSurface) @@ -984,6 +1089,7 @@ private fun PendingCard( @Composable private fun SubscriptionNotFoundCard( title: String, + isRenewEnabled: Boolean, onRenewClick: () -> Unit = {}, onLearnMoreClick: () -> Unit = {} ) { @@ -1042,6 +1148,7 @@ private fun SubscriptionNotFoundCard( Buttons.MediumTonal( onClick = onLearnMoreClick, + enabled = isRenewEnabled, colors = ButtonDefaults.filledTonalButtonColors().copy( containerColor = SignalTheme.colors.colorTransparent5, contentColor = colorResource(R.color.signal_light_colorOnSurface) @@ -1061,6 +1168,7 @@ private fun SubscriptionNotFoundCard( @Composable private fun SubscriptionMismatchMissingGooglePlayCard( state: RemoteBackupsSettingsState.BackupState.SubscriptionMismatchMissingGooglePlay, + isRenewEnabled: Boolean, onRenewClick: () -> Unit = {}, onLearnMoreClick: () -> Unit = {} ) { @@ -1068,6 +1176,7 @@ private fun SubscriptionMismatchMissingGooglePlayCard( SubscriptionNotFoundCard( title = pluralStringResource(R.plurals.RemoteBackupsSettingsFragment__your_subscription_on_this_device_is_valid, days.toInt(), days), + isRenewEnabled = isRenewEnabled, onRenewClick = onRenewClick, onLearnMoreClick = onLearnMoreClick ) @@ -1090,6 +1199,7 @@ private fun InProgressBackupRow( ArchiveUploadProgressState.State.None, ArchiveUploadProgressState.State.UserCanceled -> { ArchiveProgressIndicator() } + ArchiveUploadProgressState.State.Export -> { val progressValue by animateFloatAsState(targetValue = archiveUploadProgressState.frameExportProgress(), animationSpec = tween(durationMillis = 250)) ArchiveProgressIndicator( @@ -1098,6 +1208,7 @@ private fun InProgressBackupRow( cancel = cancelArchiveUpload ) } + ArchiveUploadProgressState.State.UploadBackupFile, ArchiveUploadProgressState.State.UploadMedia -> { val progressValue by animateFloatAsState(targetValue = archiveUploadProgressState.uploadProgress(), animationSpec = tween(durationMillis = 250)) ArchiveProgressIndicator( @@ -1130,7 +1241,9 @@ private fun ArchiveProgressIndicator( trackColor = MaterialTheme.colorScheme.secondaryContainer, progress = progress, drawStopIndicator = {}, - modifier = Modifier.weight(1f).padding(vertical = 12.dp) + modifier = Modifier + .weight(1f) + .padding(vertical = 12.dp) ) if (isCancelable) { @@ -1468,12 +1581,13 @@ private fun RemoteBackupsSettingsContentPreview() { backupsFrequency = BackupFrequency.MANUAL, requestedDialog = RemoteBackupsSettingsState.Dialog.NONE, requestedSnackbar = RemoteBackupsSettingsState.Snackbar.NONE, - contentCallbacks = object : ContentCallbacks {}, + contentCallbacks = ContentCallbacks.Emtpy, backupProgress = null, backupMediaSize = 2300000, backupState = RemoteBackupsSettingsState.BackupState.ActiveFree( messageBackupsType = MessageBackupsType.Free(mediaRetentionDays = 30) ), + backupDeleteState = DeletionState.NONE, backupRestoreState = BackupRestoreState.FromBackupStatusData(BackupStatusData.CouldNotCompleteBackup), hasRedemptionError = true, statusBarColorNestedScrollConnection = null @@ -1520,7 +1634,8 @@ private fun PendingCardPreview() { private fun SubscriptionNotFoundCardPreview() { Previews.Preview { SubscriptionNotFoundCard( - title = stringResource(R.string.RemoteBackupsSettingsFragment__your_subscription_was_not_found) + title = stringResource(R.string.RemoteBackupsSettingsFragment__your_subscription_was_not_found), + isRenewEnabled = true ) } } @@ -1537,7 +1652,8 @@ private fun SubscriptionMismatchMissingGooglePlayCardPreview() { mediaTtl = 30.days ), renewalTime = System.currentTimeMillis().milliseconds + 30.days - ) + ), + isRenewEnabled = true ) } } @@ -1556,7 +1672,8 @@ private fun BackupCardPreview() { ), renewalTime = 1727193018.seconds, price = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")) - ) + ), + buttonsEnabled = true ) BackupCard( @@ -1567,7 +1684,8 @@ private fun BackupCardPreview() { mediaTtl = 30.days ), renewalTime = 1727193018.seconds - ) + ), + buttonsEnabled = true ) BackupCard( @@ -1578,7 +1696,8 @@ private fun BackupCardPreview() { mediaTtl = 30.days ), renewalTime = 1727193018.seconds - ) + ), + buttonsEnabled = true ) BackupCard( @@ -1590,7 +1709,8 @@ private fun BackupCardPreview() { ), renewalTime = 1727193018.seconds, price = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")) - ) + ), + buttonsEnabled = true ) BackupCard( @@ -1598,7 +1718,8 @@ private fun BackupCardPreview() { messageBackupsType = MessageBackupsType.Free( mediaRetentionDays = 30 ) - ) + ), + buttonsEnabled = true ) } } @@ -1755,6 +1876,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") + } + } + appendBackupDeletionState(backupDeletionState, contentCallbacks = ContentCallbacks.Emtpy) + } + } +} + 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 0f9588f2f5..04b406fc3a 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,6 +14,7 @@ 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 @@ -27,6 +28,7 @@ import org.signal.core.util.logging.Log import org.signal.core.util.money.FiatMoney import org.signal.donations.InAppPaymentType import org.thoughtcrime.securesms.backup.ArchiveUploadProgress +import org.thoughtcrime.securesms.backup.DeletionState import org.thoughtcrime.securesms.backup.v2.BackupFrequency import org.thoughtcrime.securesms.backup.v2.BackupRepository import org.thoughtcrime.securesms.backup.v2.MessageBackupTier @@ -41,7 +43,6 @@ import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.jobs.BackupMessagesJob -import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState import org.thoughtcrime.securesms.service.MessageBackupListener @@ -76,6 +77,14 @@ class RemoteBackupsSettingsViewModel : ViewModel() { val restoreState: StateFlow = _restoreState init { + viewModelScope.launch(Dispatchers.IO) { + SignalStore.backup.deletionStateFlow + .filter { it == DeletionState.NONE } + .collect { + refresh() + } + } + viewModelScope.launch(Dispatchers.IO) { latestPurchaseId .flatMapLatest { id -> InAppPaymentsRepository.observeUpdates(id).asFlow() } @@ -178,34 +187,8 @@ class RemoteBackupsSettingsViewModel : ViewModel() { } fun turnOffAndDeleteBackups() { - viewModelScope.launch { - Log.d(TAG, "Beginning to turn off and delete backup.") - requestDialog(RemoteBackupsSettingsState.Dialog.PROGRESS_SPINNER) - - val hasMediaBackupUploaded = SignalStore.backup.backsUpMedia && SignalStore.backup.hasBackupBeenUploaded - - val succeeded = withContext(Dispatchers.IO) { - BackupRepository.turnOffAndDisableBackups() - } - - if (isActive) { - if (succeeded) { - if (hasMediaBackupUploaded && SignalStore.backup.optimizeStorage) { - Log.d(TAG, "User has optimized storage, downloading.") - requestDialog(RemoteBackupsSettingsState.Dialog.DOWNLOADING_YOUR_BACKUP) - - SignalStore.backup.optimizeStorage = false - RestoreOptimizedMediaJob.enqueue() - } else { - Log.d(TAG, "User does not have optimized storage, finished.") - requestDialog(RemoteBackupsSettingsState.Dialog.NONE) - } - refresh() - } else { - Log.d(TAG, "Failed to disable backups.") - requestDialog(RemoteBackupsSettingsState.Dialog.TURN_OFF_FAILED) - } - } + viewModelScope.launch(Dispatchers.IO) { + BackupRepository.turnOffAndDisableBackups() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupDeleteJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupDeleteJob.kt new file mode 100644 index 0000000000..d51d986c17 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupDeleteJob.kt @@ -0,0 +1,201 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.jobs + +import org.signal.core.util.logging.Log +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.InAppPaymentsRepository +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.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint +import org.thoughtcrime.securesms.jobs.protos.BackupDeleteJobData +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.whispersystems.signalservice.api.NetworkResult + +/** + * Handles deleting user backup and unsubscribing them from backups. + */ +class BackupDeleteJob private constructor( + private var backupDeleteJobData: BackupDeleteJobData, + parameters: Parameters +) : Job(parameters) { + + companion object { + const val KEY = "BackupDeleteJob" + private val TAG = Log.tag(BackupDeleteJob::class) + } + + constructor() : this( + BackupDeleteJobData(), + Parameters.Builder() + .addConstraint(NetworkConstraint.KEY) + .setMaxInstancesForFactory(1) + .build() + ) + + override fun serialize(): ByteArray = backupDeleteJobData.encode() + + override fun getFactoryKey(): String = KEY + + override fun onAdded() { + SignalStore.backup.deletionState = DeletionState.RUNNING + } + + override fun run(): Result { + val results = listOf( + cancelActiveSubscription(), + deleteMessageBackup(), + deleteMediaBackup(), + deleteLocalState() + ) + + 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 + Result.success() + } + hasRetries -> { + Log.d(TAG, "Retries were detected. Scheduling.") + Result.retry(defaultBackoff()) + } + else -> { + Log.d(TAG, "Not all stages completed and no retries were present.") + Result.failure() + } + } + } + + override fun onFailure() { + SignalStore.backup.deletionState = DeletionState.FAILED + } + + private fun cancelActiveSubscription(): Result { + if (backupDeleteJobData.completed.contains(BackupDeleteJobData.Stage.CANCEL_SUBSCRIBER)) { + Log.d(TAG, "Already canceled active subscription.") + return Result.success() + } + + Log.d(TAG, "Checking for an active backups subscription.") + val subscriberId = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP) + if (subscriberId != null) { + Log.d(TAG, "Found a subscriber. Canceling subscription.") + try { + RecurringInAppPaymentRepository.cancelActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP) + } catch (e: Exception) { + Log.w(TAG, "Failed to cancel active backups subscription. Failing.", e) + return Result.failure() + } + Log.d(TAG, "Finished canceling subscription.") + } else { + Log.d(TAG, "No subscriber found. Skipping subscription cancellation.") + } + + addStageToCompletions(BackupDeleteJobData.Stage.CANCEL_SUBSCRIBER) + return Result.success() + } + + private fun deleteMessageBackup(): Result { + if (backupDeleteJobData.completed.contains(BackupDeleteJobData.Stage.DELETE_MESSAGES)) { + Log.d(TAG, "Already deleted messages.") + return Result.success() + } + + val deleteMessageBackupResult: NetworkResult = BackupRepository.deleteBackup() + if (deleteMessageBackupResult.getCause() != null) { + Log.w(TAG, "Failed to delete message backup", deleteMessageBackupResult.getCause()) + return handleNetworkError(deleteMessageBackupResult) + } else { + Log.d(TAG, "Deleted message backup.") + } + + addStageToCompletions(BackupDeleteJobData.Stage.DELETE_MESSAGES) + return Result.success() + } + + private fun deleteMediaBackup(): Result { + if (backupDeleteJobData.completed.contains(BackupDeleteJobData.Stage.DELETE_MEDIA)) { + Log.d(TAG, "Already deleted media.") + 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) { + val deleteMediaBackupResult: NetworkResult = BackupRepository.deleteMediaBackup() + if (deleteMediaBackupResult.getCause() != null) { + Log.w(TAG, "Failed to delete media backup", deleteMediaBackupResult.getCause()) + return handleNetworkError(deleteMediaBackupResult) + } else { + Log.d(TAG, "Deleted media backup.") + } + } + + addStageToCompletions(BackupDeleteJobData.Stage.DELETE_MEDIA) + return Result.success() + } + + private fun deleteLocalState(): Result { + if (backupDeleteJobData.completed.contains(BackupDeleteJobData.Stage.CLEAR_LOCAL_STATE)) { + Log.d(TAG, "Already deleted messages.") + return Result.success() + } + + Log.d(TAG, "Clearing local backup state.") + SignalStore.backup.disableBackups() + SignalDatabase.attachments.clearAllArchiveData() + addStageToCompletions(BackupDeleteJobData.Stage.CLEAR_LOCAL_STATE) + return Result.success() + } + + private fun addStageToCompletions(stage: BackupDeleteJobData.Stage) { + backupDeleteJobData = backupDeleteJobData.newBuilder() + .completed(backupDeleteJobData.completed + stage) + .build() + } + + private fun handleNetworkError(networkResult: NetworkResult): Result { + Log.d(TAG, "An error occurred.", networkResult.getCause()) + + return when (networkResult) { + is NetworkResult.ApplicationError<*> -> (networkResult.getCause() as? RuntimeException)?.let { Result.fatalFailure(it) } ?: Result.failure() + is NetworkResult.NetworkError<*> -> Result.retry(defaultBackoff()) + is NetworkResult.StatusCodeError<*> -> handleStatusCodeError(networkResult) + is NetworkResult.Success<*> -> error("Success.") + } + } + + private fun handleStatusCodeError(statusCodeError: NetworkResult.StatusCodeError<*>): Result { + Log.d(TAG, "Status code error: ${statusCodeError.code}") + + return when (statusCodeError.code) { + 429 -> Result.retry(statusCodeError.retryAfter()?.inWholeMilliseconds ?: defaultBackoff()) + else -> Result.failure() + } + } + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): BackupDeleteJob { + val data = BackupDeleteJobData.ADAPTER.decode(serializedData!!) + + return BackupDeleteJob(data, parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index da580d76ac..15327ec079 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -130,6 +130,7 @@ public final class JobManagerFactories { put(AvatarGroupsV2DownloadJob.KEY, new AvatarGroupsV2DownloadJob.Factory()); put(BackfillDigestJob.KEY, new BackfillDigestJob.Factory()); put(BackfillDigestsForDataFileJob.KEY, new BackfillDigestsForDataFileJob.Factory()); + put(BackupDeleteJob.KEY, new BackupDeleteJob.Factory()); put(BackupMessagesJob.KEY, new BackupMessagesJob.Factory()); put(BackupRestoreJob.KEY, new BackupRestoreJob.Factory()); put(BackupRestoreMediaJob.KEY, new BackupRestoreMediaJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreOptimizedMediaJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreOptimizedMediaJob.kt index 43cfa423f7..2b72ab75d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreOptimizedMediaJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreOptimizedMediaJob.kt @@ -34,7 +34,7 @@ class RestoreOptimizedMediaJob private constructor(parameters: Parameters) : Job } } - private constructor() : this( + constructor() : this( parameters = Parameters.Builder() .setQueue("RestoreOptimizeMediaJob") .setMaxInstancesForQueue(2) 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 ec47331921..d7f1c6fa45 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty import kotlinx.coroutines.flow.Flow import okio.withLock import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.backup.DeletionState import org.thoughtcrime.securesms.backup.RestoreState import org.thoughtcrime.securesms.backup.v2.BackupFrequency import org.thoughtcrime.securesms.backup.v2.MessageBackupTier @@ -71,6 +72,7 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { private const val KEY_USER_MANUALLY_SKIPPED_MEDIA_RESTORE = "backup.user.manually.skipped.media.restore" private const val KEY_BACKUP_EXPIRED_AND_DOWNGRADED = "backup.expired.and.downgraded" + private const val KEY_BACKUP_DELETION_STATE = "backup.deletion.state" private const val KEY_MEDIA_ROOT_BACKUP_KEY = "backup.mediaRootBackupKey" @@ -86,6 +88,10 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { var usedBackupMediaSpace: Long by longValue(KEY_BACKUP_USED_MEDIA_SPACE, 0L) var lastBackupProtoSize: Long by longValue(KEY_BACKUP_LAST_PROTO_SIZE, 0L) + private val deletionStateValue = enumValue(KEY_BACKUP_DELETION_STATE, DeletionState.NONE, DeletionState.serializer) + var deletionState by deletionStateValue + val deletionStateFlow: Flow = deletionStateValue.toFlow() + var restoreState: RestoreState by enumValue(KEY_RESTORE_STATE, RestoreState.NONE, RestoreState.serializer) var optimizeStorage: Boolean by booleanValue(KEY_OPTIMIZE_STORAGE, false) var backupWithCellular: Boolean by booleanValue(KEY_BACKUP_OVER_CELLULAR, false) diff --git a/app/src/main/protowire/JobData.proto b/app/src/main/protowire/JobData.proto index 3ee608c997..21f3b9c97c 100644 --- a/app/src/main/protowire/JobData.proto +++ b/app/src/main/protowire/JobData.proto @@ -217,4 +217,16 @@ message InAppPaymentSetupJobData { message StorageSyncJobData { bool localManifestOutOfDate = 1; +} + +message BackupDeleteJobData { + enum Stage { + UNKNOWN = 0; + CANCEL_SUBSCRIBER = 1; + DELETE_MESSAGES = 2; + DELETE_MEDIA = 3; + CLEAR_LOCAL_STATE = 4; + } + + repeated Stage completed = 1; } \ 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 fc86d0a699..6ff5ae68f4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8207,6 +8207,14 @@ Uploading: %1$s of %2$s (%3$d%%) Details + + Backups have been turned off, but there was an error deleting your data from Signal\'s secure storage service. + + Failed to delete backup + + An error occurred. Please contact support. + + Backups have been turned off and your data will be deleted from Signal\'s secure storage service. @@ -8231,6 +8239,8 @@ A network failure occurred. Please try again later. + + A backup deletion is in progress. diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveApi.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveApi.kt index 6c55589d90..fb8b40b4a5 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveApi.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveApi.kt @@ -181,7 +181,7 @@ class ArchiveApi( * - 403: Forbidden. The request had insufficient permissions to perform the requested action. * - 429: Rate limited. */ - fun refreshBackup(aci: ACI, archiveServiceAccess: ArchiveServiceAccess): NetworkResult { + fun refreshBackup(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult { return getCredentialPresentation(aci, archiveServiceAccess) .map { it.toArchiveCredentialPresentation().toHeaders() } .then { headers -> @@ -203,7 +203,7 @@ class ArchiveApi( * - 429: Rate limited. * */ - fun deleteBackup(aci: ACI, archiveServiceAccess: ArchiveServiceAccess): NetworkResult { + fun deleteBackup(aci: ACI, archiveServiceAccess: ArchiveServiceAccess<*>): NetworkResult { return getCredentialPresentation(aci, archiveServiceAccess) .map { it.toArchiveCredentialPresentation().toHeaders() } .then { headers ->