From 2f6baf8743e342b00c5a78e59ab13dcd4066bd16 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Wed, 21 Jan 2026 16:58:15 -0500 Subject: [PATCH] Show upload progress for the first backup on the chat list. --- .../securesms/backup/ArchiveUploadProgress.kt | 39 ++- ...r.kt => ArchiveRestoreStatusBannerView.kt} | 18 +- .../status/ArchiveUploadStatusBannerView.kt | 278 ++++++++++++++++++ .../ArchiveUploadStatusBannerViewEvents.kt | 12 + .../ArchiveUploadStatusBannerViewState.kt | 33 +++ ...anner.kt => ArchiveRestoreStatusBanner.kt} | 12 +- .../banners/ArchiveUploadStatusBanner.kt | 110 +++++++ .../ConversationListFragment.java | 30 +- .../securesms/keyvalue/BackupValues.kt | 20 ++ app/src/main/res/drawable/symbol_visible.xml | 12 + .../res/drawable/symbol_visible_slash.xml | 21 ++ app/src/main/res/values/strings.xml | 22 ++ 12 files changed, 577 insertions(+), 30 deletions(-) rename app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/{BackupStatusBanner.kt => ArchiveRestoreStatusBannerView.kt} (97%) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/ArchiveUploadStatusBannerView.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/ArchiveUploadStatusBannerViewEvents.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/ArchiveUploadStatusBannerViewState.kt rename app/src/main/java/org/thoughtcrime/securesms/banner/banners/{MediaRestoreProgressBanner.kt => ArchiveRestoreStatusBanner.kt} (77%) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/banner/banners/ArchiveUploadStatusBanner.kt create mode 100644 app/src/main/res/drawable/symbol_visible.xml create mode 100644 app/src/main/res/drawable/symbol_visible_slash.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/ArchiveUploadProgress.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/ArchiveUploadProgress.kt index b1e732331e..3d9a0f4573 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/ArchiveUploadProgress.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/ArchiveUploadProgress.kt @@ -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 = 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 = 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 + ) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/BackupStatusBanner.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/ArchiveRestoreStatusBannerView.kt similarity index 97% rename from app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/BackupStatusBanner.kt rename to app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/ArchiveRestoreStatusBannerView.kt index e588a932cd..3bf01775ec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/BackupStatusBanner.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/ArchiveRestoreStatusBannerView.kt @@ -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) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/ArchiveUploadStatusBannerView.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/ArchiveUploadStatusBannerView.kt new file mode 100644 index 0000000000..76a370d790 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/ArchiveUploadStatusBannerView.kt @@ -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") + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/ArchiveUploadStatusBannerViewEvents.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/ArchiveUploadStatusBannerViewEvents.kt new file mode 100644 index 0000000000..997604fe60 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/ArchiveUploadStatusBannerViewEvents.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/ArchiveUploadStatusBannerViewState.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/ArchiveUploadStatusBannerViewState.kt new file mode 100644 index 0000000000..177f85064c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/ArchiveUploadStatusBannerViewState.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/banner/banners/MediaRestoreProgressBanner.kt b/app/src/main/java/org/thoughtcrime/securesms/banner/banners/ArchiveRestoreStatusBanner.kt similarity index 77% rename from app/src/main/java/org/thoughtcrime/securesms/banner/banners/MediaRestoreProgressBanner.kt rename to app/src/main/java/org/thoughtcrime/securesms/banner/banners/ArchiveRestoreStatusBanner.kt index d164f5df01..97cbe70217 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/banner/banners/MediaRestoreProgressBanner.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/banner/banners/ArchiveRestoreStatusBanner.kt @@ -13,11 +13,11 @@ import kotlinx.coroutines.flow.filter import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState.RestoreStatus -import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatusBanner +import org.thoughtcrime.securesms.backup.v2.ui.status.ArchiveRestoreStatusBanner import org.thoughtcrime.securesms.banner.Banner @OptIn(ExperimentalCoroutinesApi::class) -class MediaRestoreProgressBanner(private val listener: RestoreProgressBannerListener = EmptyListener) : Banner() { +class ArchiveRestoreStatusBanner(private val listener: RestoreProgressBannerListener) : Banner() { override val enabled: Boolean get() = ArchiveRestoreProgress.state.let { it.restoreState.isMediaRestoreOperation || it.restoreStatus == RestoreStatus.FINISHED } @@ -32,7 +32,7 @@ class MediaRestoreProgressBanner(private val listener: RestoreProgressBannerList @Composable override fun DisplayBanner(model: ArchiveRestoreProgressState, contentPadding: PaddingValues) { - BackupStatusBanner( + ArchiveRestoreStatusBanner( data = model, onBannerClick = listener::onBannerClick, onActionClick = listener::onActionClick, @@ -48,10 +48,4 @@ class MediaRestoreProgressBanner(private val listener: RestoreProgressBannerList fun onActionClick(data: ArchiveRestoreProgressState) fun onDismissComplete() } - - private object EmptyListener : RestoreProgressBannerListener { - override fun onBannerClick() = Unit - override fun onActionClick(data: ArchiveRestoreProgressState) = Unit - override fun onDismissComplete() = Unit - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/banner/banners/ArchiveUploadStatusBanner.kt b/app/src/main/java/org/thoughtcrime/securesms/banner/banners/ArchiveUploadStatusBanner.kt new file mode 100644 index 0000000000..9da6c96fce --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/banner/banners/ArchiveUploadStatusBanner.kt @@ -0,0 +1,110 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.banner.banners + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.signal.core.util.bytes +import org.thoughtcrime.securesms.backup.ArchiveUploadProgress +import org.thoughtcrime.securesms.backup.v2.ui.status.ArchiveUploadStatusBannerView +import org.thoughtcrime.securesms.backup.v2.ui.status.ArchiveUploadStatusBannerViewEvents +import org.thoughtcrime.securesms.backup.v2.ui.status.ArchiveUploadStatusBannerViewState +import org.thoughtcrime.securesms.banner.Banner +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState +import org.thoughtcrime.securesms.util.NetworkUtil + +@OptIn(ExperimentalCoroutinesApi::class) +class ArchiveUploadStatusBanner(private val listener: UploadProgressBannerListener) : Banner() { + + override val enabled: Boolean + get() = SignalStore.backup.uploadBannerVisible + + override val dataFlow: Flow by lazy { + ArchiveUploadProgress + .progress + .map { + val hasMobileData = NetworkUtil.isConnectedMobile(AppDependencies.application) + val hasWifi = NetworkUtil.isConnectedWifi(AppDependencies.application) + val canUploadOnCellular = SignalStore.backup.backupWithCellular + + if (hasWifi || (hasMobileData && canUploadOnCellular)) { + when (it.state) { + ArchiveUploadProgressState.State.None -> ArchiveUploadStatusBannerViewState.Finished(it.completedSize.bytes.toUnitString(maxPlaces = 1)) + ArchiveUploadProgressState.State.Export -> ArchiveUploadStatusBannerViewState.CreatingBackupFile + ArchiveUploadProgressState.State.UploadBackupFile, + ArchiveUploadProgressState.State.UploadMedia -> { + if (NetworkConstraint.isMet(AppDependencies.application)) { + ArchiveUploadStatusBannerViewState.Uploading( + completedSize = it.completedSize.bytes.toUnitString(maxPlaces = 1), + totalSize = it.totalSize.bytes.toUnitString(maxPlaces = 1), + progress = it.completedSize / it.totalSize.toFloat() + ) + } else { + ArchiveUploadStatusBannerViewState.PausedNoInternet + } + } + ArchiveUploadProgressState.State.UserCanceled -> ArchiveUploadStatusBannerViewState.CreatingBackupFile + } + } else if (hasMobileData) { + when (it.state) { + ArchiveUploadProgressState.State.None, + ArchiveUploadProgressState.State.Export, + ArchiveUploadProgressState.State.UploadBackupFile, + ArchiveUploadProgressState.State.UploadMedia -> { + ArchiveUploadStatusBannerViewState.PausedMissingWifi + } + ArchiveUploadProgressState.State.UserCanceled -> ArchiveUploadStatusBannerViewState.CreatingBackupFile + } + } else { + when (it.state) { + ArchiveUploadProgressState.State.None, + ArchiveUploadProgressState.State.Export, + ArchiveUploadProgressState.State.UploadBackupFile, + ArchiveUploadProgressState.State.UploadMedia -> { + ArchiveUploadStatusBannerViewState.PausedNoInternet + } + ArchiveUploadProgressState.State.UserCanceled -> ArchiveUploadStatusBannerViewState.CreatingBackupFile + } + } + } + } + + @Composable + override fun DisplayBanner(model: ArchiveUploadStatusBannerViewState, contentPadding: PaddingValues) { + ArchiveUploadStatusBannerView( + state = model, + emitter = { event -> + when (event) { + ArchiveUploadStatusBannerViewEvents.BannerClicked -> { + listener.onBannerClick() + } + ArchiveUploadStatusBannerViewEvents.CancelClicked -> { + listener.onCancelClicked() + } + ArchiveUploadStatusBannerViewEvents.HideClicked -> { + SignalStore.backup.uploadBannerVisible = false + listener.onHidden() + } + } + } + ) + } + + private val ArchiveUploadProgressState.completedSize get() = this.mediaUploadedBytes + this.backupFileUploadedBytes + private val ArchiveUploadProgressState.totalSize get() = this.mediaTotalBytes + this.backupFileTotalBytes + + interface UploadProgressBannerListener { + fun onBannerClick() + fun onCancelClicked() + fun onHidden() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index a21e2533ce..e5eeebc7ac 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -76,6 +76,7 @@ import org.thoughtcrime.securesms.MainFragment; import org.thoughtcrime.securesms.MainNavigator; import org.thoughtcrime.securesms.MuteDialog; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.backup.ArchiveUploadProgress; import org.thoughtcrime.securesms.backup.RestoreState; import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress; import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState; @@ -87,12 +88,13 @@ import org.thoughtcrime.securesms.badges.self.expired.ExpiredOneTimeBadgeBottomS import org.thoughtcrime.securesms.badges.self.expired.MonthlyDonationCanceledBottomSheetDialogFragment; import org.thoughtcrime.securesms.banner.Banner; import org.thoughtcrime.securesms.banner.BannerManager; +import org.thoughtcrime.securesms.banner.banners.ArchiveUploadStatusBanner; import org.thoughtcrime.securesms.banner.banners.CdsPermanentErrorBanner; import org.thoughtcrime.securesms.banner.banners.CdsTemporaryErrorBanner; import org.thoughtcrime.securesms.banner.banners.DeprecatedBuildBanner; import org.thoughtcrime.securesms.banner.banners.DeprecatedSdkBanner; import org.thoughtcrime.securesms.banner.banners.DozeBanner; -import org.thoughtcrime.securesms.banner.banners.MediaRestoreProgressBanner; +import org.thoughtcrime.securesms.banner.banners.ArchiveRestoreStatusBanner; import org.thoughtcrime.securesms.banner.banners.OutdatedBuildBanner; import org.thoughtcrime.securesms.banner.banners.ServiceOutageBanner; import org.thoughtcrime.securesms.banner.banners.UnauthorizedBanner; @@ -745,7 +747,7 @@ public class ConversationListFragment extends MainFragment implements Conversati } return Unit.INSTANCE; }), - new MediaRestoreProgressBanner(new MediaRestoreProgressBanner.RestoreProgressBannerListener() { + new ArchiveRestoreStatusBanner(new ArchiveRestoreStatusBanner.RestoreProgressBannerListener() { @Override public void onBannerClick() { startActivity(AppSettingsActivity.backupsSettings(requireContext())); @@ -774,6 +776,30 @@ public class ConversationListFragment extends MainFragment implements Conversati public void onDismissComplete() { bannerManager.updateContent(bannerView.get()); } + }), + new ArchiveUploadStatusBanner(new ArchiveUploadStatusBanner.UploadProgressBannerListener() { + @Override + public void onBannerClick() { + startActivity(AppSettingsActivity.remoteBackups(requireContext())); + } + + @Override + public void onCancelClicked() { + new MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.CancelBackupDialog_title) + .setMessage(R.string.CancelBackupDialog_body) + .setNegativeButton(R.string.CancelBackupDialog_continue_action, null) + .setPositiveButton(R.string.CancelBackupDialog_cancel_action, (d, w) -> { + ArchiveUploadProgress.INSTANCE.cancel(); + bannerManager.updateContent(bannerView.get()); + }) + .show(); + } + + @Override + public void onHidden() { + bannerManager.updateContent(bannerView.get()); + } }) ); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt index 58408861c7..af3ace0f77 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt @@ -47,6 +47,7 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { private const val KEY_LAST_CHECK_IN_MILLIS = "backup.lastCheckInMilliseconds" private const val KEY_LAST_CHECK_IN_SNOOZE_MILLIS = "backup.lastCheckInSnoozeMilliseconds" private const val KEY_FIRST_APP_VERSION = "backup.firstAppVersion" + private const val KEY_FINISHED_INITIAL_BACKUP = "backup.finishedInitialBackup" private const val KEY_NEXT_BACKUP_TIME = "backup.nextBackupTime" private const val KEY_LAST_BACKUP_TIME = "backup.lastBackupTime" @@ -103,6 +104,8 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { private const val KEY_NEW_LOCAL_BACKUPS_DIRECTORY = "backup.new_local_backups_directory" private const val KEY_NEW_LOCAL_BACKUPS_LAST_BACKUP_TIME = "backup.new_local_backups_last_backup_time" + private const val KEY_UPLOAD_BANNER_VISIBLE = "backup.upload_banner_visible" + private val cachedCdnCredentialsExpiresIn: Duration = 12.hours private val lock = ReentrantLock() @@ -158,6 +161,16 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { } } + /** + * Whether or not the user has completed their initial backup. This if enabling backups for the first time or going from free -> paid tier. + * It is initialized to 'true' to account for pre-existing backup users. + * Users who newly-enable backups will set this to 'false' via the aforementioned mechanism. + */ + var finishedInitialBackup: Boolean by booleanValue(KEY_FINISHED_INITIAL_BACKUP, true) + + /** Whether or not the user chose to hide the uplaod banner that appears on the chat list. */ + var uploadBannerVisible: Boolean by booleanValue(KEY_UPLOAD_BANNER_VISIBLE, false) + private val _lastBackupTimeFlow: Lazy> = lazy { MutableStateFlow(lastBackupTime) } val lastBackupTimeFlow by lazy { _lastBackupTimeFlow.value } @@ -268,6 +281,13 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { clearNotEnoughRemoteStorageSpace() clearMessageBackupFailureSheetWatermark() backupCreationError = null + + if (storedValue == null) { + Log.i(TAG, "Enabling backups. Resetting 'finished initial backup' state.") + } else if (value == MessageBackupTier.PAID) { + Log.i(TAG, "Moving to PAID backups. Resetting 'finished initial backup' state.") + finishedInitialBackup = false + } } deletionState = DeletionState.NONE diff --git a/app/src/main/res/drawable/symbol_visible.xml b/app/src/main/res/drawable/symbol_visible.xml new file mode 100644 index 0000000000..58bf36b258 --- /dev/null +++ b/app/src/main/res/drawable/symbol_visible.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/symbol_visible_slash.xml b/app/src/main/res/drawable/symbol_visible_slash.xml new file mode 100644 index 0000000000..b4b083290a --- /dev/null +++ b/app/src/main/res/drawable/symbol_visible_slash.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 19f198dbbf..91fcd7d8ce 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8080,6 +8080,15 @@ Resume + + Creating backup… + + Uploading backup + + Backup paused + + Backup complete + Waiting for Wi-Fi… @@ -8088,6 +8097,10 @@ Device has low battery %1$s of %2$s + + Hide + + Cancel backup @@ -8130,6 +8143,15 @@ Restoring your media using cellular data may result in data charges. You can connect to a Wi-Fi network to automatically resume. + + Cancel backup? + + Canceling your backup will not delete your backup. You can resume your backup at any time from Backup Settings. + + Cancel backup + + Continue backup + Google Play