Add support for several backup alert sheets.

This commit is contained in:
Alex Hart
2024-07-30 10:39:10 -03:00
committed by mtang-signal
parent fb2a332513
commit d406e8f5b8
9 changed files with 237 additions and 99 deletions

View File

@@ -31,6 +31,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.os.BundleCompat
import androidx.core.os.bundleOf
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.parcelize.Parcelize
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
@@ -82,8 +83,7 @@ class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() {
}
BackupAlert.PAYMENT_PROCESSING -> Unit
BackupAlert.MEDIA_BACKUPS_ARE_OFF, BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> {
// TODO [message-backups] -- We need to force this to download everything.
AppDependencies.jobManager.add(BackupRestoreMediaJob())
performFullMediaDownload()
}
BackupAlert.DISK_FULL -> Unit
}
@@ -102,13 +102,29 @@ class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() {
// TODO [message-backups] - Silence and remind on last day
}
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> {
// TODO [message-backups] - Silence forever
displayLastChanceDialog()
}
BackupAlert.DISK_FULL -> Unit
}
dismissAllowingStateLoss()
}
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 performFullMediaDownload() {
// TODO [message-backups] -- We need to force this to download everything
AppDependencies.jobManager.add(BackupRestoreMediaJob())
}
}
@Composable

View File

@@ -31,7 +31,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.TerminalDonationDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.InAppPaymentsBottomSheetDelegate
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.events.ReminderUpdateEvent
@@ -61,7 +61,7 @@ class AppSettingsFragment : DSLSettingsFragment(
private lateinit var reminderView: Stub<ReminderView>
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycle.addObserver(TerminalDonationDelegate(childFragmentManager, viewLifecycleOwner))
viewLifecycleOwner.lifecycle.addObserver(InAppPaymentsBottomSheetDelegate(childFragmentManager, viewLifecycleOwner))
super.onViewCreated(view, savedInstanceState)
reminderView = ViewUtil.findStubById(view, R.id.reminder_stub)

View File

@@ -53,6 +53,7 @@ import java.util.Optional
import kotlin.jvm.optionals.getOrNull
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/**
@@ -63,6 +64,9 @@ object InAppPaymentsRepository {
private const val JOB_PREFIX = "InAppPayments__"
private val TAG = Log.tag(InAppPaymentsRepository::class.java)
private val backupExpirationTimeout = 30.days
private val backupExpirationDeletion = 60.days
private val temporaryErrorProcessor = PublishProcessor.create<Pair<InAppPaymentTable.InAppPaymentId, Throwable>>()
/**
@@ -347,6 +351,32 @@ object InAppPaymentsRepository {
}
}
/**
* Determines if we are in the timeout period to display the "your backup will be deleted today" message
*/
@WorkerThread
fun getExpiredBackupDeletionState(): ExpiredBackupDeletionState {
val inAppPayment = SignalDatabase.inAppPayments.getByLatestEndOfPeriod(InAppPaymentType.RECURRING_BACKUP)
if (inAppPayment == null) {
Log.w(TAG, "InAppPayment for recurring backup not found for last day check. Clearing check.")
SignalStore.inAppPayments.showLastDayToDownloadMediaDialog = false
return ExpiredBackupDeletionState.NONE
}
val now = SignalStore.misc.estimatedServerTime.milliseconds
val lastEndOfPeriod = inAppPayment.endOfPeriod
val displayDialogStart = lastEndOfPeriod + backupExpirationTimeout
val displayDialogEnd = lastEndOfPeriod + backupExpirationDeletion
return if (now in displayDialogStart..displayDialogEnd) {
ExpiredBackupDeletionState.DELETE_TODAY
} else if (now > displayDialogEnd) {
ExpiredBackupDeletionState.EXPIRED
} else {
ExpiredBackupDeletionState.NONE
}
}
@JvmStatic
@WorkerThread
fun setShouldCancelSubscriptionBeforeNextSubscribeAttempt(subscriber: InAppPaymentSubscriberRecord, shouldCancel: Boolean) {
@@ -623,4 +653,10 @@ object InAppPaymentsRepository {
InAppPaymentData.PaymentMethodType.PAYPAL -> DonationProcessor.PAYPAL
}
}
enum class ExpiredBackupDeletionState {
NONE,
DELETE_TODAY,
EXPIRED
}
}

View File

