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.
+