mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-25 04:06:14 +00:00
Perform backup deletion in a durable job.
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -34,7 +34,7 @@ class RestoreOptimizedMediaJob private constructor(parameters: Parameters) : Job
|
||||
}
|
||||
}
|
||||
|
||||
private constructor() : this(
|
||||
constructor() : this(
|
||||
parameters = Parameters.Builder()
|
||||
.setQueue("RestoreOptimizeMediaJob")
|
||||
.setMaxInstancesForQueue(2)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user