diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/BackupStatusRow.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/BackupStatusRow.kt index 31a51a5aea..83a18344e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/BackupStatusRow.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/BackupStatusRow.kt @@ -20,6 +20,8 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsFragment.kt index cd7e4a75df..b1670bb1a3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsFragment.kt @@ -126,12 +126,16 @@ private fun BackupsSettingsContent( when (backupsSettingsState.enabledState) { BackupsSettingsState.EnabledState.Loading -> { LoadingBackupsRow() + + OtherWaysToBackUpHeading() } BackupsSettingsState.EnabledState.Inactive -> { InactiveBackupsRow( onBackupsRowClick = onBackupsRowClick ) + + OtherWaysToBackUpHeading() } is BackupsSettingsState.EnabledState.Active -> { @@ -139,29 +143,28 @@ private fun BackupsSettingsContent( enabledState = backupsSettingsState.enabledState, onBackupsRowClick = onBackupsRowClick ) + + OtherWaysToBackUpHeading() } BackupsSettingsState.EnabledState.Never -> { NeverEnabledBackupsRow( onBackupsRowClick = onBackupsRowClick ) + + OtherWaysToBackUpHeading() } BackupsSettingsState.EnabledState.Failed -> { - Text(text = "TODO") + WaitingForNetworkRow() + OtherWaysToBackUpHeading() } + + BackupsSettingsState.EnabledState.NotAvailable -> Unit } } item { - Dividers.Default() - } - - item { - Texts.SectionHeader( - text = stringResource(R.string.RemoteBackupsSettingsFragment__other_ways_to_backup) - ) - Rows.TextRow( text = stringResource(R.string.RemoteBackupsSettingsFragment__on_device_backups), label = stringResource(R.string.RemoteBackupsSettingsFragment__save_your_backups_to), @@ -172,6 +175,15 @@ private fun BackupsSettingsContent( } } +@Composable +private fun OtherWaysToBackUpHeading() { + Dividers.Default() + + Texts.SectionHeader( + text = stringResource(R.string.RemoteBackupsSettingsFragment__other_ways_to_backup) + ) +} + @Composable private fun NeverEnabledBackupsRow( onBackupsRowClick: () -> Unit = {} @@ -215,6 +227,18 @@ private fun NeverEnabledBackupsRow( ) } +@Composable +private fun WaitingForNetworkRow() { + Rows.TextRow( + text = { + Text(text = stringResource(R.string.RemoteBackupsSettingsFragment__waiting_for_network)) + }, + icon = { + CircularProgressIndicator() + } + ) +} + @Composable private fun InactiveBackupsRow( onBackupsRowClick: () -> Unit = {} @@ -327,6 +351,26 @@ private fun BackupsSettingsContentPreview() { } } +@SignalPreview +@Composable +private fun BackupsSettingsContentNotAvailablePreview() { + Previews.Preview { + BackupsSettingsContent( + backupsSettingsState = BackupsSettingsState( + enabledState = BackupsSettingsState.EnabledState.NotAvailable + ) + ) + } +} + +@SignalPreview +@Composable +private fun WaitingForNetworkRowPreview() { + Previews.Preview { + WaitingForNetworkRow() + } +} + @SignalPreview @Composable private fun InactiveBackupsRowPreview() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsState.kt index 5834dfcb2f..d5fdfd281a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsState.kt @@ -23,6 +23,11 @@ data class BackupsSettingsState( */ data object Loading : EnabledState + /** + * Google Play Billing is not available on this device + */ + data object NotAvailable : EnabledState + /** * Backups have never been enabled. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsViewModel.kt index 39ba361645..ecde2a4fb2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsViewModel.kt @@ -23,8 +23,10 @@ import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord +import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.util.InternetConnectionObserver +import org.thoughtcrime.securesms.util.RemoteConfig import java.util.Currency import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -58,6 +60,11 @@ class BackupsSettingsViewModel : ViewModel() { private fun loadEnabledState() { viewModelScope.launch(Dispatchers.IO) { + if (!RemoteConfig.messageBackups || !AppDependencies.billingApi.isApiAvailable()) { + internalStateFlow.update { it.copy(enabledState = BackupsSettingsState.EnabledState.NotAvailable) } + return@launch + } + val enabledState = when (SignalStore.backup.backupTier) { MessageBackupTier.FREE -> getEnabledStateForFreeTier() MessageBackupTier.PAID -> getEnabledStateForPaidTier() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupRestoreState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupRestoreState.kt index 531b992e84..d6c1acbee0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupRestoreState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/BackupRestoreState.kt @@ -10,7 +10,12 @@ import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatusData /** * State container for BackupStatusData, including the enabled state. */ -data class BackupRestoreState( - val enabled: Boolean, - val backupStatusData: BackupStatusData -) +sealed interface BackupRestoreState { + data object None : BackupRestoreState + data class Ready( + val bytes: String + ) : BackupRestoreState + data class FromBackupStatusData( + val backupStatusData: BackupStatusData + ) : BackupRestoreState +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt index ba2c3f4ff0..8de6a67b54 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt @@ -58,7 +58,9 @@ import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.navigation.fragment.findNavController @@ -99,6 +101,7 @@ import org.thoughtcrime.securesms.util.viewModel import java.math.BigDecimal import java.util.Currency import java.util.Locale +import kotlin.time.Duration import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -199,12 +202,20 @@ class RemoteBackupsSettingsFragment : ComposeFragment() { } } + override fun onStartMediaRestore() { + // TODO - [backups] Begin media restore. + } + override fun onCancelMediaRestore() { - // TODO - [backups] Cancel media restoration + // TODO - [backups] Cancel in-progress media restoration + } + + override fun onDisplaySkipMediaRestoreProtectionDialog() { + viewModel.requestDialog(RemoteBackupsSettingsState.Dialog.SKIP_MEDIA_RESTORE_PROTECTION) } override fun onSkipMediaRestore() { - // TODO - [backups] Skip media restoration + // TODO - [backups] Skip disk-full media restoration } override fun onLearnMoreAboutLostSubscription() { @@ -292,6 +303,8 @@ private interface ContentCallbacks { fun onSelectBackupsFrequencyChange(newFrequency: BackupFrequency) = Unit fun onTurnOffAndDeleteBackupsConfirm() = Unit fun onViewBackupKeyClick() = Unit + fun onStartMediaRestore() = Unit + fun onDisplaySkipMediaRestoreProtectionDialog() = Unit fun onSkipMediaRestore() = Unit fun onCancelMediaRestore() = Unit fun onRenewLostSubscription() = Unit @@ -365,6 +378,30 @@ private fun RemoteBackupsSettingsContent( } if (backupsEnabled) { + if (backupRestoreState !is BackupRestoreState.None) { + item { + Dividers.Default() + } + + if (backupRestoreState is BackupRestoreState.FromBackupStatusData) { + item { + BackupStatusRow( + backupStatusData = backupRestoreState.backupStatusData, + onCancelClick = contentCallbacks::onCancelMediaRestore, + onSkipClick = contentCallbacks::onSkipMediaRestore + ) + } + } else if (backupRestoreState is BackupRestoreState.Ready && backupState is RemoteBackupsSettingsState.BackupState.Canceled) { + item { + BackupReadyToDownloadRow( + ready = backupRestoreState, + endOfSubscription = backupState.renewalTime, + onDownloadClick = contentCallbacks::onStartMediaRestore + ) + } + } + } + appendBackupDetailsItems( backupProgress = backupProgress, lastBackupTimestamp = lastBackupTimestamp, @@ -374,7 +411,7 @@ private fun RemoteBackupsSettingsContent( contentCallbacks = contentCallbacks ) } else { - if (backupRestoreState.enabled) { + if (backupRestoreState is BackupRestoreState.FromBackupStatusData) { item { BackupStatusRow( backupStatusData = backupRestoreState.backupStatusData, @@ -443,6 +480,18 @@ private fun RemoteBackupsSettingsContent( onContactSupport = contentCallbacks::onContactSupport ) } + + RemoteBackupsSettingsState.Dialog.SKIP_MEDIA_RESTORE_PROTECTION -> { + SkipDownloadDialog( + renewalTime = if (backupState is RemoteBackupsSettingsState.BackupState.WithTypeAndRenewalTime) { + backupState.renewalTime + } else { + error("Unexpected dialog display without renewal time.") + }, + onDismiss = contentCallbacks::onDialogDismissed, + onSkipClick = contentCallbacks::onSkipMediaRestore + ) + } } val snackbarMessageId = remember(requestedSnackbar) { @@ -931,8 +980,8 @@ private fun FailedToTurnOffBackupDialog( onDismiss: () -> Unit ) { Dialogs.SimpleAlertDialog( - title = "TODO", - body = "TODO", + title = stringResource(R.string.RemoteBackupsSettingsFragment__couldnt_turn_off_and_delete_backups), + body = stringResource(R.string.RemoteBackupsSettingsFragment__a_network_error_occurred), confirm = stringResource(id = android.R.string.ok), onConfirm = {}, onDismiss = onDismiss @@ -968,6 +1017,25 @@ private fun DownloadingYourBackupDialog( ) } +@Composable +private fun SkipDownloadDialog( + renewalTime: Duration, + onSkipClick: () -> Unit = {}, + onDismiss: () -> Unit = {} +) { + val days = (renewalTime - System.currentTimeMillis().milliseconds).inWholeDays.toInt() + + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.RemoteBackupsSettingsFragment__skip_download_question), + body = pluralStringResource(R.plurals.RemoteBackupsSettingsFragment__if_you_skip_downloading, days, days), + confirm = stringResource(R.string.RemoteBackupsSettingsFragment__skip), + dismiss = stringResource(android.R.string.cancel), + confirmColor = MaterialTheme.colorScheme.error, + onConfirm = onSkipClick, + onDismiss = onDismiss + ) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun CircularProgressDialog( @@ -1055,6 +1123,38 @@ private fun BackupFrequencyDialog( } } +@Composable +private fun BackupReadyToDownloadRow( + ready: BackupRestoreState.Ready, + endOfSubscription: Duration, + onDownloadClick: () -> Unit = {} +) { + val days = (endOfSubscription - System.currentTimeMillis().milliseconds).inWholeDays.toInt() + val string = pluralStringResource(R.plurals.RemoteBackupsSettingsFragment__you_have_s_of_backup_data, days, ready.bytes, days) + val annotated = buildAnnotatedString { + append(string) + val startIndex = string.indexOf(ready.bytes) + val endIndex = startIndex + ready.bytes.length + + addStyle(SpanStyle(fontWeight = FontWeight.Bold), startIndex, endIndex) + } + + Column { + Text( + text = annotated, + modifier = Modifier + .horizontalGutters() + .padding(vertical = 8.dp) + ) + + Rows.TextRow( + text = stringResource(R.string.RemoteBackupsSettingsFragment__download), + icon = painterResource(R.drawable.symbol_arrow_circle_down_24), + onClick = onDownloadClick + ) + } +} + @Composable private fun getTextForFrequency(backupsFrequency: BackupFrequency): String { return when (backupsFrequency) { @@ -1082,7 +1182,7 @@ private fun RemoteBackupsSettingsContentPreview() { backupState = RemoteBackupsSettingsState.BackupState.ActiveFree( messageBackupsType = MessageBackupsType.Free(mediaRetentionDays = 30) ), - backupRestoreState = BackupRestoreState(false, BackupStatusData.CouldNotCompleteBackup) + backupRestoreState = BackupRestoreState.FromBackupStatusData(BackupStatusData.CouldNotCompleteBackup) ) } } @@ -1187,6 +1287,17 @@ private fun BackupCardPreview() { } } +@SignalPreview +@Composable +private fun BackupReadyToDownloadPreview() { + Previews.Preview { + BackupReadyToDownloadRow( + ready = BackupRestoreState.Ready("12GB"), + endOfSubscription = System.currentTimeMillis().milliseconds + 30.days + ) + } +} + @SignalPreview @Composable private fun LastBackupRowPreview() { @@ -1237,6 +1348,16 @@ private fun DownloadingYourBackupDialogPreview() { } } +@SignalPreview +@Composable +private fun SkipDownloadDialogPreview() { + Previews.Preview { + SkipDownloadDialog( + renewalTime = System.currentTimeMillis().milliseconds + 30.days + ) + } +} + @SignalPreview @Composable private fun CircularProgressDialogPreview() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt index a208d19d09..8cec27df51 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt @@ -112,7 +112,8 @@ data class RemoteBackupsSettingsState( PROGRESS_SPINNER, DOWNLOADING_YOUR_BACKUP, TURN_OFF_FAILED, - SUBSCRIPTION_NOT_FOUND + SUBSCRIPTION_NOT_FOUND, + SKIP_MEDIA_RESTORE_PROTECTION } enum class Snackbar { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt index 33308294be..f21232e0dc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt @@ -21,13 +21,13 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.reactive.asFlow import kotlinx.coroutines.withContext import org.signal.core.util.billing.BillingPurchaseResult +import org.signal.core.util.bytes import org.signal.core.util.logging.Log import org.signal.core.util.money.FiatMoney import org.signal.donations.InAppPaymentType 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.backup.v2.ui.status.BackupStatusData import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType import org.thoughtcrime.securesms.banner.banners.MediaRestoreProgressBanner import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney @@ -64,7 +64,7 @@ class RemoteBackupsSettingsViewModel : ViewModel() { ) ) - private val _restoreState: MutableStateFlow = MutableStateFlow(BackupRestoreState(false, BackupStatusData.RestoringMedia())) + private val _restoreState: MutableStateFlow = MutableStateFlow(BackupRestoreState.None) private val latestPurchaseId = MutableSharedFlow() val state: StateFlow = _state @@ -86,8 +86,12 @@ class RemoteBackupsSettingsViewModel : ViewModel() { if (restoreProgress.enabled) { Log.d(TAG, "Backup is being restored. Collecting updates.") restoreProgress.dataFlow.collectLatest { latest -> - _restoreState.update { BackupRestoreState(restoreProgress.enabled, latest) } + _restoreState.update { BackupRestoreState.FromBackupStatusData(latest) } } + } else if (SignalStore.backup.totalRestorableAttachmentSize > 0L) { + _restoreState.update { BackupRestoreState.Ready(SignalStore.backup.totalRestorableAttachmentSize.bytes.toUnitString()) } + } else { + _restoreState.update { BackupRestoreState.None } } delay(1.seconds) @@ -218,10 +222,12 @@ class RemoteBackupsSettingsViewModel : ViewModel() { price = FiatMoney.fromSignalNetworkAmount(subscription.amount, Currency.getInstance(subscription.currency)), renewalTime = subscription.endOfCurrentPeriod.seconds ) + subscription.isCanceled -> RemoteBackupsSettingsState.BackupState.Canceled( messageBackupsType = type, renewalTime = subscription.endOfCurrentPeriod.seconds ) + else -> RemoteBackupsSettingsState.BackupState.Inactive( messageBackupsType = type, renewalTime = subscription.endOfCurrentPeriod.seconds diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 533402a563..80eb18b3a1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7694,6 +7694,26 @@ Renew Learn more + + + You have %1$s of backup data that’s not on this device. Your backup will be deleted when your subscription ends in %2$d day. + You have %1$s of backup data that’s not on this device. Your backup will be deleted when your subscription ends in %2$d days. + + + Download + + Skip download? + + + If you skip downloading the remaining media and attachments in your backup will be deleted in %1$d day. + If you skip downloading the remaining media and attachments in your backup will be deleted in %1$d days. + + + Skip + + Couldn\'t turn off and delete backups + + A network error occurred. Please check your internet connection and try again.