diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt index 7342ba22e1..a9f615084c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -420,7 +420,7 @@ object BackupRepository { ServiceUtil.getNotificationManager(AppDependencies.application).cancel(NotificationIds.INITIAL_BACKUP_FAILED) } - fun markOutOfRemoteStorageError() { + fun markOutOfRemoteStorageSpaceError() { val context = AppDependencies.application val pendingIntent = PendingIntent.getActivity(context, 0, AppSettingsActivity.remoteBackups(context), cancelCurrent()) @@ -434,15 +434,15 @@ object BackupRepository { ServiceUtil.getNotificationManager(context).notify(NotificationIds.OUT_OF_REMOTE_STORAGE, notification) - SignalStore.backup.isNotEnoughRemoteStorageSpace = true + SignalStore.backup.markNotEnoughRemoteStorageSpace() } - fun clearOutOfRemoteStorageError() { - SignalStore.backup.isNotEnoughRemoteStorageSpace = false + fun clearOutOfRemoteStorageSpaceError() { + SignalStore.backup.clearNotEnoughRemoteStorageSpace() ServiceUtil.getNotificationManager(AppDependencies.application).cancel(NotificationIds.OUT_OF_REMOTE_STORAGE) } - fun shouldDisplayOutOfStorageSpaceUx(): Boolean { + fun shouldDisplayOutOfRemoteStorageSpaceUx(): Boolean { if (shouldNotDisplayBackupFailedMessaging()) { return false } @@ -450,6 +450,18 @@ object BackupRepository { return SignalStore.backup.isNotEnoughRemoteStorageSpace } + fun shouldDisplayOutOfRemoteStorageSpaceSheet(): Boolean { + if (shouldNotDisplayBackupFailedMessaging()) { + return false + } + + return SignalStore.backup.shouldDisplayNotEnoughRemoteStorageSpaceSheet + } + + fun dismissOutOfRemoteStorageSpaceSheet() { + SignalStore.backup.dismissNotEnoughRemoteStorageSpaceSheet() + } + /** * Whether the yellow dot should be displayed on the conversation list avatar. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertDelegate.kt index e068826a36..8bd2db1369 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertDelegate.kt @@ -36,6 +36,7 @@ object BackupAlertDelegate { } else if (BackupRepository.shouldDisplayNoManualBackupForTimeoutSheet()) { NoManualBackupBottomSheet().show(fragmentManager, FRAGMENT_TAG) BackupRepository.displayManualBackupNotCreatedInThresholdNotification() + } else if (BackupRepository.shouldDisplayOutOfRemoteStorageSpaceSheet()) { } displayBackupDownloadNotifier(fragmentManager) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/NoRemoteStorageSpaceAvailableBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/NoRemoteStorageSpaceAvailableBottomSheet.kt new file mode 100644 index 0000000000..54310346d0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/NoRemoteStorageSpaceAvailableBottomSheet.kt @@ -0,0 +1,117 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.ui + +import android.content.DialogInterface +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.SignalPreview +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.backup.v2.BackupRepository +import org.thoughtcrime.securesms.components.contactsupport.ContactSupportDialogFragment +import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment +import org.thoughtcrime.securesms.util.CommunicationActions + +class NoRemoteStorageSpaceAvailableBottomSheet : ComposeBottomSheetDialogFragment() { + @Composable + override fun SheetContent() { + val context = LocalContext.current + + NoRemoteStorageSpaceAvailableBottomSheetContent( + onLearnMoreClick = { + CommunicationActions.openBrowserLink(context, context.getString(R.string.backup_failed_support_url)) + }, + onContactSupportClick = { + ContactSupportDialogFragment.create( + subject = R.string.BackupAlertBottomSheet_network_failure_support_email, + filter = R.string.BackupAlertBottomSheet_export_failure_filter + ).show(parentFragmentManager, null) + + dismissAllowingStateLoss() + }, + onOkClick = { + dismissAllowingStateLoss() + } + ) + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + BackupRepository.dismissOutOfRemoteStorageSpaceSheet() + } +} + +@Composable +private fun NoRemoteStorageSpaceAvailableBottomSheetContent( + onLearnMoreClick: () -> Unit, + onContactSupportClick: () -> Unit, + onOkClick: () -> Unit +) { + val primaryActionButtonLabel = stringResource(R.string.BackupAlertBottomSheet__contact_support) + val primaryActionButtonState = remember(primaryActionButtonLabel, onContactSupportClick) { + BackupAlertActionButtonState( + label = primaryActionButtonLabel, + callback = onContactSupportClick + ) + } + + val secondaryActionButtonLabel = stringResource(android.R.string.ok) + val secondaryActionButtonState = remember(secondaryActionButtonLabel, onOkClick) { + BackupAlertActionButtonState( + label = secondaryActionButtonLabel, + callback = onOkClick + ) + } + + BackupAlertBottomSheetContainer( + icon = { + BackupAlertIcon(iconColors = BackupsIconColors.Warning) + }, + title = stringResource(R.string.BackupAlertBottomSheet__backup_failed), + primaryActionButtonState = primaryActionButtonState, + secondaryActionButtonState = secondaryActionButtonState + ) { + val text = buildAnnotatedString { + append(stringResource(id = R.string.BackupAlertBottomSheet__an_error_occurred_and)) + append(" ") + + withLink( + LinkAnnotation.Clickable(tag = "learn-more") { + onLearnMoreClick() + } + ) { + withStyle(SpanStyle(color = MaterialTheme.colorScheme.primary)) { + append(stringResource(id = R.string.BackupAlertBottomSheet__learn_more)) + } + } + } + + BackupAlertText( + text = text, + modifier = Modifier.padding(bottom = 36.dp) + ) + } +} + +@SignalPreview +@Composable +private fun NoRemoteStorageSpaceAvailableBottomSheetContentPreview() { + Previews.BottomSheetPreview { + NoRemoteStorageSpaceAvailableBottomSheetContent({}, {}, {}) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt index 7ba305df72..28e300445b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt @@ -76,7 +76,7 @@ class AppSettingsViewModel : ViewModel() { private fun getBackupFailureState(): BackupFailureState { return if (!RemoteConfig.messageBackups) { BackupFailureState.NONE - } else if (BackupRepository.shouldDisplayOutOfStorageSpaceUx()) { + } else if (BackupRepository.shouldDisplayOutOfRemoteStorageSpaceUx()) { BackupFailureState.OUT_OF_STORAGE_SPACE } else if (BackupRepository.shouldDisplayBackupFailedSettingsRow()) { BackupFailureState.BACKUP_FAILED diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt index bd49424c14..c45ca1df8e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt @@ -270,7 +270,7 @@ class RemoteBackupsSettingsViewModel : ViewModel() { } private suspend fun performStateRefresh(lastPurchase: InAppPaymentTable.InAppPayment?) { - if (BackupRepository.shouldDisplayOutOfStorageSpaceUx()) { + if (BackupRepository.shouldDisplayOutOfRemoteStorageSpaceUx()) { val paidType = BackupRepository.getPaidType() if (paidType is NetworkResult.Success) { @@ -278,7 +278,7 @@ class RemoteBackupsSettingsViewModel : ViewModel() { val estimatedSize = SignalDatabase.attachments.getEstimatedArchiveMediaSize().bytes if (estimatedSize + 300.mebiBytes <= remoteStorageAllowance) { - BackupRepository.clearOutOfRemoteStorageError() + BackupRepository.clearOutOfRemoteStorageSpaceError() } _state.update { @@ -301,7 +301,7 @@ class RemoteBackupsSettingsViewModel : ViewModel() { backupsFrequency = SignalStore.backup.backupFrequency, canBackUpUsingCellular = SignalStore.backup.backupWithCellular, canRestoreUsingCellular = SignalStore.backup.restoreWithCellular, - isOutOfStorageSpace = BackupRepository.shouldDisplayOutOfStorageSpaceUx(), + isOutOfStorageSpace = BackupRepository.shouldDisplayOutOfRemoteStorageSpaceUx(), hasRedemptionError = lastPurchase?.data?.error?.data_ == "409" ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt index 90772a0747..8684e7bed0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt @@ -554,7 +554,7 @@ fun Screen( Rows.TextRow( text = "Mark out of remote storage space", onClick = { - BackupRepository.markOutOfRemoteStorageError() + BackupRepository.markOutOfRemoteStorageSpaceError() } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/CopyAttachmentToArchiveJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/CopyAttachmentToArchiveJob.kt index acaa7549d8..d5acbcf96f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/CopyAttachmentToArchiveJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/CopyAttachmentToArchiveJob.kt @@ -148,7 +148,7 @@ class CopyAttachmentToArchiveJob private constructor(private val attachmentId: A val remoteStorageQuota = getServerQuota() ?: return Result.retry(defaultBackoff()).logW(TAG, "[$attachmentId] Failed to fetch server quota! Retrying.") if (SignalDatabase.attachments.getEstimatedArchiveMediaSize() > remoteStorageQuota.inWholeBytes) { - BackupRepository.markOutOfRemoteStorageError() + BackupRepository.markOutOfRemoteStorageSpaceError() return Result.failure() } 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 67369b7e8e..a2e7afbe24 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt @@ -75,6 +75,7 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { private const val KEY_BACKUP_ALREADY_REDEEMED = "backup.already.redeemed" private const val KEY_INVALID_BACKUP_VERSION = "backup.invalid.version" private const val KEY_NOT_ENOUGH_REMOTE_STORAGE_SPACE = "backup.not.enough.remote.storage.space" + private const val KEY_NOT_ENOUGH_REMOTE_STORAGE_SPACE_DISPLAY_SHEET = "backup.not.enough.remote.storage.space.display.sheet" private const val KEY_MANUAL_NO_BACKUP_NOTIFIED = "backup.manual.no.backup.notified" private const val KEY_USER_MANUALLY_SKIPPED_MEDIA_RESTORE = "backup.user.manually.skipped.media.restore" @@ -233,6 +234,8 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { Log.i(TAG, "Setting backup tier to $value", Throwable(), true) val serializedValue = MessageBackupTier.serialize(value) + val storedValue = MessageBackupTier.deserialize(getLong(KEY_BACKUP_TIER, -1)) + if (value != null) { store.beginWrite() .putLong(KEY_BACKUP_TIER, serializedValue) @@ -240,6 +243,12 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { .putBoolean(KEY_BACKUP_TIMESTAMP_RESTORED, true) .apply() + if (storedValue != value) { + clearNotEnoughRemoteStorageSpace() + clearMessageBackupFailure() + clearMessageBackupFailureSheetWatermark() + } + deletionState = DeletionState.NONE } else { putLong(KEY_BACKUP_TIER, serializedValue) @@ -356,7 +365,8 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { /** Store that lets you interact with media ZK credentials. */ val mediaCredentials = CredentialStore(KEY_MEDIA_CREDENTIALS, KEY_MEDIA_CDN_READ_CREDENTIALS, KEY_MEDIA_CDN_READ_CREDENTIALS_TIMESTAMP) - var isNotEnoughRemoteStorageSpace by booleanValue(KEY_NOT_ENOUGH_REMOTE_STORAGE_SPACE, false) + val isNotEnoughRemoteStorageSpace by booleanValue(KEY_NOT_ENOUGH_REMOTE_STORAGE_SPACE, false) + val shouldDisplayNotEnoughRemoteStorageSpaceSheet by booleanValue(KEY_NOT_ENOUGH_REMOTE_STORAGE_SPACE_DISPLAY_SHEET, false) var isNoBackupForManualUploadNotified by booleanValue(KEY_MANUAL_NO_BACKUP_NOTIFIED, false) @@ -383,6 +393,36 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { NoRemoteArchiveGarbageCollectionPendingConstraint.Observer.notifyListeners() } + /** + * When we are told by the server that we are out of storage space, we should show + * UX treatment to make the user aware of this. + */ + fun markNotEnoughRemoteStorageSpace() { + store.beginWrite() + .putBoolean(KEY_NOT_ENOUGH_REMOTE_STORAGE_SPACE, true) + .putBoolean(KEY_NOT_ENOUGH_REMOTE_STORAGE_SPACE_DISPLAY_SHEET, false) + .apply() + } + + /** + * When we've regained enough space, we can remove the error. + */ + fun clearNotEnoughRemoteStorageSpace() { + store.beginWrite() + .putBoolean(KEY_NOT_ENOUGH_REMOTE_STORAGE_SPACE, false) + .putBoolean(KEY_NOT_ENOUGH_REMOTE_STORAGE_SPACE_DISPLAY_SHEET, false) + .apply() + } + + /** + * Dismisses the sheet so as not to irritate the user. + */ + fun dismissNotEnoughRemoteStorageSpaceSheet() { + store.beginWrite() + .putBoolean(KEY_NOT_ENOUGH_REMOTE_STORAGE_SPACE_DISPLAY_SHEET, false) + .apply() + } + fun markMessageBackupFailure() { store.beginWrite() .putBoolean(KEY_BACKUP_FAIL, true) diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbarViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbarViewModel.kt index 9adf0d9017..dc20e0a679 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbarViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbarViewModel.kt @@ -46,7 +46,7 @@ class MainToolbarViewModel : ViewModel() { internalStateFlow.update { it.copy( hasFailedBackups = BackupRepository.shouldDisplayBackupFailedIndicator() || BackupRepository.shouldDisplayBackupAlreadyRedeemedIndicator(), - isOutOfRemoteStorageSpace = BackupRepository.shouldDisplayOutOfStorageSpaceUx(), + isOutOfRemoteStorageSpace = BackupRepository.shouldDisplayOutOfRemoteStorageSpaceUx(), hasPassphrase = !SignalStore.settings.passphraseDisabled ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0a0905c9ef..b153b64087 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7951,6 +7951,8 @@ Couldn\'t redeem your backups subscription An error occurred and your backup could not be completed. Make sure you\'re on the latest version of Signal and try again. If this problem persists, contact support. + + An error occurred and your backup could not be completed. If this problem persists, contact support. Check for update