Add UI for when the user's grace period expires or while they are in the grace period.

This commit is contained in:
Alex Hart
2025-05-15 13:35:32 -03:00
committed by Cody Henthorne
parent 5e9824a180
commit a6bfeebb24
9 changed files with 158 additions and 16 deletions

View File

@@ -142,7 +142,7 @@ object BackupRepository {
Log.w(TAG, "Received status 403. The user is not in the media tier. Updating local state.", error.exception)
SignalStore.backup.backupTier = MessageBackupTier.FREE
SignalStore.uiHints.markHasEverEnabledRemoteBackups()
// TODO [backup] If the user thought they were in media tier but aren't, feels like we should have a special UX flow for this?
SignalStore.backup.backupExpiredAndDowngraded = true
}
}
}
@@ -264,6 +264,18 @@ object BackupRepository {
return SignalStore.backup.hasBackupBeenUploaded && SignalStore.backup.hasBackupFailure
}
/**
* Displayed when the user falls out of the grace period for backups after their subscription
* expires.
*/
fun shouldDisplayBackupExpiredAndDowngradedSheet(): Boolean {
if (shouldNotDisplayBackupFailedMessaging()) {
return false
}
return SignalStore.backup.backupExpiredAndDowngraded
}
fun markBackupAlreadyRedeemedIndicatorClicked() {
SignalStore.backup.hasBackupAlreadyRedeemedError = false
}
@@ -283,6 +295,13 @@ object BackupRepository {
SignalStore.backup.updateMessageBackupFailureSheetWatermark()
}
/**
* User closed backup expiration alert sheet
*/
fun markBackupExpiredAndDowngradedSheetDismissed() {
SignalStore.backup.backupExpiredAndDowngraded = false
}
/**
* Whether or not the "Backup failed" sheet should be displayed.
* Should only be displayed if this is the failure of the initial backup creation.

View File

@@ -119,6 +119,9 @@ class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() {
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext())
BackupAlert.CouldNotRedeemBackup -> Unit
BackupAlert.ExpiredAndDowngraded -> {
startActivity(AppSettingsActivity.remoteBackups(requireContext()))
}
}
dismissAllowingStateLoss()
@@ -127,6 +130,7 @@ class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() {
@Stable
private fun performSecondaryAction() {
when (backupAlert) {
BackupAlert.ExpiredAndDowngraded -> Unit
is BackupAlert.CouldNotCompleteBackup -> Unit
BackupAlert.FailedToRenew -> Unit
is BackupAlert.MediaBackupsAreOff -> error("Use MediaBackupsAreOffBottomSheet instead.")
@@ -150,6 +154,7 @@ class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() {
when (backupAlert) {
is BackupAlert.CouldNotCompleteBackup, BackupAlert.BackupFailed -> BackupRepository.markBackupFailedSheetDismissed()
is BackupAlert.MediaWillBeDeletedToday -> BackupRepository.snoozeYourMediaWillBeDeletedTodaySheet()
is BackupAlert.ExpiredAndDowngraded -> BackupRepository.markBackupExpiredAndDowngradedSheetDismissed()
else -> Unit
}
}
@@ -205,7 +210,7 @@ fun BackupAlertSheetContent(
when (backupAlert) {
is BackupAlert.MediaBackupsAreOff -> error("Use MediaBackupsAreOffBottomSheet instead.")
BackupAlert.FailedToRenew -> {
BackupAlert.FailedToRenew, BackupAlert.ExpiredAndDowngraded -> {
Box {
Image(
imageVector = ImageVector.vectorResource(id = R.drawable.image_signal_backups),
@@ -255,6 +260,7 @@ fun BackupAlertSheetContent(
BackupAlert.BackupFailed -> BackupFailedBody()
BackupAlert.CouldNotRedeemBackup -> CouldNotRedeemBackup()
is BackupAlert.MediaBackupsAreOff -> error("Use MediaBackupsAreOffBottomSheet instead.")
BackupAlert.ExpiredAndDowngraded -> SubscriptionExpired()
}
val secondaryActionResource = rememberSecondaryActionResource(backupAlert = backupAlert)
@@ -331,6 +337,23 @@ private fun CouldNotRedeemBackup() {
}
}
@Composable
private fun SubscriptionExpired() {
Text(
text = stringResource(id = R.string.BackupAlertBottomSheet__your_subscription_couldnt_be_renewed),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 24.dp)
)
Text(
text = stringResource(id = R.string.BackupAlertBottomSheet__youll_continue_to_have_access_to_the_free),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 36.dp)
)
}
@Composable
private fun CouldNotCompleteBackup(
daysSinceLastBackup: Int
@@ -401,7 +424,7 @@ private fun BackupFailedBody() {
private fun rememberBackupsIconColors(backupAlert: BackupAlert): BackupsIconColors {
return remember(backupAlert) {
when (backupAlert) {
BackupAlert.FailedToRenew, is BackupAlert.MediaBackupsAreOff -> error("Not icon-based options.")
BackupAlert.ExpiredAndDowngraded, BackupAlert.FailedToRenew, is BackupAlert.MediaBackupsAreOff -> error("Not icon-based options.")
is BackupAlert.CouldNotCompleteBackup, BackupAlert.BackupFailed, is BackupAlert.DiskFull, BackupAlert.CouldNotRedeemBackup -> BackupsIconColors.Warning
BackupAlert.MediaWillBeDeletedToday -> BackupsIconColors.Error
}
@@ -418,6 +441,7 @@ private fun titleString(backupAlert: BackupAlert): String {
is BackupAlert.DiskFull -> stringResource(R.string.BackupAlertBottomSheet__free_up_s_on_this_device, backupAlert.requiredSpace)
BackupAlert.BackupFailed -> stringResource(R.string.BackupAlertBottomSheet__backup_failed)
BackupAlert.CouldNotRedeemBackup -> stringResource(R.string.BackupAlertBottomSheet__couldnt_redeem_your_backups_subscription)
BackupAlert.ExpiredAndDowngraded -> stringResource(R.string.BackupAlertBottomSheet__your_backups_subscription_has_expired)
}
}
@@ -433,6 +457,7 @@ private fun primaryActionString(
is BackupAlert.DiskFull -> stringResource(R.string.BackupAlertBottomSheet__got_it)
is BackupAlert.BackupFailed -> stringResource(R.string.BackupAlertBottomSheet__check_for_update)
BackupAlert.CouldNotRedeemBackup -> stringResource(R.string.BackupAlertBottomSheet__got_it)
BackupAlert.ExpiredAndDowngraded -> stringResource(R.string.BackupAlertBottomSheet__manage_backups)
}
}
@@ -441,7 +466,7 @@ private fun rememberSecondaryActionResource(backupAlert: BackupAlert): Int {
return remember(backupAlert) {
when (backupAlert) {
is BackupAlert.CouldNotCompleteBackup -> R.string.BackupAlertBottomSheet__try_later
BackupAlert.FailedToRenew -> R.string.BackupAlertBottomSheet__not_now
BackupAlert.FailedToRenew, BackupAlert.ExpiredAndDowngraded -> R.string.BackupAlertBottomSheet__not_now
is BackupAlert.MediaBackupsAreOff -> error("Not supported.")
BackupAlert.MediaWillBeDeletedToday -> R.string.BackupAlertBottomSheet__dont_download_media
is BackupAlert.DiskFull -> R.string.BackupAlertBottomSheet__skip_restore
@@ -511,6 +536,16 @@ private fun BackupAlertSheetContentPreviewCouldNotRedeemBackup() {
}
}
@SignalPreview
@Composable
private fun BackupAlertSheetContentPreviewSubscriptionExpired() {
Previews.BottomSheetPreview {
BackupAlertSheetContent(
backupAlert = BackupAlert.ExpiredAndDowngraded
)
}
}
/**
* All necessary information to display the sheet should be handed in through the specific alert.
*/
@@ -559,4 +594,10 @@ sealed class BackupAlert : Parcelable {
* Too many attempts to redeem the backup subscription have occurred this month.
*/
data object CouldNotRedeemBackup : BackupAlert()
/**
* Displayed after the user falls out of the grace period and their backups subscription is downgraded
* to the free tier.
*/
data object ExpiredAndDowngraded : BackupAlert()
}

