Add validation error UI.

This commit is contained in:
Michelle Tang
2024-12-02 09:07:46 -08:00
committed by Greyson Parrelli
parent 756262c1fe
commit 3c086f347e
13 changed files with 191 additions and 22 deletions

View File

@@ -104,6 +104,10 @@ object ArchiveUploadProgress {
updateState(PROGRESS_NONE)
}
fun onValidationFailure() {
updateState(PROGRESS_NONE)
}
private fun updateState(state: ArchiveUploadProgressState, notify: Boolean = true) {
uploadProgress = state
SignalStore.backup.archiveUploadState = state

View File

@@ -204,14 +204,27 @@ object BackupRepository {
}
/**
* Whether the "Could not complete backup" row should be displayed in settings.
* Whether the "Backup Failed" row should be displayed in settings.
* Shown when the initial backup creation has failed
*/
fun shouldDisplayBackupFailedSettingsRow(): Boolean {
if (shouldNotDisplayBackupFailedMessaging()) {
return false
}
return SignalStore.backup.hasBackupFailure
return !SignalStore.backup.hasBackupBeenUploaded && SignalStore.backup.hasBackupFailure
}
/**
* Whether the "Could not complete backup" row should be displayed in settings.
* Shown when a new backup could not be created but there is an existing one already
*/
fun shouldDisplayCouldNotCompleteBackupSettingsRow(): Boolean {
if (shouldNotDisplayBackupFailedMessaging()) {
return false
}
return SignalStore.backup.hasBackupBeenUploaded && SignalStore.backup.hasBackupFailure
}
/**
@@ -230,7 +243,8 @@ object BackupRepository {
}
/**
* Whether or not the "Could not complete backup" sheet should be displayed.
* Whether or not the "Backup failed" sheet should be displayed.
* Should only be displayed if this is the failure of the initial backup creation.
*/
@JvmStatic
fun shouldDisplayBackupFailedSheet(): Boolean {
@@ -238,7 +252,19 @@ object BackupRepository {
return false
}
return System.currentTimeMillis().milliseconds > SignalStore.backup.nextBackupFailureSheetSnoozeTime
return !SignalStore.backup.hasBackupBeenUploaded && System.currentTimeMillis().milliseconds > SignalStore.backup.nextBackupFailureSheetSnoozeTime
}
/**
* Whether or not the "Could not complete backup" sheet should be displayed.
*/
@JvmStatic
fun shouldDisplayCouldNotCompleteBackupSheet(): Boolean {
if (shouldNotDisplayBackupFailedMessaging()) {
return false
}
return SignalStore.backup.hasBackupBeenUploaded && System.currentTimeMillis().milliseconds > SignalStore.backup.nextBackupFailureSheetSnoozeTime
}
fun snoozeYourMediaWillBeDeletedTodaySheet() {
@@ -249,7 +275,7 @@ object BackupRepository {
* Whether or not the "Your media will be deleted today" sheet should be displayed.
*/
suspend fun shouldDisplayYourMediaWillBeDeletedTodaySheet(): Boolean {
if (shouldNotDisplayBackupFailedMessaging() || !SignalStore.backup.optimizeStorage) {
if (shouldNotDisplayBackupFailedMessaging() || !SignalStore.backup.hasBackupBeenUploaded || !SignalStore.backup.optimizeStorage) {
return false
}
@@ -285,7 +311,7 @@ object BackupRepository {
}
private fun shouldNotDisplayBackupFailedMessaging(): Boolean {
return !RemoteConfig.messageBackups || !SignalStore.backup.areBackupsEnabled || !SignalStore.backup.hasBackupBeenUploaded
return !RemoteConfig.messageBackups || !SignalStore.backup.areBackupsEnabled
}
/**

View File

@@ -57,6 +57,8 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.PlayStoreUtil
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
@@ -67,6 +69,8 @@ import org.signal.core.ui.R as CoreUiR
*/
class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() {
override val peekHeightPercentage: Float = 0.75f
companion object {
private const val ARG_ALERT = "alert"
@@ -126,6 +130,8 @@ class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() {
}
is BackupAlert.DiskFull -> Unit
is BackupAlert.BackupFailed ->
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext())
}
dismissAllowingStateLoss()
@@ -144,6 +150,8 @@ class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() {
is BackupAlert.DiskFull -> {
displaySkipRestoreDialog()
}
// TODO [backups] - Update support URL with backups page
BackupAlert.BackupFailed -> CommunicationActions.openBrowserLink(requireContext(), requireContext().getString(R.string.backup_support_url))
}
dismissAllowingStateLoss()
@@ -153,7 +161,7 @@ class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() {
super.onDismiss(dialog)
when (backupAlert) {
is BackupAlert.CouldNotCompleteBackup -> BackupRepository.markBackupFailedSheetDismissed()
is BackupAlert.CouldNotCompleteBackup, BackupAlert.BackupFailed -> BackupRepository.markBackupFailedSheetDismissed()
is BackupAlert.MediaWillBeDeletedToday -> BackupRepository.snoozeYourMediaWillBeDeletedTodaySheet()
else -> Unit
}
@@ -261,6 +269,7 @@ private fun BackupAlertSheetContent(
is BackupAlert.MediaBackupsAreOff -> MediaBackupsAreOffBody(backupAlert.endOfPeriodSeconds, mediaTtl)
BackupAlert.MediaWillBeDeletedToday -> MediaWillBeDeletedTodayBody()
is BackupAlert.DiskFull -> DiskFullBody(requiredSpace = backupAlert.requiredSpace)
BackupAlert.BackupFailed -> BackupFailedBody()
}
val secondaryActionResource = rememberSecondaryActionResource(backupAlert = backupAlert)
@@ -366,12 +375,22 @@ private fun DiskFullBody(requiredSpace: String) {
)
}
@Composable
private fun BackupFailedBody() {
Text(
text = stringResource(id = R.string.BackupAlertBottomSheet__an_error_occurred),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 36.dp)
)
}
@Composable
private fun rememberBackupsIconColors(backupAlert: BackupAlert): BackupsIconColors {
return remember(backupAlert) {
when (backupAlert) {
BackupAlert.FailedToRenew, is BackupAlert.MediaBackupsAreOff -> error("Not icon-based options.")
is BackupAlert.CouldNotCompleteBackup, is BackupAlert.DiskFull -> BackupsIconColors.Warning
is BackupAlert.CouldNotCompleteBackup, BackupAlert.BackupFailed, is BackupAlert.DiskFull -> BackupsIconColors.Warning
BackupAlert.MediaWillBeDeletedToday -> BackupsIconColors.Error
}
}
@@ -385,6 +404,7 @@ private fun titleString(backupAlert: BackupAlert): String {
is BackupAlert.MediaBackupsAreOff -> stringResource(R.string.BackupAlertBottomSheet__your_backups_subscription_expired)
BackupAlert.MediaWillBeDeletedToday -> stringResource(R.string.BackupAlertBottomSheet__your_media_will_be_deleted_today)
is BackupAlert.DiskFull -> stringResource(R.string.BackupAlertBottomSheet__free_up_s_on_this_device, backupAlert.requiredSpace)
BackupAlert.BackupFailed -> stringResource(R.string.BackupAlertBottomSheet__backup_failed)
}
}
@@ -399,6 +419,7 @@ private fun primaryActionString(
is BackupAlert.MediaBackupsAreOff -> stringResource(R.string.BackupAlertBottomSheet__subscribe_for_s_month, pricePerMonth)
BackupAlert.MediaWillBeDeletedToday -> stringResource(R.string.BackupAlertBottomSheet__download_media_now)
is BackupAlert.DiskFull -> stringResource(R.string.BackupAlertBottomSheet__got_it)
is BackupAlert.BackupFailed -> stringResource(R.string.BackupAlertBottomSheet__check_for_update)
}
}
@@ -411,6 +432,7 @@ private fun rememberSecondaryActionResource(backupAlert: BackupAlert): Int {
is BackupAlert.MediaBackupsAreOff -> R.string.BackupAlertBottomSheet__not_now
BackupAlert.MediaWillBeDeletedToday -> R.string.BackupAlertBottomSheet__dont_download_media
is BackupAlert.DiskFull -> R.string.BackupAlertBottomSheet__skip_restore
is BackupAlert.BackupFailed -> R.string.BackupAlertBottomSheet__learn_more
}
}
}
@@ -471,6 +493,17 @@ private fun BackupAlertSheetContentPreviewDiskFull() {
}
}
@SignalPreview
@Composable
private fun BackupAlertSheetContentPreviewBackupFailed() {
Previews.BottomSheetPreview {
BackupAlertSheetContent(
backupAlert = BackupAlert.BackupFailed,
mediaTtl = 60.days
)
}
}
/**
* All necessary information to display the sheet should be handed in through the specific alert.
*/
@@ -485,6 +518,12 @@ sealed class BackupAlert : Parcelable {
val daysSinceLastBackup: Int
) : BackupAlert()
/**
* This value is driven by the same watermarking system for [CouldNotCompleteBackup] so that only one of these sheets is shown by the system
* This value is driven by failure to complete the initial backup.
*/
data object BackupFailed : BackupAlert()
/**
* This value is driven by InAppPayment state, and will be automatically cleared when the sheet is displayed.
*/

