Add UX for out of remote storage space error presentation.

This commit is contained in:
Alex Hart
2025-06-09 15:56:08 -03:00
committed by Greyson Parrelli
parent 1424dd6892
commit dd5941b884
13 changed files with 167 additions and 70 deletions

View File

@@ -287,6 +287,10 @@ object BackupRepository {
AppDependencies.jobManager.add(CheckRestoreMediaLeftJob(RestoreAttachmentJob.constructQueueString(RestoreAttachmentJob.RestoreOperation.MANUAL)))
}
fun shouldDisplayOutOfStorageSpaceUx(): Boolean {
return false // TODO [message-backups] Wire into actual error handling.
}
/**
* Whether the yellow dot should be displayed on the conversation list avatar.
*/

View File

@@ -79,6 +79,7 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
StartLocation.BACKUPS_SETTINGS -> AppSettingsFragmentDirections.actionDirectToBackupsSettingsFragment()
StartLocation.INVITE -> AppSettingsFragmentDirections.actionDirectToInviteFragment()
StartLocation.MANAGE_STORAGE -> AppSettingsFragmentDirections.actionDirectToStoragePreferenceFragment()
}
}
@@ -178,6 +179,8 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
@JvmStatic
fun manageSubscriptions(context: Context): Intent = getIntentForStartLocation(context, StartLocation.MANAGE_SUBSCRIPTIONS)
fun manageStorage(context: Context): Intent = getIntentForStartLocation(context, StartLocation.MANAGE_STORAGE)
@JvmStatic
fun notificationProfiles(context: Context): Intent = getIntentForStartLocation(context, StartLocation.NOTIFICATION_PROFILES)
@@ -255,7 +258,8 @@ class AppSettingsActivity : DSLSettingsActivity(), GooglePayComponent {
CHAT_FOLDERS(17),
CREATE_CHAT_FOLDER(18),
BACKUPS_SETTINGS(19),
INVITE(20);
INVITE(20),
MANAGE_STORAGE(21);
companion object {
fun fromCode(code: Int?): StartLocation {

View File

@@ -250,6 +250,23 @@ private fun AppSettingsContent(
}
}
BackupFailureState.OUT_OF_STORAGE_SPACE -> {
item {
Dividers.Default()
Rows.TextRow(
text = stringResource(R.string.AppSettingsFragment__backup_storage_limit_reached),
icon = ImageVector.vectorResource(R.drawable.symbol_error_circle_fill_24),
iconTint = MaterialTheme.colorScheme.error,
onClick = {
callbacks.navigate(R.id.action_appSettingsFragment_to_remoteBackupsSettingsFragment)
}
)
Dividers.Default()
}
}
BackupFailureState.NONE -> Unit
}
@@ -518,7 +535,7 @@ private fun BackupsWarningRow(
icon = {
Box {
Icon(
painter = painterResource(R.drawable.symbol_backup_24),
imageVector = ImageVector.vectorResource(R.drawable.symbol_backup_24),
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = null
)
@@ -666,7 +683,7 @@ private fun AppSettingsContentPreview() {
showPayments = true,
showAppUpdates = true,
showBackups = true,
backupFailureState = BackupFailureState.SUBSCRIPTION_STATE_MISMATCH
backupFailureState = BackupFailureState.OUT_OF_STORAGE_SPACE
),
bannerManager = BannerManager(
banners = listOf(TestBanner())

View File

@@ -74,6 +74,8 @@ class AppSettingsViewModel : ViewModel() {
private fun getBackupFailureState(): BackupFailureState {
return if (!RemoteConfig.messageBackups) {
BackupFailureState.NONE
} else if (BackupRepository.shouldDisplayOutOfStorageSpaceUx()) {
BackupFailureState.OUT_OF_STORAGE_SPACE
} else if (BackupRepository.shouldDisplayBackupFailedSettingsRow()) {
BackupFailureState.BACKUP_FAILED
} else if (BackupRepository.shouldDisplayCouldNotCompleteBackupSettingsRow()) {

View File

@@ -13,5 +13,6 @@ enum class BackupFailureState {
BACKUP_FAILED,
COULD_NOT_COMPLETE_BACKUP,
SUBSCRIPTION_STATE_MISMATCH,
ALREADY_REDEEMED
ALREADY_REDEEMED,
OUT_OF_STORAGE_SPACE
}

View File

@@ -153,20 +153,11 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
val callbacks = remember { Callbacks() }
RemoteBackupsSettingsContent(
backupsEnabled = state.backupsEnabled,
lastBackupTimestamp = state.lastBackupTimestamp,
canBackUpUsingCellular = state.canBackUpUsingCellular,
canRestoreUsingCellular = state.canRestoreUsingCellular,
backupsFrequency = state.backupsFrequency,
requestedDialog = state.dialog,
requestedSnackbar = state.snackbar,
contentCallbacks = callbacks,
backupProgress = backupProgress,
backupMediaSize = state.backupMediaSize,
backupState = state.backupState,
state = state,
backupRestoreState = restoreState,
backupDeleteState = deleteState,
hasRedemptionError = state.hasRedemptionError,
contentCallbacks = callbacks,
backupProgress = backupProgress,
statusBarColorNestedScrollConnection = remember { StatusBarColorNestedScrollConnection(requireActivity()) }
)
}
@@ -282,6 +273,11 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
override fun onDisplayDownloadingBackupDialog() {
viewModel.requestDialog(RemoteBackupsSettingsState.Dialog.DOWNLOADING_YOUR_BACKUP)
}
override fun onManageStorageClick() {
requireActivity().finish()
requireActivity().startActivity(AppSettingsActivity.manageStorage(requireActivity()))
}
}
private fun displayBackupKey() {
@@ -370,6 +366,7 @@ private interface ContentCallbacks {
fun onRedemptionErrorDetailsClick() = Unit
fun onDisplayProgressDialog() = Unit
fun onDisplayDownloadingBackupDialog() = Unit
fun onManageStorageClick() = Unit
object Empty : ContentCallbacks
}
@@ -377,20 +374,11 @@ private interface ContentCallbacks {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RemoteBackupsSettingsContent(
backupsEnabled: Boolean,
backupState: RemoteBackupsSettingsState.BackupState,
state: RemoteBackupsSettingsState,
backupRestoreState: BackupRestoreState,
backupDeleteState: DeletionState,
lastBackupTimestamp: Long,
canBackUpUsingCellular: Boolean,
canRestoreUsingCellular: Boolean,
backupsFrequency: BackupFrequency,
requestedDialog: RemoteBackupsSettingsState.Dialog,
requestedSnackbar: RemoteBackupsSettingsState.Snackbar,
contentCallbacks: ContentCallbacks,
backupProgress: ArchiveUploadProgressState?,
backupMediaSize: Long,
hasRedemptionError: Boolean,
statusBarColorNestedScrollConnection: StatusBarColorNestedScrollConnection?
) {
val snackbarHostState = remember {
@@ -443,14 +431,23 @@ private fun RemoteBackupsSettingsContent(
BetaHeader(modifier = Modifier.padding(horizontal = 16.dp))
}
if (hasRedemptionError) {
if (state.isOutOfStorageSpace) {
item {
OutOfStorageSpaceBlock(
formattedTotalStorageSpace = state.totalAllowedStorageSpace,
onManageStorageClick = contentCallbacks::onManageStorageClick
)
}
}
if (state.isOutOfStorageSpace) {
item {
RedemptionErrorAlert(onDetailsClick = contentCallbacks::onRedemptionErrorDetailsClick)
}
}
item {
when (backupState) {
when (state.backupState) {
is RemoteBackupsSettingsState.BackupState.Loading -> {
LoadingCard()
}
@@ -460,12 +457,12 @@ private fun RemoteBackupsSettingsContent(
}
is RemoteBackupsSettingsState.BackupState.Pending -> {
PendingCard(backupState.price)
PendingCard(state.backupState.price)
}
is RemoteBackupsSettingsState.BackupState.SubscriptionMismatchMissingGooglePlay -> {
SubscriptionMismatchMissingGooglePlayCard(
state = backupState,
state = state.backupState,
onLearnMoreClick = contentCallbacks::onLearnMoreAboutLostSubscription,
onRenewClick = contentCallbacks::onRenewLostSubscription,
isRenewEnabled = backupDeleteState != DeletionState.DELETE_BACKUPS
@@ -476,7 +473,7 @@ private fun RemoteBackupsSettingsContent(
is RemoteBackupsSettingsState.BackupState.WithTypeAndRenewalTime -> {
BackupCard(
backupState = backupState,
backupState = state.backupState,
onBackupTypeActionButtonClicked = contentCallbacks::onBackupTypeActionClick,
buttonsEnabled = backupDeleteState != DeletionState.DELETE_BACKUPS
)
@@ -497,19 +494,19 @@ private fun RemoteBackupsSettingsContent(
appendBackupDeletionItems(
backupDeleteState = backupDeleteState,
backupRestoreState = backupRestoreState,
canRestoreUsingCellular = canRestoreUsingCellular,
canRestoreUsingCellular = state.canRestoreUsingCellular,
contentCallbacks = contentCallbacks
)
} else if (backupsEnabled) {
} else if (state.backupsEnabled) {
appendBackupDetailsItems(
backupState = backupState,
backupState = state.backupState,
backupRestoreState = backupRestoreState,
backupProgress = backupProgress,
lastBackupTimestamp = lastBackupTimestamp,
backupMediaSize = backupMediaSize,
backupsFrequency = backupsFrequency,
canBackUpUsingCellular = canBackUpUsingCellular,
canRestoreUsingCellular = canRestoreUsingCellular,
lastBackupTimestamp = state.lastBackupTimestamp,
backupMediaSize = state.backupMediaSize,
backupsFrequency = state.backupsFrequency,
canBackUpUsingCellular = state.canBackUpUsingCellular,
canRestoreUsingCellular = state.canRestoreUsingCellular,
contentCallbacks = contentCallbacks
)
} else {
@@ -541,7 +538,7 @@ private fun RemoteBackupsSettingsContent(
}
}
when (requestedDialog) {
when (state.dialog) {
RemoteBackupsSettingsState.Dialog.NONE -> {}
RemoteBackupsSettingsState.Dialog.TURN_OFF_FAILED -> {
FailedToTurnOffBackupDialog(
@@ -558,7 +555,7 @@ private fun RemoteBackupsSettingsContent(
RemoteBackupsSettingsState.Dialog.BACKUP_FREQUENCY -> {
BackupFrequencyDialog(
selected = backupsFrequency,
selected = state.backupsFrequency,
onSelected = contentCallbacks::onSelectBackupsFrequencyChange,
onDismiss = contentCallbacks::onDialogDismissed
)
@@ -584,8 +581,8 @@ private fun RemoteBackupsSettingsContent(
SkipDownloadDuringDeleteDialog()
} else {
SkipDownloadDialog(
renewalTime = if (backupState is RemoteBackupsSettingsState.BackupState.WithTypeAndRenewalTime) {
backupState.renewalTime
renewalTime = if (state.backupState is RemoteBackupsSettingsState.BackupState.WithTypeAndRenewalTime) {
state.backupState.renewalTime
} else {
error("Unexpected dialog display without renewal time.")
},
@@ -610,8 +607,8 @@ private fun RemoteBackupsSettingsContent(
}
}
val snackbarMessageId = remember(requestedSnackbar) {
when (requestedSnackbar) {
val snackbarMessageId = remember(state.snackbar) {
when (state.snackbar) {
RemoteBackupsSettingsState.Snackbar.NONE -> -1
RemoteBackupsSettingsState.Snackbar.BACKUP_DELETED_AND_TURNED_OFF -> R.string.RemoteBackupsSettingsFragment__backup_deleted_and_turned_off
RemoteBackupsSettingsState.Snackbar.BACKUP_TYPE_CHANGED_AND_SUBSCRIPTION_CANCELLED -> R.string.RemoteBackupsSettingsFragment__backup_type_changed_and_subcription_deleted
@@ -623,8 +620,8 @@ private fun RemoteBackupsSettingsContent(
val snackbarText = if (snackbarMessageId == -1) "" else stringResource(id = snackbarMessageId)
LaunchedEffect(requestedSnackbar) {
when (requestedSnackbar) {
LaunchedEffect(state.snackbar) {
when (state.snackbar) {
RemoteBackupsSettingsState.Snackbar.NONE -> {
snackbarHostState.currentSnackbarData?.dismiss()
}
@@ -1057,6 +1054,38 @@ private fun CallToActionButton(
}
}
@Composable
private fun OutOfStorageSpaceBlock(
formattedTotalStorageSpace: String,
onManageStorageClick: () -> Unit
) {
Dividers.Default()
Row(
modifier = Modifier.horizontalGutters().padding(vertical = 12.dp)
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_error_circle_fill_24),
tint = MaterialTheme.colorScheme.error,
contentDescription = null,
modifier = Modifier.padding(top = 4.dp, end = 4.dp, start = 2.dp).size(20.dp)
)
Column {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__youve_reached_the_s_storage_limit, formattedTotalStorageSpace),
modifier = Modifier.padding(start = 12.dp)
)
TextButton(onClick = onManageStorageClick) {
Text(text = stringResource(R.string.RemoteBackupsSettingsFragment__manage_storage))
}
}
}
Dividers.Default()
}
@Composable
private fun RedemptionErrorAlert(
onDetailsClick: () -> Unit
@@ -1678,23 +1707,26 @@ private fun getTextForFrequency(backupsFrequency: BackupFrequency): String {
private fun RemoteBackupsSettingsContentPreview() {
Previews.Preview {
RemoteBackupsSettingsContent(
backupsEnabled = true,
lastBackupTimestamp = -1,
canBackUpUsingCellular = false,
canRestoreUsingCellular = false,
backupsFrequency = BackupFrequency.MANUAL,
requestedDialog = RemoteBackupsSettingsState.Dialog.NONE,
requestedSnackbar = RemoteBackupsSettingsState.Snackbar.NONE,
contentCallbacks = ContentCallbacks.Empty,
backupProgress = null,
backupMediaSize = 2300000,
backupState = RemoteBackupsSettingsState.BackupState.ActiveFree(
messageBackupsType = MessageBackupsType.Free(mediaRetentionDays = 30)
state = RemoteBackupsSettingsState(
backupsEnabled = true,
lastBackupTimestamp = -1,
canBackUpUsingCellular = false,
canRestoreUsingCellular = false,
backupsFrequency = BackupFrequency.MANUAL,
dialog = RemoteBackupsSettingsState.Dialog.NONE,
snackbar = RemoteBackupsSettingsState.Snackbar.NONE,
backupMediaSize = 2300000,
backupState = RemoteBackupsSettingsState.BackupState.ActiveFree(
messageBackupsType = MessageBackupsType.Free(mediaRetentionDays = 30)
),
hasRedemptionError = true,
isOutOfStorageSpace = true
),
statusBarColorNestedScrollConnection = null,
backupDeleteState = DeletionState.NONE,
backupRestoreState = BackupRestoreState.FromBackupStatusData(BackupStatusData.CouldNotCompleteBackup),
hasRedemptionError = true,
statusBarColorNestedScrollConnection = null
contentCallbacks = ContentCallbacks.Empty,
backupProgress = null
)
}
}

View File

@@ -16,6 +16,8 @@ data class RemoteBackupsSettingsState(
val canBackUpUsingCellular: Boolean = false,
val canRestoreUsingCellular: Boolean = false,
val hasRedemptionError: Boolean = false,
val isOutOfStorageSpace: Boolean = false,
val totalAllowedStorageSpace: String = "",
val backupState: BackupState = BackupState.Loading,
val backupMediaSize: Long = 0,
val backupsFrequency: BackupFrequency = BackupFrequency.DAILY,

View File

@@ -240,10 +240,22 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
backupMediaSize = SignalDatabase.attachments.getEstimatedArchiveMediaSize(),
backupsFrequency = SignalStore.backup.backupFrequency,
canBackUpUsingCellular = SignalStore.backup.backupWithCellular,
canRestoreUsingCellular = SignalStore.backup.restoreWithCellular
canRestoreUsingCellular = SignalStore.backup.restoreWithCellular,
isOutOfStorageSpace = BackupRepository.shouldDisplayOutOfStorageSpaceUx()
)
}
if (BackupRepository.shouldDisplayOutOfStorageSpaceUx()) {
val paidType = BackupRepository.getBackupsType(MessageBackupTier.PAID) as? MessageBackupsType.Paid
if (paidType != null) {
_state.update {
it.copy(
totalAllowedStorageSpace = paidType.storageAllowanceBytes.bytes.toUnitString()
)
}
}
}
if (lastPurchase?.state == InAppPaymentTable.State.PENDING) {
Log.d(TAG, "We have a pending subscription.")
_state.update {

View File

@@ -152,6 +152,7 @@ data class MainToolbarState(
val callFilter: CallLogFilter = CallLogFilter.ALL,
val hasUnreadPayments: Boolean = false,
val hasFailedBackups: Boolean = false,
val isOutOfRemoteStorageSpace: Boolean = false,
val hasEnabledNotificationProfile: Boolean = false,
val showNotificationProfilesTooltip: Boolean = false,
val hasPassphrase: Boolean = false,
@@ -506,14 +507,14 @@ private fun ProxyAction(
@Composable
private fun HeadsUpIndicator(state: MainToolbarState, modifier: Modifier = Modifier) {
if (!state.hasUnreadPayments && !state.hasFailedBackups) {
if (!state.hasUnreadPayments && !state.hasFailedBackups && !state.isOutOfRemoteStorageSpace) {
return
}
val color = if (state.hasFailedBackups) {
Color(0xFFFFCC00)
} else {
MaterialTheme.colorScheme.primary
val color = when {
state.isOutOfRemoteStorageSpace -> Color.Transparent
state.hasFailedBackups -> Color(0xFFFFCC00)
else -> MaterialTheme.colorScheme.primary
}
Box(
@@ -521,7 +522,13 @@ private fun HeadsUpIndicator(state: MainToolbarState, modifier: Modifier = Modif
.size(13.dp)
.background(color = color, shape = CircleShape)
) {
// Intentionally empty
if (state.isOutOfRemoteStorageSpace) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_error_circle_fill_16),
tint = MaterialTheme.colorScheme.error,
contentDescription = null
)
}
}
}
@@ -736,7 +743,8 @@ private fun FullMainToolbarPreview() {
destination = MainNavigationListLocation.CHATS,
hasEnabledNotificationProfile = true,
proxyState = MainToolbarState.ProxyState.CONNECTED,
hasFailedBackups = true
hasFailedBackups = true,
isOutOfRemoteStorageSpace = false
),
callback = object : MainToolbarCallback by MainToolbarCallback.Empty {
override fun onSearchClick() {

View File

@@ -46,6 +46,7 @@ class MainToolbarViewModel : ViewModel() {
internalStateFlow.update {
it.copy(
hasFailedBackups = BackupRepository.shouldDisplayBackupFailedIndicator() || BackupRepository.shouldDisplayBackupAlreadyRedeemedIndicator(),
isOutOfRemoteStorageSpace = BackupRepository.shouldDisplayOutOfStorageSpaceUx(),
hasPassphrase = !SignalStore.settings.passphraseDisabled
)
}

View File

@@ -564,6 +564,13 @@
<!-- region Direct-To-Page actions -->
<action
android:id="@+id/action_direct_to_storagePreferenceFragment"
app:destination="@id/storagePreferenceFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_direct_to_backupsPreferenceFragment"

View File

@@ -5346,6 +5346,8 @@
<string name="AppSettingsFragment__couldnt_complete_backup">Couldn\'t complete backup</string>
<!-- String alerting user that backup redemption -->
<string name="AppSettingsFragment__couldnt_redeem_your_backups_subscription">Couldn\'t redeem your backups subscription</string>
<!-- String alerting user that backup storage limit has been reached -->
<string name="AppSettingsFragment__backup_storage_limit_reached">Backup storage limit reached</string>
<!-- String displayed telling user to invite their friends to Signal -->
<string name="AppSettingsFragment__invite_your_friends">Invite your friends</string>
<!-- String displayed in a toast when we successfully copy the donations subscriber id to the clipboard -->
@@ -8238,6 +8240,10 @@
<string name="RemoteBackupsSettingsFragment__backups_have_been_turned_off_and_your_data_has_been_deleted">Backups have been turned off and your data has been deleted from Signals secure storage service.</string>
<!-- Text displayed while downloading backup during deletion. First placeholder is amount downloaded, second is amount remaining, last is percentage. -->
<string name="RemoteBackupsSettingsFragment__downloading_s_of_s_d">Downloading: %1$s of %2$s (%3$d%%)</string>
<!-- Text displayed when user is out of storage space. Placeholder is storage limit in human readable form (ex. 1GB) -->
<string name="RemoteBackupsSettingsFragment__youve_reached_the_s_storage_limit">You\'ve reached the %1$s storage limit that\'s included with your backup plan. To backup new chats and media free up space.</string>
<!-- Button label to open storage management screen -->
<string name="RemoteBackupsSettingsFragment__manage_storage">Manage storage</string>
<!-- DownloadYourBackupTodayDialog -->
<!-- Dialog title -->

View File

@@ -293,6 +293,7 @@ object Rows {
iconModifier: Modifier = Modifier,
label: String? = null,
foregroundTint: Color = MaterialTheme.colorScheme.onSurface,
iconTint: Color = foregroundTint,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
enabled: Boolean = true
@@ -311,7 +312,7 @@ object Rows {
Icon(
imageVector = icon,
contentDescription = null,
tint = foregroundTint,
tint = iconTint,
modifier = iconModifier
)
}