Implement stop/resume media restore and update restore over cellular.

This commit is contained in:
Cody Henthorne
2025-05-05 19:51:36 -04:00
committed by Michelle Tang
parent 9867fa3f50
commit 93403a0d2c
15 changed files with 183 additions and 46 deletions

View File

@@ -69,8 +69,10 @@ 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.CheckRestoreMediaLeftJob
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
import org.thoughtcrime.securesms.jobs.RestoreAttachmentJob
import org.thoughtcrime.securesms.jobs.RestoreOptimizedMediaJob
import org.thoughtcrime.securesms.keyvalue.BackupValues.ArchiveServiceCredentials
import org.thoughtcrime.securesms.keyvalue.KeyValueStore
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -196,6 +198,12 @@ object BackupRepository {
}
}
@JvmStatic
fun resumeMediaRestore() {
SignalStore.backup.userManuallySkippedMediaRestore = false
RestoreOptimizedMediaJob.enqueue()
}
/**
* Cancels any relevant jobs for media restore
*/
@@ -206,6 +214,10 @@ object BackupRepository {
AppDependencies.jobManager.cancelAllInQueue(RestoreAttachmentJob.constructQueueString(RestoreAttachmentJob.RestoreOperation.RESTORE_OFFLOADED))
AppDependencies.jobManager.cancelAllInQueue(RestoreAttachmentJob.constructQueueString(RestoreAttachmentJob.RestoreOperation.INITIAL_RESTORE))
AppDependencies.jobManager.cancelAllInQueue(RestoreAttachmentJob.constructQueueString(RestoreAttachmentJob.RestoreOperation.MANUAL))
AppDependencies.jobManager.add(CheckRestoreMediaLeftJob(RestoreAttachmentJob.constructQueueString(RestoreAttachmentJob.RestoreOperation.RESTORE_OFFLOADED)))
AppDependencies.jobManager.add(CheckRestoreMediaLeftJob(RestoreAttachmentJob.constructQueueString(RestoreAttachmentJob.RestoreOperation.INITIAL_RESTORE)))
AppDependencies.jobManager.add(CheckRestoreMediaLeftJob(RestoreAttachmentJob.constructQueueString(RestoreAttachmentJob.RestoreOperation.MANUAL)))
}
/**

View File

@@ -55,9 +55,7 @@ import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.billing.launchManageBackupsSubscription
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.PlayStoreUtil
import org.signal.core.ui.R as CoreUiR
@@ -185,8 +183,7 @@ class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() {
}
private fun performFullMediaDownload() {
// TODO [backups] -- We need to force this to download everything
AppDependencies.jobManager.add(BackupRestoreMediaJob())
BackupRepository.resumeMediaRestore()
}
}

View File

@@ -283,8 +283,18 @@ sealed interface BackupStatusData {
val restoreStatus: RestoreStatus = RestoreStatus.NORMAL
) : BackupStatusData {
override val iconRes: Int = if (restoreStatus == RestoreStatus.FINISHED) R.drawable.symbol_check_circle_24 else R.drawable.symbol_backup_light
override val iconColors: BackupsIconColors = if (restoreStatus == RestoreStatus.FINISHED) BackupsIconColors.Success else BackupsIconColors.Normal
override val iconColors: BackupsIconColors = when (restoreStatus) {
RestoreStatus.FINISHED -> BackupsIconColors.Success
RestoreStatus.NORMAL -> BackupsIconColors.Normal
RestoreStatus.LOW_BATTERY,
RestoreStatus.WAITING_FOR_INTERNET,
RestoreStatus.WAITING_FOR_WIFI -> BackupsIconColors.Warning
}
override val showDismissAction: Boolean = restoreStatus == RestoreStatus.FINISHED
override val actionRes: Int = when (restoreStatus) {
RestoreStatus.WAITING_FOR_WIFI -> R.string.BackupStatus__resume
else -> NONE
}
override val title: String
@Composable get() = stringResource(
@@ -311,7 +321,7 @@ sealed interface BackupStatusData {
RestoreStatus.FINISHED -> bytesTotal.toUnitString()
}
override val progress: Float = if (bytesTotal.bytes > 0 && restoreStatus != RestoreStatus.FINISHED) {
override val progress: Float = if (bytesTotal.bytes > 0 && restoreStatus == RestoreStatus.NORMAL) {
min(1f, max(0f, bytesDownloaded.bytes.toFloat() / bytesTotal.bytes.toFloat()))
} else {
NONE.toFloat()

View File

@@ -184,9 +184,10 @@ private fun getRestoringMediaString(backupStatusData: BackupStatusData.Restoring
@Composable
private fun progressColor(backupStatusData: BackupStatusData): Color {
return when (backupStatusData) {
is BackupStatusData.RestoringMedia -> MaterialTheme.colorScheme.primary
else -> backupStatusData.iconColors.foreground
return if (backupStatusData is BackupStatusData.RestoringMedia && backupStatusData.restoreStatus == BackupStatusData.RestoreStatus.NORMAL) {
MaterialTheme.colorScheme.primary
} else {
backupStatusData.iconColors.foreground
}
}

View File

@@ -58,7 +58,7 @@ class MediaRestoreProgressBanner(private val listener: RestoreProgressBannerList
totalRestoredSize > 0 -> {
flowOf(
BackupStatusData.RestoringMedia(
bytesTotal = totalRestoredSize.bytes.also { totalRestoredSize = 0 },
bytesTotal = totalRestoredSize.bytes,
restoreStatus = BackupStatusData.RestoreStatus.FINISHED
)
)
@@ -75,7 +75,10 @@ class MediaRestoreProgressBanner(private val listener: RestoreProgressBannerList
data = model,
onBannerClick = listener::onBannerClick,
onActionClick = listener::onActionClick,
onDismissClick = listener::onDismissComplete
onDismissClick = {
totalRestoredSize = 0
listener.onDismissComplete()
}
)
}

View File

@@ -217,7 +217,7 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
}
override fun onCancelMediaRestore() {
viewModel.cancelMediaRestore()
viewModel.requestDialog(RemoteBackupsSettingsState.Dialog.CANCEL_MEDIA_RESTORE_PROTECTION)
}
override fun onDisplaySkipMediaRestoreProtectionDialog() {
@@ -245,8 +245,12 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
BackupAlertBottomSheet.create(BackupAlert.BackupFailed).show(parentFragmentManager, null)
}
override fun onRestoreUsingCellularClick(canUseCellular: Boolean) {
viewModel.setCanRestoreUsingCellular(canUseCellular)
override fun onRestoreUsingCellularConfirm() {
viewModel.requestDialog(RemoteBackupsSettingsState.Dialog.RESTORE_OVER_CELLULAR_PROTECTION)
}
override fun onRestoreUsingCellularClick() {
viewModel.setCanRestoreUsingCellular()
}
override fun onRedemptionErrorDetailsClick() {
@@ -334,7 +338,8 @@ private interface ContentCallbacks {
fun onLearnMoreAboutLostSubscription() = Unit
fun onContactSupport() = Unit
fun onLearnMoreAboutBackupFailure() = Unit
fun onRestoreUsingCellularClick(canUseCellular: Boolean) = Unit
fun onRestoreUsingCellularConfirm() = Unit
fun onRestoreUsingCellularClick() = Unit
fun onRedemptionErrorDetailsClick() = Unit
}
@@ -425,18 +430,20 @@ private fun RemoteBackupsSettingsContent(
)
}
item {
Rows.ToggleRow(
checked = canRestoreUsingCellular,
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__restore_using_cellular),
onCheckChanged = contentCallbacks::onRestoreUsingCellularClick
)
if (!canRestoreUsingCellular) {
item {
Rows.TextRow(
text = stringResource(R.string.RemoteBackupsSettingsFragment__resume_download),
icon = painterResource(R.drawable.symbol_arrow_circle_down_24),
onClick = contentCallbacks::onRestoreUsingCellularConfirm
)
}
}
} else if (backupRestoreState is BackupRestoreState.Ready && backupState is RemoteBackupsSettingsState.BackupState.Canceled) {
} else if (backupRestoreState is BackupRestoreState.Ready) {
item {
BackupReadyToDownloadRow(
ready = backupRestoreState,
endOfSubscription = backupState.renewalTime,
backupState = backupState,
onDownloadClick = contentCallbacks::onStartMediaRestore
)
}
@@ -536,6 +543,20 @@ private fun RemoteBackupsSettingsContent(
onSkipClick = contentCallbacks::onSkipMediaRestore
)
}
RemoteBackupsSettingsState.Dialog.CANCEL_MEDIA_RESTORE_PROTECTION -> {
CancelInitialRestoreDialog(
onDismiss = contentCallbacks::onDialogDismissed,
onSkipClick = contentCallbacks::onSkipMediaRestore
)
}
RemoteBackupsSettingsState.Dialog.RESTORE_OVER_CELLULAR_PROTECTION -> {
ResumeRestoreOverCellularDialog(
onDismiss = contentCallbacks::onDialogDismissed,
onResumeOverCellularClick = contentCallbacks::onRestoreUsingCellularClick
)
}
}
val snackbarMessageId = remember(requestedSnackbar) {
@@ -1164,6 +1185,37 @@ private fun SkipDownloadDialog(
)
}
@Composable
private fun CancelInitialRestoreDialog(
onSkipClick: () -> Unit = {},
onDismiss: () -> Unit = {}
) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.RemoteBackupsSettingsFragment__skip_restore_question),
body = stringResource(R.string.RemoteBackupsSettingsFragment__skip_restore_message),
confirm = stringResource(R.string.RemoteBackupsSettingsFragment__skip),
dismiss = stringResource(android.R.string.cancel),
confirmColor = MaterialTheme.colorScheme.error,
onConfirm = onSkipClick,
onDismiss = onDismiss
)
}
@Composable
private fun ResumeRestoreOverCellularDialog(
onResumeOverCellularClick: () -> Unit = {},
onDismiss: () -> Unit = {}
) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.ResumeRestoreCellular_resume_using_cellular_title),
body = stringResource(R.string.ResumeRestoreCellular_resume_using_cellular_message),
confirm = stringResource(R.string.BackupStatus__resume),
dismiss = stringResource(android.R.string.cancel),
onConfirm = onResumeOverCellularClick,
onDismiss = onDismiss
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CircularProgressDialog(
@@ -1254,11 +1306,16 @@ private fun BackupFrequencyDialog(
@Composable
private fun BackupReadyToDownloadRow(
ready: BackupRestoreState.Ready,
endOfSubscription: Duration,
backupState: RemoteBackupsSettingsState.BackupState,
onDownloadClick: () -> Unit = {}
) {
val days = (endOfSubscription - System.currentTimeMillis().milliseconds).inWholeDays.toInt()
val string = pluralStringResource(R.plurals.RemoteBackupsSettingsFragment__you_have_s_of_backup_data, days, ready.bytes, days)
val string = if (backupState is RemoteBackupsSettingsState.BackupState.Canceled) {
val days = (backupState.renewalTime - System.currentTimeMillis().milliseconds).inWholeDays.toInt()
pluralStringResource(R.plurals.RemoteBackupsSettingsFragment__you_have_s_of_backup_data, days, ready.bytes, days)
} else {
stringResource(R.string.RemoteBackupsSettingsFragment__you_have_s_of_backup_data_not_on_device, ready.bytes)
}
val annotated = buildAnnotatedString {
append(string)
val startIndex = string.indexOf(ready.bytes)
@@ -1436,7 +1493,7 @@ private fun BackupReadyToDownloadPreview() {
Previews.Preview {
BackupReadyToDownloadRow(
ready = BackupRestoreState.Ready("12GB"),
endOfSubscription = System.currentTimeMillis().milliseconds + 30.days
backupState = RemoteBackupsSettingsState.BackupState.None
)
}
}

View File

@@ -115,7 +115,9 @@ data class RemoteBackupsSettingsState(
DOWNLOADING_YOUR_BACKUP,
TURN_OFF_FAILED,
SUBSCRIPTION_NOT_FOUND,
SKIP_MEDIA_RESTORE_PROTECTION
SKIP_MEDIA_RESTORE_PROTECTION,
CANCEL_MEDIA_RESTORE_PROTECTION,
RESTORE_OVER_CELLULAR_PROTECTION
}
enum class Snackbar {

View File

@@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -83,15 +84,25 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
}
}
viewModelScope.launch(Dispatchers.Default) {
viewModelScope.launch(Dispatchers.IO) {
val restoreProgress = MediaRestoreProgressBanner()
var optimizedRemainingBytes = 0L
while (isActive) {
if (restoreProgress.enabled) {
Log.d(TAG, "Backup is being restored. Collecting updates.")
restoreProgress.dataFlow.collectLatest { latest ->
_restoreState.update { BackupRestoreState.FromBackupStatusData(latest) }
}
restoreProgress
.dataFlow
.takeWhile { it !is BackupStatusData.RestoringMedia || it.restoreStatus != BackupStatusData.RestoreStatus.FINISHED }
.collectLatest { latest ->
_restoreState.update { BackupRestoreState.FromBackupStatusData(latest) }
}
} else if (
!SignalStore.backup.optimizeStorage &&
SignalStore.backup.userManuallySkippedMediaRestore &&
SignalDatabase.attachments.getOptimizedMediaAttachmentSize().also { optimizedRemainingBytes = it } > 0
) {
_restoreState.update { BackupRestoreState.Ready(optimizedRemainingBytes.bytes.toUnitString()) }
} else if (SignalStore.backup.totalRestorableAttachmentSize > 0L) {
_restoreState.update { BackupRestoreState.Ready(SignalStore.backup.totalRestorableAttachmentSize.bytes.toUnitString()) }
} else if (BackupRepository.shouldDisplayBackupFailedSettingsRow()) {
@@ -126,9 +137,9 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
_state.update { it.copy(canBackUpUsingCellular = canBackUpUsingCellular) }
}
fun setCanRestoreUsingCellular(canRestoreUsingCellular: Boolean) {
SignalStore.backup.restoreWithCellular = canRestoreUsingCellular
_state.update { it.copy(canRestoreUsingCellular = canRestoreUsingCellular) }
fun setCanRestoreUsingCellular() {
SignalStore.backup.restoreWithCellular = true
_state.update { it.copy(canRestoreUsingCellular = true) }
}
fun setBackupsFrequency(backupsFrequency: BackupFrequency) {
@@ -139,17 +150,13 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
}
fun beginMediaRestore() {
// TODO - [backups] Begin media restore.
BackupRepository.resumeMediaRestore()
}
fun skipMediaRestore() {
BackupRepository.skipMediaRestore()
}
fun cancelMediaRestore() {
// TODO - [backups] Cancel in-progress media restoration
}
fun requestDialog(dialog: RemoteBackupsSettingsState.Dialog) {
_state.update { it.copy(dialog = dialog) }
}
@@ -269,14 +276,17 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
return
}
hasActiveSignalSubscription && hasActiveGooglePlayBillingSubscription -> {
Log.d(TAG, "Found active signal subscription and active google play subscription. Clearing mismatch.")
SignalStore.backup.subscriptionStateMismatchDetected = false
}
!hasActiveSignalSubscription && !hasActiveGooglePlayBillingSubscription -> {
Log.d(TAG, "Found inactive signal subscription and inactive google play subscription. Clearing mismatch.")
SignalStore.backup.subscriptionStateMismatchDetected = false
}
else -> {
Log.w(TAG, "Hit unexpected subscription mismatch state: signal:false, google:true")
return

View File

@@ -294,6 +294,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
fun wipeAllDataAndRestoreFromRemote() {
SignalExecutors.BOUNDED_IO.execute {
SignalStore.backup.restoreWithCellular = false
restoreFromRemote()
}
}

View File

@@ -759,6 +759,13 @@ public class ConversationListFragment extends MainFragment implements ActionMode
if (backupStatusData instanceof BackupStatusData.NotEnoughFreeSpace) {
BackupAlertBottomSheet.create(new BackupAlert.DiskFull(((BackupStatusData.NotEnoughFreeSpace) backupStatusData).getRequiredSpace()))
.show(getParentFragmentManager(), null);
} else if (backupStatusData instanceof BackupStatusData.RestoringMedia && ((BackupStatusData.RestoringMedia) backupStatusData).getRestoreStatus() == BackupStatusData.RestoreStatus.WAITING_FOR_WIFI) {
new MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.ResumeRestoreCellular_resume_using_cellular_title)
.setMessage(R.string.ResumeRestoreCellular_resume_using_cellular_message)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.BackupStatus__resume, (d, w) -> SignalStore.backup().setRestoreWithCellular(true))
.show();
}
}

View File

@@ -555,6 +555,15 @@ class AttachmentTable(
.readToSingleLong()
}
fun getOptimizedMediaAttachmentSize(): Long {
return readableDatabase
.select("SUM($DATA_SIZE)")
.from(TABLE_NAME)
.where("$TRANSFER_STATE = ?", TRANSFER_RESTORE_OFFLOADED)
.run()
.readToSingleLong()
}
/**
* Finds all of the attachmentIds of attachments that need to be uploaded to the archive cdn.
*/

