Improve free tier UX around media.

This commit is contained in:
Cody Henthorne
2025-09-22 10:19:42 -04:00
committed by Jeffrey Starke
parent c5753b96ff
commit cbfdc4b57a
8 changed files with 202 additions and 66 deletions

View File

@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.dimensionResource
@@ -29,6 +30,7 @@ import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.backups.BackupStateObserver
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.jobs.BackupMessagesJob import org.thoughtcrime.securesms.jobs.BackupMessagesJob
import org.signal.core.ui.R as CoreUiR import org.signal.core.ui.R as CoreUiR
@@ -51,15 +53,15 @@ class CreateBackupBottomSheet : ComposeBottomSheetDialogFragment() {
@Composable @Composable
override fun SheetContent() { override fun SheetContent() {
val isPaidTier: Boolean = remember { BackupStateObserver.getNonIOBackupState().isLikelyPaidTier() }
CreateBackupBottomSheetContent( CreateBackupBottomSheetContent(
isPaidTier = isPaidTier,
onBackupNowClick = { onBackupNowClick = {
BackupMessagesJob.enqueue() BackupMessagesJob.enqueue()
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to Result.BACKUP_STARTED)) setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to Result.BACKUP_STARTED))
isResultSet = true isResultSet = true
dismissAllowingStateLoss() dismissAllowingStateLoss()
},
onBackupLaterClick = {
dismissAllowingStateLoss()
} }
) )
} }
@@ -80,8 +82,8 @@ class CreateBackupBottomSheet : ComposeBottomSheetDialogFragment() {
@Composable @Composable
private fun CreateBackupBottomSheetContent( private fun CreateBackupBottomSheetContent(
onBackupNowClick: () -> Unit, isPaidTier: Boolean,
onBackupLaterClick: () -> Unit onBackupNowClick: () -> Unit
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -106,8 +108,14 @@ private fun CreateBackupBottomSheetContent(
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
val body = if (isPaidTier) {
stringResource(id = R.string.CreateBackupBottomSheet__depending_on_the_size)
} else {
stringResource(id = R.string.CreateBackupBottomSheet__free_tier)
}
Text( Text(
text = stringResource(id = R.string.CreateBackupBottomSheet__depending_on_the_size), text = body,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@@ -128,11 +136,22 @@ private fun CreateBackupBottomSheetContent(
@SignalPreview @SignalPreview
@Composable @Composable
private fun CreateBackupBottomSheetContentPreview() { private fun CreateBackupBottomSheetContentPaidPreview() {
Previews.BottomSheetPreview { Previews.BottomSheetPreview {
CreateBackupBottomSheetContent( CreateBackupBottomSheetContent(
onBackupNowClick = {}, isPaidTier = true,
onBackupLaterClick = {} onBackupNowClick = {}
)
}
}
@SignalPreview
@Composable
private fun CreateBackupBottomSheetContentFreePreview() {
Previews.BottomSheetPreview {
CreateBackupBottomSheetContent(
isPaidTier = false,
onBackupNowClick = {}
) )
} }
} }

View File

@@ -113,6 +113,7 @@ import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState
import org.thoughtcrime.securesms.payments.FiatMoneyUtil import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.CommunicationActions
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
@@ -285,6 +286,14 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
override fun onIncludeDebuglogClick(newState: Boolean) { override fun onIncludeDebuglogClick(newState: Boolean) {
viewModel.setIncludeDebuglog(newState) viewModel.setIncludeDebuglog(newState)
} }
override fun onMediaBackupSizeClick() {
viewModel.requestDialog(RemoteBackupsSettingsState.Dialog.FREE_TIER_MEDIA_EXPLAINER)
}
override fun onFreeTierBackupSizeLearnMore() {
CommunicationActions.openBrowserLink(requireContext(), "https://support.signal.org/hc/articles/9708267671322")
}
} }
private fun displayBackupKey() { private fun displayBackupKey() {
@@ -387,6 +396,8 @@ private interface ContentCallbacks {
fun onDisplayDownloadingBackupDialog() = Unit fun onDisplayDownloadingBackupDialog() = Unit
fun onManageStorageClick() = Unit fun onManageStorageClick() = Unit
fun onIncludeDebuglogClick(newState: Boolean) = Unit fun onIncludeDebuglogClick(newState: Boolean) = Unit
fun onMediaBackupSizeClick() = Unit
fun onFreeTierBackupSizeLearnMore() = Unit
object Empty : ContentCallbacks object Empty : ContentCallbacks
} }
@@ -634,6 +645,18 @@ private fun RemoteBackupsSettingsContent(
onResumeOverCellularClick = contentCallbacks::onRestoreUsingCellularClick onResumeOverCellularClick = contentCallbacks::onRestoreUsingCellularClick
) )
} }
RemoteBackupsSettingsState.Dialog.FREE_TIER_MEDIA_EXPLAINER -> {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.RemoteBackupsSettingsFragment__free_tier_storage_title),
body = pluralStringResource(R.plurals.RemoteBackupsSettingsFragment__backup_frequency_dialog_body, state.freeTierMediaRetentionDays, state.freeTierMediaRetentionDays),
confirm = stringResource(android.R.string.ok),
dismiss = stringResource(R.string.RemoteBackupsSettingsFragment__learn_more),
onConfirm = {},
onDismiss = contentCallbacks::onDialogDismissed,
onDeny = contentCallbacks::onFreeTierBackupSizeLearnMore
)
}
} }
val snackbarMessageId = remember(state.snackbar) { val snackbarMessageId = remember(state.snackbar) {
@@ -902,6 +925,7 @@ private fun LazyListScope.appendBackupDetailsItems(
item { item {
InProgressBackupRow( InProgressBackupRow(
archiveUploadProgressState = backupProgress, archiveUploadProgressState = backupProgress,
isPaidTier = state.tier == MessageBackupTier.PAID,
canBackupMessagesRun = state.canBackupMessagesJobRun, canBackupMessagesRun = state.canBackupMessagesJobRun,
canBackupUsingCellular = state.canBackUpUsingCellular, canBackupUsingCellular = state.canBackUpUsingCellular,
cancelArchiveUpload = contentCallbacks::onCancelUploadClick cancelArchiveUpload = contentCallbacks::onCancelUploadClick
@@ -909,15 +933,15 @@ private fun LazyListScope.appendBackupDetailsItems(
} }
} }
if (state.backupState.isLikelyPaidTier()) { item {
item { val sizeText = if (state.backupMediaSize < 0L) {
val sizeText = if (state.backupMediaSize < 0L) { stringResource(R.string.RemoteBackupsSettingsFragment__calculating)
stringResource(R.string.RemoteBackupsSettingsFragment__calculating) } else {
} else { state.backupMediaSize.bytes.toUnitString()
state.backupMediaSize.bytes.toUnitString() }
}
Rows.TextRow(text = { Rows.TextRow(
text = {
Column { Column {
Text( Text(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backup_size), text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backup_size),
@@ -930,8 +954,13 @@ private fun LazyListScope.appendBackupDetailsItems(
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
}) },
} onClick = if (state.backupMediaSize >= 0L && state.tier == MessageBackupTier.FREE) {
{ contentCallbacks.onMediaBackupSizeClick() }
} else {
null
}
)
} }
item { item {
@@ -1353,6 +1382,7 @@ private fun SubscriptionMismatchMissingGooglePlayCard(
@Composable @Composable
private fun InProgressBackupRow( private fun InProgressBackupRow(
archiveUploadProgressState: ArchiveUploadProgressState, archiveUploadProgressState: ArchiveUploadProgressState,
isPaidTier: Boolean,
canBackupMessagesRun: Boolean = true, canBackupMessagesRun: Boolean = true,
canBackupUsingCellular: Boolean = true, canBackupUsingCellular: Boolean = true,
cancelArchiveUpload: () -> Unit = {} cancelArchiveUpload: () -> Unit = {}
@@ -1390,7 +1420,7 @@ private fun InProgressBackupRow(
} }
Text( Text(
text = getProgressStateMessage(archiveUploadProgressState, canBackupMessagesRun, canBackupUsingCellular), text = getProgressStateMessage(archiveUploadProgressState, isPaidTier, canBackupMessagesRun, canBackupUsingCellular),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -1428,11 +1458,11 @@ private fun ArchiveProgressIndicator(
} }
@Composable @Composable
private fun getProgressStateMessage(archiveUploadProgressState: ArchiveUploadProgressState, canBackupMessagesRun: Boolean, canBackupUsingCellular: Boolean): String { private fun getProgressStateMessage(archiveUploadProgressState: ArchiveUploadProgressState, isPaidTier: Boolean, canBackupMessagesRun: Boolean, canBackupUsingCellular: Boolean): String {
return when (archiveUploadProgressState.state) { return when (archiveUploadProgressState.state) {
ArchiveUploadProgressState.State.None, ArchiveUploadProgressState.State.UserCanceled -> stringResource(R.string.RemoteBackupsSettingsFragment__processing_backup) ArchiveUploadProgressState.State.None, ArchiveUploadProgressState.State.UserCanceled -> stringResource(R.string.RemoteBackupsSettingsFragment__processing_backup)
ArchiveUploadProgressState.State.Export -> getBackupExportPhaseProgressString(archiveUploadProgressState, canBackupMessagesRun, canBackupUsingCellular) ArchiveUploadProgressState.State.Export -> getBackupExportPhaseProgressString(archiveUploadProgressState, canBackupMessagesRun, canBackupUsingCellular)
ArchiveUploadProgressState.State.UploadBackupFile, ArchiveUploadProgressState.State.UploadMedia -> getBackupUploadPhaseProgressString(archiveUploadProgressState) ArchiveUploadProgressState.State.UploadBackupFile, ArchiveUploadProgressState.State.UploadMedia -> getBackupUploadPhaseProgressString(archiveUploadProgressState, isPaidTier)
} }
} }
@@ -1464,12 +1494,16 @@ private fun getBackupExportPhaseProgressString(state: ArchiveUploadProgressState
} }
@Composable @Composable
private fun getBackupUploadPhaseProgressString(state: ArchiveUploadProgressState): String { private fun getBackupUploadPhaseProgressString(state: ArchiveUploadProgressState, isPaidTier: Boolean): String {
val formattedTotalBytes = state.uploadBytesTotal.bytes.toUnitString() val formattedTotalBytes = state.uploadBytesTotal.bytes.toUnitString()
val formattedUploadedBytes = state.uploadBytesUploaded.bytes.toUnitString() val formattedUploadedBytes = state.uploadBytesUploaded.bytes.toUnitString()
val percent = (state.uploadProgress() * 100).toInt() val percent = (state.uploadProgress() * 100).toInt()
return stringResource(R.string.RemoteBackupsSettingsFragment__uploading_s_of_s_d, formattedUploadedBytes, formattedTotalBytes, percent) return if (isPaidTier) {
stringResource(R.string.RemoteBackupsSettingsFragment__uploading_s_of_s_d, formattedUploadedBytes, formattedTotalBytes, percent)
} else {
stringResource(R.string.RemoteBackupsSettingsFragment__uploading_d, percent)
}
} }
@Composable @Composable
@@ -1949,18 +1983,20 @@ private fun LastBackupRowPreview() {
private fun InProgressRowPreview() { private fun InProgressRowPreview() {
Previews.Preview { Previews.Preview {
Column { Column {
InProgressBackupRow(archiveUploadProgressState = ArchiveUploadProgressState()) InProgressBackupRow(archiveUploadProgressState = ArchiveUploadProgressState(), isPaidTier = true)
InProgressBackupRow( InProgressBackupRow(
archiveUploadProgressState = ArchiveUploadProgressState( archiveUploadProgressState = ArchiveUploadProgressState(
state = ArchiveUploadProgressState.State.Export, state = ArchiveUploadProgressState.State.Export,
backupPhase = ArchiveUploadProgressState.BackupPhase.BackupPhaseNone backupPhase = ArchiveUploadProgressState.BackupPhase.BackupPhaseNone
) ),
isPaidTier = true
) )
InProgressBackupRow( InProgressBackupRow(
archiveUploadProgressState = ArchiveUploadProgressState( archiveUploadProgressState = ArchiveUploadProgressState(
state = ArchiveUploadProgressState.State.Export, state = ArchiveUploadProgressState.State.Export,
backupPhase = ArchiveUploadProgressState.BackupPhase.Account backupPhase = ArchiveUploadProgressState.BackupPhase.Account
) ),
isPaidTier = true
) )
InProgressBackupRow( InProgressBackupRow(
archiveUploadProgressState = ArchiveUploadProgressState( archiveUploadProgressState = ArchiveUploadProgressState(
@@ -1968,7 +2004,8 @@ private fun InProgressRowPreview() {
backupPhase = ArchiveUploadProgressState.BackupPhase.Message, backupPhase = ArchiveUploadProgressState.BackupPhase.Message,
frameExportCount = 1, frameExportCount = 1,
frameTotalCount = 1 frameTotalCount = 1
) ),
isPaidTier = true
) )
InProgressBackupRow( InProgressBackupRow(
archiveUploadProgressState = ArchiveUploadProgressState( archiveUploadProgressState = ArchiveUploadProgressState(
@@ -1976,7 +2013,8 @@ private fun InProgressRowPreview() {
backupPhase = ArchiveUploadProgressState.BackupPhase.Message, backupPhase = ArchiveUploadProgressState.BackupPhase.Message,
frameExportCount = 1000, frameExportCount = 1000,
frameTotalCount = 100_000 frameTotalCount = 100_000
) ),
isPaidTier = true
) )
InProgressBackupRow( InProgressBackupRow(
archiveUploadProgressState = ArchiveUploadProgressState( archiveUploadProgressState = ArchiveUploadProgressState(
@@ -1984,7 +2022,8 @@ private fun InProgressRowPreview() {
backupPhase = ArchiveUploadProgressState.BackupPhase.Message, backupPhase = ArchiveUploadProgressState.BackupPhase.Message,
frameExportCount = 1_000_000, frameExportCount = 1_000_000,
frameTotalCount = 100_000 frameTotalCount = 100_000
) ),
isPaidTier = true
) )
InProgressBackupRow( InProgressBackupRow(
archiveUploadProgressState = ArchiveUploadProgressState( archiveUploadProgressState = ArchiveUploadProgressState(
@@ -1994,7 +2033,19 @@ private fun InProgressRowPreview() {
backupFileTotalBytes = 50.mebiBytes.inWholeBytes, backupFileTotalBytes = 50.mebiBytes.inWholeBytes,
mediaUploadedBytes = 0, mediaUploadedBytes = 0,
mediaTotalBytes = 0 mediaTotalBytes = 0
) ),
isPaidTier = true
)
InProgressBackupRow(
archiveUploadProgressState = ArchiveUploadProgressState(
state = ArchiveUploadProgressState.State.UploadBackupFile,
backupPhase = ArchiveUploadProgressState.BackupPhase.BackupPhaseNone,
backupFileUploadedBytes = 10.mebiBytes.inWholeBytes,
backupFileTotalBytes = 50.mebiBytes.inWholeBytes,
mediaUploadedBytes = 0,
mediaTotalBytes = 0
),
isPaidTier = false
) )
InProgressBackupRow( InProgressBackupRow(
archiveUploadProgressState = ArchiveUploadProgressState( archiveUploadProgressState = ArchiveUploadProgressState(
@@ -2004,7 +2055,8 @@ private fun InProgressRowPreview() {
backupFileTotalBytes = 50.mebiBytes.inWholeBytes, backupFileTotalBytes = 50.mebiBytes.inWholeBytes,
mediaUploadedBytes = 100.mebiBytes.inWholeBytes, mediaUploadedBytes = 100.mebiBytes.inWholeBytes,
mediaTotalBytes = 1.gibiBytes.inWholeBytes mediaTotalBytes = 1.gibiBytes.inWholeBytes
) ),
isPaidTier = true
) )
} }
} }

View File

@@ -31,7 +31,8 @@ data class RemoteBackupsSettingsState(
val canBackupMessagesJobRun: Boolean = false, val canBackupMessagesJobRun: Boolean = false,
val backupMediaDetails: BackupMediaDetails? = null, val backupMediaDetails: BackupMediaDetails? = null,
val showBackupCreateFailedError: Boolean = false, val showBackupCreateFailedError: Boolean = false,
val showBackupCreateCouldNotCompleteError: Boolean = false val showBackupCreateCouldNotCompleteError: Boolean = false,
val freeTierMediaRetentionDays: Int = -1
) { ) {
data class BackupMediaDetails( data class BackupMediaDetails(
@@ -50,7 +51,8 @@ data class RemoteBackupsSettingsState(
SUBSCRIPTION_NOT_FOUND, SUBSCRIPTION_NOT_FOUND,
SKIP_MEDIA_RESTORE_PROTECTION, SKIP_MEDIA_RESTORE_PROTECTION,
CANCEL_MEDIA_RESTORE_PROTECTION, CANCEL_MEDIA_RESTORE_PROTECTION,
RESTORE_OVER_CELLULAR_PROTECTION RESTORE_OVER_CELLULAR_PROTECTION,
FREE_TIER_MEDIA_EXPLAINER
} }
enum class Snackbar { enum class Snackbar {

View File

@@ -33,6 +33,9 @@ import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState.RestoreStatus import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState.RestoreStatus
import org.thoughtcrime.securesms.backup.v2.BackupRepository import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.components.settings.app.backups.BackupState
import org.thoughtcrime.securesms.components.settings.app.backups.BackupStateObserver import org.thoughtcrime.securesms.components.settings.app.backups.BackupStateObserver
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.database.InAppPaymentTable
@@ -47,6 +50,8 @@ import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.RemoteConfig import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.TextSecurePreferences import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.NetworkResult import org.whispersystems.signalservice.api.NetworkResult
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
/** /**
@@ -163,11 +168,12 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
} }
} }
viewModelScope.launch { viewModelScope.launch(Dispatchers.IO) {
BackupStateObserver(viewModelScope).backupState.collect { state -> BackupStateObserver(viewModelScope).backupState.collect { state ->
_state.update { _state.update {
it.copy(backupState = state) it.copy(backupState = state)
} }
refreshState(null)
} }
} }
@@ -258,8 +264,10 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
private fun refreshBackupMediaSizeState() { private fun refreshBackupMediaSizeState() {
_state.update { _state.update {
val (mediaSize, mediaRetentionDays) = getBackupMediaSize(it.tier, (it.backupState as? BackupState.WithTypeAndRenewalTime)?.messageBackupsType)
it.copy( it.copy(
backupMediaSize = getBackupMediaSize(), backupMediaSize = mediaSize,
freeTierMediaRetentionDays = mediaRetentionDays,
backupMediaDetails = if (RemoteConfig.internalUser || Environment.IS_STAGING) { backupMediaDetails = if (RemoteConfig.internalUser || Environment.IS_STAGING) {
RemoteBackupsSettingsState.BackupMediaDetails( RemoteBackupsSettingsState.BackupMediaDetails(
awaitingRestore = SignalDatabase.attachments.getRemainingRestorableAttachmentSize().bytes, awaitingRestore = SignalDatabase.attachments.getRemainingRestorableAttachmentSize().bytes,
@@ -287,7 +295,7 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
if (paidType is NetworkResult.Success) { if (paidType is NetworkResult.Success) {
val remoteStorageAllowance = paidType.result.storageAllowanceBytes.bytes val remoteStorageAllowance = paidType.result.storageAllowanceBytes.bytes
val estimatedSize = SignalDatabase.attachments.getEstimatedArchiveMediaSize().bytes val estimatedSize = getBackupMediaSize(paidType.result.tier, paidType.result).first.bytes
if (estimatedSize + 300.mebiBytes <= remoteStorageAllowance) { if (estimatedSize + 300.mebiBytes <= remoteStorageAllowance) {
BackupRepository.clearOutOfRemoteStorageSpaceError() BackupRepository.clearOutOfRemoteStorageSpaceError()
@@ -303,13 +311,16 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
} }
} }
val (mediaSize, mediaRetentionDays) = getBackupMediaSize(_state.value.tier, (_state.value.backupState as? BackupState.WithTypeAndRenewalTime)?.messageBackupsType)
_state.update { _state.update {
it.copy( it.copy(
tier = SignalStore.backup.backupTier, tier = SignalStore.backup.backupTier,
backupsEnabled = SignalStore.backup.areBackupsEnabled, backupsEnabled = SignalStore.backup.areBackupsEnabled,
lastBackupTimestamp = SignalStore.backup.lastBackupTime, lastBackupTimestamp = SignalStore.backup.lastBackupTime,
canBackupMessagesJobRun = BackupMessagesConstraint.isMet(AppDependencies.application), canBackupMessagesJobRun = BackupMessagesConstraint.isMet(AppDependencies.application),
backupMediaSize = getBackupMediaSize(), backupMediaSize = mediaSize,
freeTierMediaRetentionDays = mediaRetentionDays,
canBackUpUsingCellular = SignalStore.backup.backupWithCellular, canBackUpUsingCellular = SignalStore.backup.backupWithCellular,
canRestoreUsingCellular = SignalStore.backup.restoreWithCellular, canRestoreUsingCellular = SignalStore.backup.restoreWithCellular,
isOutOfStorageSpace = BackupRepository.shouldDisplayOutOfRemoteStorageSpaceUx(), isOutOfStorageSpace = BackupRepository.shouldDisplayOutOfRemoteStorageSpaceUx(),
@@ -320,11 +331,39 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
} }
} }
private fun getBackupMediaSize(): Long { private fun getBackupMediaSize(tier: MessageBackupTier?, messageBackupsType: MessageBackupsType?): Pair<Long, Int> {
return if (SignalStore.backup.hasBackupBeenUploaded || SignalStore.backup.lastBackupTime > 0L) { if (tier == null) {
SignalDatabase.attachments.getEstimatedArchiveMediaSize() return -1L to 0
}
val mediaRetentionDays = if (messageBackupsType is MessageBackupsType.Free) {
messageBackupsType.mediaRetentionDays
} else { } else {
0L when (tier) {
MessageBackupTier.FREE -> {
when (val result = BackupRepository.getFreeType()) {
is NetworkResult.Success -> result.result.mediaRetentionDays
else -> RemoteConfig.messageQueueTime.milliseconds.inWholeDays.toInt()
}
}
MessageBackupTier.PAID -> 0
}
}
return if (SignalStore.backup.hasBackupBeenUploaded || SignalStore.backup.lastBackupTime > 0L) {
when (tier) {
MessageBackupTier.PAID -> SignalDatabase.attachments.getPaidEstimatedArchiveMediaSize() to -1
MessageBackupTier.FREE -> {
if (mediaRetentionDays > 0) {
SignalDatabase.attachments.getFreeEstimatedArchiveMediaSize(System.currentTimeMillis() - mediaRetentionDays.days.inWholeMilliseconds) to mediaRetentionDays
} else {
-1L to -1
}
}
}
} else {
0L to mediaRetentionDays
} }
} }
} }

View File

@@ -3009,28 +3009,40 @@ class AttachmentTable(
.readToList { AttachmentId(it.requireLong(ID)) } .readToList { AttachmentId(it.requireLong(ID)) }
} }
fun getEstimatedArchiveMediaSize(): Long { fun getPaidEstimatedArchiveMediaSize(): Long {
val estimatedThumbnailCount = readableDatabase return getEstimatedArchiveMediaSize()
.select("COUNT(*)") }
.from(
""" fun getFreeEstimatedArchiveMediaSize(afterTimestamp: Long): Long {
( return getEstimatedArchiveMediaSize(afterTimestamp)
SELECT DISTINCT $DATA_HASH_END, $REMOTE_KEY }
FROM $TABLE_NAME INNER JOIN ${MessageTable.TABLE_NAME} AS m ON $TABLE_NAME.$MESSAGE_ID = m.${MessageTable.ID}
WHERE private fun getEstimatedArchiveMediaSize(afterTimestamp: Long = 0L): Long {
$DATA_FILE NOT NULL AND val estimatedThumbnailCount = if (afterTimestamp == 0L) {
$DATA_HASH_END NOT NULL AND readableDatabase
$REMOTE_KEY NOT NULL AND .select("COUNT(*)")
$TRANSFER_STATE = $TRANSFER_PROGRESS_DONE AND .from(
$ARCHIVE_TRANSFER_STATE != ${ArchiveTransferState.PERMANENT_FAILURE.value} AND """
($CONTENT_TYPE LIKE 'image/%' OR $CONTENT_TYPE LIKE 'video/%') AND (
$CONTENT_TYPE != 'image/svg+xml' AND SELECT DISTINCT $DATA_HASH_END, $REMOTE_KEY
${getMessageDoesNotExpireWithinTimeoutClause(tablePrefix = "m")} FROM $TABLE_NAME INNER JOIN ${MessageTable.TABLE_NAME} AS m ON $TABLE_NAME.$MESSAGE_ID = m.${MessageTable.ID}
WHERE
$DATA_FILE NOT NULL AND
$DATA_HASH_END NOT NULL AND
$REMOTE_KEY NOT NULL AND
$TRANSFER_STATE = $TRANSFER_PROGRESS_DONE AND
$ARCHIVE_TRANSFER_STATE != ${ArchiveTransferState.PERMANENT_FAILURE.value} AND
($CONTENT_TYPE LIKE 'image/%' OR $CONTENT_TYPE LIKE 'video/%') AND
$CONTENT_TYPE != 'image/svg+xml' AND
${getMessageDoesNotExpireWithinTimeoutClause(tablePrefix = "m")}
)
"""
) )
""" .run()
) .readToSingleLong(0L)
.run() } else {
.readToSingleLong(0L) 0
}
val uploadedAttachmentBytes = readableDatabase val uploadedAttachmentBytes = readableDatabase
.rawQuery( .rawQuery(
@@ -3045,6 +3057,7 @@ class AttachmentTable(
$REMOTE_KEY NOT NULL AND $REMOTE_KEY NOT NULL AND
$TRANSFER_STATE = $TRANSFER_PROGRESS_DONE AND $TRANSFER_STATE = $TRANSFER_PROGRESS_DONE AND
$ARCHIVE_TRANSFER_STATE != ${ArchiveTransferState.PERMANENT_FAILURE.value} AND $ARCHIVE_TRANSFER_STATE != ${ArchiveTransferState.PERMANENT_FAILURE.value} AND
${if (afterTimestamp > 0) "m.${MessageTable.DATE_RECEIVED} >= $afterTimestamp AND" else ""}
${getMessageDoesNotExpireWithinTimeoutClause(tablePrefix = "m")} ${getMessageDoesNotExpireWithinTimeoutClause(tablePrefix = "m")}
) )
""" """

View File

@@ -166,7 +166,7 @@ class CopyAttachmentToArchiveJob private constructor(private val attachmentId: A
Log.w(TAG, "[$attachmentId] Insufficient storage space! Can't upload!") Log.w(TAG, "[$attachmentId] Insufficient storage space! Can't upload!")
val remoteStorageQuota = getServerQuota() ?: return Result.retry(defaultBackoff()).logW(TAG, "[$attachmentId] Failed to fetch server quota! Retrying.") val remoteStorageQuota = getServerQuota() ?: return Result.retry(defaultBackoff()).logW(TAG, "[$attachmentId] Failed to fetch server quota! Retrying.")
if (SignalDatabase.attachments.getEstimatedArchiveMediaSize() > remoteStorageQuota.inWholeBytes) { if (SignalDatabase.attachments.getPaidEstimatedArchiveMediaSize() > remoteStorageQuota.inWholeBytes) {
BackupRepository.markOutOfRemoteStorageSpaceError() BackupRepository.markOutOfRemoteStorageSpaceError()
return Result.failure() return Result.failure()
} }

View File

@@ -87,7 +87,7 @@ object QuickRegistrationRepository {
MessageBackupTier.FREE -> RegistrationProvisionMessage.Tier.FREE MessageBackupTier.FREE -> RegistrationProvisionMessage.Tier.FREE
null -> null null -> null
}, },
backupSizeBytes = SignalDatabase.attachments.getEstimatedArchiveMediaSize().takeIf { it > 0 }, backupSizeBytes = if (SignalStore.backup.backupTier == MessageBackupTier.PAID) SignalDatabase.attachments.getPaidEstimatedArchiveMediaSize().takeIf { it > 0 } else null,
restoreMethodToken = restoreMethodToken, restoreMethodToken = restoreMethodToken,
aciIdentityKeyPublic = SignalStore.account.aciIdentityKey.publicKey.serialize().toByteString(), aciIdentityKeyPublic = SignalStore.account.aciIdentityKey.publicKey.serialize().toByteString(),
aciIdentityKeyPrivate = SignalStore.account.aciIdentityKey.privateKey.serialize().toByteString(), aciIdentityKeyPrivate = SignalStore.account.aciIdentityKey.privateKey.serialize().toByteString(),

View File

@@ -862,8 +862,10 @@
<!-- CreateBackupBottomSheet --> <!-- CreateBackupBottomSheet -->
<!-- Bottom sheet title --> <!-- Bottom sheet title -->
<string name="CreateBackupBottomSheet__you_are_all_set">You\'re all set. Start your backup now.</string> <string name="CreateBackupBottomSheet__you_are_all_set">You\'re all set. Start your backup now.</string>
<!-- Bottom sheet message --> <!-- Bottom sheet paid message -->
<string name="CreateBackupBottomSheet__depending_on_the_size">Depending on the size of your backup, this could take a long time. You can use your phone as you normally do while the backup takes place.</string> <string name="CreateBackupBottomSheet__depending_on_the_size">Depending on the size of your backup, this could take a long time. You can use your phone as you normally do while the backup takes place.</string>
<!-- Bottom sheet free message -->
<string name="CreateBackupBottomSheet__free_tier">Media is added to your backup as you send and receive messages.</string>
<!-- Headline text for a bottom sheet dialog shown when the restoration of the media backup fails. --> <!-- Headline text for a bottom sheet dialog shown when the restoration of the media backup fails. -->
<string name="RestoreMediaFailedBottomSheet__Cant_restore_media">Can\'t restore media</string> <string name="RestoreMediaFailedBottomSheet__Cant_restore_media">Can\'t restore media</string>
@@ -8188,6 +8190,13 @@
<string name="RemoteBackupsSettingsFragment__to_view_your_key">To view your key, confirm it\'s you</string> <string name="RemoteBackupsSettingsFragment__to_view_your_key">To view your key, confirm it\'s you</string>
<!-- Row label for cancelling and deleting backup --> <!-- Row label for cancelling and deleting backup -->
<string name="RemoteBackupsSettingsFragment__turn_off_and_delete_backup">Turn off and delete backup</string> <string name="RemoteBackupsSettingsFragment__turn_off_and_delete_backup">Turn off and delete backup</string>
<!-- Dialog title for explainer text to on how backup size works for free tier -->
<string name="RemoteBackupsSettingsFragment__free_tier_storage_title">Backup size</string>
<!-- Dialog message for explainer text to on how backup size works for free tier -->
<plurals name="RemoteBackupsSettingsFragment__backup_frequency_dialog_body">
<item quantity="one">Your backup includes all of your text messages and your last %1$d day of media. The size will change as new media is received and old media expires.</item>
<item quantity="other">Your backup includes all of your text messages and your last %1$d days of media. The size will change as new media is received and old media expires.</item>
</plurals>
<!-- Snackbar text displayed when backup has been deleted and turned off --> <!-- Snackbar text displayed when backup has been deleted and turned off -->
<string name="RemoteBackupsSettingsFragment__backup_deleted_and_turned_off">Backup deleted and turned off.</string> <string name="RemoteBackupsSettingsFragment__backup_deleted_and_turned_off">Backup deleted and turned off.</string>
<!-- Snackbar text displayed when backup type is downgraded --> <!-- Snackbar text displayed when backup type is downgraded -->
@@ -8331,6 +8340,8 @@
<string name="RemoteBackupsSettingsFragment__a_network_error_occurred">A network error occurred. Please check your internet connection and try again.</string> <string name="RemoteBackupsSettingsFragment__a_network_error_occurred">A network error occurred. Please check your internet connection and try again.</string>
<!-- Progress message when backup file is being uploaded. First placeholder and second placeholder are formatted byte sizes (2 MB) and third is percent completion. --> <!-- Progress message when backup file is being uploaded. First placeholder and second placeholder are formatted byte sizes (2 MB) and third is percent completion. -->
<string name="RemoteBackupsSettingsFragment__uploading_s_of_s_d">Uploading: %1$s of %2$s (%3$d%%)</string> <string name="RemoteBackupsSettingsFragment__uploading_s_of_s_d">Uploading: %1$s of %2$s (%3$d%%)</string>
<!-- Progress message when backup file is being uploaded. Placeholder is percent completion. -->
<string name="RemoteBackupsSettingsFragment__uploading_d">Uploading: %1$d%%</string>
<!-- Button label to see more details about redemption error --> <!-- Button label to see more details about redemption error -->
<string name="RemoteBackupsSettingsFragment__details">Details</string> <string name="RemoteBackupsSettingsFragment__details">Details</string>
<!-- Text displayed when there was an error deleting backup --> <!-- Text displayed when there was an error deleting backup -->