From bc527a2bc1c9d5fef3e60207266237af2b17c95e Mon Sep 17 00:00:00 2001 From: Clark Date: Fri, 3 May 2024 12:20:09 -0400 Subject: [PATCH] Basic settings functionality for message backup. --- .../securesms/backup/v2/BackupRepository.kt | 5 + .../securesms/backup/v2/BackupV2Event.kt | 12 ++ .../MessageBackupsCheckoutSheet.kt | 45 ++--- .../MessageBackupsFlowActivity.kt | 12 +- .../subscription/MessageBackupsFlowState.kt | 5 +- .../MessageBackupsFlowViewModel.kt | 33 +++- .../MessageBackupsPinConfirmationScreen.kt | 152 +++++++------- .../MessageBackupsPinEducationScreen.kt | 8 +- .../MessageBackupsTypeSelectionScreen.kt | 114 ++++++----- .../app/chats/ChatsSettingsViewModel.kt | 8 +- .../backups/RemoteBackupsSettingsFragment.kt | 187 ++++++++++++------ .../backups/RemoteBackupsSettingsState.kt | 8 +- .../backups/RemoteBackupsSettingsViewModel.kt | 30 ++- .../type/BackupsTypeSettingsFragment.kt | 2 + .../jobs/ArchiveAttachmentBackfillJob.kt | 49 +++-- .../securesms/jobs/BackupMessagesJob.kt | 37 ++-- .../securesms/keyvalue/BackupValues.kt | 2 + app/src/main/protowire/JobData.proto | 2 + 18 files changed, 443 insertions(+), 268 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupV2Event.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 834f432f43..370c47c38c 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 @@ -650,3 +650,8 @@ class BackupMetadata( val usedSpace: Long, val mediaCount: Long ) + +enum class MessageBackupTier { + FREE, + PAID +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupV2Event.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupV2Event.kt new file mode 100644 index 0000000000..67b3e3745f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupV2Event.kt @@ -0,0 +1,12 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2 + +class BackupV2Event(val type: Type, val count: Long, val estimatedTotalCount: Long) { + enum class Type { + PROGRESS_MESSAGES, PROGRESS_ATTACHMENTS, FINISHED + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsCheckoutSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsCheckoutSheet.kt index 9937537671..c84ef99fd3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsCheckoutSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsCheckoutSheet.kt @@ -30,23 +30,20 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.view.updateLayoutParams -import kotlinx.collections.immutable.persistentListOf import org.signal.core.ui.BottomSheets import org.signal.core.ui.Buttons import org.signal.core.ui.Previews -import org.signal.core.util.money.FiatMoney import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton import org.thoughtcrime.securesms.databinding.PaypalButtonBinding import org.thoughtcrime.securesms.payments.FiatMoneyUtil -import java.math.BigDecimal -import java.util.Currency @OptIn(ExperimentalMaterial3Api::class) @Composable fun MessageBackupsCheckoutSheet( - messageBackupsType: MessageBackupsType, + messageBackupTier: MessageBackupTier, availablePaymentGateways: List, onDismissRequest: () -> Unit, onPaymentGatewaySelected: (GatewayResponse.Gateway) -> Unit @@ -57,7 +54,7 @@ fun MessageBackupsCheckoutSheet( modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter)) ) { SheetContent( - messageBackupsType = messageBackupsType, + messageBackupTier = messageBackupTier, availablePaymentGateways = availablePaymentGateways, onPaymentGatewaySelected = onPaymentGatewaySelected ) @@ -66,13 +63,16 @@ fun MessageBackupsCheckoutSheet( @Composable private fun SheetContent( - messageBackupsType: MessageBackupsType, + messageBackupTier: MessageBackupTier, availablePaymentGateways: List, onPaymentGatewaySelected: (GatewayResponse.Gateway) -> Unit ) { val resources = LocalContext.current.resources - val formattedPrice = remember(messageBackupsType.pricePerMonth) { - FiatMoneyUtil.format(resources, messageBackupsType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal()) + val backupTypeDetails = remember(messageBackupTier) { + getTierDetails(messageBackupTier) + } + val formattedPrice = remember(backupTypeDetails.pricePerMonth) { + FiatMoneyUtil.format(resources, backupTypeDetails.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal()) } Text( @@ -88,7 +88,7 @@ private fun SheetContent( ) MessageBackupsTypeBlock( - messageBackupsType = messageBackupsType, + messageBackupsType = backupTypeDetails, isSelected = false, onSelected = {}, enabled = false, @@ -221,29 +221,6 @@ private fun CreditOrDebitCardButton( @Preview @Composable private fun MessageBackupsCheckoutSheetPreview() { - val paidTier = MessageBackupsType( - pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("USD")), - title = "Text + All your media", - features = persistentListOf( - MessageBackupsTypeFeature( - iconResourceId = R.drawable.symbol_thread_compact_bold_16, - label = "Full text message backup" - ), - MessageBackupsTypeFeature( - iconResourceId = R.drawable.symbol_album_compact_bold_16, - label = "Full media backup" - ), - MessageBackupsTypeFeature( - iconResourceId = R.drawable.symbol_thread_compact_bold_16, - label = "1TB of storage (~250K photos)" - ), - MessageBackupsTypeFeature( - iconResourceId = R.drawable.symbol_heart_compact_bold_16, - label = "Thanks for supporting Signal!" - ) - ) - ) - val availablePaymentGateways = GatewayResponse.Gateway.values().toList() Previews.Preview { @@ -252,7 +229,7 @@ private fun MessageBackupsCheckoutSheetPreview() { modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter)) ) { SheetContent( - messageBackupsType = paidTier, + messageBackupTier = MessageBackupTier.PAID, availablePaymentGateways = availablePaymentGateways, onPaymentGatewaySelected = {} ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowActivity.kt index e787c48b04..9f334eb919 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowActivity.kt @@ -32,6 +32,10 @@ class MessageBackupsFlowActivity : PassphraseRequiredActivity() { fun MessageBackupsScreen.next() { val nextScreen = viewModel.goToNextScreen(this) + if (nextScreen == MessageBackupsScreen.COMPLETED) { + finishAfterTransition() + return + } if (nextScreen != this) { navController.navigate(nextScreen.name) } @@ -88,9 +92,9 @@ class MessageBackupsFlowActivity : PassphraseRequiredActivity() { composable(route = MessageBackupsScreen.TYPE_SELECTION.name) { MessageBackupsTypeSelectionScreen( - selectedBackupsType = state.selectedMessageBackupsType, - availableBackupsTypes = state.availableBackupsTypes, - onMessageBackupsTypeSelected = viewModel::onMessageBackupsTypeUpdated, + selectedBackupTier = state.selectedMessageBackupTier, + availableBackupTiers = state.availableBackupTiers, + onMessageBackupsTierSelected = viewModel::onMessageBackupTierUpdated, onNavigationClick = navController::popOrFinish, onReadMoreClicked = {}, onNextClicked = { MessageBackupsScreen.TYPE_SELECTION.next() } @@ -99,7 +103,7 @@ class MessageBackupsFlowActivity : PassphraseRequiredActivity() { dialog(route = MessageBackupsScreen.CHECKOUT_SHEET.name) { MessageBackupsCheckoutSheet( - messageBackupsType = state.selectedMessageBackupsType!!, + messageBackupTier = state.selectedMessageBackupTier!!, availablePaymentGateways = state.availablePaymentGateways, onDismissRequest = navController::popOrFinish, onPaymentGatewaySelected = { diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt index 738c5c4e63..2065ad380d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt @@ -5,13 +5,14 @@ package org.thoughtcrime.securesms.backup.v2.ui.subscription +import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.lock.v2.PinKeyboardType data class MessageBackupsFlowState( - val selectedMessageBackupsType: MessageBackupsType? = null, - val availableBackupsTypes: List = emptyList(), + val selectedMessageBackupTier: MessageBackupTier? = null, + val availableBackupTiers: List = emptyList(), val selectedPaymentGateway: GatewayResponse.Gateway? = null, val availablePaymentGateways: List = emptyList(), val pin: String = "", diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt index 222359eb8a..dc085c6c59 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt @@ -5,14 +5,28 @@ package org.thoughtcrime.securesms.backup.v2.ui.subscription +import android.text.TextUtils import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel +import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.lock.v2.PinKeyboardType +import org.thoughtcrime.securesms.lock.v2.SvrConstants +import org.thoughtcrime.securesms.util.FeatureFlags +import org.whispersystems.signalservice.api.kbs.PinHashUtil.verifyLocalPinHash class MessageBackupsFlowViewModel : ViewModel() { - private val internalState = mutableStateOf(MessageBackupsFlowState()) + private val internalState = mutableStateOf( + MessageBackupsFlowState( + availableBackupTiers = if (!FeatureFlags.messageBackups()) { + emptyList() + } else { + listOf(MessageBackupTier.FREE, MessageBackupTier.PAID) + } + ) + ) val state: State = internalState @@ -40,16 +54,27 @@ class MessageBackupsFlowViewModel : ViewModel() { internalState.value = state.value.copy(selectedPaymentGateway = gateway) } - fun onMessageBackupsTypeUpdated(messageBackupsType: MessageBackupsType) { - internalState.value = state.value.copy(selectedMessageBackupsType = messageBackupsType) + fun onMessageBackupTierUpdated(messageBackupTier: MessageBackupTier) { + internalState.value = state.value.copy(selectedMessageBackupTier = messageBackupTier) } private fun validatePinAndUpdateState(): MessageBackupsScreen { + val pinHash = SignalStore.svr().localPinHash + val pin = state.value.pin + + if (pinHash == null || TextUtils.isEmpty(pin) || pin.length < SvrConstants.MINIMUM_PIN_LENGTH) return MessageBackupsScreen.PIN_CONFIRMATION + + if (!verifyLocalPinHash(pinHash, pin)) { + return MessageBackupsScreen.PIN_CONFIRMATION + } return MessageBackupsScreen.TYPE_SELECTION } private fun validateTypeAndUpdateState(): MessageBackupsScreen { - return MessageBackupsScreen.CHECKOUT_SHEET + SignalStore.backup().canReadWriteToArchiveCdn = state.value.selectedMessageBackupTier == MessageBackupTier.PAID + SignalStore.backup().areBackupsEnabled = true + return MessageBackupsScreen.COMPLETED + // return MessageBackupsScreen.CHECKOUT_SHEET TODO [message-backups] Switch back to payment flow } private fun validateGatewayAndUpdateState(): MessageBackupsScreen { diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsPinConfirmationScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsPinConfirmationScreen.kt index 7468a53880..f15719a82e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsPinConfirmationScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsPinConfirmationScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField @@ -32,6 +33,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -52,93 +54,95 @@ fun MessageBackupsPinConfirmationScreen( onNextClick: () -> Unit ) { val focusRequester = remember { FocusRequester() } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter)) - ) { - LazyColumn( + Surface { + Column( modifier = Modifier - .fillMaxWidth() - .weight(1f) + .fillMaxSize() + .padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter)) ) { - item { - Text( - text = "Enter your PIN", // TODO [message-backups] Finalized copy - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.padding(top = 40.dp) - ) - } - - item { - Text( - text = "Enter your Signal PIN to enable backups", // TODO [message-backups] Finalized copy - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 16.dp) - ) - } - - item { - // TODO [message-backups] Confirm default focus state - val keyboardType = remember(pinKeyboardType) { - when (pinKeyboardType) { - PinKeyboardType.NUMERIC -> KeyboardType.NumberPassword - PinKeyboardType.ALPHA_NUMERIC -> KeyboardType.Password - } + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + item { + Text( + text = "Enter your PIN", // TODO [message-backups] Finalized copy + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(top = 40.dp) + ) } - TextField( - value = pin, - onValueChange = onPinChanged, - textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), - keyboardActions = KeyboardActions( - onDone = { onNextClick() } - ), - keyboardOptions = KeyboardOptions( - keyboardType = keyboardType - ), - modifier = Modifier - .padding(top = 72.dp) - .fillMaxWidth() - .focusRequester(focusRequester) - ) + item { + Text( + text = "Enter your Signal PIN to enable backups", // TODO [message-backups] Finalized copy + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 16.dp) + ) + } + + item { + // TODO [message-backups] Confirm default focus state + val keyboardType = remember(pinKeyboardType) { + when (pinKeyboardType) { + PinKeyboardType.NUMERIC -> KeyboardType.NumberPassword + PinKeyboardType.ALPHA_NUMERIC -> KeyboardType.Password + } + } + + TextField( + value = pin, + onValueChange = onPinChanged, + textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), + keyboardActions = KeyboardActions( + onDone = { onNextClick() } + ), + keyboardOptions = KeyboardOptions( + keyboardType = keyboardType + ), + modifier = Modifier + .padding(top = 72.dp) + .fillMaxWidth() + .focusRequester(focusRequester), + visualTransformation = PasswordVisualTransformation() + ) + } + + item { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .padding(top = 48.dp) + ) { + PinKeyboardTypeToggle( + pinKeyboardType = pinKeyboardType, + onPinKeyboardTypeSelected = onPinKeyboardTypeSelected + ) + } + } } - item { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxWidth() - .padding(top = 48.dp) + Box( + contentAlignment = Alignment.BottomEnd, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) { + Buttons.LargeTonal( + onClick = onNextClick ) { - PinKeyboardTypeToggle( - pinKeyboardType = pinKeyboardType, - onPinKeyboardTypeSelected = onPinKeyboardTypeSelected + Text( + text = "Next" // TODO [message-backups] Finalized copy ) } } - } - Box( - contentAlignment = Alignment.BottomEnd, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp) - ) { - Buttons.LargeTonal( - onClick = onNextClick - ) { - Text( - text = "Next" // TODO [message-backups] Finalized copy - ) + LaunchedEffect(Unit) { + focusRequester.requestFocus() } } - - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsPinEducationScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsPinEducationScreen.kt index b5cb5a4636..dd6ec9d6ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsPinEducationScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsPinEducationScreen.kt @@ -96,22 +96,22 @@ fun MessageBackupsPinEducationScreen( } Buttons.LargePrimary( - onClick = onGeneratePinClick, + onClick = onUseCurrentPinClick, modifier = Modifier.fillMaxWidth() ) { Text( - text = "Generate a new $recommendedPinSize-digit PIN" // TODO [message-backups] Finalized copy + text = "Use current Signal PIN" // TODO [message-backups] Finalized copy ) } TextButton( - onClick = onUseCurrentPinClick, + onClick = onGeneratePinClick, modifier = Modifier .fillMaxWidth() .padding(bottom = 16.dp) ) { Text( - text = "Use current Signal PIN" // TODO [message-backups] Finalized copy + text = "Generate a new $recommendedPinSize-digit PIN" // TODO [message-backups] Finalized copy ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt index 4d92d152bc..ecbc2a4463 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt @@ -39,16 +39,17 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withAnnotation import androidx.compose.ui.text.withStyle -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import org.signal.core.ui.Buttons import org.signal.core.ui.Previews import org.signal.core.ui.Scaffolds +import org.signal.core.ui.SignalPreview import org.signal.core.ui.theme.SignalTheme import org.signal.core.util.money.FiatMoney import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.payments.FiatMoneyUtil import java.math.BigDecimal import java.util.Currency @@ -59,9 +60,9 @@ import java.util.Currency @OptIn(ExperimentalTextApi::class) @Composable fun MessageBackupsTypeSelectionScreen( - selectedBackupsType: MessageBackupsType?, - availableBackupsTypes: List, - onMessageBackupsTypeSelected: (MessageBackupsType) -> Unit, + selectedBackupTier: MessageBackupTier?, + availableBackupTiers: List, + onMessageBackupsTierSelected: (MessageBackupTier) -> Unit, onNavigationClick: () -> Unit, onReadMoreClicked: () -> Unit, onNextClicked: () -> Unit @@ -128,13 +129,16 @@ fun MessageBackupsTypeSelectionScreen( } itemsIndexed( - availableBackupsTypes, - { _, item -> item.title } + availableBackupTiers, + { _, item -> item } ) { index, item -> + val type = remember(item) { + getTierDetails(item) + } MessageBackupsTypeBlock( - messageBackupsType = item, - isSelected = item == selectedBackupsType, - onSelected = { onMessageBackupsTypeSelected(item) }, + messageBackupsType = type, + isSelected = item == selectedBackupTier, + onSelected = { onMessageBackupsTierSelected(item) }, modifier = Modifier.padding(top = if (index == 0) 20.dp else 18.dp) ) } @@ -154,54 +158,16 @@ fun MessageBackupsTypeSelectionScreen( } } -@Preview +@SignalPreview @Composable private fun MessageBackupsTypeSelectionScreenPreview() { - val freeTier = MessageBackupsType( - pricePerMonth = FiatMoney(BigDecimal.ZERO, Currency.getInstance("USD")), - title = "Text + 30 days of media", - features = persistentListOf( - MessageBackupsTypeFeature( - iconResourceId = R.drawable.symbol_thread_compact_bold_16, - label = "Full text message backup" - ), - MessageBackupsTypeFeature( - iconResourceId = R.drawable.symbol_album_compact_bold_16, - label = "Last 30 days of media" - ) - ) - ) - - val paidTier = MessageBackupsType( - pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("USD")), - title = "Text + All your media", - features = persistentListOf( - MessageBackupsTypeFeature( - iconResourceId = R.drawable.symbol_thread_compact_bold_16, - label = "Full text message backup" - ), - MessageBackupsTypeFeature( - iconResourceId = R.drawable.symbol_album_compact_bold_16, - label = "Full media backup" - ), - MessageBackupsTypeFeature( - iconResourceId = R.drawable.symbol_thread_compact_bold_16, - label = "1TB of storage (~250K photos)" - ), - MessageBackupsTypeFeature( - iconResourceId = R.drawable.symbol_heart_compact_bold_16, - label = "Thanks for supporting Signal!" - ) - ) - ) - - var selectedBackupsType by remember { mutableStateOf(freeTier) } + var selectedBackupsType by remember { mutableStateOf(MessageBackupTier.FREE) } Previews.Preview { MessageBackupsTypeSelectionScreen( - selectedBackupsType = selectedBackupsType, - availableBackupsTypes = listOf(freeTier, paidTier), - onMessageBackupsTypeSelected = { selectedBackupsType = it }, + selectedBackupTier = MessageBackupTier.FREE, + availableBackupTiers = listOf(MessageBackupTier.FREE, MessageBackupTier.PAID), + onMessageBackupsTierSelected = { selectedBackupsType = it }, onNavigationClick = {}, onReadMoreClicked = {}, onNextClicked = {} @@ -272,7 +238,51 @@ private fun formatCostPerMonth(pricePerMonth: FiatMoney): String { @Stable data class MessageBackupsType( + val tier: MessageBackupTier, val pricePerMonth: FiatMoney, val title: String, val features: ImmutableList ) + +fun getTierDetails(tier: MessageBackupTier): MessageBackupsType { + return when (tier) { + MessageBackupTier.FREE -> MessageBackupsType( + tier = MessageBackupTier.FREE, + pricePerMonth = FiatMoney(BigDecimal.ZERO, Currency.getInstance("USD")), + title = "Text + 30 days of media", + features = persistentListOf( + MessageBackupsTypeFeature( + iconResourceId = R.drawable.symbol_thread_compact_bold_16, + label = "Full text message backup" + ), + MessageBackupsTypeFeature( + iconResourceId = R.drawable.symbol_album_compact_bold_16, + label = "Last 30 days of media" + ) + ) + ) + MessageBackupTier.PAID -> MessageBackupsType( + tier = MessageBackupTier.PAID, + pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("USD")), + title = "Text + All your media", + features = persistentListOf( + MessageBackupsTypeFeature( + iconResourceId = R.drawable.symbol_thread_compact_bold_16, + label = "Full text message backup" + ), + MessageBackupsTypeFeature( + iconResourceId = R.drawable.symbol_album_compact_bold_16, + label = "Full media backup" + ), + MessageBackupsTypeFeature( + iconResourceId = R.drawable.symbol_thread_compact_bold_16, + label = "1TB of storage (~250K photos)" + ), + MessageBackupsTypeFeature( + iconResourceId = R.drawable.symbol_heart_compact_bold_16, + label = "Thanks for supporting Signal!" + ) + ) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsViewModel.kt index 87ea0a647f..2411ce0db2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsViewModel.kt @@ -60,8 +60,12 @@ class ChatsSettingsViewModel @JvmOverloads constructor( fun refresh() { val backupsEnabled = SignalStore.settings().isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(ApplicationDependencies.getApplication()) - if (store.state.localBackupsEnabled != backupsEnabled) { - store.update { it.copy(localBackupsEnabled = backupsEnabled) } + val remoteBackupsEnabled = SignalStore.backup().areBackupsEnabled + + if (store.state.localBackupsEnabled != backupsEnabled || + store.state.remoteBackupsEnabled != remoteBackupsEnabled + ) { + store.update { it.copy(localBackupsEnabled = backupsEnabled, remoteBackupsEnabled = remoteBackupsEnabled) } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/RemoteBackupsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/RemoteBackupsSettingsFragment.kt index d1c0cd378c..6ea10b550c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/RemoteBackupsSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/RemoteBackupsSettingsFragment.kt @@ -6,6 +6,8 @@ package org.thoughtcrime.securesms.components.settings.app.chats.backups import android.content.Intent +import android.os.Bundle +import android.view.View import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -17,8 +19,10 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -34,7 +38,9 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.fragment.findNavController -import kotlinx.collections.immutable.persistentListOf +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode import org.signal.core.ui.Buttons import org.signal.core.ui.Dialogs import org.signal.core.ui.Dividers @@ -44,18 +50,18 @@ import org.signal.core.ui.Scaffolds import org.signal.core.ui.SignalPreview import org.signal.core.ui.Snackbars import org.signal.core.ui.Texts -import org.signal.core.util.money.FiatMoney import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.backup.v2.BackupV2Event +import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsFlowActivity import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsFrequency -import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType +import org.thoughtcrime.securesms.backup.v2.ui.subscription.getTierDetails import org.thoughtcrime.securesms.compose.ComposeFragment +import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle import org.thoughtcrime.securesms.payments.FiatMoneyUtil import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.navigation.safeNavigate import org.thoughtcrime.securesms.util.viewModel -import java.math.BigDecimal -import java.util.Currency import java.util.Locale /** @@ -75,13 +81,14 @@ class RemoteBackupsSettingsFragment : ComposeFragment() { val callbacks = remember { Callbacks() } RemoteBackupsSettingsContent( - messageBackupsType = state.messageBackupsType, + messageBackupTier = state.messageBackupsTier, lastBackupTimestamp = state.lastBackupTimestamp, canBackUpUsingCellular = state.canBackUpUsingCellular, backupsFrequency = state.backupsFrequency, requestedDialog = state.dialog, requestedSnackbar = state.snackbar, - contentCallbacks = callbacks + contentCallbacks = callbacks, + backupProgress = state.backupProgress ) } @@ -104,7 +111,7 @@ class RemoteBackupsSettingsFragment : ComposeFragment() { } override fun onBackupNowClick() { - // TODO [message-backups] Enqueue immediate backup + viewModel.onBackupNowClick() } override fun onTurnOffAndDeleteBackupsClick() { @@ -135,6 +142,16 @@ class RemoteBackupsSettingsFragment : ComposeFragment() { findNavController().safeNavigate(R.id.action_remoteBackupsSettingsFragment_to_backupsTypeSettingsFragment) } } + + @Subscribe(threadMode = ThreadMode.MAIN, sticky = true) + fun onEvent(backupEvent: BackupV2Event) { + viewModel.updateBackupProgress(backupEvent) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = viewLifecycleOwner) + } } /** @@ -157,13 +174,14 @@ private interface ContentCallbacks { @Composable private fun RemoteBackupsSettingsContent( - messageBackupsType: MessageBackupsType?, + messageBackupTier: MessageBackupTier?, lastBackupTimestamp: Long, canBackUpUsingCellular: Boolean, backupsFrequency: MessageBackupsFrequency, requestedDialog: RemoteBackupsSettingsState.Dialog, requestedSnackbar: RemoteBackupsSettingsState.Snackbar, - contentCallbacks: ContentCallbacks + contentCallbacks: ContentCallbacks, + backupProgress: BackupV2Event? ) { val snackbarHostState = remember { SnackbarHostState() @@ -183,13 +201,13 @@ private fun RemoteBackupsSettingsContent( ) { item { BackupTypeRow( - messageBackupsType = messageBackupsType, + messageBackupTier = messageBackupTier, onEnableBackupsClick = contentCallbacks::onEnableBackupsClick, onChangeBackupsTypeClick = contentCallbacks::onBackupsTypeClick ) } - if (messageBackupsType == null) { + if (messageBackupTier == null) { item { Rows.TextRow( text = "Payment history", @@ -205,11 +223,17 @@ private fun RemoteBackupsSettingsContent( Texts.SectionHeader(text = "Backup Details") } - item { - LastBackupRow( - lastBackupTimestamp = lastBackupTimestamp, - onBackupNowClick = {} - ) + if (backupProgress == null || backupProgress.type == BackupV2Event.Type.FINISHED) { + item { + LastBackupRow( + lastBackupTimestamp = lastBackupTimestamp, + onBackupNowClick = contentCallbacks::onBackupNowClick + ) + } + } else { + item { + InProgressBackupRow(progress = backupProgress.count.toInt(), totalProgress = backupProgress.estimatedTotalCount.toInt()) + } } item { @@ -326,14 +350,16 @@ private fun RemoteBackupsSettingsContent( @Composable private fun BackupTypeRow( - messageBackupsType: MessageBackupsType?, + messageBackupTier: MessageBackupTier?, onEnableBackupsClick: () -> Unit, onChangeBackupsTypeClick: () -> Unit ) { + val messageBackupsType = if (messageBackupTier != null) getTierDetails(messageBackupTier) else null + Row( modifier = Modifier .fillMaxWidth() - .clickable(enabled = messageBackupsType != null, onClick = onChangeBackupsTypeClick) + .clickable(enabled = messageBackupTier != null, onClick = onChangeBackupsTypeClick) .padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter)) .padding(top = 16.dp, bottom = 14.dp) ) { @@ -372,6 +398,34 @@ private fun BackupTypeRow( } } +@Composable +private fun InProgressBackupRow( + progress: Int?, + totalProgress: Int? +) { + Row( + modifier = Modifier + .padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter)) + .padding(top = 16.dp, bottom = 14.dp) + ) { + Column( + modifier = Modifier.weight(1f) + ) { + if (totalProgress == null || totalProgress == 0) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } else { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth(), progress = ((progress ?: 0) / totalProgress).toFloat()) + } + + Text( + text = "$progress/$totalProgress", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + @Composable private fun LastBackupRow( lastBackupTimestamp: Long, @@ -448,46 +502,48 @@ private fun BackupFrequencyDialog( AlertDialog( onDismissRequest = onDismiss ) { - Column( - modifier = Modifier - .background( - color = AlertDialogDefaults.containerColor, - shape = AlertDialogDefaults.shape - ) - .fillMaxWidth() - ) { - Text( - text = "Backup frequency", - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.padding(24.dp) - ) - - MessageBackupsFrequency.values().forEach { - Rows.RadioRow( - selected = selected == it, - text = getTextForFrequency(backupsFrequency = it), - label = when (it) { - MessageBackupsFrequency.NEVER -> "By tapping \"Back up now\"" - else -> null - }, - modifier = Modifier - .padding(end = 24.dp) - .clickable(onClick = { - onSelected(it) - onDismiss() - }) - ) - } - - Box( - contentAlignment = Alignment.CenterEnd, + Surface { + Column( modifier = Modifier + .background( + color = AlertDialogDefaults.containerColor, + shape = AlertDialogDefaults.shape + ) .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 24.dp) ) { - TextButton(onClick = onDismiss) { - Text(text = stringResource(id = android.R.string.cancel)) + Text( + text = "Backup frequency", + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(24.dp) + ) + + MessageBackupsFrequency.values().forEach { + Rows.RadioRow( + selected = selected == it, + text = getTextForFrequency(backupsFrequency = it), + label = when (it) { + MessageBackupsFrequency.NEVER -> "By tapping \"Back up now\"" + else -> null + }, + modifier = Modifier + .padding(end = 24.dp) + .clickable(onClick = { + onSelected(it) + onDismiss() + }) + ) + } + + Box( + contentAlignment = Alignment.CenterEnd, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 24.dp) + ) { + TextButton(onClick = onDismiss) { + Text(text = stringResource(id = android.R.string.cancel)) + } } } } @@ -509,13 +565,14 @@ private fun getTextForFrequency(backupsFrequency: MessageBackupsFrequency): Stri private fun RemoteBackupsSettingsContentPreview() { Previews.Preview { RemoteBackupsSettingsContent( - messageBackupsType = null, + messageBackupTier = null, lastBackupTimestamp = -1, canBackUpUsingCellular = false, backupsFrequency = MessageBackupsFrequency.NEVER, requestedDialog = RemoteBackupsSettingsState.Dialog.NONE, requestedSnackbar = RemoteBackupsSettingsState.Snackbar.NONE, - contentCallbacks = object : ContentCallbacks {} + contentCallbacks = object : ContentCallbacks {}, + backupProgress = null ) } } @@ -525,11 +582,7 @@ private fun RemoteBackupsSettingsContentPreview() { private fun BackupTypeRowPreview() { Previews.Preview { BackupTypeRow( - messageBackupsType = MessageBackupsType( - title = "Text + all media", - pricePerMonth = FiatMoney(BigDecimal.valueOf(3L), Currency.getInstance(Locale.US)), - features = persistentListOf() - ), + messageBackupTier = MessageBackupTier.PAID, onChangeBackupsTypeClick = {}, onEnableBackupsClick = {} ) @@ -547,6 +600,14 @@ private fun LastBackupRowPreview() { } } +@SignalPreview +@Composable +private fun InProgressRowPreview() { + Previews.Preview { + InProgressBackupRow(50, 100) + } +} + @SignalPreview @Composable private fun TurnOffAndDeleteBackupsDialogPreview() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/RemoteBackupsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/RemoteBackupsSettingsState.kt index fedf62ab1e..799f72f397 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/RemoteBackupsSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/RemoteBackupsSettingsState.kt @@ -5,17 +5,19 @@ package org.thoughtcrime.securesms.components.settings.app.chats.backups +import org.thoughtcrime.securesms.backup.v2.BackupV2Event +import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsFrequency -import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType data class RemoteBackupsSettingsState( - val messageBackupsType: MessageBackupsType? = null, + val messageBackupsTier: MessageBackupTier? = null, val canBackUpUsingCellular: Boolean = false, val backupSize: Long = 0, val backupsFrequency: MessageBackupsFrequency = MessageBackupsFrequency.DAILY, val lastBackupTimestamp: Long = 0, val dialog: Dialog = Dialog.NONE, - val snackbar: Snackbar = Snackbar.NONE + val snackbar: Snackbar = Snackbar.NONE, + val backupProgress: BackupV2Event? = null ) { enum class Dialog { NONE, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/RemoteBackupsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/RemoteBackupsSettingsViewModel.kt index d4b6fc4967..7ab97a69ea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/RemoteBackupsSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/RemoteBackupsSettingsViewModel.kt @@ -8,13 +8,30 @@ package org.thoughtcrime.securesms.components.settings.app.chats.backups import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel +import org.thoughtcrime.securesms.backup.v2.BackupV2Event +import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsFrequency +import org.thoughtcrime.securesms.jobs.BackupMessagesJob +import org.thoughtcrime.securesms.keyvalue.SignalStore /** * ViewModel for state management of RemoteBackupsSettingsFragment */ class RemoteBackupsSettingsViewModel : ViewModel() { - private val internalState = mutableStateOf(RemoteBackupsSettingsState()) + private val internalState = mutableStateOf( + RemoteBackupsSettingsState( + messageBackupsTier = if (SignalStore.backup().areBackupsEnabled) { + if (SignalStore.backup().canReadWriteToArchiveCdn) { + MessageBackupTier.PAID + } else { + MessageBackupTier.FREE + } + } else { + null + }, + lastBackupTimestamp = SignalStore.backup().lastBackupTime + ) + ) val state: State = internalState @@ -38,6 +55,17 @@ class RemoteBackupsSettingsViewModel : ViewModel() { fun turnOffAndDeleteBackups() { // TODO [message-backups] -- Delete. + SignalStore.backup().areBackupsEnabled = false internalState.value = state.value.copy(snackbar = RemoteBackupsSettingsState.Snackbar.BACKUP_DELETED_AND_TURNED_OFF) } + + fun updateBackupProgress(backupEvent: BackupV2Event?) { + internalState.value = state.value.copy(backupProgress = backupEvent, lastBackupTimestamp = SignalStore.backup().lastBackupTime) + } + + fun onBackupNowClick() { + if (state.value.backupProgress == null || state.value.backupProgress?.type == BackupV2Event.Type.FINISHED) { + BackupMessagesJob.enqueue() + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/type/BackupsTypeSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/type/BackupsTypeSettingsFragment.kt index 66b1f33184..007c5d4e4e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/type/BackupsTypeSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/backups/type/BackupsTypeSettingsFragment.kt @@ -27,6 +27,7 @@ import org.signal.core.ui.SignalPreview import org.signal.core.util.money.FiatMoney import org.signal.donations.PaymentSourceType import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsFlowActivity import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType import org.thoughtcrime.securesms.compose.ComposeFragment @@ -186,6 +187,7 @@ private fun BackupsTypeSettingsContentPreview() { BackupsTypeSettingsContent( state = BackupsTypeSettingsState( backupsType = MessageBackupsType( + tier = MessageBackupTier.PAID, pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("USD")), title = "Text + all media", features = persistentListOf() diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveAttachmentBackfillJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveAttachmentBackfillJob.kt index c6cb48706f..08e5ddc79f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveAttachmentBackfillJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveAttachmentBackfillJob.kt @@ -5,6 +5,7 @@ package org.thoughtcrime.securesms.jobs +import org.greenrobot.eventbus.EventBus import org.signal.core.util.logging.Log import org.signal.protos.resumableuploads.ResumableUpload import org.thoughtcrime.securesms.attachments.Attachment @@ -13,6 +14,7 @@ import org.thoughtcrime.securesms.attachments.AttachmentUploadUtil import org.thoughtcrime.securesms.attachments.DatabaseAttachment import org.thoughtcrime.securesms.attachments.PointerAttachment import org.thoughtcrime.securesms.backup.v2.BackupRepository +import org.thoughtcrime.securesms.backup.v2.BackupV2Event import org.thoughtcrime.securesms.database.AttachmentTable import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.dependencies.ApplicationDependencies @@ -34,7 +36,9 @@ import kotlin.time.Duration.Companion.days class ArchiveAttachmentBackfillJob private constructor( parameters: Parameters, private var attachmentId: AttachmentId?, - private var uploadSpec: ResumableUpload? + private var uploadSpec: ResumableUpload?, + private var totalCount: Int?, + private var progress: Int? ) : Job(parameters) { companion object { private val TAG = Log.tag(ArchiveAttachmentBackfillJob::class.java) @@ -42,7 +46,7 @@ class ArchiveAttachmentBackfillJob private constructor( const val KEY = "ArchiveAttachmentBackfillJob" } - constructor() : this( + constructor(progress: Int? = null, totalCount: Int? = null) : this( parameters = Parameters.Builder() .setQueue("ArchiveAttachmentBackfillJob") .setMaxInstancesForQueue(2) @@ -51,7 +55,9 @@ class ArchiveAttachmentBackfillJob private constructor( .addConstraint(NetworkConstraint.KEY) .build(), attachmentId = null, - uploadSpec = null + uploadSpec = null, + totalCount = totalCount, + progress = progress ) override fun serialize(): ByteArray { @@ -64,6 +70,7 @@ class ArchiveAttachmentBackfillJob private constructor( override fun getFactoryKey(): String = KEY override fun run(): Result { + EventBus.getDefault().postSticky(BackupV2Event(BackupV2Event.Type.PROGRESS_ATTACHMENTS, progress?.toLong() ?: 0, totalCount?.toLong() ?: 0)) var attachmentRecord: DatabaseAttachment? = if (attachmentId != null) { Log.i(TAG, "Retrying $attachmentId") SignalDatabase.attachments.getAttachment(attachmentId!!) @@ -73,7 +80,7 @@ class ArchiveAttachmentBackfillJob private constructor( if (attachmentRecord == null && attachmentId != null) { Log.w(TAG, "Attachment $attachmentId was not found! Was likely deleted during the process of archiving. Re-enqueuing job with no ID.") - ApplicationDependencies.getJobManager().add(ArchiveAttachmentBackfillJob()) + reenqueueWithIncrementedProgress() return Result.success() } @@ -84,11 +91,16 @@ class ArchiveAttachmentBackfillJob private constructor( val resetCount = SignalDatabase.attachments.resetPendingArchiveBackfills() if (resetCount > 0) { Log.w(TAG, "We thought we were done, but $resetCount items were still in progress! Need to run again to retry.") - ApplicationDependencies.getJobManager().add(ArchiveAttachmentBackfillJob()) + ApplicationDependencies.getJobManager().add( + ArchiveAttachmentBackfillJob( + progress = (totalCount ?: resetCount) - resetCount, + totalCount = totalCount ?: resetCount + ) + ) } else { Log.i(TAG, "All good! Should be done.") } - + EventBus.getDefault().postSticky(BackupV2Event(type = BackupV2Event.Type.FINISHED, count = totalCount?.toLong() ?: 0, estimatedTotalCount = totalCount?.toLong() ?: 0)) return Result.success() } @@ -97,7 +109,7 @@ class ArchiveAttachmentBackfillJob private constructor( val transferState: AttachmentTable.ArchiveTransferState? = SignalDatabase.attachments.getArchiveTransferState(attachmentRecord.attachmentId) if (transferState == null) { Log.w(TAG, "Attachment $attachmentId was not found when looking for the transfer state! Was likely just deleted. Re-enqueuing job with no ID.") - ApplicationDependencies.getJobManager().add(ArchiveAttachmentBackfillJob()) + reenqueueWithIncrementedProgress() return Result.success() } @@ -105,19 +117,19 @@ class ArchiveAttachmentBackfillJob private constructor( if (transferState == AttachmentTable.ArchiveTransferState.FINISHED) { Log.i(TAG, "Attachment $attachmentId is already finished. Skipping.") - ApplicationDependencies.getJobManager().add(ArchiveAttachmentBackfillJob()) + reenqueueWithIncrementedProgress() return Result.success() } if (transferState == AttachmentTable.ArchiveTransferState.PERMANENT_FAILURE) { Log.i(TAG, "Attachment $attachmentId is already marked as a permanent failure. Skipping.") - ApplicationDependencies.getJobManager().add(ArchiveAttachmentBackfillJob()) + reenqueueWithIncrementedProgress() return Result.success() } if (transferState == AttachmentTable.ArchiveTransferState.ATTACHMENT_TRANSFER_PENDING) { Log.i(TAG, "Attachment $attachmentId is already marked as pending transfer, meaning it's a send attachment that will be uploaded on it's own. Skipping.") - ApplicationDependencies.getJobManager().add(ArchiveAttachmentBackfillJob()) + reenqueueWithIncrementedProgress() return Result.success() } @@ -164,7 +176,7 @@ class ArchiveAttachmentBackfillJob private constructor( if (attachmentRecord == null) { Log.w(TAG, "$attachmentId was not found after uploading! Possibly deleted in a narrow race condition. Re-enqueuing job with no ID.") - ApplicationDependencies.getJobManager().add(ArchiveAttachmentBackfillJob()) + reenqueueWithIncrementedProgress() return Result.success() } @@ -174,7 +186,7 @@ class ArchiveAttachmentBackfillJob private constructor( Log.d(TAG, "Move complete!") SignalDatabase.attachments.setArchiveTransferState(attachmentRecord.attachmentId, AttachmentTable.ArchiveTransferState.FINISHED) - ApplicationDependencies.getJobManager().add(ArchiveAttachmentBackfillJob()) + reenqueueWithIncrementedProgress() Result.success() } @@ -212,6 +224,15 @@ class ArchiveAttachmentBackfillJob private constructor( } } + private fun reenqueueWithIncrementedProgress() { + ApplicationDependencies.getJobManager().add( + ArchiveAttachmentBackfillJob( + totalCount = totalCount, + progress = progress?.inc()?.coerceAtMost(totalCount ?: 0) + ) + ) + } + override fun onFailure() { attachmentId?.let { id -> Log.w(TAG, "Failed to archive $id!") @@ -261,7 +282,9 @@ class ArchiveAttachmentBackfillJob private constructor( return ArchiveAttachmentBackfillJob( parameters = parameters, attachmentId = data?.attachmentId?.let { AttachmentId(it) }, - uploadSpec = data?.uploadSpec + uploadSpec = data?.uploadSpec, + totalCount = data?.totalCount, + progress = data?.count ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupMessagesJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupMessagesJob.kt index 7ffbfe67a8..d73daf77cd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupMessagesJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupMessagesJob.kt @@ -6,10 +6,11 @@ package org.thoughtcrime.securesms.jobs import android.database.Cursor +import org.greenrobot.eventbus.EventBus import org.signal.core.util.logging.Log -import org.thoughtcrime.securesms.BuildConfig import org.thoughtcrime.securesms.attachments.DatabaseAttachment import org.thoughtcrime.securesms.backup.v2.BackupRepository +import org.thoughtcrime.securesms.backup.v2.BackupV2Event import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.jobmanager.Job @@ -54,33 +55,41 @@ class BackupMessagesJob private constructor(parameters: Parameters) : BaseJob(pa override fun onFailure() = Unit - private fun archiveAttachments() { - if (BuildConfig.MESSAGE_BACKUP_RESTORE_ENABLED) { - SignalStore.backup().canReadWriteToArchiveCdn = true - } + private fun archiveAttachments(): Boolean { + if (!SignalStore.backup().canReadWriteToArchiveCdn) return false + val batchSize = 100 + var needToBackfill = 0 + var totalCount: Int + var progress = 0 SignalDatabase.attachments.getArchivableAttachments().use { cursor -> + totalCount = cursor.count while (!cursor.isAfterLast) { val attachments = cursor.readAttachmentBatch(batchSize) when (val archiveResult = BackupRepository.archiveMedia(attachments)) { is NetworkResult.Success -> { + Log.i(TAG, "Archive call successful") for (success in archiveResult.result.sourceNotFoundResponses) { val attachmentId = archiveResult.result.mediaIdToAttachmentId(success.mediaId) - ApplicationDependencies - .getJobManager() - .startChain(AttachmentUploadJob(attachmentId)) - .then(ArchiveAttachmentJob(attachmentId)) - .enqueue() + Log.i(TAG, "Attachment $attachmentId not found on cdn, will need to re-upload") + needToBackfill++ } + progress += attachments.size } else -> { Log.e(TAG, "Failed to archive $archiveResult") } } + EventBus.getDefault().postSticky(BackupV2Event(BackupV2Event.Type.PROGRESS_ATTACHMENTS, (progress - needToBackfill).toLong(), totalCount.toLong())) } } + if (needToBackfill > 0) { + ApplicationDependencies.getJobManager().add(ArchiveAttachmentBackfillJob(totalCount = totalCount, progress = progress - needToBackfill)) + return true + } + return false } private fun Cursor.readAttachmentBatch(batchSize: Int): List { @@ -96,6 +105,7 @@ class BackupMessagesJob private constructor(parameters: Parameters) : BaseJob(pa } override fun onRun() { + EventBus.getDefault().postSticky(BackupV2Event(type = BackupV2Event.Type.PROGRESS_MESSAGES, count = 0, estimatedTotalCount = 0)) val tempBackupFile = BlobProvider.getInstance().forNonAutoEncryptingSingleSessionOnDisk(ApplicationDependencies.getApplication()) val outputStream = FileOutputStream(tempBackupFile) @@ -104,11 +114,14 @@ class BackupMessagesJob private constructor(parameters: Parameters) : BaseJob(pa FileInputStream(tempBackupFile).use { BackupRepository.uploadBackupFile(it, tempBackupFile.length()) } - - archiveAttachments() + val needBackfill = archiveAttachments() if (!tempBackupFile.delete()) { Log.e(TAG, "Failed to delete temp backup file") } + SignalStore.backup().lastBackupTime = System.currentTimeMillis() + if (!needBackfill) { + EventBus.getDefault().postSticky(BackupV2Event(BackupV2Event.Type.FINISHED, 0, 0)) + } } override fun onShouldRetry(e: Exception): Boolean = false 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 3d73cedc34..a5189e4348 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt @@ -21,6 +21,7 @@ internal class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { private const val KEY_RESTORE_STATE = "backup.restoreState" private const val KEY_NEXT_BACKUP_TIME = "backup.nextBackupTime" + private const val KEY_LAST_BACKUP_TIME = "backup.lastBackupTime" private const val KEY_CDN_BACKUP_DIRECTORY = "backup.cdn.directory" private const val KEY_CDN_BACKUP_MEDIA_DIRECTORY = "backup.cdn.mediaDirectory" @@ -49,6 +50,7 @@ internal class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { var optimizeStorage: Boolean by booleanValue(KEY_OPTIMIZE_STORAGE, false) var nextBackupTime: Long by longValue(KEY_NEXT_BACKUP_TIME, -1) + var lastBackupTime: Long by longValue(KEY_LAST_BACKUP_TIME, -1) var areBackupsEnabled: Boolean get() { diff --git a/app/src/main/protowire/JobData.proto b/app/src/main/protowire/JobData.proto index 29964bc2dc..2d82d28378 100644 --- a/app/src/main/protowire/JobData.proto +++ b/app/src/main/protowire/JobData.proto @@ -55,4 +55,6 @@ message ArchiveAttachmentJobData { message ArchiveAttachmentBackfillJobData { optional uint64 attachmentId = 1; ResumableUpload uploadSpec = 2; + optional uint32 count = 3; + optional uint32 totalCount = 4; } \ No newline at end of file