View File

@@ -88,6 +88,7 @@ class AttachmentDownloadJob private constructor(
return when (val transferState = databaseAttachment.transferState) {
AttachmentTable.TRANSFER_PROGRESS_DONE -> null
AttachmentTable.TRANSFER_RESTORE_IN_PROGRESS,
AttachmentTable.TRANSFER_RESTORE_OFFLOADED,
AttachmentTable.TRANSFER_NEEDS_RESTORE -> RestoreAttachmentJob.restoreAttachment(databaseAttachment)
@@ -112,7 +113,6 @@ class AttachmentDownloadJob private constructor(
}
}
AttachmentTable.TRANSFER_RESTORE_IN_PROGRESS,
AttachmentTable.TRANSFER_PROGRESS_STARTED,
AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE -> {
Log.d(TAG, "${databaseAttachment.attachmentId} is downloading or permanently failed, transferState: $transferState")

View File

@@ -28,6 +28,7 @@ import org.thoughtcrime.securesms.events.PartProgressEvent
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.JobLogger.format
import org.thoughtcrime.securesms.jobmanager.impl.BatteryNotLowConstraint
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.jobmanager.impl.RestoreAttachmentConstraint
import org.thoughtcrime.securesms.jobs.protos.RestoreAttachmentJobData
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -117,8 +118,14 @@ class RestoreAttachmentJob private constructor(
private constructor(messageId: Long, attachmentId: AttachmentId, manual: Boolean, queue: String) : this(
Parameters.Builder()
.setQueue(queue)
.addConstraint(RestoreAttachmentConstraint.KEY)
.addConstraint(BatteryNotLowConstraint.KEY)
.apply {
if (manual) {
addConstraint(NetworkConstraint.KEY)
} else {
addConstraint(RestoreAttachmentConstraint.KEY)
addConstraint(BatteryNotLowConstraint.KEY)
}
}
.setLifespan(TimeUnit.DAYS.toMillis(30))
.build(),
messageId,

View File

@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.jobs
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
@@ -17,6 +18,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
class RestoreOptimizedMediaJob private constructor(parameters: Parameters) : Job(parameters) {
companion object {
private val TAG = Log.tag(RestoreOptimizedMediaJob::class)
const val KEY = "RestoreOptimizeMediaJob"
fun enqueue() {
@@ -42,6 +44,12 @@ class RestoreOptimizedMediaJob private constructor(parameters: Parameters) : Job
override fun run(): Result {
if (SignalStore.backup.optimizeStorage && !SignalStore.backup.userManuallySkippedMediaRestore) {
Log.i(TAG, "User is optimizing media and has not skipped restore, skipping.")
return Result.success()
}
if (!SignalStore.backup.optimizeStorage && SignalStore.backup.userManuallySkippedMediaRestore) {
Log.i(TAG, "User is not optimizing media but elected to skip media restore, skipping.")
return Result.success()
}