View File

@@ -22,11 +22,11 @@ object BackupAlertDelegate {
lifecycle.coroutineScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
if (BackupRepository.shouldDisplayBackupFailedSheet()) {
BackupAlertBottomSheet.create(BackupAlert.BackupFailed).show(fragmentManager, null)
} else if (BackupRepository.shouldDisplayCouldNotCompleteBackupSheet()) {
BackupAlertBottomSheet.create(BackupAlert.CouldNotCompleteBackup(daysSinceLastBackup = SignalStore.backup.daysSinceLastBackup)).show(fragmentManager, null)
}
if (BackupRepository.shouldDisplayYourMediaWillBeDeletedTodaySheet()) {
BackupAlertBottomSheet.create(BackupAlert.MediaWillBeDeletedToday)
} else if (BackupRepository.shouldDisplayYourMediaWillBeDeletedTodaySheet()) {
BackupAlertBottomSheet.create(BackupAlert.MediaWillBeDeletedToday).show(fragmentManager, null)
}
}
}

View File

@@ -195,6 +195,12 @@ fun BackupStatusBannerPreview() {
BackupStatusBanner(
data = BackupStatusData.CouldNotCompleteBackup
)
HorizontalDivider()
BackupStatusBanner(
data = BackupStatusData.BackupFailed
)
}
}
}
@@ -235,6 +241,19 @@ sealed interface BackupStatusData {
override val iconColors: BackupsIconColors = BackupsIconColors.Warning
}
/**
* Initial backup creation failure
*/
data object BackupFailed : BackupStatusData {
override val iconRes: Int = R.drawable.symbol_backup_error_24
override val title: String
@Composable
get() = stringResource(androidx.biometric.R.string.default_error_msg)
override val iconColors: BackupsIconColors = BackupsIconColors.Warning
}
/**
* User does not have enough space on their device to complete backup restoration
*/

