From e70549563877fd65ddc755cca828f1b6c0da69b5 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 25 Jun 2025 14:05:37 -0300 Subject: [PATCH] Add 30 day reminder for manual backups. Co-authored-by: Michelle Tang --- .../securesms/backup/v2/BackupRepository.kt | 65 ++++ .../backup/v2/ui/BackupAlertBottomSheet.kt | 303 ++++++++---------- .../backup/v2/ui/BackupAlertDelegate.kt | 3 + .../v2/ui/BackupAlertSheetComponents.kt | 226 +++++++++++++ .../backup/v2/ui/NoManualBackupBottomSheet.kt | 82 +++++ .../securesms/keyvalue/BackupValues.kt | 6 + .../notifications/NotificationIds.java | 1 + app/src/main/res/values/strings.xml | 22 ++ 8 files changed, 532 insertions(+), 176 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertSheetComponents.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/NoManualBackupBottomSheet.kt 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 12ea088414..5e113f6fe7 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 @@ -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. */ 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 bf1c59509e..2aa2464c99 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 @@ -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) } } 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 f51b0c7a36..e068826a36 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 @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertSheetComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertSheetComponents.kt new file mode 100644 index 0000000000..c517e47534 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertSheetComponents.kt @@ -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 +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/NoManualBackupBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/NoManualBackupBottomSheet.kt new file mode 100644 index 0000000000..0a735b42c6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/NoManualBackupBottomSheet.kt @@ -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 + ) + } +} 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 f2c661b97e..3bca9202b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt @@ -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. diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java index fd61eae3d6..00fa84dcee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationIds.java @@ -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() { } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2f1e7898b1..9a878c9324 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8076,6 +8076,18 @@ OK + + + + No backup for %1$d day + No backup for %1$d days + + + + You haven\'t completed a backup for %1$d day. To ensure your messages and media are preserved, create a backup now. + You haven\'t completed a backup for %1$d days. To ensure your messages and media are preserved, create a backup now. + + %1$s/month, renews %2$s @@ -8617,6 +8629,16 @@ Backup failed An error occurred and your backup could not be completed. Tap for details. + + + No backup for %1$d day + No backup for %1$d days + + + + You haven’t completed a backup for %1$d day. Create a backup now. + You haven’t completed a backup for %1$d days. Create a backup now. +