Perform backup deletion in a durable job.

This commit is contained in:
Alex Hart
2025-05-28 13:07:09 -03:00
committed by GitHub
parent 8900721064
commit 6a40f4a4f4
17 changed files with 603 additions and 91 deletions

View File

@@ -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(

View File

@@ -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<DeletionState> = Serializer()
}
class Serializer : LongSerializer<DeletionState> {
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
}
}
}
}

View File

@@ -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<Unit> {
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<Unit> {
return initBackupAndFetchAuth()
.then { credential ->
SignalNetwork.archive.deleteBackup(SignalStore.account.requireAci(), credential.mediaBackupAccess)
}
}
fun debugDeleteAllArchivedMedia(): NetworkResult<Unit> {
val itemLimit = 1000
return debugGetArchivedMediaState()

View File

@@ -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 = {},

View File

@@ -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
}
}

View File

@@ -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<MessageBackupsFlowState> = internalStateFlow
val deletionState: Flow<DeletionState> = SignalStore.backup.deletionStateFlow
init {
viewModelScope.launch {

View File

@@ -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<MessageBackupsType>,
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
)
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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<BackupRestoreState> = _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()
}
}

View File

@@ -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<Unit> = 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<MessageBackupTier> = 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<Unit> = 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 <T> handleNetworkError(networkResult: NetworkResult<T>): 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<BackupDeleteJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): BackupDeleteJob {
val data = BackupDeleteJobData.ADAPTER.decode(serializedData!!)
return BackupDeleteJob(data, parameters)
}
}
}

View File

@@ -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());

View File

@@ -34,7 +34,7 @@ class RestoreOptimizedMediaJob private constructor(parameters: Parameters) : Job
}
}
private constructor() : this(
constructor() : this(
parameters = Parameters.Builder()
.setQueue("RestoreOptimizeMediaJob")
.setMaxInstancesForQueue(2)

View File

@@ -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<DeletionState> = 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)