Implement new top-level backups settings.

This commit is contained in:
Alex Hart 2024-09-27 11:04:57 -03:00 committed by GitHub
parent ea33fa2af1
commit 5bdc7c2740
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 870 additions and 618 deletions

View file

@ -105,6 +105,7 @@ object BackupRepository {
403 -> {
Log.w(TAG, "Received status 403. The user is not in the media tier. Updating local state.", error.exception)
SignalStore.backup.backupTier = MessageBackupTier.FREE
SignalStore.uiHints.markHasEverEnabledRemoteBackups()
// TODO [backup] If the user thought they were in media tier but aren't, feels like we should have a special UX flow for this?
}
}
@ -846,6 +847,11 @@ object BackupRepository {
Log.i(TAG, "Could not retrieve backup tier.", e)
null
}
if (SignalStore.backup.backupTier != null) {
SignalStore.uiHints.markHasEverEnabledRemoteBackups()
}
return SignalStore.backup.backupTier
}

View file

@ -182,6 +182,7 @@ class MessageBackupsFlowViewModel : ViewModel() {
MessageBackupTier.FREE -> {
SignalStore.backup.areBackupsEnabled = true
SignalStore.backup.backupTier = MessageBackupTier.FREE
SignalStore.uiHints.markHasEverEnabledRemoteBackups()
state.copy(stage = MessageBackupsStage.COMPLETED)
}

View file

@ -201,6 +201,17 @@ class AppSettingsFragment : DSLSettingsFragment(
isEnabled = state.isRegisteredAndUpToDate()
)
if (RemoteConfig.messageBackups) {
clickPref(
title = DSLSettingsText.from(R.string.preferences_chats__backups),
// TODO [message-backups] -- icon
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_backupsSettingsFragment)
},
isEnabled = state.isRegisteredAndUpToDate()
)
}
clickPref(
title = DSLSettingsText.from(R.string.preferences__data_and_storage),
icon = DSLSettingsIcon.from(R.drawable.symbol_data_24),

View file

@ -0,0 +1,384 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.backups
import android.os.Bundle
import android.view.View
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dividers
import org.signal.core.ui.Previews
import org.signal.core.ui.Rows
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.Texts
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBackupsCheckoutLauncher.createBackupsCheckoutLauncher
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import java.math.BigDecimal
import java.util.Currency
import java.util.Locale
import kotlin.time.Duration.Companion.seconds
/**
* Top-level backups settings screen.
*/
class BackupsSettingsFragment : ComposeFragment() {
private lateinit var checkoutLauncher: ActivityResultLauncher<Unit>
private val viewModel: BackupsSettingsViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
checkoutLauncher = createBackupsCheckoutLauncher {
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_remoteBackupsSettingsFragment)
}
}
override fun onResume() {
super.onResume()
viewModel.refreshState()
}
@Composable
override fun FragmentContent() {
val state by viewModel.stateFlow.collectAsState()
BackupsSettingsContent(
backupsSettingsState = state,
onNavigationClick = { findNavController().popBackStack() },
onBackupsRowClick = {
when (state.enabledState) {
is BackupsSettingsState.EnabledState.Active, BackupsSettingsState.EnabledState.Inactive -> {
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_remoteBackupsSettingsFragment)
}
BackupsSettingsState.EnabledState.Never -> {
checkoutLauncher.launch(Unit)
}
else -> Unit
}
},
onOnDeviceBackupsRowClick = { findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_backupsPreferenceFragment) }
)
}
}
@Composable
private fun BackupsSettingsContent(
backupsSettingsState: BackupsSettingsState,
onNavigationClick: () -> Unit = {},
onBackupsRowClick: () -> Unit = {},
onOnDeviceBackupsRowClick: () -> Unit = {}
) {
Scaffolds.Settings(
title = stringResource(R.string.preferences_chats__backups),
navigationIconPainter = painterResource(R.drawable.symbol_arrow_left_24),
onNavigationClick = onNavigationClick
) { paddingValues ->
LazyColumn(
modifier = Modifier.padding(paddingValues)
) {
item {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__back_up_your_message_history),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.core_ui__gutter), vertical = 16.dp)
)
}
item {
when (backupsSettingsState.enabledState) {
BackupsSettingsState.EnabledState.Loading -> {
LoadingBackupsRow()
}
BackupsSettingsState.EnabledState.Inactive -> {
InactiveBackupsRow(
onBackupsRowClick = onBackupsRowClick
)
}
is BackupsSettingsState.EnabledState.Active -> {
ActiveBackupsRow(
enabledState = backupsSettingsState.enabledState,
onBackupsRowClick = onBackupsRowClick
)
}
BackupsSettingsState.EnabledState.Never -> {
NeverEnabledBackupsRow(
onBackupsRowClick = onBackupsRowClick
)
}
BackupsSettingsState.EnabledState.Failed -> {
Text(text = "TODO")
}
}
}
item {
Dividers.Default()
}
item {
Texts.SectionHeader(
text = stringResource(R.string.RemoteBackupsSettingsFragment__other_ways_to_backup)
)
Rows.TextRow(
text = stringResource(R.string.RemoteBackupsSettingsFragment__on_device_backups),
label = stringResource(R.string.RemoteBackupsSettingsFragment__save_your_backups_to),
onClick = onOnDeviceBackupsRowClick
)
}
}
}
}
@Composable
private fun NeverEnabledBackupsRow(
onBackupsRowClick: () -> Unit = {}
) {
Rows.TextRow(
modifier = Modifier.height(IntrinsicSize.Min),
icon = {
Box(
modifier = Modifier
.fillMaxHeight()
.padding(top = 12.dp)
) {
Icon(
painter = painterResource(R.drawable.symbol_backup_24),
contentDescription = null
)
}
},
text = {
Column {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups)
)
Text(
text = stringResource(R.string.BackupsSettingsFragment_automatic_backups_with_signals),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
Buttons.MediumTonal(
onClick = onBackupsRowClick,
modifier = Modifier.padding(top = 12.dp)
) {
Text(
text = stringResource(R.string.BackupsSettingsFragment_set_up)
)
}
}
}
)
}
@Composable
private fun InactiveBackupsRow(
onBackupsRowClick: () -> Unit = {}
) {
Rows.TextRow(
text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups),
label = stringResource(R.string.preferences_off),
icon = painterResource(R.drawable.symbol_backup_24),
onClick = onBackupsRowClick
)
}
@Composable
private fun ActiveBackupsRow(
enabledState: BackupsSettingsState.EnabledState.Active,
onBackupsRowClick: () -> Unit = {}
) {
Rows.TextRow(
modifier = Modifier.height(IntrinsicSize.Min),
icon = {
Box(
contentAlignment = Alignment.TopCenter,
modifier = Modifier
.fillMaxHeight()
.padding(top = 12.dp)
) {
Icon(
painter = painterResource(R.drawable.symbol_backup_24),
contentDescription = null
)
}
},
text = {
Column {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups)
)
when (enabledState.type) {
is MessageBackupsType.Paid -> {
Text(
text = stringResource(
R.string.BackupsSettingsFragment_s_month_renews_s,
FiatMoneyUtil.format(LocalContext.current.resources, enabledState.type.pricePerMonth),
DateUtils.formatDateWithYear(Locale.getDefault(), enabledState.expiresAt.inWholeMilliseconds)
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
}
is MessageBackupsType.Free -> {
Text(
text = stringResource(
R.string.RemoteBackupsSettingsFragment__your_backup_plan_is_free
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
}
}
Text(
text = stringResource(
R.string.BackupsSettingsFragment_last_backup_s,
DateUtils.getDatelessRelativeTimeSpanFormattedDate(
LocalContext.current,
Locale.getDefault(),
enabledState.lastBackupAt.inWholeMilliseconds
).value
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
}
},
onClick = onBackupsRowClick
)
}
@Composable
private fun LoadingBackupsRow() {
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.padding(horizontal = dimensionResource(R.dimen.core_ui__gutter))
) {
CircularProgressIndicator()
}
}
@SignalPreview
@Composable
private fun BackupsSettingsContentPreview() {
Previews.Preview {
BackupsSettingsContent(
backupsSettingsState = BackupsSettingsState(
enabledState = BackupsSettingsState.EnabledState.Active(
type = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(4), Currency.getInstance("CAD")),
storageAllowanceBytes = 1_000_000
),
expiresAt = 0.seconds,
lastBackupAt = 0.seconds
)
)
)
}
}
@SignalPreview
@Composable
private fun InactiveBackupsRowPreview() {
Previews.Preview {
InactiveBackupsRow()
}
}
@SignalPreview
@Composable
private fun ActivePaidBackupsRowPreview() {
Previews.Preview {
ActiveBackupsRow(
enabledState = BackupsSettingsState.EnabledState.Active(
type = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(4), Currency.getInstance("CAD")),
storageAllowanceBytes = 1_000_000
),
expiresAt = 0.seconds,
lastBackupAt = 0.seconds
)
)
}
}
@SignalPreview
@Composable
private fun ActiveFreeBackupsRowPreview() {
Previews.Preview {
ActiveBackupsRow(
enabledState = BackupsSettingsState.EnabledState.Active(
type = MessageBackupsType.Free(
mediaRetentionDays = 30
),
expiresAt = 0.seconds,
lastBackupAt = 0.seconds
)
)
}
}
@SignalPreview
@Composable
private fun LoadingBackupsRowPreview() {
Previews.Preview {
LoadingBackupsRow()
}
}
@SignalPreview
@Composable
private fun NeverEnabledBackupsRowPreview() {
Previews.Preview {
NeverEnabledBackupsRow()
}
}