@@ -0,0 +1,162 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.completed
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlert
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlertBottomSheet
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.self.expired.MonthlyDonationCanceledBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPendingBottomSheet
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPendingBottomSheetArgs
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragmentArgs
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* Handles displaying bottom sheets for in-app payments. The current policy is to "fire and forget".
*/
class InAppPaymentsBottomSheetDelegate(
private val fragmentManager: FragmentManager,
private val lifecycleOwner: LifecycleOwner,
private vararg val supportedTypes: InAppPaymentSubscriberRecord.Type = arrayOf(InAppPaymentSubscriberRecord.Type.DONATION)
) : DefaultLifecycleObserver {
companion object {
private val inAppPaymentProcessingErrors = listOf(
InAppPaymentData.Error.Type.PAYMENT_PROCESSING,
InAppPaymentData.Error.Type.STRIPE_FAILURE,
InAppPaymentData.Error.Type.STRIPE_CODED_ERROR,
InAppPaymentData.Error.Type.STRIPE_DECLINED_ERROR,
InAppPaymentData.Error.Type.PAYPAL_CODED_ERROR,
InAppPaymentData.Error.Type.PAYPAL_DECLINED_ERROR
)
}
private val lifecycleDisposable = LifecycleDisposable().apply {
bindTo(lifecycleOwner)
}
private val badgeRepository = TerminalDonationRepository()
override fun onResume(owner: LifecycleOwner) {
if (InAppPaymentSubscriberRecord.Type.DONATION in supportedTypes) {
handleLegacyTerminalDonationSheets()
handleLegacyVerifiedMonthlyDonationSheets()
handleInAppPaymentDonationSheets()
}
if (InAppPaymentSubscriberRecord.Type.BACKUP in supportedTypes) {
handleInAppPaymentBackupsSheets()
}
}
/**
* Handles terminal donations consumed from the InAppPayments values. These are only ever set by the legacy jobs,
* and will be completely removed close to when the jobs are removed. (We might want an additional 90 days?)
*/
private fun handleLegacyTerminalDonationSheets() {
val donations = SignalStore.inAppPayments.consumeTerminalDonations()
for (donation in donations) {
if (donation.isLongRunningPaymentMethod && (donation.error == null || donation.error.type != DonationErrorValue.Type.REDEMPTION)) {
TerminalDonationBottomSheet.show(fragmentManager, donation)
} else if (donation.error != null) {
lifecycleDisposable += badgeRepository.getBadge(donation).observeOn(AndroidSchedulers.mainThread()).subscribe { badge ->
val args = ThanksForYourSupportBottomSheetDialogFragmentArgs.Builder(badge).build().toBundle()
val sheet = ThanksForYourSupportBottomSheetDialogFragment()
sheet.arguments = args
sheet.show(fragmentManager, null)
}
}
}
}
/**
* Handles the 'verified' sheet that appears after a user externally verifies a payment and returns to the application.
* These are only ever set by the legacy jobs, and will be completely removed close to when the jobs are removed. (We might
* want an additional 90 days?)
*/
private fun handleLegacyVerifiedMonthlyDonationSheets() {
SignalStore.inAppPayments.consumeVerifiedSubscription3DSData()?.also {
DonationPendingBottomSheet().apply {
arguments = DonationPendingBottomSheetArgs.Builder(it.inAppPayment).build().toBundle()
}.show(fragmentManager, null)
}
}
/**
* Handles the new in-app payment sheets for donations.
*/
private fun handleInAppPaymentDonationSheets() {
lifecycleDisposable += Single.fromCallable {
SignalDatabase.inAppPayments.consumeDonationPaymentsToNotifyUser()
}.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribeBy { inAppPayments ->
for (payment in inAppPayments) {
if (payment.data.error == null && payment.state == InAppPaymentTable.State.END) {
ThanksForYourSupportBottomSheetDialogFragment()
.apply { arguments = ThanksForYourSupportBottomSheetDialogFragmentArgs.Builder(Badges.fromDatabaseBadge(payment.data.badge!!)).build().toBundle() }
.show(fragmentManager, null)
} else if (payment.data.error != null && payment.state == InAppPaymentTable.State.PENDING) {
DonationPendingBottomSheet().apply {
arguments = DonationPendingBottomSheetArgs.Builder(payment).build().toBundle()
}.show(fragmentManager, null)
} else if (isUnexpectedCancellation(payment.state, payment.data) && SignalStore.inAppPayments.showMonthlyDonationCanceledDialog) {
MonthlyDonationCanceledBottomSheetDialogFragment.show(fragmentManager)
}
}
}
}
/**
* Handles the new in-app payment sheets for backups.
*/
private fun handleInAppPaymentBackupsSheets() {
lifecycleDisposable += Single.fromCallable {
SignalDatabase.inAppPayments.consumeBackupPaymentsToNotifyUser()
}.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribeBy { inAppPayments ->
for (payment in inAppPayments) {
if (isPaymentProcessingError(payment.state, payment.data)) {
BackupAlertBottomSheet.create(BackupAlert.COULD_NOT_COMPLETE_BACKUP).show(fragmentManager, null)
} else if (isUnexpectedCancellation(payment.state, payment.data)) {
BackupAlertBottomSheet.create(BackupAlert.MEDIA_BACKUPS_ARE_OFF).show(fragmentManager, null)
}
}
}
if (SignalStore.inAppPayments.showLastDayToDownloadMediaDialog) {
lifecycleDisposable += Single.fromCallable {
InAppPaymentsRepository.getExpiredBackupDeletionState()
}.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribeBy {
if (it == InAppPaymentsRepository.ExpiredBackupDeletionState.DELETE_TODAY) {
BackupAlertBottomSheet.create(BackupAlert.MEDIA_WILL_BE_DELETED_TODAY).show(fragmentManager, null)
}
}
}
}
private fun isUnexpectedCancellation(inAppPaymentState: InAppPaymentTable.State, inAppPaymentData: InAppPaymentData): Boolean {
return inAppPaymentState == InAppPaymentTable.State.END && inAppPaymentData.error != null && inAppPaymentData.cancellation != null && inAppPaymentData.cancellation.reason != InAppPaymentData.Cancellation.Reason.MANUAL
}
private fun isPaymentProcessingError(inAppPaymentState: InAppPaymentTable.State, inAppPaymentData: InAppPaymentData): Boolean {
return inAppPaymentState == InAppPaymentTable.State.END && inAppPaymentData.error != null && (inAppPaymentData.error.type in inAppPaymentProcessingErrors)
}
}

