Basic settings functionality for message backup.

This commit is contained in:
Clark
2024-05-03 12:20:09 -04:00
committed by Alex Hart
parent 0a3f96935a
commit bc527a2bc1
18 changed files with 443 additions and 268 deletions

View File

@@ -650,3 +650,8 @@ class BackupMetadata(
val usedSpace: Long, val usedSpace: Long,
val mediaCount: Long val mediaCount: Long
) )
enum class MessageBackupTier {
FREE,
PAID
}

View File

@@ -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
}
}

View File

@@ -30,23 +30,20 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import kotlinx.collections.immutable.persistentListOf
import org.signal.core.ui.BottomSheets import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews import org.signal.core.ui.Previews
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R 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.donate.gateway.GatewayResponse
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
import org.thoughtcrime.securesms.databinding.PaypalButtonBinding import org.thoughtcrime.securesms.databinding.PaypalButtonBinding
import org.thoughtcrime.securesms.payments.FiatMoneyUtil import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import java.math.BigDecimal
import java.util.Currency
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MessageBackupsCheckoutSheet( fun MessageBackupsCheckoutSheet(
messageBackupsType: MessageBackupsType, messageBackupTier: MessageBackupTier,
availablePaymentGateways: List<GatewayResponse.Gateway>, availablePaymentGateways: List<GatewayResponse.Gateway>,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onPaymentGatewaySelected: (GatewayResponse.Gateway) -> Unit onPaymentGatewaySelected: (GatewayResponse.Gateway) -> Unit
@@ -57,7 +54,7 @@ fun MessageBackupsCheckoutSheet(
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter)) modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
) { ) {
SheetContent( SheetContent(
messageBackupsType = messageBackupsType, messageBackupTier = messageBackupTier,
availablePaymentGateways = availablePaymentGateways, availablePaymentGateways = availablePaymentGateways,
onPaymentGatewaySelected = onPaymentGatewaySelected onPaymentGatewaySelected = onPaymentGatewaySelected
) )
@@ -66,13 +63,16 @@ fun MessageBackupsCheckoutSheet(
@Composable @Composable
private fun SheetContent( private fun SheetContent(
messageBackupsType: MessageBackupsType, messageBackupTier: MessageBackupTier,
availablePaymentGateways: List<GatewayResponse.Gateway>, availablePaymentGateways: List<GatewayResponse.Gateway>,
onPaymentGatewaySelected: (GatewayResponse.Gateway) -> Unit onPaymentGatewaySelected: (GatewayResponse.Gateway) -> Unit
) { ) {
val resources = LocalContext.current.resources val resources = LocalContext.current.resources
val formattedPrice = remember(messageBackupsType.pricePerMonth) { val backupTypeDetails = remember(messageBackupTier) {
FiatMoneyUtil.format(resources, messageBackupsType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal()) getTierDetails(messageBackupTier)
}
val formattedPrice = remember(backupTypeDetails.pricePerMonth) {
FiatMoneyUtil.format(resources, backupTypeDetails.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
} }
Text( Text(
@@ -88,7 +88,7 @@ private fun SheetContent(
) )
MessageBackupsTypeBlock( MessageBackupsTypeBlock(
messageBackupsType = messageBackupsType, messageBackupsType = backupTypeDetails,
isSelected = false, isSelected = false,
onSelected = {}, onSelected = {},
enabled = false, enabled = false,
@@ -221,29 +221,6 @@ private fun CreditOrDebitCardButton(
@Preview @Preview
@Composable @Composable
private fun MessageBackupsCheckoutSheetPreview() { 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() val availablePaymentGateways = GatewayResponse.Gateway.values().toList()
Previews.Preview { Previews.Preview {
@@ -252,7 +229,7 @@ private fun MessageBackupsCheckoutSheetPreview() {
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter)) modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
) { ) {
SheetContent( SheetContent(
messageBackupsType = paidTier, messageBackupTier = MessageBackupTier.PAID,
availablePaymentGateways = availablePaymentGateways, availablePaymentGateways = availablePaymentGateways,
onPaymentGatewaySelected = {} onPaymentGatewaySelected = {}
) )

View File

@@ -32,6 +32,10 @@ class MessageBackupsFlowActivity : PassphraseRequiredActivity() {
fun MessageBackupsScreen.next() { fun MessageBackupsScreen.next() {
val nextScreen = viewModel.goToNextScreen(this) val nextScreen = viewModel.goToNextScreen(this)
if (nextScreen == MessageBackupsScreen.COMPLETED) {
finishAfterTransition()
return
}
if (nextScreen != this) { if (nextScreen != this) {
navController.navigate(nextScreen.name) navController.navigate(nextScreen.name)
} }
@@ -88,9 +92,9 @@ class MessageBackupsFlowActivity : PassphraseRequiredActivity() {
composable(route = MessageBackupsScreen.TYPE_SELECTION.name) { composable(route = MessageBackupsScreen.TYPE_SELECTION.name) {
MessageBackupsTypeSelectionScreen( MessageBackupsTypeSelectionScreen(
selectedBackupsType = state.selectedMessageBackupsType, selectedBackupTier = state.selectedMessageBackupTier,
availableBackupsTypes = state.availableBackupsTypes, availableBackupTiers = state.availableBackupTiers,
onMessageBackupsTypeSelected = viewModel::onMessageBackupsTypeUpdated, onMessageBackupsTierSelected = viewModel::onMessageBackupTierUpdated,
onNavigationClick = navController::popOrFinish, onNavigationClick = navController::popOrFinish,
onReadMoreClicked = {}, onReadMoreClicked = {},
onNextClicked = { MessageBackupsScreen.TYPE_SELECTION.next() } onNextClicked = { MessageBackupsScreen.TYPE_SELECTION.next() }
@@ -99,7 +103,7 @@ class MessageBackupsFlowActivity : PassphraseRequiredActivity() {
dialog(route = MessageBackupsScreen.CHECKOUT_SHEET.name) { dialog(route = MessageBackupsScreen.CHECKOUT_SHEET.name) {
MessageBackupsCheckoutSheet( MessageBackupsCheckoutSheet(
messageBackupsType = state.selectedMessageBackupsType!!, messageBackupTier = state.selectedMessageBackupTier!!,
availablePaymentGateways = state.availablePaymentGateways, availablePaymentGateways = state.availablePaymentGateways,
onDismissRequest = navController::popOrFinish, onDismissRequest = navController::popOrFinish,
onPaymentGatewaySelected = { onPaymentGatewaySelected = {

View File

@@ -5,13 +5,14 @@
package org.thoughtcrime.securesms.backup.v2.ui.subscription 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.components.settings.app.subscription.donate.gateway.GatewayResponse
import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
data class MessageBackupsFlowState( data class MessageBackupsFlowState(
val selectedMessageBackupsType: MessageBackupsType? = null, val selectedMessageBackupTier: MessageBackupTier? = null,
val availableBackupsTypes: List<MessageBackupsType> = emptyList(), val availableBackupTiers: List<MessageBackupTier> = emptyList(),
val selectedPaymentGateway: GatewayResponse.Gateway? = null, val selectedPaymentGateway: GatewayResponse.Gateway? = null,
val availablePaymentGateways: List<GatewayResponse.Gateway> = emptyList(), val availablePaymentGateways: List<GatewayResponse.Gateway> = emptyList(),
val pin: String = "", val pin: String = "",

View File

@@ -5,14 +5,28 @@
package org.thoughtcrime.securesms.backup.v2.ui.subscription package org.thoughtcrime.securesms.backup.v2.ui.subscription
import android.text.TextUtils
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel 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.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.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() { 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<MessageBackupsFlowState> = internalState val state: State<MessageBackupsFlowState> = internalState
@@ -40,16 +54,27 @@ class MessageBackupsFlowViewModel : ViewModel() {
internalState.value = state.value.copy(selectedPaymentGateway = gateway) internalState.value = state.value.copy(selectedPaymentGateway = gateway)
} }
fun onMessageBackupsTypeUpdated(messageBackupsType: MessageBackupsType) { fun onMessageBackupTierUpdated(messageBackupTier: MessageBackupTier) {
internalState.value = state.value.copy(selectedMessageBackupsType = messageBackupsType) internalState.value = state.value.copy(selectedMessageBackupTier = messageBackupTier)
} }
private fun validatePinAndUpdateState(): MessageBackupsScreen { 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 return MessageBackupsScreen.TYPE_SELECTION
} }
private fun validateTypeAndUpdateState(): MessageBackupsScreen { 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 { private fun validateGatewayAndUpdateState(): MessageBackupsScreen {

View File

@@ -16,6 +16,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField 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.dimensionResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardType 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.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -52,93 +54,95 @@ fun MessageBackupsPinConfirmationScreen(
onNextClick: () -> Unit onNextClick: () -> Unit
) { ) {
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
Surface {
Column( Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
) {
LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxSize()
.weight(1f) .padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
) { ) {
item { LazyColumn(
Text( modifier = Modifier
text = "Enter your PIN", // TODO [message-backups] Finalized copy .fillMaxWidth()
style = MaterialTheme.typography.headlineMedium, .weight(1f)
modifier = Modifier.padding(top = 40.dp) ) {
) item {
} Text(
text = "Enter your PIN", // TODO [message-backups] Finalized copy
item { style = MaterialTheme.typography.headlineMedium,
Text( modifier = Modifier.padding(top = 40.dp)
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( item {
value = pin, Text(
onValueChange = onPinChanged, text = "Enter your Signal PIN to enable backups", // TODO [message-backups] Finalized copy
textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), style = MaterialTheme.typography.bodyLarge,
keyboardActions = KeyboardActions( color = MaterialTheme.colorScheme.onSurfaceVariant,
onDone = { onNextClick() } modifier = Modifier.padding(top = 16.dp)
), )
keyboardOptions = KeyboardOptions( }
keyboardType = keyboardType
), item {
modifier = Modifier // TODO [message-backups] Confirm default focus state
.padding(top = 72.dp) val keyboardType = remember(pinKeyboardType) {
.fillMaxWidth() when (pinKeyboardType) {
.focusRequester(focusRequester) 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(
Box( contentAlignment = Alignment.BottomEnd,
contentAlignment = Alignment.Center, modifier = Modifier
modifier = Modifier .fillMaxWidth()
.fillMaxWidth() .padding(vertical = 16.dp)
.padding(top = 48.dp) ) {
Buttons.LargeTonal(
onClick = onNextClick
) { ) {
PinKeyboardTypeToggle( Text(
pinKeyboardType = pinKeyboardType, text = "Next" // TODO [message-backups] Finalized copy
onPinKeyboardTypeSelected = onPinKeyboardTypeSelected
) )
} }
} }
}
Box( LaunchedEffect(Unit) {
contentAlignment = Alignment.BottomEnd, focusRequester.requestFocus()
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
Buttons.LargeTonal(
onClick = onNextClick
) {
Text(
text = "Next" // TODO [message-backups] Finalized copy
)
} }
} }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
} }
} }

View File

@@ -96,22 +96,22 @@ fun MessageBackupsPinEducationScreen(
} }
Buttons.LargePrimary( Buttons.LargePrimary(
onClick = onGeneratePinClick, onClick = onUseCurrentPinClick,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text( Text(
text = "Generate a new $recommendedPinSize-digit PIN" // TODO [message-backups] Finalized copy text = "Use current Signal PIN" // TODO [message-backups] Finalized copy
) )
} }
TextButton( TextButton(
onClick = onUseCurrentPinClick, onClick = onGeneratePinClick,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(bottom = 16.dp) .padding(bottom = 16.dp)
) { ) {
Text( Text(
text = "Use current Signal PIN" // TODO [message-backups] Finalized copy text = "Generate a new $recommendedPinSize-digit PIN" // TODO [message-backups] Finalized copy
) )
} }
} }

View File

@@ -39,16 +39,17 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withAnnotation import androidx.compose.ui.text.withAnnotation
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import org.signal.core.ui.Buttons import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.theme.SignalTheme import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.money.FiatMoney import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.payments.FiatMoneyUtil import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import java.math.BigDecimal import java.math.BigDecimal
import java.util.Currency import java.util.Currency
@@ -59,9 +60,9 @@ import java.util.Currency
@OptIn(ExperimentalTextApi::class) @OptIn(ExperimentalTextApi::class)
@Composable @Composable
fun MessageBackupsTypeSelectionScreen( fun MessageBackupsTypeSelectionScreen(
selectedBackupsType: MessageBackupsType?, selectedBackupTier: MessageBackupTier?,
availableBackupsTypes: List<MessageBackupsType>, availableBackupTiers: List<MessageBackupTier>,
onMessageBackupsTypeSelected: (MessageBackupsType) -> Unit, onMessageBackupsTierSelected: (MessageBackupTier) -> Unit,
onNavigationClick: () -> Unit, onNavigationClick: () -> Unit,
onReadMoreClicked: () -> Unit, onReadMoreClicked: () -> Unit,
onNextClicked: () -> Unit onNextClicked: () -> Unit
@@ -128,13 +129,16 @@ fun MessageBackupsTypeSelectionScreen(
} }
itemsIndexed( itemsIndexed(
availableBackupsTypes, availableBackupTiers,
{ _, item -> item.title } { _, item -> item }
) { index, item -> ) { index, item ->
val type = remember(item) {
getTierDetails(item)
}
MessageBackupsTypeBlock( MessageBackupsTypeBlock(
messageBackupsType = item, messageBackupsType = type,
isSelected = item == selectedBackupsType, isSelected = item == selectedBackupTier,
onSelected = { onMessageBackupsTypeSelected(item) }, onSelected = { onMessageBackupsTierSelected(item) },
modifier = Modifier.padding(top = if (index == 0) 20.dp else 18.dp) modifier = Modifier.padding(top = if (index == 0) 20.dp else 18.dp)
) )
} }
@@ -154,54 +158,16 @@ fun MessageBackupsTypeSelectionScreen(
} }
} }
@Preview @SignalPreview
@Composable @Composable
private fun MessageBackupsTypeSelectionScreenPreview() { private fun MessageBackupsTypeSelectionScreenPreview() {
val freeTier = MessageBackupsType( var selectedBackupsType by remember { mutableStateOf(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"
)
)
)
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) }
Previews.Preview { Previews.Preview {
MessageBackupsTypeSelectionScreen( MessageBackupsTypeSelectionScreen(
selectedBackupsType = selectedBackupsType, selectedBackupTier = MessageBackupTier.FREE,
availableBackupsTypes = listOf(freeTier, paidTier), availableBackupTiers = listOf(MessageBackupTier.FREE, MessageBackupTier.PAID),
onMessageBackupsTypeSelected = { selectedBackupsType = it }, onMessageBackupsTierSelected = { selectedBackupsType = it },
onNavigationClick = {}, onNavigationClick = {},
onReadMoreClicked = {}, onReadMoreClicked = {},
onNextClicked = {} onNextClicked = {}
@@ -272,7 +238,51 @@ private fun formatCostPerMonth(pricePerMonth: FiatMoney): String {
@Stable @Stable
data class MessageBackupsType( data class MessageBackupsType(
val tier: MessageBackupTier,
val pricePerMonth: FiatMoney, val pricePerMonth: FiatMoney,
val title: String, val title: String,
val features: ImmutableList<MessageBackupsTypeFeature> val features: ImmutableList<MessageBackupsTypeFeature>
) )
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!"
)
)
)
}
}

View File

@@ -60,8 +60,12 @@ class ChatsSettingsViewModel @JvmOverloads constructor(
fun refresh() { fun refresh() {
val backupsEnabled = SignalStore.settings().isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(ApplicationDependencies.getApplication()) val backupsEnabled = SignalStore.settings().isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(ApplicationDependencies.getApplication())
if (store.state.localBackupsEnabled != backupsEnabled) { val remoteBackupsEnabled = SignalStore.backup().areBackupsEnabled
store.update { it.copy(localBackupsEnabled = backupsEnabled) }
if (store.state.localBackupsEnabled != backupsEnabled ||
store.state.remoteBackupsEnabled != remoteBackupsEnabled
) {
store.update { it.copy(localBackupsEnabled = backupsEnabled, remoteBackupsEnabled = remoteBackupsEnabled) }
} }
} }
} }

View File

@@ -6,6 +6,8 @@
package org.thoughtcrime.securesms.components.settings.app.chats.backups package org.thoughtcrime.securesms.components.settings.app.chats.backups
import android.content.Intent import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box 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.AlertDialog
import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable 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.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.fragment.findNavController 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.Buttons
import org.signal.core.ui.Dialogs import org.signal.core.ui.Dialogs
import org.signal.core.ui.Dividers 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.SignalPreview
import org.signal.core.ui.Snackbars import org.signal.core.ui.Snackbars
import org.signal.core.ui.Texts import org.signal.core.ui.Texts
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R 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.MessageBackupsFlowActivity
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsFrequency 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.compose.ComposeFragment
import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
import org.thoughtcrime.securesms.payments.FiatMoneyUtil import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.navigation.safeNavigate import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.viewModel import org.thoughtcrime.securesms.util.viewModel
import java.math.BigDecimal
import java.util.Currency
import java.util.Locale import java.util.Locale
/** /**
@@ -75,13 +81,14 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
val callbacks = remember { Callbacks() } val callbacks = remember { Callbacks() }
RemoteBackupsSettingsContent( RemoteBackupsSettingsContent(
messageBackupsType = state.messageBackupsType, messageBackupTier = state.messageBackupsTier,
lastBackupTimestamp = state.lastBackupTimestamp, lastBackupTimestamp = state.lastBackupTimestamp,
canBackUpUsingCellular = state.canBackUpUsingCellular, canBackUpUsingCellular = state.canBackUpUsingCellular,
backupsFrequency = state.backupsFrequency, backupsFrequency = state.backupsFrequency,
requestedDialog = state.dialog, requestedDialog = state.dialog,
requestedSnackbar = state.snackbar, requestedSnackbar = state.snackbar,
contentCallbacks = callbacks contentCallbacks = callbacks,
backupProgress = state.backupProgress
) )
} }
@@ -104,7 +111,7 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
} }
override fun onBackupNowClick() { override fun onBackupNowClick() {
// TODO [message-backups] Enqueue immediate backup viewModel.onBackupNowClick()
} }
override fun onTurnOffAndDeleteBackupsClick() { override fun onTurnOffAndDeleteBackupsClick() {
@@ -135,6 +142,16 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
findNavController().safeNavigate(R.id.action_remoteBackupsSettingsFragment_to_backupsTypeSettingsFragment) 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 @Composable
private fun RemoteBackupsSettingsContent( private fun RemoteBackupsSettingsContent(
messageBackupsType: MessageBackupsType?, messageBackupTier: MessageBackupTier?,
lastBackupTimestamp: Long, lastBackupTimestamp: Long,
canBackUpUsingCellular: Boolean, canBackUpUsingCellular: Boolean,
backupsFrequency: MessageBackupsFrequency, backupsFrequency: MessageBackupsFrequency,
requestedDialog: RemoteBackupsSettingsState.Dialog, requestedDialog: RemoteBackupsSettingsState.Dialog,
requestedSnackbar: RemoteBackupsSettingsState.Snackbar, requestedSnackbar: RemoteBackupsSettingsState.Snackbar,
contentCallbacks: ContentCallbacks contentCallbacks: ContentCallbacks,
backupProgress: BackupV2Event?
) { ) {
val snackbarHostState = remember { val snackbarHostState = remember {
SnackbarHostState() SnackbarHostState()
@@ -183,13 +201,13 @@ private fun RemoteBackupsSettingsContent(
) { ) {
item { item {
BackupTypeRow( BackupTypeRow(
messageBackupsType = messageBackupsType, messageBackupTier = messageBackupTier,
onEnableBackupsClick = contentCallbacks::onEnableBackupsClick, onEnableBackupsClick = contentCallbacks::onEnableBackupsClick,
onChangeBackupsTypeClick = contentCallbacks::onBackupsTypeClick onChangeBackupsTypeClick = contentCallbacks::onBackupsTypeClick
) )
} }
if (messageBackupsType == null) { if (messageBackupTier == null) {
item { item {
Rows.TextRow( Rows.TextRow(
text = "Payment history", text = "Payment history",
@@ -205,11 +223,17 @@ private fun RemoteBackupsSettingsContent(
Texts.SectionHeader(text = "Backup Details") Texts.SectionHeader(text = "Backup Details")
} }
item { if (backupProgress == null || backupProgress.type == BackupV2Event.Type.FINISHED) {
LastBackupRow( item {
lastBackupTimestamp = lastBackupTimestamp, LastBackupRow(
onBackupNowClick = {} lastBackupTimestamp = lastBackupTimestamp,
) onBackupNowClick = contentCallbacks::onBackupNowClick
)
}
} else {
item {
InProgressBackupRow(progress = backupProgress.count.toInt(), totalProgress = backupProgress.estimatedTotalCount.toInt())
}
} }
item { item {
@@ -326,14 +350,16 @@ private fun RemoteBackupsSettingsContent(
@Composable @Composable
private fun BackupTypeRow( private fun BackupTypeRow(
messageBackupsType: MessageBackupsType?, messageBackupTier: MessageBackupTier?,
onEnableBackupsClick: () -> Unit, onEnableBackupsClick: () -> Unit,
onChangeBackupsTypeClick: () -> Unit onChangeBackupsTypeClick: () -> Unit
) { ) {
val messageBackupsType = if (messageBackupTier != null) getTierDetails(messageBackupTier) else null
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(enabled = messageBackupsType != null, onClick = onChangeBackupsTypeClick) .clickable(enabled = messageBackupTier != null, onClick = onChangeBackupsTypeClick)
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter)) .padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
.padding(top = 16.dp, bottom = 14.dp) .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 @Composable
private fun LastBackupRow( private fun LastBackupRow(
lastBackupTimestamp: Long, lastBackupTimestamp: Long,
@@ -448,46 +502,48 @@ private fun BackupFrequencyDialog(
AlertDialog( AlertDialog(
onDismissRequest = onDismiss onDismissRequest = onDismiss
) { ) {
Column( Surface {
modifier = Modifier Column(
.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,
modifier = Modifier modifier = Modifier
.background(
color = AlertDialogDefaults.containerColor,
shape = AlertDialogDefaults.shape
)
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 24.dp)
) { ) {
TextButton(onClick = onDismiss) { Text(
Text(text = stringResource(id = android.R.string.cancel)) 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() { private fun RemoteBackupsSettingsContentPreview() {
Previews.Preview { Previews.Preview {
RemoteBackupsSettingsContent( RemoteBackupsSettingsContent(
messageBackupsType = null, messageBackupTier = null,
lastBackupTimestamp = -1, lastBackupTimestamp = -1,
canBackUpUsingCellular = false, canBackUpUsingCellular = false,
backupsFrequency = MessageBackupsFrequency.NEVER, backupsFrequency = MessageBackupsFrequency.NEVER,
requestedDialog = RemoteBackupsSettingsState.Dialog.NONE, requestedDialog = RemoteBackupsSettingsState.Dialog.NONE,
requestedSnackbar = RemoteBackupsSettingsState.Snackbar.NONE, requestedSnackbar = RemoteBackupsSettingsState.Snackbar.NONE,
contentCallbacks = object : ContentCallbacks {} contentCallbacks = object : ContentCallbacks {},
backupProgress = null
) )
} }
} }
@@ -525,11 +582,7 @@ private fun RemoteBackupsSettingsContentPreview() {
private fun BackupTypeRowPreview() { private fun BackupTypeRowPreview() {
Previews.Preview { Previews.Preview {
BackupTypeRow( BackupTypeRow(
messageBackupsType = MessageBackupsType( messageBackupTier = MessageBackupTier.PAID,
title = "Text + all media",
pricePerMonth = FiatMoney(BigDecimal.valueOf(3L), Currency.getInstance(Locale.US)),
features = persistentListOf()
),
onChangeBackupsTypeClick = {}, onChangeBackupsTypeClick = {},
onEnableBackupsClick = {} onEnableBackupsClick = {}
) )
@@ -547,6 +600,14 @@ private fun LastBackupRowPreview() {
} }
} }
@SignalPreview
@Composable
private fun InProgressRowPreview() {
Previews.Preview {
InProgressBackupRow(50, 100)
}
}
@SignalPreview @SignalPreview
@Composable @Composable
private fun TurnOffAndDeleteBackupsDialogPreview() { private fun TurnOffAndDeleteBackupsDialogPreview() {

View File

@@ -5,17 +5,19 @@
package org.thoughtcrime.securesms.components.settings.app.chats.backups 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.MessageBackupsFrequency
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
data class RemoteBackupsSettingsState( data class RemoteBackupsSettingsState(
val messageBackupsType: MessageBackupsType? = null, val messageBackupsTier: MessageBackupTier? = null,
val canBackUpUsingCellular: Boolean = false, val canBackUpUsingCellular: Boolean = false,
val backupSize: Long = 0, val backupSize: Long = 0,
val backupsFrequency: MessageBackupsFrequency = MessageBackupsFrequency.DAILY, val backupsFrequency: MessageBackupsFrequency = MessageBackupsFrequency.DAILY,
val lastBackupTimestamp: Long = 0, val lastBackupTimestamp: Long = 0,
val dialog: Dialog = Dialog.NONE, val dialog: Dialog = Dialog.NONE,
val snackbar: Snackbar = Snackbar.NONE val snackbar: Snackbar = Snackbar.NONE,
val backupProgress: BackupV2Event? = null
) { ) {
enum class Dialog { enum class Dialog {
NONE, NONE,

View File

@@ -8,13 +8,30 @@ package org.thoughtcrime.securesms.components.settings.app.chats.backups
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel 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.backup.v2.ui.subscription.MessageBackupsFrequency
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
/** /**
* ViewModel for state management of RemoteBackupsSettingsFragment * ViewModel for state management of RemoteBackupsSettingsFragment
*/ */
class RemoteBackupsSettingsViewModel : ViewModel() { 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<RemoteBackupsSettingsState> = internalState val state: State<RemoteBackupsSettingsState> = internalState
@@ -38,6 +55,17 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
fun turnOffAndDeleteBackups() { fun turnOffAndDeleteBackups() {
// TODO [message-backups] -- Delete. // TODO [message-backups] -- Delete.
SignalStore.backup().areBackupsEnabled = false
internalState.value = state.value.copy(snackbar = RemoteBackupsSettingsState.Snackbar.BACKUP_DELETED_AND_TURNED_OFF) 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()
}
}
} }

View File

@@ -27,6 +27,7 @@ import org.signal.core.ui.SignalPreview
import org.signal.core.util.money.FiatMoney import org.signal.core.util.money.FiatMoney
import org.signal.donations.PaymentSourceType import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.R 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.MessageBackupsFlowActivity
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.compose.ComposeFragment import org.thoughtcrime.securesms.compose.ComposeFragment
@@ -186,6 +187,7 @@ private fun BackupsTypeSettingsContentPreview() {
BackupsTypeSettingsContent( BackupsTypeSettingsContent(
state = BackupsTypeSettingsState( state = BackupsTypeSettingsState(
backupsType = MessageBackupsType( backupsType = MessageBackupsType(
tier = MessageBackupTier.PAID,
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("USD")), pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("USD")),
title = "Text + all media", title = "Text + all media",
features = persistentListOf() features = persistentListOf()

View File

@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.jobs package org.thoughtcrime.securesms.jobs
import org.greenrobot.eventbus.EventBus
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.signal.protos.resumableuploads.ResumableUpload import org.signal.protos.resumableuploads.ResumableUpload
import org.thoughtcrime.securesms.attachments.Attachment 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.DatabaseAttachment
import org.thoughtcrime.securesms.attachments.PointerAttachment import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.backup.v2.BackupRepository 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.AttachmentTable
import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
@@ -34,7 +36,9 @@ import kotlin.time.Duration.Companion.days
class ArchiveAttachmentBackfillJob private constructor( class ArchiveAttachmentBackfillJob private constructor(
parameters: Parameters, parameters: Parameters,
private var attachmentId: AttachmentId?, private var attachmentId: AttachmentId?,
private var uploadSpec: ResumableUpload? private var uploadSpec: ResumableUpload?,
private var totalCount: Int?,
private var progress: Int?
) : Job(parameters) { ) : Job(parameters) {
companion object { companion object {
private val TAG = Log.tag(ArchiveAttachmentBackfillJob::class.java) private val TAG = Log.tag(ArchiveAttachmentBackfillJob::class.java)
@@ -42,7 +46,7 @@ class ArchiveAttachmentBackfillJob private constructor(
const val KEY = "ArchiveAttachmentBackfillJob" const val KEY = "ArchiveAttachmentBackfillJob"
} }
constructor() : this( constructor(progress: Int? = null, totalCount: Int? = null) : this(
parameters = Parameters.Builder() parameters = Parameters.Builder()
.setQueue("ArchiveAttachmentBackfillJob") .setQueue("ArchiveAttachmentBackfillJob")
.setMaxInstancesForQueue(2) .setMaxInstancesForQueue(2)
@@ -51,7 +55,9 @@ class ArchiveAttachmentBackfillJob private constructor(
.addConstraint(NetworkConstraint.KEY) .addConstraint(NetworkConstraint.KEY)
.build(), .build(),
attachmentId = null, attachmentId = null,
uploadSpec = null uploadSpec = null,
totalCount = totalCount,
progress = progress
) )
override fun serialize(): ByteArray { override fun serialize(): ByteArray {
@@ -64,6 +70,7 @@ class ArchiveAttachmentBackfillJob private constructor(
override fun getFactoryKey(): String = KEY override fun getFactoryKey(): String = KEY
override fun run(): Result { override fun run(): Result {
EventBus.getDefault().postSticky(BackupV2Event(BackupV2Event.Type.PROGRESS_ATTACHMENTS, progress?.toLong() ?: 0, totalCount?.toLong() ?: 0))
var attachmentRecord: DatabaseAttachment? = if (attachmentId != null) { var attachmentRecord: DatabaseAttachment? = if (attachmentId != null) {
Log.i(TAG, "Retrying $attachmentId") Log.i(TAG, "Retrying $attachmentId")
SignalDatabase.attachments.getAttachment(attachmentId!!) SignalDatabase.attachments.getAttachment(attachmentId!!)
@@ -73,7 +80,7 @@ class ArchiveAttachmentBackfillJob private constructor(
if (attachmentRecord == null && attachmentId != null) { 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.") 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() return Result.success()
} }
@@ -84,11 +91,16 @@ class ArchiveAttachmentBackfillJob private constructor(
val resetCount = SignalDatabase.attachments.resetPendingArchiveBackfills() val resetCount = SignalDatabase.attachments.resetPendingArchiveBackfills()
if (resetCount > 0) { if (resetCount > 0) {
Log.w(TAG, "We thought we were done, but $resetCount items were still in progress! Need to run again to retry.") 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 { } else {
Log.i(TAG, "All good! Should be done.") 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() return Result.success()
} }
@@ -97,7 +109,7 @@ class ArchiveAttachmentBackfillJob private constructor(
val transferState: AttachmentTable.ArchiveTransferState? = SignalDatabase.attachments.getArchiveTransferState(attachmentRecord.attachmentId) val transferState: AttachmentTable.ArchiveTransferState? = SignalDatabase.attachments.getArchiveTransferState(attachmentRecord.attachmentId)
if (transferState == null) { 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.") 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() return Result.success()
} }
@@ -105,19 +117,19 @@ class ArchiveAttachmentBackfillJob private constructor(
if (transferState == AttachmentTable.ArchiveTransferState.FINISHED) { if (transferState == AttachmentTable.ArchiveTransferState.FINISHED) {
Log.i(TAG, "Attachment $attachmentId is already finished. Skipping.") Log.i(TAG, "Attachment $attachmentId is already finished. Skipping.")
ApplicationDependencies.getJobManager().add(ArchiveAttachmentBackfillJob()) reenqueueWithIncrementedProgress()
return Result.success() return Result.success()
} }
if (transferState == AttachmentTable.ArchiveTransferState.PERMANENT_FAILURE) { if (transferState == AttachmentTable.ArchiveTransferState.PERMANENT_FAILURE) {
Log.i(TAG, "Attachment $attachmentId is already marked as a permanent failure. Skipping.") Log.i(TAG, "Attachment $attachmentId is already marked as a permanent failure. Skipping.")
ApplicationDependencies.getJobManager().add(ArchiveAttachmentBackfillJob()) reenqueueWithIncrementedProgress()
return Result.success() return Result.success()
} }
if (transferState == AttachmentTable.ArchiveTransferState.ATTACHMENT_TRANSFER_PENDING) { 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.") 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() return Result.success()
} }
@@ -164,7 +176,7 @@ class ArchiveAttachmentBackfillJob private constructor(
if (attachmentRecord == null) { 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.") 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() return Result.success()
} }
@@ -174,7 +186,7 @@ class ArchiveAttachmentBackfillJob private constructor(
Log.d(TAG, "Move complete!") Log.d(TAG, "Move complete!")
SignalDatabase.attachments.setArchiveTransferState(attachmentRecord.attachmentId, AttachmentTable.ArchiveTransferState.FINISHED) SignalDatabase.attachments.setArchiveTransferState(attachmentRecord.attachmentId, AttachmentTable.ArchiveTransferState.FINISHED)
ApplicationDependencies.getJobManager().add(ArchiveAttachmentBackfillJob()) reenqueueWithIncrementedProgress()
Result.success() 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() { override fun onFailure() {
attachmentId?.let { id -> attachmentId?.let { id ->
Log.w(TAG, "Failed to archive $id!") Log.w(TAG, "Failed to archive $id!")
@@ -261,7 +282,9 @@ class ArchiveAttachmentBackfillJob private constructor(
return ArchiveAttachmentBackfillJob( return ArchiveAttachmentBackfillJob(
parameters = parameters, parameters = parameters,
attachmentId = data?.attachmentId?.let { AttachmentId(it) }, attachmentId = data?.attachmentId?.let { AttachmentId(it) },
uploadSpec = data?.uploadSpec uploadSpec = data?.uploadSpec,
totalCount = data?.totalCount,
progress = data?.count
) )
} }
} }

View File

@@ -6,10 +6,11 @@
package org.thoughtcrime.securesms.jobs package org.thoughtcrime.securesms.jobs
import android.database.Cursor import android.database.Cursor
import org.greenrobot.eventbus.EventBus
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.attachments.DatabaseAttachment import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.backup.v2.BackupRepository import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.BackupV2Event
import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.Job import org.thoughtcrime.securesms.jobmanager.Job
@@ -54,33 +55,41 @@ class BackupMessagesJob private constructor(parameters: Parameters) : BaseJob(pa
override fun onFailure() = Unit override fun onFailure() = Unit
private fun archiveAttachments() { private fun archiveAttachments(): Boolean {
if (BuildConfig.MESSAGE_BACKUP_RESTORE_ENABLED) { if (!SignalStore.backup().canReadWriteToArchiveCdn) return false
SignalStore.backup().canReadWriteToArchiveCdn = true
}
val batchSize = 100 val batchSize = 100
var needToBackfill = 0
var totalCount: Int
var progress = 0
SignalDatabase.attachments.getArchivableAttachments().use { cursor -> SignalDatabase.attachments.getArchivableAttachments().use { cursor ->
totalCount = cursor.count
while (!cursor.isAfterLast) { while (!cursor.isAfterLast) {
val attachments = cursor.readAttachmentBatch(batchSize) val attachments = cursor.readAttachmentBatch(batchSize)
when (val archiveResult = BackupRepository.archiveMedia(attachments)) { when (val archiveResult = BackupRepository.archiveMedia(attachments)) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
Log.i(TAG, "Archive call successful")
for (success in archiveResult.result.sourceNotFoundResponses) { for (success in archiveResult.result.sourceNotFoundResponses) {
val attachmentId = archiveResult.result.mediaIdToAttachmentId(success.mediaId) val attachmentId = archiveResult.result.mediaIdToAttachmentId(success.mediaId)
ApplicationDependencies Log.i(TAG, "Attachment $attachmentId not found on cdn, will need to re-upload")
.getJobManager() needToBackfill++
.startChain(AttachmentUploadJob(attachmentId))
.then(ArchiveAttachmentJob(attachmentId))
.enqueue()
} }
progress += attachments.size
} }
else -> { else -> {
Log.e(TAG, "Failed to archive $archiveResult") 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<DatabaseAttachment> { private fun Cursor.readAttachmentBatch(batchSize: Int): List<DatabaseAttachment> {
@@ -96,6 +105,7 @@ class BackupMessagesJob private constructor(parameters: Parameters) : BaseJob(pa
} }
override fun onRun() { override fun onRun() {
EventBus.getDefault().postSticky(BackupV2Event(type = BackupV2Event.Type.PROGRESS_MESSAGES, count = 0, estimatedTotalCount = 0))
val tempBackupFile = BlobProvider.getInstance().forNonAutoEncryptingSingleSessionOnDisk(ApplicationDependencies.getApplication()) val tempBackupFile = BlobProvider.getInstance().forNonAutoEncryptingSingleSessionOnDisk(ApplicationDependencies.getApplication())
val outputStream = FileOutputStream(tempBackupFile) val outputStream = FileOutputStream(tempBackupFile)
@@ -104,11 +114,14 @@ class BackupMessagesJob private constructor(parameters: Parameters) : BaseJob(pa
FileInputStream(tempBackupFile).use { FileInputStream(tempBackupFile).use {
BackupRepository.uploadBackupFile(it, tempBackupFile.length()) BackupRepository.uploadBackupFile(it, tempBackupFile.length())
} }
val needBackfill = archiveAttachments()
archiveAttachments()
if (!tempBackupFile.delete()) { if (!tempBackupFile.delete()) {
Log.e(TAG, "Failed to delete temp backup file") 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 override fun onShouldRetry(e: Exception): Boolean = false

View File

@@ -21,6 +21,7 @@ internal class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
private const val KEY_RESTORE_STATE = "backup.restoreState" private const val KEY_RESTORE_STATE = "backup.restoreState"
private const val KEY_NEXT_BACKUP_TIME = "backup.nextBackupTime" 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_DIRECTORY = "backup.cdn.directory"
private const val KEY_CDN_BACKUP_MEDIA_DIRECTORY = "backup.cdn.mediaDirectory" 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 optimizeStorage: Boolean by booleanValue(KEY_OPTIMIZE_STORAGE, false)
var nextBackupTime: Long by longValue(KEY_NEXT_BACKUP_TIME, -1) var nextBackupTime: Long by longValue(KEY_NEXT_BACKUP_TIME, -1)
var lastBackupTime: Long by longValue(KEY_LAST_BACKUP_TIME, -1)
var areBackupsEnabled: Boolean var areBackupsEnabled: Boolean
get() { get() {

View File

@@ -55,4 +55,6 @@ message ArchiveAttachmentJobData {
message ArchiveAttachmentBackfillJobData { message ArchiveAttachmentBackfillJobData {
optional uint64 attachmentId = 1; optional uint64 attachmentId = 1;
ResumableUpload uploadSpec = 2; ResumableUpload uploadSpec = 2;
optional uint32 count = 3;
optional uint32 totalCount = 4;
} }