Add new dialog and sheet for handling offloaded media after a subscription is canceled or expires.

This commit is contained in:
Alex Hart
2025-06-09 15:41:10 -03:00
committed by Greyson Parrelli
parent 18b5354944
commit 1424dd6892
11 changed files with 562 additions and 68 deletions

View File

@@ -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()
}
/**

View File

@@ -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.

View File

@@ -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
}
}
}

View File

@@ -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"
)
}
}

View File

@@ -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() {

View File

@@ -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,

View File

@@ -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()
}
}
}

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -7835,9 +7835,11 @@
<!-- Sheet body part 2 when media backups have been disabled. -->
<string name="BackupAlertBottomSheet__you_can_begin_paying_for_backups_again">You can begin paying for backups again at any time to continue backing up all your media. </string>
<!-- Sheet title when user\'s media will be deleted today -->
<string name="BackupAlertBottomSheet__your_media_will_be_deleted_today">Your media will be deleted today</string>
<!-- Sheet body part 1 when user\'s media will be deleted today -->
<string name="BackupAlertBottomSheet__your_signal_media_backup_plan_has_been">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.</string>
<string name="BackupAlertBottomSheet__download_your_backup_data_today">Download your backup data today</string>
<!-- Sheet title when user\'s media will be deleted at the end of their grace period -->
<string name="BackupAlertBottomSheet__download_your_backup_data">Download your backup data</string>
<!-- Sheet body part 1 when user\'s media will be deleted after grace period lapses -->
<string name="BackupAlertBottomSheet__you_have_s_of_media_thats_not_on_this_device">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.</string>
<!-- Sheet title when user does not have enough space to download their backup. Placeholder is formatted byte size, for example 12GB. -->
<string name="BackupAlertBottomSheet__free_up_s_on_this_device">Free up %1$s on this device</string>
<!-- Sheet body part 1 when user does not have enough space to download their backup. Placeholder is the amount of space needed. -->
@@ -7876,9 +7878,9 @@
<!-- Primary action button text prompting user to subscriber. Placeholder is formatted price -->
<string name="BackupAlertBottomSheet__subscribe_for_s_month">Subscribe for %1$s/month</string>
<!-- Primary action button text prompting user to download their media -->
<string name="BackupAlertBottomSheet__download_media_now">Download media now</string>
<string name="BackupAlertBottomSheet__download_backup_now">Download backup now</string>
<!-- Secondary action button text to dismiss sheet, skipping media download -->
<string name="BackupAlertBottomSheet__dont_download_media">Don\'t download media</string>
<string name="BackupAlertBottomSheet__dont_download_backup">Don\'t download backup</string>
<!-- Secondary generic action button to dismiss sheet without performing an action -->
<string name="BackupAlertBottomSheet__not_now">Not now</string>
<!-- Secondary action button to dismiss could not complete backup sheet -->
@@ -8176,6 +8178,8 @@
<item quantity="one">Your subscription on this device is valid for the next %1$d day. Renew to continue using Signal Backups</item>
<item quantity="other">Your subscription on this device is valid for the next %1$d days. Renew to continue using Signal Backups</item>
</plurals>
<!-- Button label to start subscription resubscribe after cancellation -->
<string name="RemoteBackupsSettingsFragment__resubscribe">Resubscribe</string>
<!-- Button label to start subscription renewal -->
<string name="RemoteBackupsSettingsFragment__renew">Renew</string>
<!-- Button label to learn more about why subscription disappeared -->
@@ -8190,10 +8194,7 @@
<item quantity="other">Processing %1$s of ~%2$s messages (%3$d%%)</item>
</plurals>
<!-- Displayed in row when backup is available for download and users subscription has expired. First placeholder is data size e.g. 12MB, second is days before expiration -->
<plurals name="RemoteBackupsSettingsFragment__you_have_s_of_backup_data">
<item quantity="one">You have %1$s of backup data thats not on this device. Your backup will be deleted when your subscription ends in %2$d day.</item>
<item quantity="other">You have %1$s of backup data thats not on this device. Your backup will be deleted when your subscription ends in %2$d days.</item>
</plurals>
<string name="RemoteBackupsSettingsFragment__you_have_s_of_backup_data">You have %1$s of backup data that\'s not on this device. Without a paid subscription media can\'t be offloaded.</string>
<!-- Display in a row when backup is available for download but the user has canceled the restore. Place holder is data size e.g. 12MB -->
<string name="RemoteBackupsSettingsFragment__you_have_s_of_backup_data_not_on_device">You have %1$s of backup data thats not on this device. </string>
<!-- Displayed in row when backup is available for download to let user start download -->
@@ -8238,6 +8239,16 @@
<!-- 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>
<!-- DownloadYourBackupTodayDialog -->
<!-- Dialog title -->
<string name="DownloadYourBackupTodayDialog__download_your_backup_today">Download your backup today</string>
<!-- Dialog body. Placeholder is human readable byte count, for example 2.3 MB -->
<string name="DownloadYourBackupTodayDialog__you_have_s_of_backup_data">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.</string>
<!-- Dialog positive action label -->
<string name="DownloadYourBackupTodayDialog__download">Download</string>
<!-- Dialog negative action label -->
<string name="DownloadYourBackupTodayDialog__dont_download">Don\'t download</string>
<!-- SubscriptionNotFoundBottomSheet -->
<!-- Displayed as a bottom sheet title -->
<string name="SubscriptionNotFoundBottomSheet__subscription_not_found">Subscription not found</string>

View File

@@ -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)
}
}