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