View file

@ -0,0 +1,46 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.backups
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import kotlin.time.Duration
/**
* Screen state for top-level backups settings screen.
*/
data class BackupsSettingsState(
val enabledState: EnabledState = EnabledState.Loading
) {
/**
* Describes the 'enabled' state of backups.
*/
sealed interface EnabledState {
/**
* Loading data for this row
*/
data object Loading : EnabledState
/**
* Backups have never been enabled.
*/
data object Never : EnabledState
/**
* Backups were active at one point, but have been turned off.
*/
data object Inactive : EnabledState
/**
* Backup state couldn't be retrieved from the server for some reason
*/
data object Failed : EnabledState
/**
* Backups are currently active.
*/
data class Active(val type: MessageBackupsType, val expiresAt: Duration, val lastBackupAt: Duration) : EnabledState
}
}

View file

@ -0,0 +1,116 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.backups
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.asFlow
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
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.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.InternetConnectionObserver
import java.util.Currency
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class BackupsSettingsViewModel : ViewModel() {
companion object {
private val TAG = Log.tag(BackupsSettingsViewModel::class)
}
private val internalStateFlow = MutableStateFlow(BackupsSettingsState())
val stateFlow: StateFlow<BackupsSettingsState> = internalStateFlow
init {
viewModelScope.launch(Dispatchers.Default) {
InternetConnectionObserver.observe().asFlow()
.distinctUntilChanged()
.filter { it }
.drop(1)
.collect {
refreshState()
}
}
}
fun refreshState() {
Log.d(TAG, "Refreshing state.")
loadEnabledState()
}
private fun loadEnabledState() {
viewModelScope.launch(Dispatchers.IO) {
val enabledState = when (SignalStore.backup.backupTier) {
MessageBackupTier.FREE -> getEnabledStateForFreeTier()
MessageBackupTier.PAID -> getEnabledStateForPaidTier()
null -> getEnabledStateForNoTier()
}
internalStateFlow.update { it.copy(enabledState = enabledState) }
}
}
private suspend fun getEnabledStateForFreeTier(): BackupsSettingsState.EnabledState {
return try {
BackupsSettingsState.EnabledState.Active(
expiresAt = 0.seconds,
lastBackupAt = SignalStore.backup.lastBackupTime.milliseconds,
type = BackupRepository.getBackupsType(MessageBackupTier.FREE)!!
)
} catch (e: Exception) {
Log.w(TAG, "Failed to build enabled state.", e)
BackupsSettingsState.EnabledState.Failed
}
}
private suspend fun getEnabledStateForPaidTier(): BackupsSettingsState.EnabledState {
return try {
val backupType = BackupRepository.getBackupsType(MessageBackupTier.PAID) as MessageBackupsType.Paid
val activeSubscription = RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP).getOrThrow()
if (activeSubscription.isActive) {
BackupsSettingsState.EnabledState.Active(
expiresAt = activeSubscription.activeSubscription.endOfCurrentPeriod.seconds,
lastBackupAt = SignalStore.backup.lastBackupTime.milliseconds,
type = MessageBackupsType.Paid(
pricePerMonth = FiatMoney.fromSignalNetworkAmount(
activeSubscription.activeSubscription.amount,
Currency.getInstance(activeSubscription.activeSubscription.currency)
),
storageAllowanceBytes = backupType.storageAllowanceBytes
)
)
} else {
BackupsSettingsState.EnabledState.Inactive
}
} catch (e: Exception) {
Log.w(TAG, "Failed to build enabled state.", e)
BackupsSettingsState.EnabledState.Failed
}
}
private fun getEnabledStateForNoTier(): BackupsSettingsState.EnabledState {
return if (SignalStore.uiHints.hasEverEnabledRemoteBackups) {
BackupsSettingsState.EnabledState.Never
} else {
BackupsSettingsState.EnabledState.Inactive
}
}
}

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.chats.backups
package org.thoughtcrime.securesms.components.settings.app.backups.remote
import android.os.Bundle
import android.view.View
@ -18,9 +18,11 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@ -44,6 +46,7 @@ import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.fragment.app.setFragmentResultListener
@ -58,16 +61,18 @@ import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.Snackbars
import org.signal.core.ui.Texts
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
import org.thoughtcrime.securesms.backup.v2.BackupFrequency
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.components.settings.app.chats.backups.type.BackupsTypeSettingsFragment
import org.thoughtcrime.securesms.components.settings.app.backups.type.BackupsTypeSettingsFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBackupsCheckoutLauncher.createBackupsCheckoutLauncher
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.fonts.SignalSymbols
import org.thoughtcrime.securesms.fonts.SignalSymbols.SignalSymbol
import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.DateUtils
@ -75,7 +80,10 @@ import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.viewModel
import java.math.BigDecimal
import java.util.Currency
import java.util.Locale
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Remote backups settings fragment.
@ -105,7 +113,8 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
requestedSnackbar = state.snackbar,
contentCallbacks = callbacks,
backupProgress = backupProgress,
backupSize = state.backupSize
backupSize = state.backupSize,
renewalTime = state.renewalTime
)
}
@ -115,18 +124,14 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
findNavController().popBackStack()
}
override fun onEnableBackupsClick() {
checkoutLauncher.launch(Unit)
override fun onBackupTypeActionClick(tier: MessageBackupTier) {
// TODO [message-backups]
}
override fun onBackUpUsingCellularClick(canUseCellular: Boolean) {
viewModel.setCanBackUpUsingCellular(canUseCellular)
}
override fun onViewPaymentHistory() {
findNavController().safeNavigate(R.id.action_remoteBackupsSettingsFragment_to_remoteBackupsPaymentHistoryFragment)
}
override fun onBackupNowClick() {
viewModel.onBackupNowClick()
}
@ -191,10 +196,9 @@ class RemoteBackupsSettingsFragment : ComposeFragment() {
*/
private interface ContentCallbacks {
fun onNavigationClick() = Unit
fun onEnableBackupsClick() = Unit
fun onBackupsTypeClick() = Unit
fun onBackupTypeActionClick(tier: MessageBackupTier) = Unit
fun onBackUpUsingCellularClick(canUseCellular: Boolean) = Unit
fun onViewPaymentHistory() = Unit
fun onBackupNowClick() = Unit
fun onTurnOffAndDeleteBackupsClick() = Unit
fun onChangeBackupFrequencyClick() = Unit
@ -207,6 +211,7 @@ private interface ContentCallbacks {
@Composable
private fun RemoteBackupsSettingsContent(
messageBackupsType: MessageBackupsType?,
renewalTime: Duration,
lastBackupTimestamp: Long,
canBackUpUsingCellular: Boolean,
backupsFrequency: BackupFrequency,
@ -232,99 +237,88 @@ private fun RemoteBackupsSettingsContent(
modifier = Modifier
.padding(it)
) {
item {
BackupTypeRow(
messageBackupsType = messageBackupsType,
onEnableBackupsClick = contentCallbacks::onEnableBackupsClick,
onChangeBackupsTypeClick = contentCallbacks::onBackupsTypeClick
)
if (messageBackupsType != null) {
item {
BackupTypeRow(
messageBackupsType = messageBackupsType,
renewalTime = renewalTime,
onBackupTypeActionButtonClicked = contentCallbacks::onBackupTypeActionClick
)
}
}
if (messageBackupsType == null) {
item {
Texts.SectionHeader(text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backup_details))
}
if (backupProgress == null || backupProgress.state == ArchiveUploadProgressState.State.None) {
item {
Rows.TextRow(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__payment_history),
onClick = contentCallbacks::onViewPaymentHistory
LastBackupRow(
lastBackupTimestamp = lastBackupTimestamp,
onBackupNowClick = contentCallbacks::onBackupNowClick
)
}
} else {
item {
Dividers.Default()
InProgressBackupRow(progress = backupProgress.completedAttachments.toInt(), totalProgress = backupProgress.totalAttachments.toInt())
}
}
item {
Texts.SectionHeader(text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backup_details))
}
if (backupProgress == null || backupProgress.state == ArchiveUploadProgressState.State.None) {
item {
LastBackupRow(
lastBackupTimestamp = lastBackupTimestamp,
onBackupNowClick = contentCallbacks::onBackupNowClick
item {
Rows.TextRow(text = {
Column {
Text(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backup_size),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = Util.getPrettyFileSize(backupSize),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
item {
InProgressBackupRow(progress = backupProgress.completedAttachments.toInt(), totalProgress = backupProgress.totalAttachments.toInt())
}
}
})
}
item {
Rows.TextRow(text = {
item {
Rows.TextRow(
text = {
Column {
Text(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backup_size),
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backup_frequency),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = Util.getPrettyFileSize(backupSize),
text = getTextForFrequency(backupsFrequency = backupsFrequency),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
})
}
},
onClick = contentCallbacks::onChangeBackupFrequencyClick
)
}
item {
Rows.TextRow(
text = {
Column {
Text(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backup_frequency),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = getTextForFrequency(backupsFrequency = backupsFrequency),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
onClick = contentCallbacks::onChangeBackupFrequencyClick
)
}
item {
Rows.ToggleRow(
checked = canBackUpUsingCellular,
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__back_up_using_cellular),
onCheckChanged = contentCallbacks::onBackUpUsingCellularClick
)
}
item {
Rows.ToggleRow(
checked = canBackUpUsingCellular,
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__back_up_using_cellular),
onCheckChanged = contentCallbacks::onBackUpUsingCellularClick
)
}
item {
Dividers.Default()
}
item {
Dividers.Default()
}
item {
Rows.TextRow(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__turn_off_and_delete_backup),
foregroundTint = MaterialTheme.colorScheme.error,
onClick = contentCallbacks::onTurnOffAndDeleteBackupsClick
)
}
item {
Rows.TextRow(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__turn_off_and_delete_backup),
foregroundTint = MaterialTheme.colorScheme.error,
onClick = contentCallbacks::onTurnOffAndDeleteBackupsClick
)
}
}
}
@ -383,63 +377,71 @@ private fun RemoteBackupsSettingsContent(
@Composable
private fun BackupTypeRow(
messageBackupsType: MessageBackupsType?,
onEnableBackupsClick: () -> Unit,
onChangeBackupsTypeClick: () -> Unit
messageBackupsType: MessageBackupsType,
renewalTime: Duration,
onBackupTypeActionButtonClicked: (MessageBackupTier) -> Unit = {}
) {
Row(
Column(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = messageBackupsType != null, onClick = onChangeBackupsTypeClick)
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
.padding(top = 16.dp, bottom = 14.dp)
.padding(horizontal = 16.dp, vertical = 12.dp)
.background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(12.dp))
.padding(24.dp)
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backup_type),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
if (messageBackupsType == null) {
Text(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__backups_disabled),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else if (messageBackupsType is MessageBackupsType.Paid) {
val localResources = LocalContext.current.resources
val formattedCurrency = remember(messageBackupsType.pricePerMonth) {
FiatMoneyUtil.format(localResources, messageBackupsType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
Row {
Column {
val title = when (messageBackupsType) {
is MessageBackupsType.Paid -> stringResource(R.string.MessageBackupsTypeSelectionScreen__text_plus_all_your_media)
is MessageBackupsType.Free -> pluralStringResource(R.plurals.MessageBackupsTypeSelectionScreen__text_plus_d_days_of_media, messageBackupsType.mediaRetentionDays, messageBackupsType.mediaRetentionDays)
}
Text(
text = stringResource(id = R.string.RemoteBackupsSettingsFragment__s_dot_s_per_month, stringResource(id = R.string.MessageBackupsTypeSelectionScreen__text_plus_all_your_media), formattedCurrency)
text = buildAnnotatedString {
SignalSymbol(SignalSymbols.Weight.REGULAR, SignalSymbols.Glyph.CHECKMARK)
append(" ")
append(title)
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
} else {
val retentionDays = (messageBackupsType as MessageBackupsType.Free).mediaRetentionDays
val localResources = LocalContext.current.resources
val formattedCurrency = remember {
val currency = SignalStore.inAppPayments.getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.BACKUP)
FiatMoneyUtil.format(localResources, FiatMoney(BigDecimal.ZERO, currency), FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
val cost = when (messageBackupsType) {
is MessageBackupsType.Paid -> stringResource(R.string.RemoteBackupsSettingsFragment__s_per_month, FiatMoneyUtil.format(LocalContext.current.resources, messageBackupsType.pricePerMonth))
is MessageBackupsType.Free -> stringResource(R.string.RemoteBackupsSettingsFragment__your_backup_plan_is_free)
}
Text(
text = stringResource(
id = R.string.RemoteBackupsSettingsFragment__s_dot_s_per_month,
pluralStringResource(id = R.plurals.MessageBackupsTypeSelectionScreen__text_plus_d_days_of_media, retentionDays, retentionDays),
formattedCurrency
)
text = cost,
modifier = Modifier.padding(top = 12.dp)
)
if (messageBackupsType is MessageBackupsType.Paid) {
if (renewalTime > 0.seconds) {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__renews_s, DateUtils.formatDateWithYear(Locale.getDefault(), renewalTime.inWholeMilliseconds))
)
}
}
}
// Icon
}
if (messageBackupsType == null) {
Buttons.Small(onClick = onEnableBackupsClick) {
Text(text = stringResource(id = R.string.RemoteBackupsSettingsFragment__enable_backups))
}
val buttonText = when (messageBackupsType) {
is MessageBackupsType.Paid -> stringResource(R.string.RemoteBackupsSettingsFragment__manage_or_cancel)
is MessageBackupsType.Free -> stringResource(R.string.RemoteBackupsSettingsFragment__upgrade)
}
Buttons.LargeTonal(
onClick = { onBackupTypeActionButtonClicked(messageBackupsType.tier) },
colors = ButtonDefaults.filledTonalButtonColors().copy(
containerColor = SignalTheme.colors.colorTransparent5
),
modifier = Modifier.padding(top = 12.dp)
) {
Text(
text = buttonText
)
}
}
}
@ -668,7 +670,7 @@ private fun getTextForFrequency(backupsFrequency: BackupFrequency): String {
private fun RemoteBackupsSettingsContentPreview() {
Previews.Preview {
RemoteBackupsSettingsContent(
messageBackupsType = null,
messageBackupsType = MessageBackupsType.Free(mediaRetentionDays = 30),
lastBackupTimestamp = -1,
canBackUpUsingCellular = false,
backupsFrequency = BackupFrequency.MANUAL,
@ -676,6 +678,7 @@ private fun RemoteBackupsSettingsContentPreview() {
requestedSnackbar = RemoteBackupsSettingsState.Snackbar.NONE,
contentCallbacks = object : ContentCallbacks {},
backupProgress = null,
renewalTime = 1727193018.seconds,
backupSize = 2300000
)
}
@ -685,13 +688,22 @@ private fun RemoteBackupsSettingsContentPreview() {
@Composable
private fun BackupTypeRowPreview() {
Previews.Preview {
BackupTypeRow(
messageBackupsType = MessageBackupsType.Free(
mediaRetentionDays = 30
),
onChangeBackupsTypeClick = {},
onEnableBackupsClick = {}
)
Column {
BackupTypeRow(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000
),
renewalTime = 1727193018.seconds
)
BackupTypeRow(
messageBackupsType = MessageBackupsType.Free(
mediaRetentionDays = 30
),
renewalTime = 0.seconds
)
}
}
}

View file

@ -3,10 +3,12 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.chats.backups
package org.thoughtcrime.securesms.components.settings.app.backups.remote
import org.thoughtcrime.securesms.backup.v2.BackupFrequency
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
data class RemoteBackupsSettingsState(
val messageBackupsType: MessageBackupsType? = null,
@ -14,6 +16,7 @@ data class RemoteBackupsSettingsState(
val backupSize: Long = 0,
val backupsFrequency: BackupFrequency = BackupFrequency.DAILY,
val lastBackupTimestamp: Long = 0,
val renewalTime: Duration = 0.seconds,
val dialog: Dialog = Dialog.NONE,
val snackbar: Snackbar = Snackbar.NONE
) {

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.chats.backups
package org.thoughtcrime.securesms.components.settings.app.backups.remote
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -17,11 +17,14 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.thoughtcrime.securesms.backup.v2.BackupFrequency
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.service.MessageBackupListener
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/**
* ViewModel for state management of RemoteBackupsSettingsFragment
@ -40,6 +43,19 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
init {
refresh()
viewModelScope.launch {
val activeSubscription = withContext(Dispatchers.IO) {
RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP)
}
if (activeSubscription.isSuccess) {
val subscription = activeSubscription.getOrThrow().activeSubscription
if (subscription.isActive && subscription != null) {
_state.update { it.copy(renewalTime = subscription.endOfCurrentPeriod.seconds) }
}
}
}
}
fun setCanBackUpUsingCellular(canBackUpUsingCellular: Boolean) {

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.chats.backups.type
package org.thoughtcrime.securesms.components.settings.app.backups.type
import android.os.Bundle
import android.view.View
@ -39,7 +39,6 @@ import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.viewModel
import java.math.BigDecimal
import java.util.Locale
@ -86,10 +85,6 @@ class BackupsTypeSettingsFragment : ComposeFragment() {
findNavController().popBackStack()
}
override fun onPaymentHistoryClick() {
findNavController().safeNavigate(R.id.action_backupsTypeSettingsFragment_to_remoteBackupsPaymentHistoryFragment)
}
override fun onChangeOrCancelSubscriptionClick() {
checkoutLauncher.launch(Unit)
}

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.chats.backups.type
package org.thoughtcrime.securesms.components.settings.app.backups.type
import androidx.compose.runtime.Stable
import org.signal.donations.PaymentSourceType

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.chats.backups.type
package org.thoughtcrime.securesms.components.settings.app.backups.type
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope

View file

@ -1,337 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.chats.backups.history
import android.content.Intent
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.fragment.findNavController
import androidx.navigation.navArgument
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toPersistentList
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dialogs
import org.signal.core.ui.Dividers
import org.signal.core.ui.Previews
import org.signal.core.ui.Rows
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.Texts
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.receipts.ReceiptImageRenderer
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.compose.Nav
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.DateUtils
import java.math.BigDecimal
import java.util.Calendar
import java.util.Currency
import java.util.Locale
/**
* Displays a list or detail view of in-app-payment receipts related to
* backups.
*/
class RemoteBackupsPaymentHistoryFragment : ComposeFragment() {
private val viewModel: RemoteBackupsPaymentHistoryViewModel by viewModels()
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsState()
val navController = rememberNavController()
LaunchedEffect(Unit) {
navController.setOnBackPressedDispatcher(requireActivity().onBackPressedDispatcher)
navController.enableOnBackPressed(true)
}
val onNavigationClick = remember {
{
if (!navController.popBackStack()) {
findNavController().popBackStack()
}
}
}
Nav.Host(navController = navController, startDestination = "list") {
composable("list") {
PaymentHistoryContent(
state = state,
onNavigationClick = onNavigationClick,
onRecordClick = { navController.navigate("detail/${it.id}") }
)
}
composable("detail/{recordId}", listOf(navArgument("recordId") { type = NavType.LongType })) { backStackEntry ->
val recordId = backStackEntry.arguments?.getLong("recordId")!!
val record = state.records[recordId]!!
PaymentHistoryDetails(
record = record,
onNavigationClick = onNavigationClick,
onShareClick = this@RemoteBackupsPaymentHistoryFragment::onShareClick
)
if (state.displayProgressDialog) {
Dialogs.IndeterminateProgressDialog()
}
}
}
}
private fun onShareClick(record: InAppPaymentReceiptRecord) {
viewModel.onStartRenderingBitmap()
ReceiptImageRenderer.renderPng(
requireContext(),
viewLifecycleOwner,
record,
getString(R.string.RemoteBackupsPaymentHistoryFragment__text_and_all_media_backup),
object : ReceiptImageRenderer.Callback {
override fun onBitmapRendered() {
viewModel.onEndRenderingBitmap()
}
override fun onStartActivity(intent: Intent) {
startActivity(intent)
}
}
)
}
}
@Composable
private fun PaymentHistoryContent(
state: RemoteBackupsPaymentHistoryState,
onNavigationClick: () -> Unit,
onRecordClick: (InAppPaymentReceiptRecord) -> Unit
) {
Scaffolds.Settings(
title = stringResource(id = R.string.RemoteBackupsPaymentHistoryFragment__payment_history),
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24),
onNavigationClick = onNavigationClick
) {
val itemList = remember(state.records) { state.records.values.toPersistentList() }
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(it)
) {
itemsIndexed(
items = itemList,
key = { _, item -> item.id }
) { idx, item ->
val previous = itemList.getOrNull(idx - 1)
val previousYear = rememberYear(timestamp = previous?.timestamp ?: 0)
val ourYear = rememberYear(timestamp = item.timestamp)
if (previousYear != ourYear) {
Texts.SectionHeader(text = "$ourYear")
}
PaymentHistoryRow(item, onRecordClick)
}
}
}
}
@Composable
private fun rememberYear(timestamp: Long): Int {
if (timestamp == 0L) {
return -1
}
val calendar = remember {
Calendar.getInstance()
}
return remember(timestamp) {
calendar.timeInMillis = timestamp
calendar.get(Calendar.YEAR)
}
}
@Composable
private fun PaymentHistoryRow(
record: InAppPaymentReceiptRecord,
onRecordClick: (InAppPaymentReceiptRecord) -> Unit
) {
val date = remember(record.timestamp) {
DateUtils.formatDateWithYear(Locale.getDefault(), record.timestamp)
}
val onClick = remember(record) {
{ onRecordClick(record) }
}
Rows.TextRow(text = {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = date,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = stringResource(id = R.string.RemoteBackupsPaymentHistoryFragment__text_and_all_media_backup),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
val resources = LocalContext.current.resources
val fiat = remember(record.amount) {
FiatMoneyUtil.format(resources, record.amount, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
}
Text(text = fiat)
}, onClick = onClick)
}
@Composable
private fun PaymentHistoryDetails(
record: InAppPaymentReceiptRecord,
onNavigationClick: () -> Unit,
onShareClick: (InAppPaymentReceiptRecord) -> Unit
) {
Scaffolds.Settings(
title = stringResource(id = R.string.RemoteBackupsPaymentHistoryFragment__payment_details),
onNavigationClick = onNavigationClick,
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(it)
) {
val resources = LocalContext.current.resources
val formattedAmount = remember(record.amount) {
FiatMoneyUtil.format(resources, record.amount, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
}
Image(
painter = painterResource(id = R.drawable.ic_signal_logo_type),
contentDescription = null,
modifier = Modifier
.align(alignment = Alignment.CenterHorizontally)
.padding(top = 24.dp, bottom = 16.dp)
)
Text(
text = formattedAmount,
style = MaterialTheme.typography.displayMedium,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Dividers.Default()
Rows.TextRow(
text = stringResource(id = R.string.RemoteBackupsPaymentHistoryFragment__backup_type),
label = stringResource(id = R.string.RemoteBackupsPaymentHistoryFragment__text_and_all_media_backup)
)
val formattedDate = remember(record.timestamp) {
DateUtils.formatDateWithYear(Locale.getDefault(), record.timestamp)
}
Rows.TextRow(
text = stringResource(id = R.string.RemoteBackupsPaymentHistoryFragment__date_paid),
label = formattedDate
)
Spacer(modifier = Modifier.weight(1f))
Buttons.LargePrimary(
onClick = { onShareClick(record) },
modifier = Modifier
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
.padding(bottom = 24.dp)
.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.RemoteBackupsPaymentHistoryFragment__share))
}
}
}
}
@SignalPreview
@Composable
private fun PaymentHistoryContentPreview() {
Previews.Preview {
PaymentHistoryContent(
state = RemoteBackupsPaymentHistoryState(
records = persistentMapOf(
1L to testRecord()
)
),
onNavigationClick = {},
onRecordClick = {}
)
}
}
@SignalPreview
@Composable
private fun PaymentHistoryRowPreview() {
Previews.Preview {
PaymentHistoryRow(
record = testRecord(),
onRecordClick = {}
)
}
}
@SignalPreview
@Composable
private fun PaymentDetailsContentPreview() {
Previews.Preview {
PaymentHistoryDetails(
record = testRecord(),
onNavigationClick = {},
onShareClick = {}
)
}
}
private fun testRecord(): InAppPaymentReceiptRecord {
return InAppPaymentReceiptRecord(
id = 1,
amount = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD")),
timestamp = 1718739691000,
type = InAppPaymentReceiptRecord.Type.RECURRING_BACKUP,
subscriptionLevel = 201
)
}

View file

@ -1,16 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.chats.backups.history
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
object RemoteBackupsPaymentHistoryRepository {
fun getReceipts(): List<InAppPaymentReceiptRecord> {
return SignalDatabase.donationReceipts.getReceipts(InAppPaymentReceiptRecord.Type.RECURRING_BACKUP)
}
}

View file

@ -1,17 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.chats.backups.history
import androidx.compose.runtime.Stable
import kotlinx.collections.immutable.PersistentMap
import kotlinx.collections.immutable.persistentMapOf
import org.thoughtcrime.securesms.database.model.InAppPaymentReceiptRecord
@Stable
data class RemoteBackupsPaymentHistoryState(
val records: PersistentMap<Long, InAppPaymentReceiptRecord> = persistentMapOf(),
val displayProgressDialog: Boolean = false
)

View file

@ -1,40 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.chats.backups.history
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.collections.immutable.toPersistentMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class RemoteBackupsPaymentHistoryViewModel : ViewModel() {
private val internalStateFlow = MutableStateFlow(RemoteBackupsPaymentHistoryState())
val state: StateFlow<RemoteBackupsPaymentHistoryState> = internalStateFlow
init {
viewModelScope.launch {
val receipts = withContext(Dispatchers.IO) {
RemoteBackupsPaymentHistoryRepository.getReceipts()
}
internalStateFlow.update { state -> state.copy(records = receipts.associateBy { it.id }.toPersistentMap()) }
}
}
fun onStartRenderingBitmap() {
internalStateFlow.update { it.copy(displayProgressDialog = true) }
}
fun onEndRenderingBitmap() {
internalStateFlow.update { it.copy(displayProgressDialog = false) }
}
}

View file

@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.components.settings.app.subscription
import androidx.annotation.CheckResult
import androidx.annotation.WorkerThread
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
@ -46,18 +47,26 @@ object RecurringInAppPaymentRepository {
private val donationsService = AppDependencies.donationsService
fun getActiveSubscription(type: InAppPaymentSubscriberRecord.Type): Single<ActiveSubscription> {
val localSubscription = InAppPaymentsRepository.getSubscriber(type)
return if (localSubscription != null) {
Single.fromCallable { donationsService.getSubscription(localSubscription.subscriberId) }
.subscribeOn(Schedulers.io())
.flatMap(ServiceResponse<ActiveSubscription>::flattenResult)
.doOnSuccess { activeSubscription ->
if (activeSubscription.isActive && activeSubscription.activeSubscription.endOfCurrentPeriod > SignalStore.inAppPayments.getLastEndOfPeriod()) {
InAppPaymentKeepAliveJob.enqueueAndTrackTime(System.currentTimeMillis().milliseconds)
}
}
} else {
Single.just(ActiveSubscription.EMPTY)
return Single.fromCallable {
getActiveSubscriptionSync(type).getOrThrow()
}.subscribeOn(Schedulers.io())
}
@WorkerThread
fun getActiveSubscriptionSync(type: InAppPaymentSubscriberRecord.Type): Result<ActiveSubscription> {
val response = InAppPaymentsRepository.getSubscriber(type)?.let {
donationsService.getSubscription(it.subscriberId)
} ?: return Result.success(ActiveSubscription.EMPTY)
return try {
val result = response.resultOrThrow
if (result.isActive && result.activeSubscription.endOfCurrentPeriod > SignalStore.inAppPayments.getLastEndOfPeriod()) {
InAppPaymentKeepAliveJob.enqueueAndTrackTime(System.currentTimeMillis().milliseconds)
}
Result.success(result)
} catch (e: Exception) {
Result.failure(e)
}
}

View file

@ -10,6 +10,12 @@ import android.graphics.Typeface
import android.text.SpannableStringBuilder
import android.text.TextPaint
import android.text.style.MetricAffectingSpan
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.withStyle
/**
* Helper object for working with the SignalSymbols font
@ -17,6 +23,7 @@ import android.text.style.MetricAffectingSpan
object SignalSymbols {
enum class Glyph(val unicode: Char) {
CHECKMARK('\u2713'),
CHEVRON_RIGHT('\uE025'),
PERSON_CIRCLE('\uE05E')
}
@ -43,6 +50,17 @@ object SignalSymbols {
return text
}
@Composable
fun AnnotatedString.Builder.SignalSymbol(weight: Weight, glyph: Glyph) {
withStyle(
SpanStyle(
fontFamily = FontFamily(getTypeface(LocalContext.current, weight))
)
) {
append(glyph.unicode.toString())
}
}
private fun getTypeface(context: Context, weight: Weight): Typeface {
return when (weight) {
Weight.BOLD -> getBoldWeightedFont(context)

View file

@ -258,6 +258,7 @@ class InAppPaymentRedemptionJob private constructor(
Log.i(TAG, "Enabling backups and setting backup tier to PAID", true)
SignalStore.backup.areBackupsEnabled = true
SignalStore.backup.backupTier = MessageBackupTier.PAID
SignalStore.uiHints.markHasEverEnabledRemoteBackups()
}
}

View file

@ -516,6 +516,7 @@ class InAppPaymentValues internal constructor(store: KeyValueStore) : SignalStor
SignalStore.backup.areBackupsEnabled = true
SignalStore.backup.backupTier = MessageBackupTier.PAID
SignalStore.uiHints.markHasEverEnabledRemoteBackups()
}
val subscriber = InAppPaymentsRepository.requireSubscriber(subscriberType)

View file

@ -27,6 +27,7 @@ public class UiHintValues extends SignalStoreValues {
private static final String DISMISSED_CONTACTS_PERMISSION_BANNER = "uihints.dismissed_contacts_permission_banner";
private static final String HAS_SEEN_DELETE_SYNC_EDUCATION_SHEET = "uihints.has_seen_delete_sync_education_sheet";
private static final String LAST_SUPPORT_VERSION_SEEN = "uihints.last_support_version_seen";
private static final String HAS_EVER_ENABLED_REMOTE_BACKUPS = "uihints.has_ever_enabled_remote_backups";
UiHintValues(@NonNull KeyValueStore store) {
super(store);
@ -39,7 +40,7 @@ public class UiHintValues extends SignalStoreValues {
@Override
@NonNull List<String> getKeysToIncludeInBackup() {
return Arrays.asList(NEVER_DISPLAY_PULL_TO_FILTER_TIP, HAS_COMPLETED_USERNAME_ONBOARDING, HAS_SEEN_TEXT_FORMATTING_ALERT);
return Arrays.asList(NEVER_DISPLAY_PULL_TO_FILTER_TIP, HAS_COMPLETED_USERNAME_ONBOARDING, HAS_SEEN_TEXT_FORMATTING_ALERT, HAS_EVER_ENABLED_REMOTE_BACKUPS);
}
public void markHasSeenGroupSettingsMenuToast() {
@ -200,4 +201,12 @@ public class UiHintValues extends SignalStoreValues {
public void setLastSupportVersionSeen(int version) {
putInteger(LAST_SUPPORT_VERSION_SEEN, version);
}
public void markHasEverEnabledRemoteBackups() {
putBoolean(HAS_EVER_ENABLED_REMOTE_BACKUPS, true);
}
public boolean getHasEverEnabledRemoteBackups() {
return getBoolean(HAS_EVER_ENABLED_REMOTE_BACKUPS, false);
}
}

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M12 2.88c-5.04 0-9.13 4.08-9.13 9.12 0 2.34 0.88 4.47 2.33 6.09l0.48-0.48c0.41-0.4 1.11-0.21 1.26 0.35l0.76 2.96c0.14 0.56-0.37 1.06-0.92 0.92l-2.96-0.76c-0.57-0.15-0.76-0.85-0.35-1.26l0.5-0.5C2.2 17.4 1.11 14.83 1.11 12 1.13 6 6 1.12 12 1.12 18 1.13 22.88 6 22.88 12c0 6-4.87 10.88-10.88 10.88-0.48 0-0.88-0.4-0.88-0.88s0.4-0.88 0.88-0.88c5.04 0 9.13-4.08 9.13-9.12 0-5.04-4.09-9.13-9.13-9.13Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M10.74 5.23c0.02-0.4 0.35-0.73 0.76-0.73 0.4 0 0.74 0.32 0.76 0.73l0.2 6.31 4.31 0.2c0.41 0.02 0.73 0.35 0.73 0.76 0 0.4-0.32 0.74-0.73 0.76l-5.2 0.24H11.5c-0.55 0-1-0.45-1-1v-0.06l0.24-7.2Z"/>
</vector>

View file

@ -29,6 +29,13 @@
android:defaultValue="true"
app:argType="boolean" />
</action>
<action
android:id="@+id/action_appSettingsFragment_to_backupsSettingsFragment"
app:destination="@id/backupsSettingsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_appSettingsFragment_to_accountSettingsFragment"
app:destination="@id/accountSettingsFragment"
@ -960,20 +967,26 @@
</fragment>
<fragment
android:id="@+id/remoteBackupsSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.chats.backups.RemoteBackupsSettingsFragment">
android:id="@+id/backupsSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.backups.BackupsSettingsFragment">
<action
android:id="@+id/action_remoteBackupsSettingsFragment_to_backupsTypeSettingsFragment"
app:destination="@id/backupsTypeSettingsFragment"
android:id="@+id/action_backupsSettingsFragment_to_backupsPreferenceFragment"
app:destination="@id/backupsPreferenceFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<fragment
android:id="@+id/remoteBackupsSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.backups.remote.RemoteBackupsSettingsFragment">
<action
android:id="@+id/action_remoteBackupsSettingsFragment_to_remoteBackupsPaymentHistoryFragment"
app:destination="@id/remoteBackupsPaymentHistoryFragment"
android:id="@+id/action_remoteBackupsSettingsFragment_to_backupsTypeSettingsFragment"
app:destination="@id/backupsTypeSettingsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
@ -987,21 +1000,10 @@
<fragment
android:id="@+id/backupsTypeSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.chats.backups.type.BackupsTypeSettingsFragment">
android:name="org.thoughtcrime.securesms.components.settings.app.backups.type.BackupsTypeSettingsFragment">
<action
android:id="@+id/action_backupsTypeSettingsFragment_to_remoteBackupsPaymentHistoryFragment"
app:destination="@id/remoteBackupsPaymentHistoryFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<fragment
android:id="@+id/remoteBackupsPaymentHistoryFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.chats.backups.history.RemoteBackupsPaymentHistoryFragment" />
<include app:graph="@navigation/username_link_settings" />
<include app:graph="@navigation/story_privacy_settings" />

View file

@ -10,6 +10,13 @@
android:name="org.thoughtcrime.securesms.components.settings.app.AppSettingsFragment"
android:label="app_settings_fragment"
tools:layout="@layout/dsl_settings_fragment">
<action
android:id="@+id/action_appSettingsFragment_to_backupsSettingsFragment"
app:destination="@id/backupsSettingsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_appSettingsFragment_to_usernameLinkSettingsFragment"
app:destination="@id/username_link_settings"
@ -966,20 +973,34 @@
</fragment>
<fragment
android:id="@+id/remoteBackupsSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.chats.backups.RemoteBackupsSettingsFragment">
android:id="@+id/backupsSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.backups.BackupsSettingsFragment">
<action
android:id="@+id/action_remoteBackupsSettingsFragment_to_backupsTypeSettingsFragment"
app:destination="@id/backupsTypeSettingsFragment"
android:id="@+id/action_backupsSettingsFragment_to_backupsPreferenceFragment"
app:destination="@id/backupsPreferenceFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_remoteBackupsSettingsFragment_to_remoteBackupsPaymentHistoryFragment"
app:destination="@id/remoteBackupsPaymentHistoryFragment"
android:id="@+id/action_backupsSettingsFragment_to_remoteBackupsSettingsFragment"
app:destination="@id/remoteBackupsSettingsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<fragment
android:id="@+id/remoteBackupsSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.backups.remote.RemoteBackupsSettingsFragment">
<action
android:id="@+id/action_remoteBackupsSettingsFragment_to_backupsTypeSettingsFragment"
app:destination="@id/backupsTypeSettingsFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
@ -991,21 +1012,10 @@
android:defaultValue="false" />
</fragment>
<fragment
android:id="@+id/remoteBackupsPaymentHistoryFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.chats.backups.history.RemoteBackupsPaymentHistoryFragment"></fragment>
<fragment
android:id="@+id/backupsTypeSettingsFragment"
android:name="org.thoughtcrime.securesms.components.settings.app.chats.backups.type.BackupsTypeSettingsFragment">
android:name="org.thoughtcrime.securesms.components.settings.app.backups.type.BackupsTypeSettingsFragment">
<action
android:id="@+id/action_backupsTypeSettingsFragment_to_remoteBackupsPaymentHistoryFragment"
app:destination="@id/remoteBackupsPaymentHistoryFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<include app:graph="@navigation/username_link_settings" />

View file

@ -7336,19 +7336,15 @@
<!-- Educational bottom sheet confirm/dismiss button text shown to notify about delete syncs causing deletes to happen across all devices -->
<string name="DeleteSyncEducation_acknowledge_button">OK</string>
<!-- RemoteBackupsPaymentHistoryFragment -->
<!-- Title of the screen for payment history -->
<string name="RemoteBackupsPaymentHistoryFragment__payment_history">Payment history</string>
<!-- Description for backup rows -->
<string name="RemoteBackupsPaymentHistoryFragment__text_and_all_media_backup">Text and all media backup</string>
<!-- Title of the screen for payment details -->
<string name="RemoteBackupsPaymentHistoryFragment__payment_details">Payment details</string>
<!-- Title of row specifying the type of backup -->
<string name="RemoteBackupsPaymentHistoryFragment__backup_type">Backup type</string>
<!-- Title of row specifying the date the backup was paid on -->
<string name="RemoteBackupsPaymentHistoryFragment__date_paid">Date paid</string>
<!-- Button label to share the receipt -->
<string name="RemoteBackupsPaymentHistoryFragment__share">Share</string>
<!-- BackupsSettingsFragment -->
<!-- Subtitle for row for active backup, first placeholder is formatted amount, second is renewal date -->
<string name="BackupsSettingsFragment_s_month_renews_s">%1$s/month, renews %2$s</string>
<!-- Subtitle for row for active backup, placeholder is last date of backup -->
<string name="BackupsSettingsFragment_last_backup_s">Last backup %1$s</string>
<!-- Subtitle for row for no backup ever created -->
<string name="BackupsSettingsFragment_automatic_backups_with_signals">Automatic backups with Signal\'s secure end-to-end encrypted storage service.</string>
<!-- Action button label to set up backups -->
<string name="BackupsSettingsFragment_set_up">Set up</string>
<!-- RemoteBackupsSettingsFragment -->
<!-- Displayed on the title bar -->
@ -7376,13 +7372,27 @@
<!-- Snackbar text displayed when backup will be created overnight -->
<string name="RemoteBackupsSettingsFragment__backup_will_be_created_overnight">Backup will be created overnight.</string>
<!-- Title text in row detailing selected backup type -->
<string name="RemoteBackupsSettingsFragment__backup_type">Backup type</string>
<string name="RemoteBackupsSettingsFragment__backup_plan">Backup plan</string>
<!-- Subtitle text in row detailing selected backup type displayed when backups are disabled -->
<string name="RemoteBackupsSettingsFragment__backups_disabled">Backups disabled</string>
<!-- Format string for backup and cost. First placeholder is backup title, second is cost per month. -->
<string name="RemoteBackupsSettingsFragment__s_dot_s_per_month">%1$s · %2$s/month</string>
<!-- Button label to enable backups -->
<string name="RemoteBackupsSettingsFragment__enable_backups">Enable backups</string>
<!-- Format string for backup and cost. First placeholder is cost per month. -->
<string name="RemoteBackupsSettingsFragment__s_per_month">%1$s/month</string>
<!-- String for free backups -->
<string name="RemoteBackupsSettingsFragment__your_backup_plan_is_free">Your backup plan is free</string>
<!-- Displays the date the subscription will renew -->
<string name="RemoteBackupsSettingsFragment__renews_s">Renews %1$s</string>
<!-- Displayed at the top of the screen when no backup is currently enabled -->
<string name="RemoteBackupsSettingsFragment__back_up_your_message_history">Back up your message history so you never lose data when you get a new phone or reinstall Signal.</string>
<!-- Section header for other ways to back up -->
<string name="RemoteBackupsSettingsFragment__other_ways_to_backup">Other ways to backup</string>
<!-- Row title for performing on-device backup -->
<string name="RemoteBackupsSettingsFragment__on_device_backups">On-device backups</string>
<!-- Row label for performing on-device backup -->
<string name="RemoteBackupsSettingsFragment__save_your_backups_to">Save your backups to a folder on this device</string>
<!-- Button label to manage or cancel backups -->
<string name="RemoteBackupsSettingsFragment__manage_or_cancel">Manage or cancel</string>
<!-- button label to upgrade backups -->
<string name="RemoteBackupsSettingsFragment__upgrade">Upgrade</string>
<!-- Progress indicator subtext displayed when a backup is in progress -->
<string name="RemoteBackupsSettingsFragment__d_slash_d">%1$d/%2$d</string>
<!-- Title text in row detailing last backup -->