Add 30 day reminder for manual backups.

Co-authored-by: Michelle Tang <mtang@signal.org>
This commit is contained in:
Alex Hart
2025-06-25 14:05:37 -03:00
committed by Cody Henthorne
parent 82531630c7
commit e705495638
8 changed files with 532 additions and 176 deletions

View File

@@ -88,6 +88,8 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.DataRestoreConstraint
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.jobmanager.impl.WifiConstraint
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob
import org.thoughtcrime.securesms.jobs.BackupDeleteJob
import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
@@ -158,6 +160,7 @@ object BackupRepository {
private const val LOCAL_MAIN_DB_SNAPSHOT_NAME = "local-signal-snapshot"
private const val LOCAL_KEYVALUE_DB_SNAPSHOT_NAME = "local-signal-key-value-snapshot"
private const val RECENT_RECIPIENTS_MAX = 50
private val MANUAL_BACKUP_NOTIFICATION_THRESHOLD = 30.days
private val resetInitializedStateErrorAction: StatusCodeErrorAction = { error ->
when (error.code) {
@@ -321,6 +324,29 @@ object BackupRepository {
}
}
fun displayManualBackupNotCreatedInThresholdNotification() {
if (SignalStore.backup.lastBackupTime <= 0) {
return
}
val daysSinceLastBackup = (System.currentTimeMillis().milliseconds - SignalStore.backup.lastBackupTime.milliseconds).inWholeDays.toInt()
val context = AppDependencies.application
val pendingIntent = PendingIntent.getActivity(context, 0, AppSettingsActivity.remoteBackups(context), cancelCurrent())
val notification = NotificationCompat.Builder(context, NotificationChannels.getInstance().APP_ALERTS)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.resources.getQuantityString(R.plurals.Notification_no_backup_for_d_days, daysSinceLastBackup, daysSinceLastBackup))
.setContentText(context.resources.getQuantityString(R.plurals.Notification_you_have_not_completed_a_backup, daysSinceLastBackup, daysSinceLastBackup))
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
ServiceUtil.getNotificationManager(context).notify(NotificationIds.MANUAL_BACKUP_NOT_CREATED, notification)
}
fun cancelManualBackupNotCreatedInThresholdNotification() {
ServiceUtil.getNotificationManager(AppDependencies.application).cancel(NotificationIds.MANUAL_BACKUP_NOT_CREATED)
}
@Discouraged("This is only public to allow internal settings to call it directly.")
fun displayInitialBackupFailureNotification() {
val context = AppDependencies.application
@@ -432,6 +458,45 @@ object BackupRepository {
SignalStore.backup.hasBackupAlreadyRedeemedError = false
}
/**
* Whether or not the "No backup" for manual backups should be displayed.
* This should only be displayed after a set threshold has passed and the user
* has set the MANUAL backups frequency.
*/
fun shouldDisplayNoManualBackupForTimeoutSheet(): Boolean {
if (shouldNotDisplayBackupFailedMessaging()) {
return false
}
if (SignalStore.backup.backupFrequency != BackupFrequency.MANUAL) {
return false
}
if (SignalStore.backup.lastBackupTime <= 0) {
return false
}
val isNetworkConstraintMet = if (SignalStore.backup.backupWithCellular) {
NetworkConstraint.isMet(AppDependencies.application)
} else {
WifiConstraint.isMet(AppDependencies.application)
}
if (!isNetworkConstraintMet) {
return false
}
val durationSinceLastBackup = System.currentTimeMillis().milliseconds - SignalStore.backup.lastBackupTime.milliseconds
if (durationSinceLastBackup < MANUAL_BACKUP_NOTIFICATION_THRESHOLD) {
return false
}
val display = !SignalStore.backup.isNoBackupForManualUploadNotified
SignalStore.backup.isNoBackupForManualUploadNotified = false
return display
}
/**
* Updates the watermark for the indicator display.
*/

View File

@@ -8,40 +8,26 @@ package org.thoughtcrime.securesms.backup.v2.ui
import android.content.DialogInterface
import android.os.Parcelable
import androidx.appcompat.app.AlertDialog
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
@@ -51,8 +37,6 @@ import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.parcelize.Parcelize
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.theme.SignalTheme
@@ -66,7 +50,6 @@ 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
/**
* Notifies the user of an issue with their backup.
@@ -96,14 +79,10 @@ class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() {
@Composable
override fun SheetContent() {
val performPrimaryAction = remember(backupAlert) {
createPrimaryAction()
}
BackupAlertSheetContent(
AlertContainer(
backupAlert = backupAlert,
onPrimaryActionClick = performPrimaryAction,
onSecondaryActionClick = this::performSecondaryAction
primaryActionButtonState = rememberPrimaryAction(backupAlert, remember(backupAlert) { createPrimaryAction() }),
secondaryActionButtonState = rememberSecondaryAction(backupAlert) { performSecondaryAction() }
)
}
@@ -146,12 +125,14 @@ class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() {
is BackupAlert.DiskFull -> {
displaySkipRestoreDialog()
}
BackupAlert.BackupFailed -> {
ContactSupportDialogFragment.create(
subject = R.string.BackupAlertBottomSheet_network_failure_support_email,
filter = R.string.BackupAlertBottomSheet_export_failure_filter
).show(parentFragmentManager, null)
}
BackupAlert.CouldNotRedeemBackup -> CommunicationActions.openBrowserLink(requireContext(), requireContext().getString(R.string.backup_support_url)) // TODO [backups] final url
}
@@ -192,105 +173,56 @@ class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() {
}
@Composable
fun BackupAlertSheetContent(
private fun AlertContainer(
backupAlert: BackupAlert,
onPrimaryActionClick: () -> Unit = {},
onSecondaryActionClick: () -> Unit = {}
primaryActionButtonState: BackupAlertActionButtonState,
secondaryActionButtonState: BackupAlertActionButtonState? = null
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(id = CoreUiR.dimen.gutter))
) {
BottomSheets.Handle()
BackupAlertBottomSheetContainer(
icon = { AlertIcon(backupAlert) },
title = titleString(backupAlert),
primaryActionButtonState = primaryActionButtonState,
secondaryActionButtonState = secondaryActionButtonState,
content = { Body(backupAlert) }
)
}
Spacer(modifier = Modifier.size(26.dp))
when (backupAlert) {
is BackupAlert.MediaBackupsAreOff -> error("Use MediaBackupsAreOffBottomSheet instead.")
BackupAlert.FailedToRenew, BackupAlert.ExpiredAndDowngraded -> {
Box {
Image(
imageVector = ImageVector.vectorResource(id = R.drawable.image_signal_backups),
contentDescription = null,
modifier = Modifier
.size(80.dp)
.padding(2.dp)
)
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_error_circle_fill_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.align(Alignment.TopEnd)
)
}
}
else -> {
val iconColors = rememberBackupsIconColors(backupAlert = backupAlert)
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_backup_light),
contentDescription = null,
tint = iconColors.foreground,
modifier = Modifier
.size(80.dp)
.background(color = iconColors.background, shape = CircleShape)
.padding(20.dp)
)
}
@Composable
private fun AlertIcon(backupAlert: BackupAlert) {
when (backupAlert) {
is BackupAlert.MediaBackupsAreOff -> error("Use MediaBackupsAreOffBottomSheet instead.")
BackupAlert.FailedToRenew, BackupAlert.ExpiredAndDowngraded -> {
BackupAlertImage()
}
Text(
text = titleString(backupAlert = backupAlert),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 16.dp, bottom = 6.dp)
)
when (backupAlert) {
is BackupAlert.CouldNotCompleteBackup -> CouldNotCompleteBackup(
daysSinceLastBackup = backupAlert.daysSinceLastBackup
)
BackupAlert.FailedToRenew -> PaymentProcessingBody()
is BackupAlert.DownloadYourBackupData -> DownloadYourBackupData(backupAlert.formattedSize)
is BackupAlert.DiskFull -> DiskFullBody(requiredSpace = backupAlert.requiredSpace)
BackupAlert.BackupFailed -> BackupFailedBody()
BackupAlert.CouldNotRedeemBackup -> CouldNotRedeemBackup()
is BackupAlert.MediaBackupsAreOff -> error("Use MediaBackupsAreOffBottomSheet instead.")
BackupAlert.ExpiredAndDowngraded -> SubscriptionExpired()
}
val secondaryActionResource = rememberSecondaryActionResource(backupAlert = backupAlert)
val padBottom = if (secondaryActionResource > 0) 16.dp else 56.dp
Buttons.LargeTonal(
onClick = onPrimaryActionClick,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = padBottom)
) {
Text(text = primaryActionString(backupAlert = backupAlert))
}
if (secondaryActionResource > 0) {
TextButton(
onClick = onSecondaryActionClick,
modifier = Modifier.padding(bottom = 32.dp)
) {
Text(text = stringResource(id = secondaryActionResource))
}
else -> {
val iconColors = rememberBackupsIconColors(backupAlert = backupAlert)
BackupAlertIcon(iconColors = iconColors)
}
}
}
@Composable
private fun Body(backupAlert: BackupAlert) {
when (val alert = backupAlert) {
is BackupAlert.CouldNotCompleteBackup -> CouldNotCompleteBackup(
daysSinceLastBackup = alert.daysSinceLastBackup
)
BackupAlert.FailedToRenew -> PaymentProcessingBody()
is BackupAlert.DownloadYourBackupData -> DownloadYourBackupData(alert.formattedSize)
is BackupAlert.DiskFull -> DiskFullBody(requiredSpace = alert.requiredSpace)
BackupAlert.BackupFailed -> BackupFailedBody()
BackupAlert.CouldNotRedeemBackup -> CouldNotRedeemBackup()
is BackupAlert.MediaBackupsAreOff -> error("Use MediaBackupsAreOffBottomSheet instead.")
BackupAlert.ExpiredAndDowngraded -> SubscriptionExpired()
}
}
@Composable
private fun CouldNotRedeemBackup() {
Text(
BackupAlertText(
text = stringResource(R.string.BackupAlertBottomSheet__too_many_devices_have_tried),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 16.dp)
)
@@ -338,17 +270,13 @@ private fun CouldNotRedeemBackup() {
@Composable
private fun SubscriptionExpired() {
Text(
BackupAlertText(
text = stringResource(id = R.string.BackupAlertBottomSheet__your_subscription_couldnt_be_renewed),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 24.dp)
)
Text(
BackupAlertText(
text = stringResource(id = R.string.BackupAlertBottomSheet__youll_continue_to_have_access_to_the_free),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 36.dp)
)
}
@@ -357,54 +285,42 @@ private fun SubscriptionExpired() {
private fun CouldNotCompleteBackup(
daysSinceLastBackup: Int
) {
Text(
BackupAlertText(
text = pluralStringResource(id = R.plurals.BackupAlertBottomSheet__your_device_hasnt, daysSinceLastBackup, daysSinceLastBackup),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 60.dp)
)
}
@Composable
private fun PaymentProcessingBody() {
Text(
BackupAlertText(
text = stringResource(id = R.string.BackupAlertBottomSheet__check_to_make_sure_your_payment_method),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 60.dp)
)
}
@Composable
private fun DownloadYourBackupData(formattedSize: String) {
Text(
BackupAlertText(
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)
)
Text(
BackupAlertText(
text = stringResource(id = R.string.BackupAlertBottomSheet__you_can_begin_paying_for_backups_again),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 36.dp)
)
}
@Composable
private fun DiskFullBody(requiredSpace: String) {
Text(
BackupAlertText(
text = stringResource(id = R.string.BackupAlertBottomSheet__to_finish_downloading_your_signal_backup, requiredSpace),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 24.dp)
)
Text(
BackupAlertText(
text = stringResource(R.string.BackupAlertBottomSheet__to_free_up_space_offload),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 36.dp)
)
}
@@ -427,10 +343,8 @@ private fun BackupFailedBody() {
}
}
Text(
BackupAlertText(
text = text,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 36.dp)
)
}
@@ -459,6 +373,7 @@ private fun titleString(backupAlert: BackupAlert): String {
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)
@@ -467,10 +382,11 @@ private fun titleString(backupAlert: BackupAlert): String {
}
@Composable
private fun primaryActionString(
backupAlert: BackupAlert
): String {
return when (backupAlert) {
private fun rememberPrimaryAction(
backupAlert: BackupAlert,
callback: () -> Unit
): BackupAlertActionButtonState {
val label = when (backupAlert) {
is BackupAlert.CouldNotCompleteBackup -> stringResource(R.string.BackupAlertBottomSheet__back_up_now)
BackupAlert.FailedToRenew -> stringResource(R.string.BackupAlertBottomSheet__manage_subscription)
is BackupAlert.MediaBackupsAreOff -> error("Not supported.")
@@ -480,20 +396,41 @@ private fun primaryActionString(
BackupAlert.CouldNotRedeemBackup -> stringResource(R.string.BackupAlertBottomSheet__got_it)
BackupAlert.ExpiredAndDowngraded -> stringResource(R.string.BackupAlertBottomSheet__manage_backups)
}
return remember(backupAlert, callback) {
BackupAlertActionButtonState(
label = label,
callback = callback
)
}
}
@Composable
private fun rememberSecondaryActionResource(backupAlert: BackupAlert): Int {
return remember(backupAlert) {
when (backupAlert) {
is BackupAlert.CouldNotCompleteBackup -> R.string.BackupAlertBottomSheet__try_later
BackupAlert.FailedToRenew, BackupAlert.ExpiredAndDowngraded -> R.string.BackupAlertBottomSheet__not_now
is BackupAlert.MediaBackupsAreOff -> error("Not supported.")
is BackupAlert.DownloadYourBackupData -> R.string.BackupAlertBottomSheet__dont_download_backup
is BackupAlert.DiskFull -> R.string.BackupAlertBottomSheet__skip_restore
is BackupAlert.BackupFailed -> R.string.BackupAlertBottomSheet__contact_support
BackupAlert.CouldNotRedeemBackup -> R.string.BackupAlertBottomSheet__learn_more
}
private fun rememberSecondaryAction(
backupAlert: BackupAlert,
callback: () -> Unit
): BackupAlertActionButtonState? {
val labelResource = when (backupAlert) {
is BackupAlert.CouldNotCompleteBackup -> R.string.BackupAlertBottomSheet__try_later
BackupAlert.FailedToRenew, BackupAlert.ExpiredAndDowngraded -> R.string.BackupAlertBottomSheet__not_now
is BackupAlert.MediaBackupsAreOff -> error("Not supported.")
is BackupAlert.DownloadYourBackupData -> R.string.BackupAlertBottomSheet__dont_download_backup
is BackupAlert.DiskFull -> R.string.BackupAlertBottomSheet__skip_restore
is BackupAlert.BackupFailed -> R.string.BackupAlertBottomSheet__contact_support
BackupAlert.CouldNotRedeemBackup -> R.string.BackupAlertBottomSheet__learn_more
}
if (labelResource <= 0) {
return null
}
val label = stringResource(labelResource)
return remember(backupAlert, callback) {
BackupAlertActionButtonState(
label = label,
callback = callback
)
}
}
@@ -501,9 +438,11 @@ private fun rememberSecondaryActionResource(backupAlert: BackupAlert): Int {
@Composable
private fun BackupAlertSheetContentPreviewGeneric() {
Previews.BottomSheetPreview {
BackupAlertSheetContent(
backupAlert = BackupAlert.CouldNotCompleteBackup(daysSinceLastBackup = 7)
)
val backupAlert = BackupAlert.CouldNotCompleteBackup(daysSinceLastBackup = 7)
val primaryActionButtonState = rememberPrimaryAction(backupAlert) { }
val secondaryActionButtonState = rememberSecondaryAction(backupAlert) { }
AlertContainer(backupAlert, primaryActionButtonState, secondaryActionButtonState)
}
}
@@ -511,9 +450,11 @@ private fun BackupAlertSheetContentPreviewGeneric() {
@Composable
private fun BackupAlertSheetContentPreviewPayment() {
Previews.BottomSheetPreview {
BackupAlertSheetContent(
backupAlert = BackupAlert.FailedToRenew
)
val backupAlert = BackupAlert.FailedToRenew
val primaryActionButtonState = rememberPrimaryAction(backupAlert) { }
val secondaryActionButtonState = rememberSecondaryAction(backupAlert) { }
AlertContainer(backupAlert, primaryActionButtonState, secondaryActionButtonState)
}
}
@@ -521,12 +462,14 @@ private fun BackupAlertSheetContentPreviewPayment() {
@Composable
private fun BackupAlertSheetContentPreviewDelete() {
Previews.BottomSheetPreview {
BackupAlertSheetContent(
backupAlert = BackupAlert.DownloadYourBackupData(
isLastDay = false,
formattedSize = "2.3MB"
)
val backupAlert = BackupAlert.DownloadYourBackupData(
isLastDay = false,
formattedSize = "2.3MB"
)
val primaryActionButtonState = rememberPrimaryAction(backupAlert) { }
val secondaryActionButtonState = rememberSecondaryAction(backupAlert) { }
AlertContainer(backupAlert, primaryActionButtonState, secondaryActionButtonState)
}
}
@@ -534,9 +477,11 @@ private fun BackupAlertSheetContentPreviewDelete() {
@Composable
private fun BackupAlertSheetContentPreviewDiskFull() {
Previews.BottomSheetPreview {
BackupAlertSheetContent(
backupAlert = BackupAlert.DiskFull(requiredSpace = "12GB")
)
val backupAlert = BackupAlert.DiskFull(requiredSpace = "12GB")
val primaryActionButtonState = rememberPrimaryAction(backupAlert) { }
val secondaryActionButtonState = rememberSecondaryAction(backupAlert) { }
AlertContainer(backupAlert, primaryActionButtonState, secondaryActionButtonState)
}
}
@@ -544,9 +489,11 @@ private fun BackupAlertSheetContentPreviewDiskFull() {
@Composable
private fun BackupAlertSheetContentPreviewBackupFailed() {
Previews.BottomSheetPreview {
BackupAlertSheetContent(
backupAlert = BackupAlert.BackupFailed
)
val backupAlert = BackupAlert.BackupFailed
val primaryActionButtonState = rememberPrimaryAction(backupAlert) { }
val secondaryActionButtonState = rememberSecondaryAction(backupAlert) { }
AlertContainer(backupAlert, primaryActionButtonState, secondaryActionButtonState)
}
}
@@ -554,9 +501,11 @@ private fun BackupAlertSheetContentPreviewBackupFailed() {
@Composable
private fun BackupAlertSheetContentPreviewCouldNotRedeemBackup() {
Previews.BottomSheetPreview {
BackupAlertSheetContent(
backupAlert = BackupAlert.CouldNotRedeemBackup
)
val backupAlert = BackupAlert.CouldNotRedeemBackup
val primaryActionButtonState = rememberPrimaryAction(backupAlert) { }
val secondaryActionButtonState = rememberSecondaryAction(backupAlert) { }
AlertContainer(backupAlert, primaryActionButtonState, secondaryActionButtonState)
}
}
@@ -564,9 +513,11 @@ private fun BackupAlertSheetContentPreviewCouldNotRedeemBackup() {
@Composable
private fun BackupAlertSheetContentPreviewSubscriptionExpired() {
Previews.BottomSheetPreview {
BackupAlertSheetContent(
backupAlert = BackupAlert.ExpiredAndDowngraded
)
val backupAlert = BackupAlert.ExpiredAndDowngraded
val primaryActionButtonState = rememberPrimaryAction(backupAlert) { }
val secondaryActionButtonState = rememberSecondaryAction(backupAlert) { }
AlertContainer(backupAlert, primaryActionButtonState, secondaryActionButtonState)
}
}

View File

@@ -33,6 +33,9 @@ object BackupAlertDelegate {
BackupAlertBottomSheet.create(BackupAlert.CouldNotCompleteBackup(daysSinceLastBackup = SignalStore.backup.daysSinceLastBackup)).show(fragmentManager, FRAGMENT_TAG)
} else if (BackupRepository.shouldDisplayBackupExpiredAndDowngradedSheet()) {
BackupAlertBottomSheet.create(BackupAlert.ExpiredAndDowngraded).show(fragmentManager, FRAGMENT_TAG)
} else if (BackupRepository.shouldDisplayNoManualBackupForTimeoutSheet()) {
NoManualBackupBottomSheet().show(fragmentManager, FRAGMENT_TAG)
BackupRepository.displayManualBackupNotCreatedInThresholdNotification()
}
displayBackupDownloadNotifier(fragmentManager)

View File

@@ -0,0 +1,226 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.signal.core.ui.R as CoreUiR
/**
* Container for a backup alert sheet.
*
* Primary action padding will change depending on presence of secondary action.
*/
@Composable
fun BackupAlertBottomSheetContainer(
icon: @Composable () -> Unit,
title: String,
primaryActionButtonState: BackupAlertActionButtonState,
secondaryActionButtonState: BackupAlertActionButtonState? = null,
content: @Composable () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(id = CoreUiR.dimen.gutter))
) {
BottomSheets.Handle()
Spacer(modifier = Modifier.size(26.dp))
icon()
BackupAlertTitle(title = title)
content()
BackupAlertPrimaryActionButton(
text = primaryActionButtonState.label,
onClick = primaryActionButtonState.callback,
modifier = Modifier.padding(
bottom = if (secondaryActionButtonState != null) {
16.dp
} else {
56.dp
}
)
)
if (secondaryActionButtonState != null) {
BackupAlertSecondaryActionButton(
text = secondaryActionButtonState.label,
onClick = secondaryActionButtonState.callback
)
}
}
}
/**
* Backup alert sheet icon for the top of the sheet, vector only.
*/
@Composable
fun BackupAlertIcon(
iconColors: BackupsIconColors
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_backup_light),
contentDescription = null,
tint = iconColors.foreground,
modifier = Modifier
.size(80.dp)
.background(color = iconColors.background, shape = CircleShape)
.padding(20.dp)
)
}
/**
* Backup alert sheet image for the top of the sheet displaying a backup icon and alert indicator.
*/
@Composable
fun BackupAlertImage() {
Box {
Image(
imageVector = ImageVector.vectorResource(id = R.drawable.image_signal_backups),
contentDescription = null,
modifier = Modifier
.size(80.dp)
.padding(2.dp)
)
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_error_circle_fill_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.align(Alignment.TopEnd)
)
}
}
@Composable
private fun BackupAlertTitle(
title: String
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 16.dp, bottom = 6.dp)
)
}
/**
* Properly styled Text for Backup Alert sheets
*/
@Composable
fun BackupAlertText(
text: String,
modifier: Modifier = Modifier
) {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = modifier
)
}
/**
* Properly styled Text for Backup Alert sheets
*/
@Composable
fun BackupAlertText(
text: AnnotatedString,
modifier: Modifier = Modifier
) {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = modifier
)
}
@Composable
private fun BackupAlertPrimaryActionButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Buttons.LargeTonal(
onClick = onClick,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.then(modifier)
) {
Text(text = text)
}
}
@Composable
private fun BackupAlertSecondaryActionButton(
text: String,
onClick: () -> Unit
) {
TextButton(
onClick = onClick,
modifier = Modifier.padding(bottom = 32.dp)
) {
Text(text = text)
}
}
@SignalPreview
@Composable
private fun BackupAlertBottomSheetContainerPreview() {
Previews.BottomSheetPreview {
BackupAlertBottomSheetContainer(
icon = { BackupAlertIcon(iconColors = BackupsIconColors.Warning) },
title = "Test backup alert",
primaryActionButtonState = BackupAlertActionButtonState("Test Primary", callback = {}),
secondaryActionButtonState = BackupAlertActionButtonState("Test Secondary", callback = {})
) {
BackupAlertText(text = "Content", modifier = Modifier.padding(bottom = 60.dp))
}
}
}
/**
* Immutable state class for alert sheet actions.
*/
@Immutable
data class BackupAlertActionButtonState(
val label: String,
val callback: () -> Unit
)