View File

@@ -20,17 +20,19 @@ 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.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.signal.core.ui.Previews
@@ -48,10 +50,13 @@ import org.signal.core.ui.R as CoreUiR
fun BackupStatusRow(
backupStatusData: BackupStatusData,
onSkipClick: () -> Unit = {},
onCancelClick: () -> Unit = {}
onCancelClick: () -> Unit = {},
onLearnMoreClick: () -> Unit = {}
) {
Column {
if (backupStatusData !is BackupStatusData.CouldNotCompleteBackup) {
if (backupStatusData !is BackupStatusData.CouldNotCompleteBackup &&
backupStatusData !is BackupStatusData.BackupFailed
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter))
@@ -120,6 +125,40 @@ fun BackupStatusRow(
modifier = Modifier.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter))
)
}
BackupStatusData.BackupFailed -> {
val inlineContentMap = mapOf(
"yellow_bullet" to InlineTextContent(
Placeholder(12.sp, 12.sp, PlaceholderVerticalAlign.TextCenter)
) {
Box(
modifier = Modifier
.size(12.dp)
.background(color = backupStatusData.iconColors.foreground, shape = CircleShape)
)
}
)
Text(
text = buildAnnotatedString {
appendInlineContent("yellow_bullet")
append(" ")
append(stringResource(R.string.BackupStatusRow__your_last_backup_latest_version))
append(" ")
withLink(
LinkAnnotation.Clickable(
stringResource(R.string.BackupStatusRow__learn_more),
styles = TextLinkStyles(style = SpanStyle(color = MaterialTheme.colorScheme.primary))
) {
onLearnMoreClick()
}
) {
append(stringResource(R.string.BackupStatusRow__learn_more))
}
},
inlineContent = inlineContentMap,
modifier = Modifier.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter))
)
}
}
}
}
@@ -241,3 +280,13 @@ fun BackupStatusRowCouldNotCompleteBackupPreview() {
)
}
}
@SignalPreview
@Composable
fun BackupStatusRowBackupFailedPreview() {
Previews.Preview {
BackupStatusRow(
backupStatusData = BackupStatusData.BackupFailed
)
}
}

View File

