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