View File

@@ -0,0 +1,82 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
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.SignalStore
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
/**
* Displays an alert to the user if they've passed a given threshold without
* performing a manual backup.
*/
class NoManualBackupBottomSheet : ComposeBottomSheetDialogFragment() {
@Composable
override fun SheetContent() {
val durationSinceLastBackup = remember {
System.currentTimeMillis().milliseconds - SignalStore.backup.lastBackupTime.milliseconds
}
NoManualBackupSheetContent(
durationSinceLastBackup = durationSinceLastBackup,
onBackUpNowClick = {
BackupMessagesJob.enqueue()
startActivity(AppSettingsActivity.remoteBackups(requireActivity()))
dismissAllowingStateLoss()
},
onNotNowClick = this::dismissAllowingStateLoss
)
}
}
@Composable
private fun NoManualBackupSheetContent(
durationSinceLastBackup: Duration,
onBackUpNowClick: () -> Unit = {},
onNotNowClick: () -> Unit = {}
) {
val primaryActionLabel = stringResource(R.string.BackupAlertBottomSheet__back_up_now)
val primaryAction = remember { BackupAlertActionButtonState(primaryActionLabel, onBackUpNowClick) }
val secondaryActionLabel = stringResource(android.R.string.cancel)
val secondaryAction = remember { BackupAlertActionButtonState(secondaryActionLabel, onNotNowClick) }
val days: Int = durationSinceLastBackup.inWholeDays.toInt()
BackupAlertBottomSheetContainer(
icon = { BackupAlertIcon(iconColors = BackupsIconColors.Warning) },
title = pluralStringResource(R.plurals.NoManualBackupBottomSheet__no_backup_for_d_days, days, days),
primaryActionButtonState = primaryAction,
secondaryActionButtonState = secondaryAction
) {
BackupAlertText(
text = pluralStringResource(R.plurals.NoManualBackupBottomSheet__you_have_not_completed_a_backup, days, days),
modifier = Modifier.padding(bottom = 38.dp)
)
}
}
@SignalPreview
@Composable
private fun NoManualBackupSheetContentPreview() {
Previews.BottomSheetPreview {
NoManualBackupSheetContent(
durationSinceLastBackup = 30.days
)
}
}