View File

@@ -1,89 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.completed
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.self.expired.MonthlyDonationCanceledBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPendingBottomSheet
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPendingBottomSheetArgs
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSData
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragmentArgs
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* Handles displaying the "Thank You" or "Donation completed" sheet when the user navigates to an appropriate screen.
* These sheets are one-shot.
*/
class TerminalDonationDelegate(
private val fragmentManager: FragmentManager,
private val lifecycleOwner: LifecycleOwner
) : DefaultLifecycleObserver {
private val lifecycleDisposable = LifecycleDisposable().apply {
bindTo(lifecycleOwner)
}
private val badgeRepository = TerminalDonationRepository()
override fun onResume(owner: LifecycleOwner) {
val donations = SignalStore.inAppPayments.consumeTerminalDonations()
for (donation in donations) {
if (donation.isLongRunningPaymentMethod && (donation.error == null || donation.error.type != DonationErrorValue.Type.REDEMPTION)) {
TerminalDonationBottomSheet.show(fragmentManager, donation)
} else if (donation.error != null) {
lifecycleDisposable += badgeRepository.getBadge(donation).observeOn(AndroidSchedulers.mainThread()).subscribe { badge ->
val args = ThanksForYourSupportBottomSheetDialogFragmentArgs.Builder(badge).build().toBundle()
val sheet = ThanksForYourSupportBottomSheetDialogFragment()
sheet.arguments = args
sheet.show(fragmentManager, null)
}
}
}
val verifiedMonthlyDonation: Stripe3DSData? = SignalStore.inAppPayments.consumeVerifiedSubscription3DSData()
if (verifiedMonthlyDonation != null) {
DonationPendingBottomSheet().apply {
arguments = DonationPendingBottomSheetArgs.Builder(verifiedMonthlyDonation.inAppPayment).build().toBundle()
}.show(fragmentManager, null)
}
handleInAppPaymentSheets()
}
private fun handleInAppPaymentSheets() {
lifecycleDisposable += Single.fromCallable {
SignalDatabase.inAppPayments.consumeDonationPaymentsToNotifyUser()
}.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribeBy { inAppPayments ->
for (payment in inAppPayments) {
if (payment.data.error == null && payment.state == InAppPaymentTable.State.END) {
ThanksForYourSupportBottomSheetDialogFragment()
.apply { arguments = ThanksForYourSupportBottomSheetDialogFragmentArgs.Builder(Badges.fromDatabaseBadge(payment.data.badge!!)).build().toBundle() }
.show(fragmentManager, null)
} else if (payment.data.error != null && payment.state == InAppPaymentTable.State.PENDING) {
DonationPendingBottomSheet().apply {
arguments = DonationPendingBottomSheetArgs.Builder(payment).build().toBundle()
}.show(fragmentManager, null)
} else if (payment.data.error != null && payment.data.cancellation != null && payment.data.cancellation.reason != InAppPaymentData.Cancellation.Reason.MANUAL && SignalStore.inAppPayments.showMonthlyDonationCanceledDialog) {
MonthlyDonationCanceledBottomSheetDialogFragment.show(fragmentManager)
}
}
}
}
}