@@ -215,7 +215,7 @@ private fun AppSettingsContent(
}
}
BackupFailureState.COULD_NOT_COMPLETE_BACKUP -> {
BackupFailureState.BACKUP_FAILED, BackupFailureState.COULD_NOT_COMPLETE_BACKUP -> {
item {
Dividers.Default()

View File

@@ -72,6 +72,8 @@ class AppSettingsViewModel : ViewModel() {
private fun getBackupFailureState(): BackupFailureState {
return if (BackupRepository.shouldDisplayBackupFailedSettingsRow()) {
BackupFailureState.BACKUP_FAILED
} else if (BackupRepository.shouldDisplayCouldNotCompleteBackupSettingsRow()) {
BackupFailureState.COULD_NOT_COMPLETE_BACKUP
} else if (SignalStore.backup.subscriptionStateMismatchDetected) {
BackupFailureState.SUBSCRIPTION_STATE_MISMATCH

View File

@@ -10,6 +10,7 @@ package org.thoughtcrime.securesms.components.settings.app
*/
enum class BackupFailureState {
NONE,
BACKUP_FAILED,
COULD_NOT_COMPLETE_BACKUP,
SUBSCRIPTION_STATE_MISMATCH
}

View File

@@ -83,6 +83,8 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
import org.thoughtcrime.securesms.backup.v2.BackupFrequency
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlert
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlertBottomSheet
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatusData
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatusRow
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
@@ -233,6 +235,10 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
requireActivity().finish()
requireActivity().startActivity(AppSettingsActivity.help(requireContext(), HelpFragment.REMOTE_BACKUPS_INDEX))
}
override fun onLearnMoreAboutBackupFailure() {
BackupAlertBottomSheet.create(BackupAlert.BackupFailed).show(parentFragmentManager, null)
}
}
private fun displayBackupKey() {
@@ -314,6 +320,7 @@ private interface ContentCallbacks {
fun onRenewLostSubscription() = Unit
fun onLearnMoreAboutLostSubscription() = Unit
fun onContactSupport() = Unit
fun onLearnMoreAboutBackupFailure() = Unit
}
@Composable
@@ -392,7 +399,8 @@ private fun RemoteBackupsSettingsContent(
BackupStatusRow(
backupStatusData = backupRestoreState.backupStatusData,
onCancelClick = contentCallbacks::onCancelMediaRestore,
onSkipClick = contentCallbacks::onSkipMediaRestore
onSkipClick = contentCallbacks::onSkipMediaRestore,
onLearnMoreClick = contentCallbacks::onLearnMoreAboutBackupFailure
)
}
} else if (backupRestoreState is BackupRestoreState.Ready && backupState is RemoteBackupsSettingsState.BackupState.Canceled) {
@@ -420,7 +428,8 @@ private fun RemoteBackupsSettingsContent(
BackupStatusRow(
backupStatusData = backupRestoreState.backupStatusData,
onCancelClick = contentCallbacks::onCancelMediaRestore,
onSkipClick = contentCallbacks::onSkipMediaRestore
onSkipClick = contentCallbacks::onSkipMediaRestore,
onLearnMoreClick = contentCallbacks::onLearnMoreAboutBackupFailure
)
}
}
@@ -920,8 +929,14 @@ private fun InProgressBackupRow(
)
}
val inProgressText = if (totalProgress == null || totalProgress == 0) {
stringResource(R.string.RemoteBackupsSettingsFragment__processing_backup)
} else {
stringResource(R.string.RemoteBackupsSettingsFragment__d_slash_d, progress ?: 0, totalProgress)
}
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__d_slash_d, progress ?: 0, totalProgress ?: 0),
text = inProgressText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)

View File

@@ -92,6 +92,8 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
} else if (SignalStore.backup.totalRestorableAttachmentSize > 0L) {
_restoreState.update { BackupRestoreState.Ready(SignalStore.backup.totalRestorableAttachmentSize.bytes.toUnitString()) }
} else if (BackupRepository.shouldDisplayBackupFailedSettingsRow()) {
_restoreState.update { BackupRestoreState.FromBackupStatusData(BackupStatusData.BackupFailed) }
} else if (BackupRepository.shouldDisplayCouldNotCompleteBackupSettingsRow()) {
_restoreState.update { BackupRestoreState.FromBackupStatusData(BackupStatusData.CouldNotCompleteBackup) }
} else {
_restoreState.update { BackupRestoreState.None }

View File

@@ -101,8 +101,8 @@ class BackupMessagesJob private constructor(parameters: Parameters) : Job(parame
return Result.retry(defaultBackoff())
}
is ArchiveValidator.ValidationResult.ValidationError -> {
// TODO [backup] UX
Log.w(TAG, "The backup file fails validation! Message: " + result.exception.message)
ArchiveUploadProgress.onValidationFailure()
return Result.failure()
}
}