View File

@@ -7,6 +7,7 @@ import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.RestoreState
import org.thoughtcrime.securesms.backup.v2.BackupFrequency
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.jobmanager.impl.NoRemoteArchiveGarbageCollectionPendingConstraint
import org.thoughtcrime.securesms.jobmanager.impl.RestoreAttachmentConstraintObserver
@@ -72,6 +73,7 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
private const val KEY_BACKUP_ALREADY_REDEEMED = "backup.already.redeemed"
private const val KEY_INVALID_BACKUP_VERSION = "backup.invalid.version"
private const val KEY_NOT_ENOUGH_REMOTE_STORAGE_SPACE = "backup.not.enough.remote.storage.space"
private const val KEY_MANUAL_NO_BACKUP_NOTIFIED = "backup.manual.no.backup.notified"
private const val KEY_USER_MANUALLY_SKIPPED_MEDIA_RESTORE = "backup.user.manually.skipped.media.restore"
private const val KEY_BACKUP_EXPIRED_AND_DOWNGRADED = "backup.expired.and.downgraded"
@@ -115,6 +117,8 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
get() = getLong(KEY_LAST_BACKUP_TIME, -1)
set(value) {
putLong(KEY_LAST_BACKUP_TIME, value)
isNoBackupForManualUploadNotified = false
BackupRepository.cancelManualBackupNotCreatedInThresholdNotification()
clearMessageBackupFailureSheetWatermark()
}
@@ -317,6 +321,8 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
var isNotEnoughRemoteStorageSpace by booleanValue(KEY_NOT_ENOUGH_REMOTE_STORAGE_SPACE, false)
var isNoBackupForManualUploadNotified by booleanValue(KEY_MANUAL_NO_BACKUP_NOTIFIED, false)
/**
* If true, it means we have been told that remote storage is full, but we have not yet run any of our "garbage collection" tasks, like committing deletes
* or pruning orphaned media.

View File

@@ -36,6 +36,7 @@ public final class NotificationIds {
public static final int NEW_LINKED_DEVICE = 120400;
public static final int OUT_OF_REMOTE_STORAGE = 120500;
public static final int INITIAL_BACKUP_FAILED = 120501;
public static final int MANUAL_BACKUP_NOT_CREATED = 120502;
private NotificationIds() { }