View File

@@ -29,6 +29,8 @@ object BackupAlertDelegate {
BackupAlertBottomSheet.create(BackupAlert.CouldNotCompleteBackup(daysSinceLastBackup = SignalStore.backup.daysSinceLastBackup)).show(fragmentManager, null)
} else if (withContext(Dispatchers.IO) { BackupRepository.shouldDisplayYourMediaWillBeDeletedTodaySheet() }) {
BackupAlertBottomSheet.create(BackupAlert.MediaWillBeDeletedToday).show(fragmentManager, null)
} else if (BackupRepository.shouldDisplayBackupExpiredAndDowngradedSheet()) {
BackupAlertBottomSheet.create(BackupAlert.ExpiredAndDowngraded).show(fragmentManager, null)
}
}
}

View File

@@ -165,7 +165,7 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
@Stable
private inner class Callbacks : ContentCallbacks {
override fun onNavigationClick() {
findNavController().popBackStack()
requireActivity().onBackPressedDispatcher.onBackPressed()
}
override fun onBackupTypeActionClick(tier: MessageBackupTier) {
@@ -433,6 +433,14 @@ private fun RemoteBackupsSettingsContent(
onBackupTypeActionButtonClicked = contentCallbacks::onBackupTypeActionClick
)
}
RemoteBackupsSettingsState.BackupState.NotFound -> {
SubscriptionNotFoundCard(
title = stringResource(R.string.RemoteBackupsSettingsFragment__your_subscription_was_not_found),
onRenewClick = contentCallbacks::onRenewLostSubscription,
onLearnMoreClick = contentCallbacks::onLearnMoreAboutLostSubscription
)
}
}
}
@@ -849,7 +857,9 @@ private fun RedemptionErrorAlert(
Text(
text = stringResource(R.string.AppSettingsFragment__couldnt_redeem_your_backups_subscription),
modifier = Modifier.padding(start = 16.dp, end = 4.dp).weight(1f)
modifier = Modifier
.padding(start = 16.dp, end = 4.dp)
.weight(1f)
)
Buttons.Small(onClick = onDetailsClick) {
@@ -939,8 +949,8 @@ private fun PendingCard(
}
@Composable
private fun SubscriptionMismatchMissingGooglePlayCard(
state: RemoteBackupsSettingsState.BackupState.SubscriptionMismatchMissingGooglePlay,
private fun SubscriptionNotFoundCard(
title: String,
onRenewClick: () -> Unit = {},
onLearnMoreClick: () -> Unit = {}
) {
@@ -951,11 +961,9 @@ private fun SubscriptionMismatchMissingGooglePlayCard(
.background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(12.dp))
.padding(24.dp)
) {
val days by rememberUpdatedState((state.renewalTime - System.currentTimeMillis().milliseconds).inWholeDays)
Row {
Text(
text = pluralStringResource(R.plurals.RemoteBackupsSettingsFragment__your_subscription_on_this_device_is_valid, days.toInt(), days),
text = title,
modifier = Modifier
.weight(1f)
.padding(end = 13.dp)
@@ -1017,6 +1025,21 @@ private fun SubscriptionMismatchMissingGooglePlayCard(
}
}
@Composable
private fun SubscriptionMismatchMissingGooglePlayCard(
state: RemoteBackupsSettingsState.BackupState.SubscriptionMismatchMissingGooglePlay,
onRenewClick: () -> Unit = {},
onLearnMoreClick: () -> Unit = {}
) {
val days by rememberUpdatedState((state.renewalTime - System.currentTimeMillis().milliseconds).inWholeDays)
SubscriptionNotFoundCard(
title = pluralStringResource(R.plurals.RemoteBackupsSettingsFragment__your_subscription_on_this_device_is_valid, days.toInt(), days),
onRenewClick = onRenewClick,
onLearnMoreClick = onLearnMoreClick
)
}
@Composable
private fun InProgressBackupRow(
archiveUploadProgressState: ArchiveUploadProgressState
@@ -1430,6 +1453,16 @@ private fun PendingCardPreview() {
}
}
@SignalPreview
@Composable
private fun SubscriptionNotFoundCardPreview() {
Previews.Preview {
SubscriptionNotFoundCard(
title = stringResource(R.string.RemoteBackupsSettingsFragment__your_subscription_was_not_found)
)
}
}
@SignalPreview
@Composable
private fun SubscriptionMismatchMissingGooglePlayCardPreview() {

View File

@@ -85,6 +85,11 @@ data class RemoteBackupsSettingsState(
override val renewalTime: Duration = 0.seconds
) : WithTypeAndRenewalTime
/**
* User has an active backup but no active subscription
*/
data object NotFound : BackupState
/**
* User has a canceled paid tier backup
*/

View File

@@ -345,11 +345,19 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
)
}
} else {
Log.d(TAG, "ActiveSubscription had null subscription object. Updating UI state with INACTIVE subscription.")
_state.update {
it.copy(
backupState = RemoteBackupsSettingsState.BackupState.Inactive(type)
)
Log.d(TAG, "ActiveSubscription had null subscription object.")
if (SignalStore.backup.areBackupsEnabled) {
_state.update {
it.copy(
backupState = RemoteBackupsSettingsState.BackupState.NotFound
)
}
} else {
_state.update {
it.copy(
backupState = RemoteBackupsSettingsState.BackupState.Inactive(type)
)
}
}
}
} else {

View File

@@ -522,6 +522,22 @@ fun Screen(
Dividers.Default()
Rows.TextRow(
text = "Mark backup failure",
label = "This will display the error sheet when returning to the chats list.",
onClick = {
SignalStore.backup.internalSetBackupFailedErrorState()
}
)
Rows.TextRow(
text = "Mark backup expired and downgraded",
label = "This will not actually downgrade the user.",
onClick = {
SignalStore.backup.backupExpiredAndDowngraded = true
}
)
Spacer(modifier = Modifier.height(8.dp))
}
}

View File

@@ -70,6 +70,7 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
private const val KEY_INVALID_BACKUP_VERSION = "backup.invalid.version"
private const val KEY_USER_MANUALLY_SKIPPED_MEDIA_RESTORE = "backup.user.manually.skipped.media.restore"
private const val KEY_BACKUP_EXPIRED_AND_DOWNGRADED = "backup.expired.and.downgraded"
private const val KEY_MEDIA_ROOT_BACKUP_KEY = "backup.mediaRootBackupKey"
@@ -111,6 +112,8 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
var userManuallySkippedMediaRestore: Boolean by booleanValue(KEY_USER_MANUALLY_SKIPPED_MEDIA_RESTORE, false)
var backupExpiredAndDowngraded: Boolean by booleanValue(KEY_BACKUP_EXPIRED_AND_DOWNGRADED, false)
/**
* The last time the device notified the server that the archive is still in use.
*/
@@ -242,6 +245,11 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
*/
var spaceAvailableOnDiskBytes: Long by longValue(KEY_BACKUP_FAIL_SPACE_REMAINING, -1L)
fun internalSetBackupFailedErrorState() {
markMessageBackupFailure()
putLong(KEY_BACKUP_FAIL_SHEET_SNOOZE_TIME, 0)
}
/**
* Call when the user disables backups. Clears/resets all relevant fields.
*/