Show upload progress for the first backup on the chat list.

This commit is contained in:
Greyson Parrelli
2026-01-21 16:58:15 -05:00
committed by Alex Hart
parent 4c43bf2228
commit 2f6baf8743
12 changed files with 577 additions and 30 deletions

View File

@@ -46,13 +46,11 @@ object ArchiveUploadProgress {
private val TAG = Log.tag(ArchiveUploadProgress::class)
private val PROGRESS_NONE = ArchiveUploadProgressState(
state = ArchiveUploadProgressState.State.None
)
private val _progress: MutableSharedFlow<Unit> = MutableSharedFlow(replay = 1)
private var uploadProgress: ArchiveUploadProgressState = SignalStore.backup.archiveUploadState ?: PROGRESS_NONE
private var uploadProgress: ArchiveUploadProgressState = SignalStore.backup.archiveUploadState ?: ArchiveUploadProgressState(
state = ArchiveUploadProgressState.State.None
)
private val attachmentProgress: MutableMap<AttachmentId, AttachmentProgressDetails> = ConcurrentHashMap()
@@ -76,7 +74,11 @@ object ArchiveUploadProgress {
if (!SignalStore.backup.backsUpMedia) {
Log.i(TAG, "Doesn't upload media. Done!")
return@map PROGRESS_NONE
SignalStore.backup.finishedInitialBackup = true
return@map uploadProgress.copy(
state = ArchiveUploadProgressState.State.None,
backupPhase = ArchiveUploadProgressState.BackupPhase.BackupPhaseNone
)
}
val pendingMediaUploadBytes = SignalDatabase.attachments.getPendingArchiveUploadBytes() - attachmentProgress.values.sumOf { it.bytesUploaded }
@@ -87,7 +89,12 @@ object ArchiveUploadProgress {
Log.i(TAG, "We uploaded media as part of the backup. We should enqueue another backup now to ensure that CDN info is properly written.")
BackupMessagesJob.enqueue()
}
return@map PROGRESS_NONE
SignalStore.backup.finishedInitialBackup = true
return@map uploadProgress.copy(
state = ArchiveUploadProgressState.State.None,
backupPhase = ArchiveUploadProgressState.BackupPhase.BackupPhaseNone,
mediaUploadedBytes = uploadProgress.mediaTotalBytes
)
}
// It's possible that new attachments may be pending upload after we start a backup.
@@ -96,7 +103,7 @@ object ArchiveUploadProgress {
// the progress bar may occasionally be including media that is not actually referenced in the active backup file.
val totalMediaUploadBytes = max(uploadProgress.mediaTotalBytes, pendingMediaUploadBytes)
ArchiveUploadProgressState(
uploadProgress.copy(
state = ArchiveUploadProgressState.State.UploadMedia,
mediaUploadedBytes = totalMediaUploadBytes - pendingMediaUploadBytes,
mediaTotalBytes = totalMediaUploadBytes
@@ -109,9 +116,13 @@ object ArchiveUploadProgress {
.flowOn(Dispatchers.IO)
val inProgress
get() = uploadProgress.state != ArchiveUploadProgressState.State.None
get() = uploadProgress.state != ArchiveUploadProgressState.State.None && uploadProgress.state != ArchiveUploadProgressState.State.UserCanceled
fun begin() {
if (!SignalStore.backup.finishedInitialBackup) {
SignalStore.backup.uploadBannerVisible = true
}
updateState(overrideCancel = true) {
ArchiveUploadProgressState(
state = ArchiveUploadProgressState.State.Export
@@ -124,6 +135,8 @@ object ArchiveUploadProgress {
}
fun cancel() {
SignalStore.backup.uploadBannerVisible = false
updateState {
ArchiveUploadProgressState(
state = ArchiveUploadProgressState.State.UserCanceled
@@ -213,6 +226,7 @@ object ArchiveUploadProgress {
fun onMessageBackupFinishedEarly() {
resetState()
SignalStore.backup.finishedInitialBackup = true
}
fun onMainBackupFileUploadFailure() {
@@ -224,7 +238,12 @@ object ArchiveUploadProgress {
if (shouldRevertToUploadMedia) {
onAttachmentSectionStarted(SignalDatabase.attachments.getPendingArchiveUploadBytes())
} else {
updateState { PROGRESS_NONE }
updateState {
it.copy(
state = ArchiveUploadProgressState.State.None,
backupPhase = ArchiveUploadProgressState.BackupPhase.BackupPhaseNone
)
}
}
}

View File

@@ -52,7 +52,7 @@ private const val NONE = -1
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun BackupStatusBanner(
fun ArchiveRestoreStatusBanner(
data: ArchiveRestoreProgressState,
onBannerClick: () -> Unit = {},
onActionClick: (ArchiveRestoreProgressState) -> Unit = {},
@@ -311,46 +311,46 @@ private fun ArchiveRestoreProgressState.actionResource(): Int {
@DayNightPreviews
@Composable
fun BackupStatusBannerPreview() {
private fun ArchiveRestoreStatusBannerPreview() {
Previews.Preview {
Column {
BackupStatusBanner(
ArchiveRestoreStatusBanner(
data = ArchiveRestoreProgressState(restoreState = RestoreState.RESTORING_MEDIA, restoreStatus = RestoreStatus.RESTORING, remainingRestoreSize = 800.mebiBytes, totalRestoreSize = 1024.mebiBytes)
)
HorizontalDivider()
BackupStatusBanner(
ArchiveRestoreStatusBanner(
data = ArchiveRestoreProgressState(restoreState = RestoreState.CALCULATING_MEDIA, restoreStatus = RestoreStatus.RESTORING, remainingRestoreSize = 1024.mebiBytes, totalRestoreSize = 1024.mebiBytes)
)
HorizontalDivider()
BackupStatusBanner(
ArchiveRestoreStatusBanner(
data = ArchiveRestoreProgressState(restoreState = RestoreState.CANCELING_MEDIA, restoreStatus = RestoreStatus.RESTORING, remainingRestoreSize = 200.mebiBytes, totalRestoreSize = 1024.mebiBytes)
)
HorizontalDivider()
BackupStatusBanner(
ArchiveRestoreStatusBanner(
data = ArchiveRestoreProgressState(restoreState = RestoreState.RESTORING_MEDIA, restoreStatus = RestoreStatus.WAITING_FOR_WIFI, remainingRestoreSize = 800.mebiBytes, totalRestoreSize = 1024.mebiBytes)
)
HorizontalDivider()
BackupStatusBanner(
ArchiveRestoreStatusBanner(
data = ArchiveRestoreProgressState(restoreState = RestoreState.RESTORING_MEDIA, restoreStatus = RestoreStatus.WAITING_FOR_INTERNET, remainingRestoreSize = 800.mebiBytes, totalRestoreSize = 1024.mebiBytes)
)
HorizontalDivider()
BackupStatusBanner(
ArchiveRestoreStatusBanner(
data = ArchiveRestoreProgressState(restoreState = RestoreState.NONE, restoreStatus = RestoreStatus.FINISHED, remainingRestoreSize = 0.mebiBytes, totalRestoreSize = 0.mebiBytes, totalToRestoreThisRun = 1024.mebiBytes)
)
HorizontalDivider()
BackupStatusBanner(
ArchiveRestoreStatusBanner(
data = ArchiveRestoreProgressState(restoreState = RestoreState.RESTORING_MEDIA, restoreStatus = RestoreStatus.NOT_ENOUGH_DISK_SPACE, remainingRestoreSize = 500.mebiBytes, totalRestoreSize = 1024.mebiBytes)
)
}

View File

@@ -0,0 +1,278 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.status
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.res.painterResource
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.DropdownMenus
import org.signal.core.ui.compose.Previews
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.BackupsIconColors
/**
* Displays a "heads up" widget containing information about the current
* status of the user's backup.
*/
@OptIn(ExperimentalLayoutApi::class, ExperimentalFoundationApi::class)
@Composable
fun ArchiveUploadStatusBannerView(
state: ArchiveUploadStatusBannerViewState,
emitter: (ArchiveUploadStatusBannerViewEvents) -> Unit = {},
contentPadding: PaddingValues = PaddingValues(horizontal = 12.dp, vertical = 8.dp)
) {
val iconRes: Int = when (state) {
ArchiveUploadStatusBannerViewState.CreatingBackupFile -> R.drawable.symbol_backup_light
is ArchiveUploadStatusBannerViewState.Uploading -> R.drawable.symbol_backup_light
is ArchiveUploadStatusBannerViewState.PausedMissingWifi -> R.drawable.symbol_backup_light
is ArchiveUploadStatusBannerViewState.PausedNoInternet -> R.drawable.symbol_backup_light
is ArchiveUploadStatusBannerViewState.Finished -> R.drawable.symbol_check_circle_24
}
val iconColor: Color = when (state) {
ArchiveUploadStatusBannerViewState.CreatingBackupFile -> BackupsIconColors.Normal.foreground
is ArchiveUploadStatusBannerViewState.Uploading -> BackupsIconColors.Normal.foreground
is ArchiveUploadStatusBannerViewState.PausedMissingWifi -> BackupsIconColors.Warning.foreground
is ArchiveUploadStatusBannerViewState.PausedNoInternet -> BackupsIconColors.Warning.foreground
is ArchiveUploadStatusBannerViewState.Finished -> BackupsIconColors.Success.foreground
}
val title: String = when (state) {
ArchiveUploadStatusBannerViewState.CreatingBackupFile -> stringResource(R.string.BackupStatus__status_creating_backup)
is ArchiveUploadStatusBannerViewState.Uploading -> stringResource(R.string.BackupStatus__uploading_backup)
is ArchiveUploadStatusBannerViewState.PausedMissingWifi -> stringResource(R.string.BackupStatus__upload_paused)
is ArchiveUploadStatusBannerViewState.PausedNoInternet -> stringResource(R.string.BackupStatus__upload_paused)
is ArchiveUploadStatusBannerViewState.Finished -> stringResource(R.string.BackupStatus__upload_complete)
}
val status: String? = when (state) {
ArchiveUploadStatusBannerViewState.CreatingBackupFile -> null
is ArchiveUploadStatusBannerViewState.Uploading -> stringResource(R.string.BackupStatus__status_size_of_size, state.completedSize, state.totalSize)
is ArchiveUploadStatusBannerViewState.PausedMissingWifi -> stringResource(R.string.BackupStatus__status_waiting_for_wifi)
is ArchiveUploadStatusBannerViewState.PausedNoInternet -> stringResource(R.string.BackupStatus__status_no_internet)
is ArchiveUploadStatusBannerViewState.Finished -> state.uploadedSize
}
val showIndeterminateProgress: Boolean = when (state) {
ArchiveUploadStatusBannerViewState.CreatingBackupFile -> true
else -> false
}
val progress: Float? = when (state) {
is ArchiveUploadStatusBannerViewState.Uploading -> state.progress
else -> null
}
val actionLabelRes: Int? = when (state) {
is ArchiveUploadStatusBannerViewState.PausedMissingWifi -> R.string.BackupStatus__resume
else -> null
}
val showDismiss: Boolean = when (state) {
is ArchiveUploadStatusBannerViewState.Finished -> true
else -> false
}
val dropdownAvailable: Boolean = when (state) {
is ArchiveUploadStatusBannerViewState.Finished -> false
else -> true
}
val menuController = remember { DropdownMenus.MenuController() }
Box(modifier = Modifier.padding(contentPadding)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.border(1.dp, color = MaterialTheme.colorScheme.outline.copy(alpha = 0.38f), shape = RoundedCornerShape(12.dp))
.clip(RoundedCornerShape(12.dp))
.fillMaxWidth()
.defaultMinSize(minHeight = 48.dp)
.combinedClickable(
onClick = { emitter(ArchiveUploadStatusBannerViewEvents.BannerClicked) },
onLongClick = { menuController.show() }
)
.padding(12.dp)
) {
Icon(
painter = painterResource(id = iconRes),
contentDescription = null,
tint = iconColor,
modifier = Modifier
.padding(start = 4.dp)
.size(24.dp)
)
FlowRow(
horizontalArrangement = Arrangement.SpaceBetween,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.padding(start = 12.dp)
.weight(1f)
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.padding(end = 20.dp)
.align(Alignment.CenterVertically)
)
status?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(end = 12.dp)
.align(Alignment.CenterVertically)
)
}
}
if (showIndeterminateProgress) {
CircularProgressIndicator(
strokeWidth = 3.dp,
strokeCap = StrokeCap.Round,
modifier = Modifier
.size(24.dp, 24.dp)
)
}
progress?.let {
CircularProgressIndicator(
progress = { it },
strokeWidth = 3.dp,
strokeCap = StrokeCap.Round,
modifier = Modifier
.size(24.dp, 24.dp)
)
}
if (showDismiss) {
val interactionSource = remember { MutableInteractionSource() }
Icon(
painter = painterResource(id = R.drawable.symbol_x_24),
contentDescription = stringResource(R.string.Material3SearchToolbar__close),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.size(24.dp)
.clickable(
interactionSource = interactionSource,
indication = ripple(bounded = false),
onClick = { emitter(ArchiveUploadStatusBannerViewEvents.HideClicked) }
)
)
}
}
if (dropdownAvailable) {
Box(modifier = Modifier.align(Alignment.BottomEnd)) {
DropdownMenus.Menu(
controller = menuController,
offsetX = 0.dp,
offsetY = 10.dp
) {
DropdownMenus.ItemWithIcon(
menuController = menuController,
drawableResId = R.drawable.symbol_visible_slash,
stringResId = R.string.BackupStatus__hide,
onClick = { emitter(ArchiveUploadStatusBannerViewEvents.HideClicked) }
)
DropdownMenus.ItemWithIcon(
menuController = menuController,
drawableResId = R.drawable.symbol_x_circle_24,
stringResId = R.string.BackupStatus__cancel_upload,
onClick = { emitter(ArchiveUploadStatusBannerViewEvents.CancelClicked) }
)
}
}
}
}
}
@DayNightPreviews
@Composable
private fun ArchiveUploadStatusBannerViewPreview() {
Previews.Preview {
Column {
ArchiveUploadStatusBannerView(
state = ArchiveUploadStatusBannerViewState.CreatingBackupFile
)
HorizontalDivider()
ArchiveUploadStatusBannerView(
state = ArchiveUploadStatusBannerViewState.Uploading(
completedSize = "224 MB",
totalSize = "1 GB",
progress = 0.22f
)
)
HorizontalDivider()
ArchiveUploadStatusBannerView(
state = ArchiveUploadStatusBannerViewState.Uploading(
completedSize = "0 B",
totalSize = "1 GB",
progress = 0f
)
)
HorizontalDivider()
ArchiveUploadStatusBannerView(
state = ArchiveUploadStatusBannerViewState.PausedMissingWifi
)
HorizontalDivider()
ArchiveUploadStatusBannerView(
state = ArchiveUploadStatusBannerViewState.PausedNoInternet
)
HorizontalDivider()
ArchiveUploadStatusBannerView(
state = ArchiveUploadStatusBannerViewState.Finished(uploadedSize = "1 GB")
)
}
}
}

View File

@@ -0,0 +1,12 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.status
sealed interface ArchiveUploadStatusBannerViewEvents {
data object BannerClicked : ArchiveUploadStatusBannerViewEvents
data object CancelClicked : ArchiveUploadStatusBannerViewEvents
data object HideClicked : ArchiveUploadStatusBannerViewEvents
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.status
/**
* Renderable state for [ArchiveUploadStatusBannerView].
* Each state carries only the data it needs; rendering logic lives in the composable.
*/
sealed interface ArchiveUploadStatusBannerViewState {
/** Creating the intiial backup file. */
data object CreatingBackupFile : ArchiveUploadStatusBannerViewState
/** Actively uploading media with progress. */
data class Uploading(
val completedSize: String,
val totalSize: String,
val progress: Float
) : ArchiveUploadStatusBannerViewState
/** Restore paused because Wi-Fi is required. */
data object PausedMissingWifi : ArchiveUploadStatusBannerViewState
/** Restore paused because there is no internet connection. */
data object PausedNoInternet : ArchiveUploadStatusBannerViewState
/** Restore completed successfully. */
data class Finished(
val uploadedSize: String
) : ArchiveUploadStatusBannerViewState
}