diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupCreationEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupCreationEvent.kt new file mode 100644 index 0000000000..b5b7b16240 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupCreationEvent.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup + +/** + * EventBus event for backup creation progress. Each subclass identifies the backup destination, + * allowing subscribers to receive only the events they care about via the @Subscribe method + * parameter type. + */ +sealed class BackupCreationEvent(val progress: BackupCreationProgress) { + class RemoteEncrypted(progress: BackupCreationProgress) : BackupCreationEvent(progress) + class LocalEncrypted(progress: BackupCreationProgress) : BackupCreationEvent(progress) + class LocalPlaintext(progress: BackupCreationProgress) : BackupCreationEvent(progress) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupCreationProgress.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupCreationProgress.kt new file mode 100644 index 0000000000..f6a30c8881 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupCreationProgress.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup + +/** + * Unified progress model for backup creation, shared across all backup destinations + * (remote encrypted, local encrypted, local plaintext). + * + * The export phase is identical regardless of destination — the same data is serialized. + * The transfer phase differs: remote uploads to CDN, local writes to disk. + */ +sealed interface BackupCreationProgress { + data object Idle : BackupCreationProgress + data object Canceled : BackupCreationProgress + + /** + * The backup is being exported from the database into a serialized format. + */ + data class Exporting( + val phase: ExportPhase, + val frameExportCount: Long = 0, + val frameTotalCount: Long = 0 + ) : BackupCreationProgress + + /** + * Post-export phase: the backup file and/or media are being transferred to their destination. + * For remote backups this means uploading; for local backups this means writing to disk. + * + * [completed] and [total] are unitless — they may represent bytes (remote upload) or + * item counts (local attachment export). The ratio [completed]/[total] yields progress. + */ + data class Transferring( + val completed: Long, + val total: Long, + val mediaPhase: Boolean + ) : BackupCreationProgress + + enum class ExportPhase { + NONE, + ACCOUNT, + RECIPIENT, + THREAD, + CALL, + STICKER, + NOTIFICATION_PROFILE, + CHAT_FOLDER, + MESSAGE + } + + fun exportProgress(): Float { + return when (this) { + is Exporting -> if (frameTotalCount == 0L) 0f else frameExportCount / frameTotalCount.toFloat() + else -> 0f + } + } + + fun transferProgress(): Float { + return when (this) { + is Transferring -> if (total == 0L) 0f else completed / total.toFloat() + else -> 0f + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/LocalBackupV2Event.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/LocalBackupV2Event.kt deleted file mode 100644 index c83fcd07fe..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/LocalBackupV2Event.kt +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright 2024 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.backup.v2 - -class LocalBackupV2Event(val type: Type, val count: Long = 0, val estimatedTotalCount: Long = 0) { - enum class Type { - PROGRESS_ACCOUNT, - PROGRESS_RECIPIENT, - PROGRESS_THREAD, - PROGRESS_CALL, - PROGRESS_STICKER, - NOTIFICATION_PROFILE, - CHAT_FOLDER, - PROGRESS_MESSAGE, - PROGRESS_ATTACHMENT, - FINISHED - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/local/LocalArchiver.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/local/LocalArchiver.kt index 53af2d3524..f52771e7cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/local/LocalArchiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/local/LocalArchiver.kt @@ -15,8 +15,9 @@ import org.signal.core.util.Util import org.signal.core.util.logging.Log import org.signal.core.util.readFully import org.thoughtcrime.securesms.attachments.AttachmentId +import org.thoughtcrime.securesms.backup.BackupCreationEvent +import org.thoughtcrime.securesms.backup.BackupCreationProgress import org.thoughtcrime.securesms.backup.v2.BackupRepository -import org.thoughtcrime.securesms.backup.v2.LocalBackupV2Event import org.thoughtcrime.securesms.backup.v2.local.proto.FilesFrame import org.thoughtcrime.securesms.backup.v2.local.proto.Metadata import org.thoughtcrime.securesms.database.AttachmentTable @@ -233,45 +234,54 @@ object LocalArchiver { private var lastVerboseUpdate: Long = 0 override fun onAccount() { - EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.PROGRESS_ACCOUNT)) + post(BackupCreationProgress.Exporting(phase = BackupCreationProgress.ExportPhase.ACCOUNT)) } override fun onRecipient() { - EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.PROGRESS_RECIPIENT)) + post(BackupCreationProgress.Exporting(phase = BackupCreationProgress.ExportPhase.RECIPIENT)) } override fun onThread() { - EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.PROGRESS_THREAD)) + post(BackupCreationProgress.Exporting(phase = BackupCreationProgress.ExportPhase.THREAD)) } override fun onCall() { - EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.PROGRESS_CALL)) + post(BackupCreationProgress.Exporting(phase = BackupCreationProgress.ExportPhase.CALL)) } override fun onSticker() { - EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.PROGRESS_STICKER)) + post(BackupCreationProgress.Exporting(phase = BackupCreationProgress.ExportPhase.STICKER)) } override fun onNotificationProfile() { - EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.NOTIFICATION_PROFILE)) + post(BackupCreationProgress.Exporting(phase = BackupCreationProgress.ExportPhase.NOTIFICATION_PROFILE)) } override fun onChatFolder() { - EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.CHAT_FOLDER)) + post(BackupCreationProgress.Exporting(phase = BackupCreationProgress.ExportPhase.CHAT_FOLDER)) } override fun onMessage(currentProgress: Long, approximateCount: Long) { - if (lastVerboseUpdate > System.currentTimeMillis() || lastVerboseUpdate + 1000 < System.currentTimeMillis() || currentProgress >= approximateCount) { - EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.PROGRESS_MESSAGE, currentProgress, approximateCount)) - lastVerboseUpdate = System.currentTimeMillis() - } + if (shouldThrottle(currentProgress >= approximateCount)) return + post(BackupCreationProgress.Exporting(phase = BackupCreationProgress.ExportPhase.MESSAGE, frameExportCount = currentProgress, frameTotalCount = approximateCount)) } override fun onAttachment(currentProgress: Long, totalCount: Long) { - if (lastVerboseUpdate > System.currentTimeMillis() || lastVerboseUpdate + 1000 < System.currentTimeMillis() || currentProgress >= totalCount) { - EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.PROGRESS_ATTACHMENT, currentProgress, totalCount)) - lastVerboseUpdate = System.currentTimeMillis() + if (shouldThrottle(currentProgress >= totalCount)) return + post(BackupCreationProgress.Transferring(completed = currentProgress, total = totalCount, mediaPhase = true)) + } + + private fun shouldThrottle(forceUpdate: Boolean): Boolean { + val now = System.currentTimeMillis() + if (forceUpdate || lastVerboseUpdate > now || lastVerboseUpdate + 1000 < now) { + lastVerboseUpdate = now + return false } + return true + } + + private fun post(progress: BackupCreationProgress) { + EventBus.getDefault().post(BackupCreationEvent.LocalEncrypted(progress)) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/BackupCreationProgressRow.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/BackupCreationProgressRow.kt new file mode 100644 index 0000000000..6d2ec6d4a2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/BackupCreationProgressRow.kt @@ -0,0 +1,187 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.ui.status + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.backup.BackupCreationProgress +import org.signal.core.ui.R as CoreUiR + +@Composable +fun BackupCreationProgressRow( + progress: BackupCreationProgress, + isRemote: Boolean, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .padding(horizontal = dimensionResource(id = CoreUiR.dimen.gutter)) + .padding(top = 16.dp, bottom = 14.dp) + ) { + Column( + modifier = Modifier.weight(1f) + ) { + BackupCreationProgressIndicator(progress = progress) + + Text( + text = getProgressMessage(progress, isRemote), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@Composable +private fun BackupCreationProgressIndicator( + progress: BackupCreationProgress +) { + val fraction = when (progress) { + is BackupCreationProgress.Exporting -> progress.exportProgress() + is BackupCreationProgress.Transferring -> progress.transferProgress() + else -> 0f + } + + val hasDeterminateProgress = when (progress) { + is BackupCreationProgress.Exporting -> progress.frameTotalCount > 0 && progress.phase == BackupCreationProgress.ExportPhase.MESSAGE + is BackupCreationProgress.Transferring -> progress.total > 0 + else -> false + } + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + if (hasDeterminateProgress) { + val animatedProgress by animateFloatAsState(targetValue = fraction, animationSpec = tween(durationMillis = 250)) + LinearProgressIndicator( + trackColor = MaterialTheme.colorScheme.secondaryContainer, + progress = { animatedProgress }, + drawStopIndicator = {}, + modifier = Modifier + .weight(1f) + .padding(vertical = 12.dp) + ) + } else { + LinearProgressIndicator( + trackColor = MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier + .weight(1f) + .padding(vertical = 12.dp) + ) + } + } +} + +@Composable +private fun getProgressMessage(progress: BackupCreationProgress, isRemote: Boolean): String { + return when (progress) { + is BackupCreationProgress.Exporting -> getExportPhaseMessage(progress) + is BackupCreationProgress.Transferring -> getTransferPhaseMessage(progress, isRemote) + else -> stringResource(R.string.BackupCreationProgressRow__processing_backup) + } +} + +@Composable +private fun getExportPhaseMessage(progress: BackupCreationProgress.Exporting): String { + return when (progress.phase) { + BackupCreationProgress.ExportPhase.MESSAGE -> { + if (progress.frameTotalCount > 0) { + stringResource( + R.string.BackupCreationProgressRow__processing_messages_s_of_s_d, + "%,d".format(progress.frameExportCount), + "%,d".format(progress.frameTotalCount), + (progress.exportProgress() * 100).toInt() + ) + } else { + stringResource(R.string.BackupCreationProgressRow__processing_messages) + } + } + BackupCreationProgress.ExportPhase.NONE -> stringResource(R.string.BackupCreationProgressRow__processing_backup) + else -> stringResource(R.string.BackupCreationProgressRow__preparing_backup) + } +} + +@Composable +private fun getTransferPhaseMessage(progress: BackupCreationProgress.Transferring, isRemote: Boolean): String { + val percent = (progress.transferProgress() * 100).toInt() + return if (isRemote) { + stringResource(R.string.BackupCreationProgressRow__uploading_media_d, percent) + } else { + stringResource(R.string.BackupCreationProgressRow__exporting_media_d, percent) + } +} + +@DayNightPreviews +@Composable +private fun ExportingIndeterminatePreview() { + Previews.Preview { + BackupCreationProgressRow( + progress = BackupCreationProgress.Exporting(phase = BackupCreationProgress.ExportPhase.NONE), + isRemote = false + ) + } +} + +@DayNightPreviews +@Composable +private fun ExportingMessagesPreview() { + Previews.Preview { + BackupCreationProgressRow( + progress = BackupCreationProgress.Exporting( + phase = BackupCreationProgress.ExportPhase.MESSAGE, + frameExportCount = 1000, + frameTotalCount = 100_000 + ), + isRemote = false + ) + } +} + +@DayNightPreviews +@Composable +private fun TransferringLocalPreview() { + Previews.Preview { + BackupCreationProgressRow( + progress = BackupCreationProgress.Transferring( + completed = 50, + total = 200, + mediaPhase = true + ), + isRemote = false + ) + } +} + +@DayNightPreviews +@Composable +private fun TransferringRemotePreview() { + Previews.Preview { + BackupCreationProgressRow( + progress = BackupCreationProgress.Transferring( + completed = 50, + total = 200, + mediaPhase = true + ), + isRemote = true + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/BackupProgressState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/BackupProgressState.kt deleted file mode 100644 index 344d16de39..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/BackupProgressState.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2026 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.components.settings.app.backups.local - -/** - * Progress indicator state for the on-device backups creation/verification workflow. - */ -sealed class BackupProgressState { - data object Idle : BackupProgressState() - - /** - * Represents either backup creation or verification progress. - * - * @param summary High-level status label (e.g. "In progress…", "Verifying backup…") - * @param percentLabel Secondary progress label (either a percent string or a count-based string) - * @param progressFraction Optional progress fraction in \\([0, 1]\\). Null indicates indeterminate progress. - */ - data class InProgress( - val summary: String, - val percentLabel: String, - val progressFraction: Float? - ) : BackupProgressState() -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsSettingsScreen.kt index 50063fe13b..03cf5f4c84 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsSettingsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsSettingsScreen.kt @@ -5,10 +5,8 @@ package org.thoughtcrime.securesms.components.settings.app.backups.local import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text @@ -38,6 +36,8 @@ import org.signal.core.ui.compose.Rows import org.signal.core.ui.compose.Scaffolds import org.signal.core.ui.compose.Snackbars import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.backup.BackupCreationProgress +import org.thoughtcrime.securesms.backup.v2.ui.status.BackupCreationProgressRow import org.thoughtcrime.securesms.components.compose.rememberBiometricsAuthentication import org.thoughtcrime.securesms.util.BackupUtil import org.signal.core.ui.R as CoreUiR @@ -120,51 +120,26 @@ internal fun LocalBackupsSettingsScreen( ) } } else { - val isCreating = state.progress is BackupProgressState.InProgress + val isCreating = state.progress !is BackupCreationProgress.Idle - item { - Rows.TextRow( - text = { - Column { - Text( - text = stringResource(id = R.string.BackupsPreferenceFragment__create_backup), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface - ) - - if (state.progress is BackupProgressState.InProgress) { + if (isCreating) { + item { + BackupCreationProgressRow( + progress = state.progress, + isRemote = false + ) + } + } else { + item { + Rows.TextRow( + text = { + Column { Text( - text = state.progress.summary, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 4.dp) + text = stringResource(id = R.string.BackupsPreferenceFragment__create_backup), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface ) - if (state.progress.progressFraction == null) { - LinearProgressIndicator( - trackColor = MaterialTheme.colorScheme.secondaryContainer, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - ) - } else { - LinearProgressIndicator( - trackColor = MaterialTheme.colorScheme.secondaryContainer, - progress = { state.progress.progressFraction }, - drawStopIndicator = {}, - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - ) - } - - Text( - text = state.progress.percentLabel, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 8.dp) - ) - } else { Text( text = state.lastBackupLabel.orEmpty(), style = MaterialTheme.typography.bodyMedium, @@ -172,11 +147,10 @@ internal fun LocalBackupsSettingsScreen( modifier = Modifier.padding(top = 4.dp) ) } - } - }, - enabled = !isCreating, - onClick = callback::onCreateBackupClick - ) + }, + onClick = callback::onCreateBackupClick + ) + } } item { @@ -224,7 +198,7 @@ internal fun LocalBackupsSettingsScreen( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding( - horizontal = dimensionResource(id = org.signal.core.ui.R.dimen.gutter), + horizontal = dimensionResource(id = CoreUiR.dimen.gutter), vertical = 16.dp ) ) @@ -294,7 +268,7 @@ private fun LocalBackupsSettingsEnabledIdlePreview() { lastBackupLabel = "Last backup: 1 hour ago", folderDisplayName = "/storage/emulated/0/Signal/Backups", scheduleTimeLabel = "1:00 AM", - progress = BackupProgressState.Idle + progress = BackupCreationProgress.Idle ), callback = LocalBackupsSettingsCallback.Empty ) @@ -303,7 +277,7 @@ private fun LocalBackupsSettingsEnabledIdlePreview() { @DayNightPreview @Composable -private fun LocalBackupsSettingsEnabledInProgressIndeterminatePreview() { +private fun LocalBackupsSettingsEnabledExportingIndeterminatePreview() { Previews.Preview { LocalBackupsSettingsScreen( state = LocalBackupsSettingsState( @@ -311,10 +285,8 @@ private fun LocalBackupsSettingsEnabledInProgressIndeterminatePreview() { lastBackupLabel = "Last backup: 1 hour ago", folderDisplayName = "/storage/emulated/0/Signal/Backups", scheduleTimeLabel = "1:00 AM", - progress = BackupProgressState.InProgress( - summary = "In progress…", - percentLabel = "123 so far…", - progressFraction = null + progress = BackupCreationProgress.Exporting( + phase = BackupCreationProgress.ExportPhase.ACCOUNT ) ), callback = LocalBackupsSettingsCallback.Empty @@ -324,7 +296,7 @@ private fun LocalBackupsSettingsEnabledInProgressIndeterminatePreview() { @DayNightPreview @Composable -private fun LocalBackupsSettingsEnabledInProgressPercentPreview() { +private fun LocalBackupsSettingsEnabledExportingMessagesPreview() { Previews.Preview { LocalBackupsSettingsScreen( state = LocalBackupsSettingsState( @@ -332,10 +304,31 @@ private fun LocalBackupsSettingsEnabledInProgressPercentPreview() { lastBackupLabel = "Last backup: 1 hour ago", folderDisplayName = "/storage/emulated/0/Signal/Backups", scheduleTimeLabel = "1:00 AM", - progress = BackupProgressState.InProgress( - summary = "In progress…", - percentLabel = "42.0% so far…", - progressFraction = 0.42f + progress = BackupCreationProgress.Exporting( + phase = BackupCreationProgress.ExportPhase.MESSAGE, + frameExportCount = 42000, + frameTotalCount = 100000 + ) + ), + callback = LocalBackupsSettingsCallback.Empty + ) + } +} + +@DayNightPreview +@Composable +private fun LocalBackupsSettingsEnabledTransferringPreview() { + Previews.Preview { + LocalBackupsSettingsScreen( + state = LocalBackupsSettingsState( + backupsEnabled = true, + lastBackupLabel = "Last backup: 1 hour ago", + folderDisplayName = "/storage/emulated/0/Signal/Backups", + scheduleTimeLabel = "1:00 AM", + progress = BackupCreationProgress.Transferring( + completed = 50, + total = 200, + mediaPhase = true ) ), callback = LocalBackupsSettingsCallback.Empty @@ -353,7 +346,7 @@ private fun LocalBackupsSettingsEnabledNonLegacyPreview() { lastBackupLabel = "Last backup: 1 hour ago", folderDisplayName = "Signal Backups", scheduleTimeLabel = "1:00 AM", - progress = BackupProgressState.Idle + progress = BackupCreationProgress.Idle ), callback = LocalBackupsSettingsCallback.Empty ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsSettingsState.kt index d081677df6..2ef973d0ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsSettingsState.kt @@ -4,8 +4,10 @@ */ package org.thoughtcrime.securesms.components.settings.app.backups.local +import org.thoughtcrime.securesms.backup.BackupCreationProgress + /** - * Immutable state for the on-device (legacy) backups settings screen. + * Immutable state for the on-device backups settings screen. * * This is intended to be the single source of truth for UI rendering (i.e. a single `StateFlow` * emission fully describes what the screen should display). @@ -16,5 +18,5 @@ data class LocalBackupsSettingsState( val lastBackupLabel: String? = null, val folderDisplayName: String? = null, val scheduleTimeLabel: String? = null, - val progress: BackupProgressState = BackupProgressState.Idle + val progress: BackupCreationProgress = BackupCreationProgress.Idle ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsViewModel.kt index f2ce414bca..eda90893a4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/local/LocalBackupsViewModel.kt @@ -20,8 +20,9 @@ import org.greenrobot.eventbus.ThreadMode import org.signal.core.ui.util.StorageUtil import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.backup.BackupCreationEvent +import org.thoughtcrime.securesms.backup.BackupCreationProgress import org.thoughtcrime.securesms.backup.BackupPassphrase -import org.thoughtcrime.securesms.backup.v2.LocalBackupV2Event import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeyCredentialManagerHandler import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState import org.thoughtcrime.securesms.dependencies.AppDependencies @@ -31,7 +32,6 @@ import org.thoughtcrime.securesms.util.BackupUtil import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.TextSecurePreferences import org.thoughtcrime.securesms.util.formatHours -import java.text.NumberFormat import java.time.LocalTime import java.util.Locale @@ -44,11 +44,6 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler { private val TAG = Log.tag(LocalBackupsViewModel::class) } - private val formatter: NumberFormat = NumberFormat.getInstance().apply { - minimumFractionDigits = 1 - maximumFractionDigits = 1 - } - private val internalSettingsState = MutableStateFlow( LocalBackupsSettingsState( backupsEnabled = SignalStore.backup.newLocalBackupsEnabled, @@ -117,46 +112,14 @@ class LocalBackupsViewModel : ViewModel(), BackupKeyCredentialManagerHandler { } fun onBackupStarted() { - val context = AppDependencies.application internalSettingsState.update { - it.copy( - progress = BackupProgressState.InProgress( - summary = context.getString(R.string.BackupsPreferenceFragment__in_progress), - percentLabel = context.getString(R.string.BackupsPreferenceFragment__d_so_far, 0), - progressFraction = null - ) - ) + it.copy(progress = BackupCreationProgress.Exporting(phase = BackupCreationProgress.ExportPhase.NONE)) } } @Subscribe(threadMode = ThreadMode.MAIN) - fun onBackupEvent(event: LocalBackupV2Event) { - val context = AppDependencies.application - when (event.type) { - LocalBackupV2Event.Type.FINISHED -> { - internalSettingsState.update { it.copy(progress = BackupProgressState.Idle) } - } - - else -> { - val summary = context.getString(R.string.BackupsPreferenceFragment__in_progress) - val progressState = if (event.estimatedTotalCount == 0L) { - BackupProgressState.InProgress( - summary = summary, - percentLabel = context.getString(R.string.BackupsPreferenceFragment__d_so_far, event.count), - progressFraction = null - ) - } else { - val fraction = ((event.count / event.estimatedTotalCount.toDouble()) / 100.0).toFloat().coerceIn(0f, 1f) - BackupProgressState.InProgress( - summary = summary, - percentLabel = context.getString(R.string.BackupsPreferenceFragment__s_so_far, formatter.format((event.count / event.estimatedTotalCount.toDouble()))), - progressFraction = fraction - ) - } - - internalSettingsState.update { it.copy(progress = progressState) } - } - } + fun onBackupEvent(event: BackupCreationEvent.LocalEncrypted) { + internalSettingsState.update { it.copy(progress = event.progress) } } override fun updateBackupKeySaveState(newState: BackupKeySaveState?) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalArchiveJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalArchiveJob.kt index 8ecaa8a173..12c3114644 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalArchiveJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalArchiveJob.kt @@ -7,13 +7,15 @@ import org.greenrobot.eventbus.ThreadMode import org.signal.core.util.Stopwatch import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.backup.BackupCreationEvent +import org.thoughtcrime.securesms.backup.BackupCreationProgress import org.thoughtcrime.securesms.backup.BackupFileIOError import org.thoughtcrime.securesms.backup.FullBackupExporter.BackupCanceledException -import org.thoughtcrime.securesms.backup.v2.LocalBackupV2Event import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.jobmanager.Job import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.notifications.NotificationChannels @@ -106,14 +108,14 @@ class LocalArchiveJob internal constructor(parameters: Parameters) : Job(paramet snapshotFileSystem.finalize() stopwatch.split("archive-finalize") - EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.FINISHED)) + EventBus.getDefault().post(BackupCreationEvent.LocalEncrypted(BackupCreationProgress.Idle)) } catch (e: BackupCanceledException) { - EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.FINISHED)) + EventBus.getDefault().post(BackupCreationEvent.LocalEncrypted(BackupCreationProgress.Idle)) Log.w(TAG, "Archive cancelled") throw e } catch (e: IOException) { Log.w(TAG, "Error during archive!", e) - EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.FINISHED)) + EventBus.getDefault().post(BackupCreationEvent.LocalEncrypted(BackupCreationProgress.Idle)) BackupFileIOError.postNotificationForException(context, e) throw e } finally { @@ -148,23 +150,49 @@ class LocalArchiveJob internal constructor(parameters: Parameters) : Job(paramet private class ProgressUpdater { var notification: NotificationController? = null - private var previousType: LocalBackupV2Event.Type? = null + private var previousPhase: NotificationPhase? = null @Subscribe(threadMode = ThreadMode.POSTING) - fun onEvent(event: LocalBackupV2Event) { + fun onEvent(event: BackupCreationEvent.LocalEncrypted) { val notification = notification ?: return + val progress = event.progress - if (previousType != event.type) { - notification.replaceTitle(event.type.toString()) // todo [local-backup] use actual strings - previousType = event.type - } + when (progress) { + is BackupCreationProgress.Exporting -> { + val phase = NotificationPhase.Export(progress.phase) + if (previousPhase != phase) { + notification.replaceTitle(progress.phase.toString()) + previousPhase = phase + } + if (progress.frameTotalCount == 0L) { + notification.setIndeterminateProgress() + } else { + notification.setProgress(progress.frameTotalCount, progress.frameExportCount) + } + } - if (event.estimatedTotalCount == 0L) { - notification.setIndeterminateProgress() - } else { - notification.setProgress(event.estimatedTotalCount, event.count) + is BackupCreationProgress.Transferring -> { + if (previousPhase !is NotificationPhase.Transfer) { + notification.replaceTitle(AppDependencies.application.getString(R.string.LocalArchiveJob__exporting_media)) + previousPhase = NotificationPhase.Transfer + } + if (progress.total == 0L) { + notification.setIndeterminateProgress() + } else { + notification.setProgress(progress.total, progress.completed) + } + } + + else -> { + notification.setIndeterminateProgress() + } } } + + private sealed interface NotificationPhase { + data class Export(val phase: BackupCreationProgress.ExportPhase) : NotificationPhase + data object Transfer : NotificationPhase + } } class Factory : Job.Factory { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 35578b86cb..850a035fc3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8744,6 +8744,24 @@ Scan QR code + + + Processing backup… + + Preparing backup… + + Processing messages… + + Processing messages: %1$s of %2$s (%3$d%%) + + Uploading media: %1$d%% + + Exporting media: %1$d%% + + + + Exporting media… + Improvements to on-device backups