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 bd77ff0ba1..e289a2bd13 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 @@ -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. diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt index 5d1d7833f4..d8dc25167f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt @@ -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() } 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 b0b785140b..a596f7d86d 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 @@ -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) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt index 73bb7d3059..e856b4fad5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt @@ -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() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt index d93e4d17e9..fde3c8961a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt @@ -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 */ 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 cfaf1ecaea..e0685e4a19 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 @@ -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 { 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 39ae5a0249..b5ebfc9a53 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 @@ -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)) } } 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 938eb91351..5eec3d57cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt @@ -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. */ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fdf16ab037..d823ed251d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7837,6 +7837,12 @@ Your backups subscription failed to renew Check to make sure your payment method is up to date. Tap Manage subscription and under Payment methods tap Update. + + Your backups subscription has expired + + Your subscription couldn\'t be renewed with your payment method. To continue backing up media you need an active subscription. + + You\'ll continue to have access to the free Signal Backups plan. Couldn\'t complete backup @@ -7848,6 +7854,8 @@ Learn more Skip restore + + Manage backups Back up now @@ -8140,6 +8148,8 @@ Downloading your backup Depending on the size of your backup, this could take a long time. You can use your phone as you normally do while the download takes place. + + Your subscription was not found on this device. Renew to continue using Signal Backups. Your subscription on this device is valid for the next %1$d day. Renew to continue using Signal Backups