From 1424dd689297b4b02cf1ce2c3dc6c4a45bf7b900 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Mon, 9 Jun 2025 15:41:10 -0300 Subject: [PATCH] Add new dialog and sheet for handling offloaded media after a subscription is canceled or expires. --- .../securesms/backup/v2/BackupRepository.kt | 54 ++--- .../backup/v2/ui/BackupAlertBottomSheet.kt | 57 +++--- .../backup/v2/ui/BackupAlertDelegate.kt | 20 +- .../v2/ui/DownloadYourBackupTodayDialog.kt | 81 ++++++++ .../remote/RemoteBackupsSettingsFragment.kt | 23 ++- .../jobs/BackupSubscriptionCheckJob.kt | 31 +++ .../keyvalue/BackupDownloadNotifierUtil.kt | 107 ++++++++++ .../securesms/keyvalue/BackupValues.kt | 27 +++ app/src/main/protowire/KeyValue.proto | 12 ++ app/src/main/res/values/strings.xml | 29 ++- .../BackupDownloadNotifierUtilTest.kt | 189 ++++++++++++++++++ 11 files changed, 562 insertions(+), 68 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/DownloadYourBackupTodayDialog.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupDownloadNotifierUtil.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/keyvalue/BackupDownloadNotifierUtilTest.kt 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 325bfadf14..9e7eed8d1e 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 @@ -9,6 +9,7 @@ import android.database.Cursor import android.os.Environment import android.os.StatFs import androidx.annotation.WorkerThread +import kotlinx.coroutines.withContext import okio.ByteString import okio.ByteString.Companion.toByteString import org.greenrobot.eventbus.EventBus @@ -19,6 +20,7 @@ import org.signal.core.util.EventTimer import org.signal.core.util.Stopwatch import org.signal.core.util.bytes import org.signal.core.util.concurrent.LimitedWorker +import org.signal.core.util.concurrent.SignalDispatchers import org.signal.core.util.concurrent.SignalExecutors import org.signal.core.util.forceForeignKeyConstraintsEnabled import org.signal.core.util.fullWalCheckpoint @@ -58,6 +60,7 @@ import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupReader import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupWriter import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupReader import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupWriter +import org.thoughtcrime.securesms.backup.v2.ui.BackupAlert import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider @@ -124,8 +127,8 @@ import java.util.Currency import java.util.Locale import java.util.concurrent.atomic.AtomicLong import kotlin.time.Duration.Companion.days -import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds object BackupRepository { @@ -391,45 +394,43 @@ object BackupRepository { return SignalStore.backup.hasBackupBeenUploaded && System.currentTimeMillis().milliseconds > SignalStore.backup.nextBackupFailureSheetSnoozeTime } - fun snoozeYourMediaWillBeDeletedTodaySheet() { - SignalStore.backup.lastCheckInSnoozeMillis = System.currentTimeMillis() + fun snoozeDownloadYourBackupData() { + SignalStore.backup.snoozeDownloadNotifier() } /** * Whether or not the "Your media will be deleted today" sheet should be displayed. */ - suspend fun shouldDisplayYourMediaWillBeDeletedTodaySheet(): Boolean { - if (shouldNotDisplayBackupFailedMessaging() || !SignalStore.backup.hasBackupBeenUploaded || !SignalStore.backup.optimizeStorage) { - return false + suspend fun getDownloadYourBackupData(): BackupAlert.DownloadYourBackupData? { + if (shouldNotDisplayBackupFailedMessaging()) { + return null } - val paidType = try { - getPaidType() - } catch (e: IOException) { - Log.w(TAG, "Failed to retrieve paid type.", e) - return false + val state = SignalStore.backup.backupDownloadNotifierState ?: return null + val nextSheetDisplayTime = state.lastSheetDisplaySeconds.seconds + state.intervalSeconds.seconds + + val remainingAttachmentSize = withContext(SignalDispatchers.IO) { + SignalDatabase.attachments.getRemainingRestorableAttachmentSize() } - if (paidType == null) { - Log.w(TAG, "Paid type is not available on this device.") - return false + if (remainingAttachmentSize <= 0L) { + SignalStore.backup.clearDownloadNotifierState() + return null } - val lastCheckIn = SignalStore.backup.lastCheckInMillis.milliseconds - if (lastCheckIn == 0.milliseconds) { - Log.w(TAG, "LastCheckIn has not yet been set.") - return false - } - - val lastSnoozeTime = SignalStore.backup.lastCheckInSnoozeMillis.milliseconds val now = System.currentTimeMillis().milliseconds - val mediaTtl = paidType.mediaTtl - val mediaExpiration = lastCheckIn + mediaTtl - val isNowAfterSnooze = now < lastSnoozeTime || now >= lastSnoozeTime + 4.hours - val isNowWithin24HoursOfMediaExpiration = now < mediaExpiration && (mediaExpiration - now) <= 1.days + return if (nextSheetDisplayTime <= now) { + val lastDay = state.entitlementExpirationSeconds.seconds - 1.days - return isNowAfterSnooze && isNowWithin24HoursOfMediaExpiration + BackupAlert.DownloadYourBackupData( + isLastDay = now >= lastDay, + formattedSize = remainingAttachmentSize.bytes.toUnitString(), + type = state.type + ) + } else { + null + } } private fun shouldNotDisplayBackupFailedMessaging(): Boolean { @@ -1088,6 +1089,7 @@ object BackupRepository { SignalStore.backup.backupTier = MessageBackupTier.PAID SignalStore.backup.lastCheckInMillis = System.currentTimeMillis() SignalStore.backup.lastCheckInSnoozeMillis = 0 + SignalStore.backup.clearDownloadNotifierState() } /** 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 d8dc25167f..fc78daf93f 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 @@ -56,6 +56,7 @@ import org.thoughtcrime.securesms.billing.launchManageBackupsSubscription import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment import org.thoughtcrime.securesms.jobs.BackupMessagesJob +import org.thoughtcrime.securesms.keyvalue.protos.BackupDownloadNotifierState import org.thoughtcrime.securesms.util.CommunicationActions import org.thoughtcrime.securesms.util.PlayStoreUtil import org.signal.core.ui.R as CoreUiR @@ -110,7 +111,7 @@ class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() { BackupAlert.FailedToRenew -> launchManageBackupsSubscription() is BackupAlert.MediaBackupsAreOff -> error("Use MediaBackupsAreOffBottomSheet instead.") - BackupAlert.MediaWillBeDeletedToday -> { + is BackupAlert.DownloadYourBackupData -> { performFullMediaDownload() } @@ -134,10 +135,7 @@ class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() { is BackupAlert.CouldNotCompleteBackup -> Unit BackupAlert.FailedToRenew -> Unit is BackupAlert.MediaBackupsAreOff -> error("Use MediaBackupsAreOffBottomSheet instead.") - BackupAlert.MediaWillBeDeletedToday -> { - displayLastChanceDialog() - } - + is BackupAlert.DownloadYourBackupData -> Unit is BackupAlert.DiskFull -> { displaySkipRestoreDialog() } @@ -153,23 +151,12 @@ class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() { when (backupAlert) { is BackupAlert.CouldNotCompleteBackup, BackupAlert.BackupFailed -> BackupRepository.markBackupFailedSheetDismissed() - is BackupAlert.MediaWillBeDeletedToday -> BackupRepository.snoozeYourMediaWillBeDeletedTodaySheet() + is BackupAlert.DownloadYourBackupData -> BackupRepository.snoozeDownloadYourBackupData() is BackupAlert.ExpiredAndDowngraded -> BackupRepository.markBackupExpiredAndDowngradedSheetDismissed() else -> Unit } } - private fun displayLastChanceDialog() { - MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.BackupAlertBottomSheet__media_will_be_deleted) - .setMessage(R.string.BackupAlertBottomSheet__the_media_stored_in_your_backup) - .setPositiveButton(R.string.BackupAlertBottomSheet__download) { _, _ -> - performFullMediaDownload() - } - .setNegativeButton(R.string.BackupAlertBottomSheet__dont_download, null) - .show() - } - private fun displaySkipRestoreDialog() { MaterialAlertDialogBuilder(requireContext()) .setTitle((R.string.BackupAlertBottomSheet__skip_restore_question)) @@ -255,7 +242,7 @@ fun BackupAlertSheetContent( ) BackupAlert.FailedToRenew -> PaymentProcessingBody() - BackupAlert.MediaWillBeDeletedToday -> MediaWillBeDeletedTodayBody() + is BackupAlert.DownloadYourBackupData -> DownloadYourBackupData(backupAlert.formattedSize) is BackupAlert.DiskFull -> DiskFullBody(requiredSpace = backupAlert.requiredSpace) BackupAlert.BackupFailed -> BackupFailedBody() BackupAlert.CouldNotRedeemBackup -> CouldNotRedeemBackup() @@ -377,9 +364,9 @@ private fun PaymentProcessingBody() { } @Composable -private fun MediaWillBeDeletedTodayBody() { +private fun DownloadYourBackupData(formattedSize: String) { Text( - text = stringResource(id = R.string.BackupAlertBottomSheet__your_signal_media_backup_plan_has_been), + text = stringResource(id = R.string.BackupAlertBottomSheet__you_have_s_of_media_thats_not_on_this_device, formattedSize), textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 24.dp) @@ -426,7 +413,7 @@ private fun rememberBackupsIconColors(backupAlert: BackupAlert): BackupsIconColo when (backupAlert) { 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 + is BackupAlert.DownloadYourBackupData -> BackupsIconColors.Error } } } @@ -437,7 +424,13 @@ private fun titleString(backupAlert: BackupAlert): String { is BackupAlert.CouldNotCompleteBackup -> stringResource(R.string.BackupAlertBottomSheet__couldnt_complete_backup) BackupAlert.FailedToRenew -> stringResource(R.string.BackupAlertBottomSheet__your_backups_subscription_failed_to_renew) is BackupAlert.MediaBackupsAreOff -> error("Use MediaBackupsAreOffBottomSheet instead.") - BackupAlert.MediaWillBeDeletedToday -> stringResource(R.string.BackupAlertBottomSheet__your_media_will_be_deleted_today) + is BackupAlert.DownloadYourBackupData -> { + if (backupAlert.isLastDay) { + stringResource(R.string.BackupAlertBottomSheet__download_your_backup_data_today) + } else { + stringResource(R.string.BackupAlertBottomSheet__download_your_backup_data) + } + } 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) @@ -453,7 +446,7 @@ private fun primaryActionString( is BackupAlert.CouldNotCompleteBackup -> stringResource(R.string.BackupAlertBottomSheet__back_up_now) BackupAlert.FailedToRenew -> stringResource(R.string.BackupAlertBottomSheet__manage_subscription) is BackupAlert.MediaBackupsAreOff -> error("Not supported.") - BackupAlert.MediaWillBeDeletedToday -> stringResource(R.string.BackupAlertBottomSheet__download_media_now) + is BackupAlert.DownloadYourBackupData -> stringResource(R.string.BackupAlertBottomSheet__download_backup_now) 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) @@ -468,7 +461,7 @@ private fun rememberSecondaryActionResource(backupAlert: BackupAlert): Int { is BackupAlert.CouldNotCompleteBackup -> R.string.BackupAlertBottomSheet__try_later 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.DownloadYourBackupData -> R.string.BackupAlertBottomSheet__dont_download_backup is BackupAlert.DiskFull -> R.string.BackupAlertBottomSheet__skip_restore is BackupAlert.BackupFailed -> R.string.BackupAlertBottomSheet__learn_more BackupAlert.CouldNotRedeemBackup -> R.string.BackupAlertBottomSheet__learn_more @@ -501,7 +494,10 @@ private fun BackupAlertSheetContentPreviewPayment() { private fun BackupAlertSheetContentPreviewDelete() { Previews.BottomSheetPreview { BackupAlertSheetContent( - backupAlert = BackupAlert.MediaWillBeDeletedToday + backupAlert = BackupAlert.DownloadYourBackupData( + isLastDay = false, + formattedSize = "2.3MB" + ) ) } } @@ -580,9 +576,16 @@ sealed class BackupAlert : Parcelable { ) : BackupAlert() /** - * TODO [backups] - This value is driven as "60D after the last time the user pinged their backup" + * When a user's subscription becomes cancelled or has a payment failure, we will alert the user + * up to two times regarding their media deletion via a sheet, and once in the last 4 hours with a dialog. + * + * This value drives viewing the sheet. */ - data object MediaWillBeDeletedToday : BackupAlert() + data class DownloadYourBackupData( + val isLastDay: Boolean, + val formattedSize: String, + val type: BackupDownloadNotifierState.Type = BackupDownloadNotifierState.Type.SHEET + ) : BackupAlert() /** * The disk is full. Contains a value representing the amount of space that must be freed. 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 a596f7d86d..db3ec5e3cb 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 @@ -9,11 +9,12 @@ import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope import androidx.lifecycle.repeatOnLifecycle -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.signal.core.util.concurrent.SignalDispatchers import org.thoughtcrime.securesms.backup.v2.BackupRepository import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.keyvalue.protos.BackupDownloadNotifierState /** * Delegate that controls whether and which backup alert sheet is displayed. @@ -27,12 +28,25 @@ object BackupAlertDelegate { BackupAlertBottomSheet.create(BackupAlert.BackupFailed).show(fragmentManager, null) } else if (BackupRepository.shouldDisplayCouldNotCompleteBackupSheet()) { 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) } + + displayBackupDownloadNotifier(fragmentManager) } } } + + private suspend fun displayBackupDownloadNotifier(fragmentManager: FragmentManager) { + val downloadYourBackupToday = withContext(SignalDispatchers.IO) { BackupRepository.getDownloadYourBackupData() } + when (downloadYourBackupToday?.type) { + BackupDownloadNotifierState.Type.SHEET -> { + BackupAlertBottomSheet.create(downloadYourBackupToday).show(fragmentManager, null) + } + BackupDownloadNotifierState.Type.DIALOG -> { + DownloadYourBackupTodayDialog.create(downloadYourBackupToday).show(fragmentManager, null) + } + null -> Unit + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/DownloadYourBackupTodayDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/DownloadYourBackupTodayDialog.kt new file mode 100644 index 0000000000..def1204aa0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/DownloadYourBackupTodayDialog.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.ui + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.core.os.BundleCompat +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import org.signal.core.ui.compose.Dialogs +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.compose.ComposeDialogFragment + +/** + * Displays a "last chance" dialog to the user to begin a media restore. + */ +class DownloadYourBackupTodayDialog : ComposeDialogFragment() { + + companion object { + + private const val ARGS = "args" + + fun create(downloadYourBackupData: BackupAlert.DownloadYourBackupData): DialogFragment { + return DownloadYourBackupTodayDialog().apply { + arguments = bundleOf(ARGS to downloadYourBackupData) + } + } + } + + private val backupAlert: BackupAlert.DownloadYourBackupData by lazy(LazyThreadSafetyMode.NONE) { + BundleCompat.getParcelable(requireArguments(), ARGS, BackupAlert.DownloadYourBackupData::class.java)!! + } + + @Composable + override fun DialogContent() { + DownloadYourBackupTodayDialogContent( + sizeToDownload = backupAlert.formattedSize, + onConfirm = { + BackupRepository.resumeMediaRestore() + }, + onDismiss = { + BackupRepository.snoozeDownloadYourBackupData() + dismissAllowingStateLoss() + } + ) + } +} + +@Composable +private fun DownloadYourBackupTodayDialogContent( + sizeToDownload: String, + onConfirm: () -> Unit = {}, + onDismiss: () -> Unit = {} +) { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.DownloadYourBackupTodayDialog__download_your_backup_today), + body = stringResource(R.string.DownloadYourBackupTodayDialog__you_have_s_of_backup_data, sizeToDownload), + confirm = stringResource(R.string.DownloadYourBackupTodayDialog__download), + dismiss = stringResource(R.string.DownloadYourBackupTodayDialog__dont_download), + dismissColor = MaterialTheme.colorScheme.error, + onDismiss = onDismiss, + onConfirm = onConfirm + ) +} + +@SignalPreview +@Composable +private fun DownloadYourBackupTodayDialogContentPreview() { + Previews.Preview { + DownloadYourBackupTodayDialogContent( + sizeToDownload = "2.3GB" + ) + } +} 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 0cf0361368..3b0539b485 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 @@ -1028,7 +1028,7 @@ private fun BackupCard( ) } else if (backupState is RemoteBackupsSettingsState.BackupState.Canceled) { CallToActionButton( - text = stringResource(R.string.RemoteBackupsSettingsFragment__renew), + text = stringResource(R.string.RemoteBackupsSettingsFragment__resubscribe), enabled = buttonsEnabled, onClick = { onBackupTypeActionButtonClicked(MessageBackupTier.FREE) } ) @@ -1634,8 +1634,7 @@ private fun BackupReadyToDownloadRow( onDownloadClick: () -> Unit = {} ) { val string = if (backupState is RemoteBackupsSettingsState.BackupState.Canceled) { - val days = (backupState.renewalTime - System.currentTimeMillis().milliseconds).inWholeDays.toInt() - pluralStringResource(R.plurals.RemoteBackupsSettingsFragment__you_have_s_of_backup_data, days, ready.bytes, days) + stringResource(R.string.RemoteBackupsSettingsFragment__you_have_s_of_backup_data, ready.bytes) } else { stringResource(R.string.RemoteBackupsSettingsFragment__you_have_s_of_backup_data_not_on_device, ready.bytes) } @@ -1841,6 +1840,24 @@ private fun BackupReadyToDownloadPreview() { } } +@SignalPreview +@Composable +private fun BackupReadyToDownloadAfterCancelPreview() { + Previews.Preview { + BackupReadyToDownloadRow( + ready = BackupRestoreState.Ready("12GB"), + backupState = RemoteBackupsSettingsState.BackupState.Canceled( + messageBackupsType = MessageBackupsType.Paid( + pricePerMonth = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD")), + storageAllowanceBytes = 10.gibiBytes.bytes, + mediaTtl = 30.days + ), + renewalTime = System.currentTimeMillis().days + 10.days + ) + ) + } +} + @SignalPreview @Composable private fun LastBackupRowPreview() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt index bec5f74493..8d51940517 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt @@ -26,11 +26,14 @@ import org.thoughtcrime.securesms.jobmanager.CoroutineJob import org.thoughtcrime.securesms.jobmanager.Job import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.net.SignalNetwork import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.util.RemoteConfig import org.whispersystems.signalservice.api.storage.IAPSubscriptionId +import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration import kotlin.concurrent.withLock +import kotlin.time.Duration.Companion.seconds /** * Checks and rectifies state pertaining to backups subscriptions. @@ -114,6 +117,8 @@ class BackupSubscriptionCheckJob private constructor(parameters: Parameters) : C val activeSubscription = RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP).getOrNull() val hasActiveSignalSubscription = activeSubscription?.isActive == true + checkForFailedOrCanceledSubscriptionState(activeSubscription) + Log.i(TAG, "Synchronizing backup tier with value from server.") BackupRepository.getBackupTier().runIfSuccessful { SignalStore.backup.backupTier = it @@ -148,6 +153,32 @@ class BackupSubscriptionCheckJob private constructor(parameters: Parameters) : C } } + /** + * Checks for a payment failure / subscription cancellation. If either of these things occur, we will mark when to display + * the "download your data" notifier sheet. + */ + private fun checkForFailedOrCanceledSubscriptionState(activeSubscription: ActiveSubscription?) { + val containsFailedPaymentOrCancellation = activeSubscription?.isFailedPayment == true || activeSubscription?.isCanceled == true + if (containsFailedPaymentOrCancellation && activeSubscription.activeSubscription != null) { + Log.i(TAG, "Subscription either has a payment failure or has been canceled.") + + val response = SignalNetwork.account.whoAmI() + response.runIfSuccessful { whoAmI -> + val backupExpiration = whoAmI.entitlements?.backup?.expirationSeconds?.seconds + if (backupExpiration != null) { + Log.i(TAG, "Marking subscription failed or canceled.") + SignalStore.backup.setDownloadNotifierToTriggerAtHalfwayPoint(backupExpiration) + } else { + Log.w(TAG, "Failed to mark, no entitlement was found on WhoAmIResponse") + } + } + + if (response.getCause() != null) { + Log.w(TAG, "Failed to get WhoAmI from service.", response.getCause()) + } + } + } + private fun enqueueRedemptionForNewToken(localDevicePurchaseToken: String, localProductPrice: FiatMoney) { RecurringInAppPaymentRepository.ensureSubscriberIdSync( subscriberType = InAppPaymentSubscriberRecord.Type.BACKUP, diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupDownloadNotifierUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupDownloadNotifierUtil.kt new file mode 100644 index 0000000000..7ac62eb403 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupDownloadNotifierUtil.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.keyvalue + +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.keyvalue.BackupValues.Companion.TAG +import org.thoughtcrime.securesms.keyvalue.protos.BackupDownloadNotifierState +import kotlin.math.max +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** + * Manages setting and snoozing notifiers informing the user to download their backup + * before it is deleted from the Signal service. + * + * This is only meant to be delegated to from [BackupValues] + */ +object BackupDownloadNotifierUtil { + + /** + * Sets the notifier to trigger half way between now and the entitlement expiration time. + * + * @param state The current state, or null. + * @param entitlementExpirationTime The time the user's backup entitlement expires + * @param now The current time, for testing. + * + * @return the new state value. + */ + fun setDownloadNotifierToTriggerAtHalfwayPoint( + state: BackupDownloadNotifierState?, + entitlementExpirationTime: Duration, + now: Duration = System.currentTimeMillis().milliseconds + ): BackupDownloadNotifierState? { + if (state?.entitlementExpirationSeconds == entitlementExpirationTime.inWholeSeconds) { + Log.d(TAG, "Entitlement expiration time already present.") + return state + } + + if (now >= entitlementExpirationTime) { + Log.i(TAG, "Entitlement expiration time is in the past. Clearing state.") + return null + } + + val timeRemaining = entitlementExpirationTime - now + val halfWayPoint = (entitlementExpirationTime - timeRemaining / 2) + val lastDay = entitlementExpirationTime - 1.days + + val nextIntervalSeconds: Duration = when { + timeRemaining <= 1.days -> 0.seconds + timeRemaining <= 4.days -> lastDay - now + else -> halfWayPoint - now + } + + return BackupDownloadNotifierState( + entitlementExpirationSeconds = entitlementExpirationTime.inWholeSeconds, + lastSheetDisplaySeconds = now.inWholeSeconds, + intervalSeconds = nextIntervalSeconds.inWholeSeconds, + type = BackupDownloadNotifierState.Type.SHEET + ) + } + + /** + * Sets the notifier to trigger either one day before or four hours before expiration. + * + * @param state The current state, or null. + * @param now The current time, for testing. + * + * @return The new state value. + */ + fun snoozeDownloadNotifier( + state: BackupDownloadNotifierState?, + now: Duration = System.currentTimeMillis().milliseconds + ): BackupDownloadNotifierState? { + state ?: return null + + if (state.type == BackupDownloadNotifierState.Type.DIALOG) { + Log.i(TAG, "Clearing state after dismissing download notifier dialog.") + return null + } + + val lastDay = state.entitlementExpirationSeconds.seconds - 1.days + + return if (now >= lastDay) { + val fourHoursPriorToExpiration = state.entitlementExpirationSeconds.seconds - 4.hours + + state.newBuilder() + .lastSheetDisplaySeconds(now.inWholeSeconds) + .intervalSeconds(max(0L, (fourHoursPriorToExpiration - now).inWholeSeconds)) + .type(BackupDownloadNotifierState.Type.DIALOG) + .build() + } else { + val timeUntilLastDay = lastDay - now + + state.newBuilder() + .lastSheetDisplaySeconds(now.inWholeSeconds) + .intervalSeconds(timeUntilLastDay.inWholeSeconds) + .type(BackupDownloadNotifierState.Type.SHEET) + .build() + } + } +} 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 2da9685222..7a50fe5078 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt @@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.jobmanager.impl.NoRemoteArchiveGarbageCollectionPendingConstraint import org.thoughtcrime.securesms.jobmanager.impl.RestoreAttachmentConstraintObserver import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState +import org.thoughtcrime.securesms.keyvalue.protos.BackupDownloadNotifierState import org.thoughtcrime.securesms.util.RemoteConfig import org.thoughtcrime.securesms.util.Util import org.whispersystems.signalservice.api.archive.ArchiveServiceCredential @@ -52,6 +53,7 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { private const val KEY_CDN_MEDIA_PATH = "backup.cdn.mediaPath" + private const val KEY_BACKUP_DOWNLOAD_NOTIFIER_STATE = "backup.downloadNotifierState" private const val KEY_BACKUP_OVER_CELLULAR = "backup.useCellular" private const val KEY_RESTORE_OVER_CELLULAR = "backup.restore.useCellular" private const val KEY_OPTIMIZE_STORAGE = "backup.optimizeStorage" @@ -97,6 +99,9 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { var optimizeStorage: Boolean by booleanValue(KEY_OPTIMIZE_STORAGE, false) var backupWithCellular: Boolean by booleanValue(KEY_BACKUP_OVER_CELLULAR, false) + var backupDownloadNotifierState: BackupDownloadNotifierState? by protoValue(KEY_BACKUP_DOWNLOAD_NOTIFIER_STATE, BackupDownloadNotifierState.ADAPTER) + private set + var restoreWithCellular: Boolean get() = getBoolean(KEY_RESTORE_OVER_CELLULAR, false) set(value) { @@ -252,6 +257,28 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { */ var spaceAvailableOnDiskBytes: Long by longValue(KEY_BACKUP_FAIL_SPACE_REMAINING, -1L) + /** + * Sets the notifier to trigger half way between now and the entitlement expiration time. + */ + fun setDownloadNotifierToTriggerAtHalfwayPoint(entitlementExpirationTime: Duration) { + backupDownloadNotifierState = BackupDownloadNotifierUtil.setDownloadNotifierToTriggerAtHalfwayPoint(backupDownloadNotifierState, entitlementExpirationTime) + } + + /** + * Sets the notifier to trigger 24hrs before the end of the grace period. + * + */ + fun snoozeDownloadNotifier() { + backupDownloadNotifierState = BackupDownloadNotifierUtil.snoozeDownloadNotifier(backupDownloadNotifierState) + } + + /** + * Clears the notifier state, done when the user subscribes to the paid tier. + */ + fun clearDownloadNotifierState() { + backupDownloadNotifierState = null + } + fun internalSetBackupFailedErrorState() { markMessageBackupFailure() putLong(KEY_BACKUP_FAIL_SHEET_SNOOZE_TIME, 0) diff --git a/app/src/main/protowire/KeyValue.proto b/app/src/main/protowire/KeyValue.proto index 790b468c3b..e754345cf6 100644 --- a/app/src/main/protowire/KeyValue.proto +++ b/app/src/main/protowire/KeyValue.proto @@ -49,4 +49,16 @@ message ArchiveUploadProgressState { uint64 backupFileTotalBytes = 6; uint64 mediaUploadedBytes = 7; uint64 mediaTotalBytes = 8; +} + +message BackupDownloadNotifierState { + enum Type { + SHEET = 0; + DIALOG = 1; + } + + uint64 entitlementExpirationSeconds = 1; + uint64 lastSheetDisplaySeconds = 2; + uint64 intervalSeconds = 3; + Type type = 4; } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bd0a4f2f79..b39aa9ebab 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7835,9 +7835,11 @@ You can begin paying for backups again at any time to continue backing up all your media. - Your media will be deleted today - - Your Signal media backup plan has been canceled because we couldn\'t process your payment. This is your last chance to download the media in your backup before it is deleted. + Download your backup data today + + Download your backup data + + You have %1$s of media that\'s not on this device. The media and attachments stored in your backup will be permanently deleted without a paid subscription. Free up %1$s on this device @@ -7876,9 +7878,9 @@ Subscribe for %1$s/month - Download media now + Download backup now - Don\'t download media + Don\'t download backup Not now @@ -8176,6 +8178,8 @@ Your subscription on this device is valid for the next %1$d day. Renew to continue using Signal Backups Your subscription on this device is valid for the next %1$d days. Renew to continue using Signal Backups + + Resubscribe Renew @@ -8190,10 +8194,7 @@ Processing %1$s of ~%2$s messages (%3$d%%) - - You have %1$s of backup data that’s not on this device. Your backup will be deleted when your subscription ends in %2$d day. - You have %1$s of backup data that’s not on this device. Your backup will be deleted when your subscription ends in %2$d days. - + You have %1$s of backup data that\'s not on this device. Without a paid subscription media can\'t be offloaded. You have %1$s of backup data that’s not on this device. @@ -8238,6 +8239,16 @@ Downloading: %1$s of %2$s (%3$d%%) + + + Download your backup today + + You have %1$s of backup data that\'s not on this device. Your media and attachments will be permanently deleted without a paid subscription. + + Download + + Don\'t download + Subscription not found diff --git a/app/src/test/java/org/thoughtcrime/securesms/keyvalue/BackupDownloadNotifierUtilTest.kt b/app/src/test/java/org/thoughtcrime/securesms/keyvalue/BackupDownloadNotifierUtilTest.kt new file mode 100644 index 0000000000..7e5777e1d9 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/keyvalue/BackupDownloadNotifierUtilTest.kt @@ -0,0 +1,189 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.keyvalue + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNull +import org.junit.Test +import org.thoughtcrime.securesms.keyvalue.protos.BackupDownloadNotifierState +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.seconds + +class BackupDownloadNotifierUtilTest { + @Test + fun `Given within one day of expiration, when I setDownloadNotifierToTriggerAtHalfwayPoint, then I expect 0 interval`() { + val expectedIntervalFromNow = 0.seconds + val expiration = 30.days + val now = 29.days + + val expected = BackupDownloadNotifierState( + entitlementExpirationSeconds = expiration.inWholeSeconds, + lastSheetDisplaySeconds = now.inWholeSeconds, + intervalSeconds = expectedIntervalFromNow.inWholeSeconds, + type = BackupDownloadNotifierState.Type.SHEET + ) + + val result = BackupDownloadNotifierUtil.setDownloadNotifierToTriggerAtHalfwayPoint( + entitlementExpirationTime = expiration, + now = now, + state = null + ) + + assertThat(result).isEqualTo(expected) + } + + @Test + fun `Given within four days of expiration, when I setDownloadNotifierToTriggerAtHalfwayPoint, then I expect to be notified on the last day`() { + val expectedIntervalFromNow = 3.days + val expiration = 30.days + val now = 26.days + + val expected = BackupDownloadNotifierState( + entitlementExpirationSeconds = expiration.inWholeSeconds, + lastSheetDisplaySeconds = now.inWholeSeconds, + intervalSeconds = expectedIntervalFromNow.inWholeSeconds, + type = BackupDownloadNotifierState.Type.SHEET + ) + + val result = BackupDownloadNotifierUtil.setDownloadNotifierToTriggerAtHalfwayPoint( + entitlementExpirationTime = expiration, + now = now, + state = null + ) + + assertThat(result).isEqualTo(expected) + } + + @Test + fun `Given more than four days until expiration, when I setDownloadNotifierToTriggerAtHalfwayPoint, then I expect to be notified at the halfway point`() { + val expectedIntervalFromNow = 5.days + val expiration = 30.days + val now = 20.days + + val expected = BackupDownloadNotifierState( + entitlementExpirationSeconds = expiration.inWholeSeconds, + lastSheetDisplaySeconds = now.inWholeSeconds, + intervalSeconds = expectedIntervalFromNow.inWholeSeconds, + type = BackupDownloadNotifierState.Type.SHEET + ) + + val result = BackupDownloadNotifierUtil.setDownloadNotifierToTriggerAtHalfwayPoint( + entitlementExpirationTime = expiration, + now = now, + state = null + ) + + assertThat(result).isEqualTo(expected) + } + + @Test + fun `Given an expired entitlement, when I setDownloadNotifierToTriggerAtHalfwayPoint, then I expect null`() { + val expiration = 28.days + val now = 29.days + + val result = BackupDownloadNotifierUtil.setDownloadNotifierToTriggerAtHalfwayPoint( + entitlementExpirationTime = expiration, + now = now, + state = null + ) + + assertThat(result).isNull() + } + + @Test + fun `Given a repeat expiration time, when I setDownloadNotifierToTriggerAtHalfwayPoint, then I expect to return the exact same state`() { + val expiration = 30.days + val now = 20.days + + val expectedState = BackupDownloadNotifierState( + entitlementExpirationSeconds = expiration.inWholeSeconds, + intervalSeconds = 0L, + lastSheetDisplaySeconds = 0L, + type = BackupDownloadNotifierState.Type.DIALOG + ) + + val result = BackupDownloadNotifierUtil.setDownloadNotifierToTriggerAtHalfwayPoint( + entitlementExpirationTime = expiration, + now = now, + state = expectedState + ) + + assertThat(result).isEqualTo(expectedState) + } + + @Test + fun `Given a null state, when I snoozeDownloadNotifier, then I expect null`() { + val result = BackupDownloadNotifierUtil.snoozeDownloadNotifier(state = null) + + assertThat(result).isNull() + } + + @Test + fun `Given a DIALOG type, when I snoozeDownloadNotifier, then I expect null`() { + val state = BackupDownloadNotifierState( + entitlementExpirationSeconds = 0L, + intervalSeconds = 0L, + lastSheetDisplaySeconds = 0L, + type = BackupDownloadNotifierState.Type.DIALOG + ) + + val result = BackupDownloadNotifierUtil.snoozeDownloadNotifier(state = state) + + assertThat(result).isNull() + } + + @Test + fun `Given within one day of expiration, when I snoozeDownloadNotifier, then I expect dialog 4hrs before expiration`() { + val now = 0.hours + val expiration = 12.hours + val state = BackupDownloadNotifierState( + entitlementExpirationSeconds = expiration.inWholeSeconds, + lastSheetDisplaySeconds = 0, + intervalSeconds = 0 + ) + + val expected = BackupDownloadNotifierState( + entitlementExpirationSeconds = expiration.inWholeSeconds, + lastSheetDisplaySeconds = now.inWholeSeconds, + intervalSeconds = 8.hours.inWholeSeconds, + type = BackupDownloadNotifierState.Type.DIALOG + ) + + val result = BackupDownloadNotifierUtil.snoozeDownloadNotifier( + state = state, + now = now + ) + + assertThat(result).isEqualTo(expected) + } + + @Test + fun `Given more than one day until expiration, when I snoozeDownloadNotifier, then I expect sheet one day before expiration`() { + val now = 0.days + val expiration = 5.days + val state = BackupDownloadNotifierState( + entitlementExpirationSeconds = expiration.inWholeSeconds, + lastSheetDisplaySeconds = 0, + intervalSeconds = 0 + ) + + val expected = BackupDownloadNotifierState( + entitlementExpirationSeconds = expiration.inWholeSeconds, + lastSheetDisplaySeconds = now.inWholeSeconds, + intervalSeconds = 4.days.inWholeSeconds, + type = BackupDownloadNotifierState.Type.SHEET + ) + + val result = BackupDownloadNotifierUtil.snoozeDownloadNotifier( + state = state, + now = now + ) + + assertThat(result).isEqualTo(expected) + } +}