View File

@@ -20,7 +20,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.TerminalDonationDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.InAppPaymentsBottomSheetDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
@@ -67,7 +67,7 @@ class ManageDonationsFragment :
private val viewModel: ManageDonationsViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycle.addObserver(TerminalDonationDelegate(childFragmentManager, viewLifecycleOwner))
viewLifecycleOwner.lifecycle.addObserver(InAppPaymentsBottomSheetDelegate(childFragmentManager, viewLifecycleOwner))
super.onViewCreated(view, savedInstanceState)
}

View File

@@ -114,7 +114,7 @@ import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder;
import org.thoughtcrime.securesms.components.reminder.UsernameOutOfSyncReminder;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment;
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.TerminalDonationDelegate;
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.InAppPaymentsBottomSheetDelegate;
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation;
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
@@ -277,7 +277,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
getViewLifecycleOwner().getLifecycle().addObserver(new TerminalDonationDelegate(getParentFragmentManager(), getViewLifecycleOwner()));
getViewLifecycleOwner().getLifecycle().addObserver(new InAppPaymentsBottomSheetDelegate(getParentFragmentManager(), getViewLifecycleOwner()));
BackupAlertDelegate.delegate(getParentFragmentManager(), getViewLifecycleOwner().getLifecycle());
lifecycleDisposable = new LifecycleDisposable();

View File

@@ -70,6 +70,7 @@ class InAppPaymentValues internal constructor(store: KeyValueStore) : SignalStor
private const val SUBSCRIPTION_CANCELATION_TIMESTAMP = "donation.subscription.cancelation.timestamp"
private const val SUBSCRIPTION_CANCELATION_WATERMARK = "donation.subscription.cancelation.watermark"
private const val SHOW_CANT_PROCESS_DIALOG = "show.cant.process.dialog"
private const val SHOW_LAST_DAY_TO_DOWNLOAD_MEDIA_DIALOG = "inapppayment.show.last.day.to.download.media.dialog"
/**
* The current request context for subscription. This should be stored until either
@@ -160,7 +161,8 @@ class InAppPaymentValues internal constructor(store: KeyValueStore) : SignalStor
SUBSCRIPTION_EOP_STARTED_TO_CONVERT,
SUBSCRIPTION_EOP_STARTED_TO_REDEEM,
SUBSCRIPTION_EOP_REDEEMED,
SUBSCRIPTION_PAYMENT_SOURCE_TYPE
SUBSCRIPTION_PAYMENT_SOURCE_TYPE,
SHOW_LAST_DAY_TO_DOWNLOAD_MEDIA_DIALOG
)
private val recurringDonationCurrencyPublisher: Subject<Currency> by lazy { BehaviorSubject.createDefault(getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.DONATION)) }
@@ -417,6 +419,9 @@ class InAppPaymentValues internal constructor(store: KeyValueStore) : SignalStor
@get:JvmName("showCantProcessDialog")
var showMonthlyDonationCanceledDialog: Boolean by booleanValue(SHOW_CANT_PROCESS_DIALOG, true)
@get:JvmName("showLastDayToDownloadMediaDialog")
var showLastDayToDownloadMediaDialog: Boolean by booleanValue(SHOW_LAST_DAY_TO_DOWNLOAD_MEDIA_DIALOG, false)
/**
* Denotes that the previous attempt to subscribe failed in some way. Either an
* automatic renewal failed resulting in an unexpected expiration, or payment failed

View File

@@ -7149,6 +7149,14 @@
<string name="BackupAlertBottomSheet__download_later">Download later</string>
<!-- Secondary action button text to dismiss sheet, skipping media download -->
<string name="BackupAlertBottomSheet__dont_download_media">Don\'t download media</string>
<!-- Dialog title for last chance to download backup -->
<string name="BackupAlertBottomSheet__media_will_be_deleted">Media will be deleted</string>
<!-- Dialog message for last chance to download backup -->
<string name="BackupAlertBottomSheet__the_media_stored_in_your_backup">The media stored in your backup will be permanently deleted today. This is your last chance to download it.</string>
<!-- Dialog action to download now -->
<string name="BackupAlertBottomSheet__download">Download</string>
<!-- Dialog action to not download now -->
<string name="BackupAlertBottomSheet__dont_download">Don\'t download</string>
<!-- BackupStatus -->
<!-- Status title when user does not have enough free space to download their media. Placeholder is required disk space. -->