Add additional backup screen states.

This commit is contained in:
Alex Hart
2025-06-12 14:42:20 -03:00
committed by Michelle Tang
parent 9bde632c6d
commit a5ff92b831
9 changed files with 584 additions and 545 deletions

View File

@@ -0,0 +1,103 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.backups
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Describes the state of the user's selected backup tier.
*/
sealed interface BackupState {
/**
* Backups are not available on this device
*/
data object NotAvailable : BackupState
/**
* User has no active backup tier, no tier history
*/
data object None : BackupState
/**
* The exact backup state is being loaded from the network.
*/
data object Loading : BackupState
/**
* User has a paid backup subscription pending redemption
*/
data class Pending(
val price: FiatMoney
) : BackupState
/**
* A backup state with a type and renewal time
*/
sealed interface WithTypeAndRenewalTime : BackupState {
val messageBackupsType: MessageBackupsType
val renewalTime: Duration
fun isActive(): Boolean = false
}
/**
* User has an active paid backup. Pricing comes from the subscription object.
*/
data class ActivePaid(
override val messageBackupsType: MessageBackupsType.Paid,
val price: FiatMoney,
override val renewalTime: Duration
) : WithTypeAndRenewalTime {
override fun isActive(): Boolean = true
}
/**
* User has an active free backup.
*/
data class ActiveFree(
override val messageBackupsType: MessageBackupsType.Free,
override val renewalTime: Duration = 0.seconds
) : WithTypeAndRenewalTime {
override fun isActive(): Boolean = true
}
/**
* User has an inactive backup
*/
data class Inactive(
override val messageBackupsType: MessageBackupsType,
override val renewalTime: Duration = 0.seconds
) : WithTypeAndRenewalTime
/**
* User has an active backup but no active subscription
*/
data object NotFound : BackupState
/**
* User has a canceled paid tier backup
*/
data class Canceled(
override val messageBackupsType: MessageBackupsType,
override val renewalTime: Duration
) : WithTypeAndRenewalTime
/**
* Subscription mismatch detected.
*/
data class SubscriptionMismatchMissingGooglePlay(
override val messageBackupsType: MessageBackupsType,
override val renewalTime: Duration
) : WithTypeAndRenewalTime
/**
* An error occurred retrieving the network state
*/
data object Error : BackupState
}

View File

@@ -0,0 +1,257 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.backups
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.signal.core.util.billing.BillingPurchaseResult
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.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import java.math.BigDecimal
import java.util.Currency
import java.util.Locale
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
/**
* Manages BackupState information gathering for the UI.
*/
object BackupStateRepository {
private val TAG = Log.tag(BackupStateRepository::class)
suspend fun resolveBackupState(lastPurchase: InAppPaymentTable.InAppPayment?): BackupState {
if (lastPurchase?.state == InAppPaymentTable.State.PENDING) {
Log.d(TAG, "We have a pending subscription.")
return BackupState.Pending(
price = lastPurchase.data.amount!!.toFiatMoney()
)
}
if (SignalStore.backup.subscriptionStateMismatchDetected) {
Log.d(TAG, "[subscriptionStateMismatchDetected] A mismatch was detected.")
val hasActiveGooglePlayBillingSubscription = when (val purchaseResult = AppDependencies.billingApi.queryPurchases()) {
is BillingPurchaseResult.Success -> {
Log.d(TAG, "[subscriptionStateMismatchDetected] Found a purchase: $purchaseResult")
purchaseResult.isAcknowledged && purchaseResult.isAutoRenewing
}
else -> {
Log.d(TAG, "[subscriptionStateMismatchDetected] No purchase found in Google Play Billing: $purchaseResult")
false
}
} || SignalStore.backup.backupTierInternalOverride == MessageBackupTier.PAID
Log.d(TAG, "[subscriptionStateMismatchDetected] hasActiveGooglePlayBillingSubscription: $hasActiveGooglePlayBillingSubscription")
val activeSubscription = withContext(Dispatchers.IO) {
RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP).getOrNull()
}
val hasActiveSignalSubscription = activeSubscription?.isActive == true
Log.d(TAG, "[subscriptionStateMismatchDetected] hasActiveSignalSubscription: $hasActiveSignalSubscription")
when {
hasActiveSignalSubscription && !hasActiveGooglePlayBillingSubscription -> {
val type = buildPaidTypeFromSubscription(activeSubscription.activeSubscription)
if (type == null) {
Log.d(TAG, "[subscriptionMismatchDetected] failed to load backup configuration. Likely a network error.")
return BackupState.Error
}
return BackupState.SubscriptionMismatchMissingGooglePlay(
messageBackupsType = type,
renewalTime = activeSubscription.activeSubscription.endOfCurrentPeriod.seconds
)
}
hasActiveSignalSubscription && hasActiveGooglePlayBillingSubscription -> {
Log.d(TAG, "Found active signal subscription and active google play subscription. Clearing mismatch.")
SignalStore.backup.subscriptionStateMismatchDetected = false
}
!hasActiveSignalSubscription && !hasActiveGooglePlayBillingSubscription -> {
Log.d(TAG, "Found inactive signal subscription and inactive google play subscription. Clearing mismatch.")
SignalStore.backup.subscriptionStateMismatchDetected = false
}
else -> {
Log.w(TAG, "Hit unexpected subscription mismatch state: signal:false, google:true")
return BackupState.NotFound
}
}
}
return when (SignalStore.backup.latestBackupTier) {
MessageBackupTier.PAID -> {
getPaidBackupState(lastPurchase)
}
MessageBackupTier.FREE -> {
getFreeBackupState()
}
null -> {
Log.d(TAG, "Updating UI state with NONE null tier.")
return BackupState.None
}
}
}
private suspend fun getPaidBackupState(lastPurchase: InAppPaymentTable.InAppPayment?): BackupState {
Log.d(TAG, "Attempting to retrieve subscription details for active PAID backup.")
val type = withContext(Dispatchers.IO) {
BackupRepository.getBackupsType(MessageBackupTier.PAID) as? MessageBackupsType.Paid
}
Log.d(TAG, "Attempting to retrieve current subscription...")
val activeSubscription = withContext(Dispatchers.IO) {
RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP)
}
return if (activeSubscription.isSuccess) {
Log.d(TAG, "Retrieved subscription details.")
val subscription = activeSubscription.getOrThrow().activeSubscription
if (subscription != null) {
Log.d(TAG, "Subscription found. Updating UI state with subscription details. Status: ${subscription.status}")
val subscriberType = type ?: buildPaidTypeFromSubscription(subscription)
if (subscriberType == null) {
Log.d(TAG, "Failed to create backup type. Possible network error.")
BackupState.Error
} else {
when {
subscription.isCanceled && subscription.isActive -> BackupState.Canceled(
messageBackupsType = subscriberType,
renewalTime = subscription.endOfCurrentPeriod.seconds
)
subscription.isActive -> BackupState.ActivePaid(
messageBackupsType = subscriberType,
price = FiatMoney.fromSignalNetworkAmount(subscription.amount, Currency.getInstance(subscription.currency)),
renewalTime = subscription.endOfCurrentPeriod.seconds
)
else -> BackupState.Inactive(
messageBackupsType = subscriberType,
renewalTime = subscription.endOfCurrentPeriod.seconds
)
}
}
} else {
Log.d(TAG, "ActiveSubscription had null subscription object.")
if (SignalStore.backup.areBackupsEnabled) {
BackupState.NotFound
} else if (lastPurchase != null && lastPurchase.endOfPeriod > System.currentTimeMillis().milliseconds) {
val canceledType = type ?: buildPaidTypeFromInAppPayment(lastPurchase)
if (canceledType == null) {
Log.w(TAG, "Failed to load canceled type information. Possible network error.")
BackupState.Error
} else {
BackupState.Canceled(
messageBackupsType = canceledType,
renewalTime = lastPurchase.endOfPeriod
)
}
} else {
val inactiveType = type ?: buildPaidTypeWithoutPricing()
if (inactiveType == null) {
Log.w(TAG, "Failed to load inactive type information. Possible network error.")
BackupState.Error
} else {
BackupState.Inactive(
messageBackupsType = inactiveType,
renewalTime = lastPurchase?.endOfPeriod ?: 0.seconds
)
}
}
}
} else {
Log.d(TAG, "Failed to load ActiveSubscription data. Updating UI state with error.")
BackupState.Error
}
}
private suspend fun getFreeBackupState(): BackupState {
val type = withContext(Dispatchers.IO) {
BackupRepository.getBackupsType(MessageBackupTier.FREE) as MessageBackupsType.Free
}
val backupState = if (SignalStore.backup.areBackupsEnabled) {
BackupState.ActiveFree(type)
} else {
BackupState.Inactive(type)
}
Log.d(TAG, "Updating UI state with $backupState FREE tier.")
return backupState
}
/**
* Builds out a Paid type utilizing pricing information stored in the user's active subscription object.
*
* @return A paid type, or null if we were unable to get the backup level configuration.
*/
private fun buildPaidTypeFromSubscription(subscription: ActiveSubscription.Subscription): MessageBackupsType.Paid? {
val config = BackupRepository.getBackupLevelConfiguration() ?: return null
val price = FiatMoney.fromSignalNetworkAmount(subscription.amount, Currency.getInstance(subscription.currency))
return MessageBackupsType.Paid(
pricePerMonth = price,
storageAllowanceBytes = config.storageAllowanceBytes,
mediaTtl = config.mediaTtlDays.days
)
}
/**
* Builds out a Paid type utilizing pricing information stored in the given in-app payment.
*
* @return A paid type, or null if we were unable to get the backup level configuration.
*/
private fun buildPaidTypeFromInAppPayment(inAppPayment: InAppPaymentTable.InAppPayment): MessageBackupsType.Paid? {
val config = BackupRepository.getBackupLevelConfiguration() ?: return null
val price = inAppPayment.data.amount!!.toFiatMoney()
return MessageBackupsType.Paid(
pricePerMonth = price,
storageAllowanceBytes = config.storageAllowanceBytes,
mediaTtl = config.mediaTtlDays.days
)
}
/**
* In the case of an Inactive subscription, we only care about the storage allowance and TTL, both of which we can
* grab from the backup level configuration.
*
* @return A paid type, or null if we were unable to get the backup level configuration.
*/
private fun buildPaidTypeWithoutPricing(): MessageBackupsType? {
val config = BackupRepository.getBackupLevelConfiguration() ?: return null
return MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.ZERO, Currency.getInstance(Locale.getDefault())),
storageAllowanceBytes = config.storageAllowanceBytes,
mediaTtl = config.mediaTtlDays.days
)
}
}

View File

@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -33,6 +34,7 @@ import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -59,6 +61,7 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
import java.math.BigDecimal import java.math.BigDecimal
import java.util.Currency import java.util.Currency
import java.util.Locale import java.util.Locale
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import org.signal.core.ui.R as CoreUiR import org.signal.core.ui.R as CoreUiR
@@ -91,16 +94,16 @@ class BackupsSettingsFragment : ComposeFragment() {
backupsSettingsState = state, backupsSettingsState = state,
onNavigationClick = { requireActivity().onNavigateUp() }, onNavigationClick = { requireActivity().onNavigateUp() },
onBackupsRowClick = { onBackupsRowClick = {
when (state.enabledState) { when (state.backupState) {
is BackupsSettingsState.EnabledState.Active, BackupsSettingsState.EnabledState.Inactive -> { BackupState.Loading, BackupState.Error, BackupState.NotAvailable -> Unit
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_remoteBackupsSettingsFragment)
}
BackupsSettingsState.EnabledState.Never -> { BackupState.None -> {
checkoutLauncher.launch(null) checkoutLauncher.launch(null)
} }
else -> Unit else -> {
findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_remoteBackupsSettingsFragment)
}
} }
}, },
onOnDeviceBackupsRowClick = { findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_backupsPreferenceFragment) }, onOnDeviceBackupsRowClick = { findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_backupsPreferenceFragment) },
@@ -152,14 +155,14 @@ private fun BackupsSettingsContent(
} }
item { item {
when (backupsSettingsState.enabledState) { when (backupsSettingsState.backupState) {
BackupsSettingsState.EnabledState.Loading -> { BackupState.Loading -> {
LoadingBackupsRow() LoadingBackupsRow()
OtherWaysToBackUpHeading() OtherWaysToBackUpHeading()
} }
BackupsSettingsState.EnabledState.Inactive -> { is BackupState.Inactive -> {
InactiveBackupsRow( InactiveBackupsRow(
onBackupsRowClick = onBackupsRowClick onBackupsRowClick = onBackupsRowClick
) )
@@ -167,16 +170,17 @@ private fun BackupsSettingsContent(
OtherWaysToBackUpHeading() OtherWaysToBackUpHeading()
} }
is BackupsSettingsState.EnabledState.Active -> { is BackupState.ActiveFree, is BackupState.ActivePaid -> {
ActiveBackupsRow( ActiveBackupsRow(
enabledState = backupsSettingsState.enabledState, backupState = backupsSettingsState.backupState,
onBackupsRowClick = onBackupsRowClick onBackupsRowClick = onBackupsRowClick,
lastBackupAt = backupsSettingsState.lastBackupAt
) )
OtherWaysToBackUpHeading() OtherWaysToBackUpHeading()
} }
BackupsSettingsState.EnabledState.Never -> { BackupState.None -> {
NeverEnabledBackupsRow( NeverEnabledBackupsRow(
onBackupsRowClick = onBackupsRowClick onBackupsRowClick = onBackupsRowClick
) )
@@ -184,12 +188,46 @@ private fun BackupsSettingsContent(
OtherWaysToBackUpHeading() OtherWaysToBackUpHeading()
} }
BackupsSettingsState.EnabledState.Failed -> { BackupState.Error -> {
WaitingForNetworkRow() WaitingForNetworkRow()
OtherWaysToBackUpHeading() OtherWaysToBackUpHeading()
} }
BackupsSettingsState.EnabledState.NotAvailable -> Unit BackupState.NotAvailable -> Unit
BackupState.NotFound -> {
NotFoundBackupRow(
onBackupsRowClick = onBackupsRowClick
)
OtherWaysToBackUpHeading()
}
is BackupState.Pending -> {
PendingBackupRow(
onBackupsRowClick = onBackupsRowClick
)
OtherWaysToBackUpHeading()
}
is BackupState.Canceled -> {
ActiveBackupsRow(
backupState = backupsSettingsState.backupState,
lastBackupAt = backupsSettingsState.lastBackupAt
)
OtherWaysToBackUpHeading()
}
is BackupState.SubscriptionMismatchMissingGooglePlay -> {
ActiveBackupsRow(
backupState = backupsSettingsState.backupState,
lastBackupAt = backupsSettingsState.lastBackupAt
)
OtherWaysToBackUpHeading()
}
} }
} }
@@ -292,9 +330,75 @@ private fun InactiveBackupsRow(
) )
} }
@Composable
private fun NotFoundBackupRow(
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 {
TextWithBetaLabel(text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups))
Text(
text = stringResource(R.string.BackupsSettingsFragment_subscription_not_found_on_this_device),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
onClick = onBackupsRowClick
)
}
@Composable
private fun PendingBackupRow(
onBackupsRowClick: () -> Unit = {}
) {
Rows.TextRow(
modifier = Modifier.height(IntrinsicSize.Min),
icon = {
Box(
contentAlignment = Alignment.TopCenter,
modifier = Modifier
.fillMaxHeight()
.padding(top = 12.dp)
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp)
)
}
},
text = {
Column {
TextWithBetaLabel(text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups))
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__payment_pending),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium
)
}
},
onClick = onBackupsRowClick
)
}
@Composable @Composable
private fun ActiveBackupsRow( private fun ActiveBackupsRow(
enabledState: BackupsSettingsState.EnabledState.Active, backupState: BackupState.WithTypeAndRenewalTime,
lastBackupAt: Duration,
onBackupsRowClick: () -> Unit = {} onBackupsRowClick: () -> Unit = {}
) { ) {
Rows.TextRow( Rows.TextRow(
@@ -316,13 +420,13 @@ private fun ActiveBackupsRow(
Column { Column {
TextWithBetaLabel(text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups)) TextWithBetaLabel(text = stringResource(R.string.RemoteBackupsSettingsFragment__signal_backups))
when (enabledState.type) { when (val type = backupState.messageBackupsType) {
is MessageBackupsType.Paid -> { is MessageBackupsType.Paid -> {
Text( Text(
text = stringResource( text = stringResource(
R.string.BackupsSettingsFragment_s_month_renews_s, R.string.BackupsSettingsFragment_s_month_renews_s,
FiatMoneyUtil.format(LocalContext.current.resources, enabledState.type.pricePerMonth), FiatMoneyUtil.format(LocalContext.current.resources, type.pricePerMonth),
DateUtils.formatDateWithYear(Locale.getDefault(), enabledState.expiresAt.inWholeMilliseconds) DateUtils.formatDateWithYear(Locale.getDefault(), backupState.renewalTime.inWholeMilliseconds)
), ),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium
@@ -346,7 +450,7 @@ private fun ActiveBackupsRow(
DateUtils.getDatelessRelativeTimeSpanFormattedDate( DateUtils.getDatelessRelativeTimeSpanFormattedDate(
LocalContext.current, LocalContext.current,
Locale.getDefault(), Locale.getDefault(),
enabledState.lastBackupAt.inWholeMilliseconds lastBackupAt.inWholeMilliseconds
).value ).value
), ),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -404,14 +508,14 @@ private fun BackupsSettingsContentPreview() {
Previews.Preview { Previews.Preview {
BackupsSettingsContent( BackupsSettingsContent(
backupsSettingsState = BackupsSettingsState( backupsSettingsState = BackupsSettingsState(
enabledState = BackupsSettingsState.EnabledState.Active( backupState = BackupState.ActivePaid(
type = MessageBackupsType.Paid( messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(4), Currency.getInstance("CAD")), pricePerMonth = FiatMoney(BigDecimal.valueOf(4), Currency.getInstance("CAD")),
storageAllowanceBytes = 1_000_000, storageAllowanceBytes = 1_000_000,
mediaTtl = 30.days mediaTtl = 30.days
), ),
expiresAt = 0.seconds, renewalTime = 0.seconds,
lastBackupAt = 0.seconds price = FiatMoney(BigDecimal.valueOf(4), Currency.getInstance("CAD"))
) )
) )
) )
@@ -424,7 +528,7 @@ private fun BackupsSettingsContentNotAvailablePreview() {
Previews.Preview { Previews.Preview {
BackupsSettingsContent( BackupsSettingsContent(
backupsSettingsState = BackupsSettingsState( backupsSettingsState = BackupsSettingsState(
enabledState = BackupsSettingsState.EnabledState.NotAvailable backupState = BackupState.NotAvailable
) )
) )
} }
@@ -436,7 +540,7 @@ private fun BackupsSettingsContentBackupTierInternalOverridePreview() {
Previews.Preview { Previews.Preview {
BackupsSettingsContent( BackupsSettingsContent(
backupsSettingsState = BackupsSettingsState( backupsSettingsState = BackupsSettingsState(
enabledState = BackupsSettingsState.EnabledState.Never, backupState = BackupState.None,
showBackupTierInternalOverride = true, showBackupTierInternalOverride = true,
backupTierInternalOverride = null backupTierInternalOverride = null
) )
@@ -460,20 +564,37 @@ private fun InactiveBackupsRowPreview() {
} }
} }
@SignalPreview
@Composable
private fun NotFoundBackupRowPreview() {
Previews.Preview {
NotFoundBackupRow()
}
}
@SignalPreview
@Composable
private fun PendingBackupRowPreview() {
Previews.Preview {
PendingBackupRow()
}
}
@SignalPreview @SignalPreview
@Composable @Composable
private fun ActivePaidBackupsRowPreview() { private fun ActivePaidBackupsRowPreview() {
Previews.Preview { Previews.Preview {
ActiveBackupsRow( ActiveBackupsRow(
enabledState = BackupsSettingsState.EnabledState.Active( backupState = BackupState.ActivePaid(
type = MessageBackupsType.Paid( messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(4), Currency.getInstance("CAD")), pricePerMonth = FiatMoney(BigDecimal.valueOf(4), Currency.getInstance("CAD")),
storageAllowanceBytes = 1_000_000, storageAllowanceBytes = 1_000_000,
mediaTtl = 30.days mediaTtl = 30.days
), ),
expiresAt = 0.seconds, renewalTime = 0.seconds,
lastBackupAt = 0.seconds price = FiatMoney(BigDecimal.valueOf(4), Currency.getInstance("CAD"))
) ),
lastBackupAt = 0.seconds
) )
} }
} }
@@ -483,13 +604,13 @@ private fun ActivePaidBackupsRowPreview() {
private fun ActiveFreeBackupsRowPreview() { private fun ActiveFreeBackupsRowPreview() {
Previews.Preview { Previews.Preview {
ActiveBackupsRow( ActiveBackupsRow(
enabledState = BackupsSettingsState.EnabledState.Active( backupState = BackupState.ActiveFree(
type = MessageBackupsType.Free( messageBackupsType = MessageBackupsType.Free(
mediaRetentionDays = 30 mediaRetentionDays = 30
), ),
expiresAt = 0.seconds, renewalTime = 0.seconds
lastBackupAt = 0.seconds ),
) lastBackupAt = 0.seconds
) )
} }
} }

View File

@@ -6,49 +6,16 @@
package org.thoughtcrime.securesms.components.settings.app.backups package org.thoughtcrime.securesms.components.settings.app.backups
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType import org.thoughtcrime.securesms.keyvalue.SignalStore
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
/** /**
* Screen state for top-level backups settings screen. * Screen state for top-level backups settings screen.
*/ */
data class BackupsSettingsState( data class BackupsSettingsState(
val enabledState: EnabledState = EnabledState.Loading, val backupState: BackupState = BackupState.Loading,
val lastBackupAt: Duration = SignalStore.backup.lastBackupTime.milliseconds,
val showBackupTierInternalOverride: Boolean = false, val showBackupTierInternalOverride: Boolean = false,
val backupTierInternalOverride: MessageBackupTier? = null val backupTierInternalOverride: MessageBackupTier? = null
) { )
/**
* Describes the 'enabled' state of backups.
*/
sealed interface EnabledState {
/**
* Loading data for this row
*/
data object Loading : EnabledState
/**
* Google Play Billing is not available on this device
*/
data object NotAvailable : 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

@@ -20,21 +20,16 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.asFlow import kotlinx.coroutines.rx3.asFlow
import org.signal.core.util.concurrent.SignalDispatchers import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.backup.DeletionState import org.thoughtcrime.securesms.backup.DeletionState
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.Environment import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.InternetConnectionObserver import org.thoughtcrime.securesms.util.InternetConnectionObserver
import org.thoughtcrime.securesms.util.RemoteConfig import org.thoughtcrime.securesms.util.RemoteConfig
import java.util.Currency
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class BackupsSettingsViewModel : ViewModel() { class BackupsSettingsViewModel : ViewModel() {
@@ -56,7 +51,7 @@ class BackupsSettingsViewModel : ViewModel() {
.drop(1) .drop(1)
.collect { .collect {
Log.d(TAG, "Triggering refresh from internet reconnect.") Log.d(TAG, "Triggering refresh from internet reconnect.")
loadRequests.tryEmit(Unit) loadRequests.emit(Unit)
} }
} }
@@ -67,6 +62,13 @@ class BackupsSettingsViewModel : ViewModel() {
Log.d(TAG, "-- Completed state load.") Log.d(TAG, "-- Completed state load.")
} }
} }
viewModelScope.launch(SignalDispatchers.Default) {
InAppPaymentsRepository.observeLatestBackupPayment().collect {
Log.d(TAG, "Triggering refresh from payment state change.")
loadRequests.emit(Unit)
}
}
} }
override fun onCleared() { override fun onCleared() {
@@ -75,7 +77,9 @@ class BackupsSettingsViewModel : ViewModel() {
fun refreshState() { fun refreshState() {
Log.d(TAG, "Refreshing state from manual call.") Log.d(TAG, "Refreshing state from manual call.")
loadRequests.tryEmit(Unit) viewModelScope.launch(SignalDispatchers.Default) {
loadRequests.emit(Unit)
}
} }
@WorkerThread @WorkerThread
@@ -83,18 +87,16 @@ class BackupsSettingsViewModel : ViewModel() {
return viewModelScope.launch(SignalDispatchers.IO) { return viewModelScope.launch(SignalDispatchers.IO) {
if (!RemoteConfig.messageBackups) { if (!RemoteConfig.messageBackups) {
Log.w(TAG, "Remote backups are not available on this device.") Log.w(TAG, "Remote backups are not available on this device.")
internalStateFlow.update { it.copy(enabledState = BackupsSettingsState.EnabledState.NotAvailable, showBackupTierInternalOverride = false) } internalStateFlow.update { it.copy(backupState = BackupState.NotAvailable, showBackupTierInternalOverride = false) }
} else { } else {
val enabledState = when (SignalStore.backup.backupTier) { val latestPurchase = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_BACKUP)
MessageBackupTier.FREE -> getEnabledStateForFreeTier() val enabledState = BackupStateRepository.resolveBackupState(latestPurchase)
MessageBackupTier.PAID -> getEnabledStateForPaidTier()
null -> getEnabledStateForNoTier()
}
Log.d(TAG, "Found enabled state $enabledState. Updating UI state.") Log.d(TAG, "Found enabled state $enabledState. Updating UI state.")
internalStateFlow.update { internalStateFlow.update {
it.copy( it.copy(
enabledState = enabledState, backupState = enabledState,
lastBackupAt = SignalStore.backup.lastBackupTime.milliseconds,
showBackupTierInternalOverride = RemoteConfig.internalUser || Environment.IS_STAGING, showBackupTierInternalOverride = RemoteConfig.internalUser || Environment.IS_STAGING,
backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride
) )
@@ -108,62 +110,4 @@ class BackupsSettingsViewModel : ViewModel() {
SignalStore.backup.deletionState = DeletionState.NONE SignalStore.backup.deletionState = DeletionState.NONE
refreshState() refreshState()
} }
private suspend fun getEnabledStateForFreeTier(): BackupsSettingsState.EnabledState {
return try {
Log.d(TAG, "Attempting to grab enabled state for free tier.")
val backupType = BackupRepository.getBackupsType(MessageBackupTier.FREE)!!
Log.d(TAG, "Retrieved backup type. Returning active state...")
BackupsSettingsState.EnabledState.Active(
expiresAt = 0.seconds,
lastBackupAt = SignalStore.backup.lastBackupTime.milliseconds,
type = backupType
)
} catch (e: Exception) {
Log.w(TAG, "Failed to build enabled state.", e)
BackupsSettingsState.EnabledState.Failed
}
}
@WorkerThread
private fun getEnabledStateForPaidTier(): BackupsSettingsState.EnabledState {
return try {
Log.d(TAG, "Attempting to grab enabled state for paid tier.")
val backupConfiguration = BackupRepository.getBackupLevelConfiguration() ?: return BackupsSettingsState.EnabledState.Failed
Log.d(TAG, "Retrieved backup type. Grabbing active subscription...")
val activeSubscription = RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP).getOrThrow()
Log.d(TAG, "Retrieved subscription. Active? ${activeSubscription.isActive}")
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 = backupConfiguration.storageAllowanceBytes,
mediaTtl = backupConfiguration.mediaTtlDays.days
)
)
} else {
BackupsSettingsState.EnabledState.Inactive
}
} catch (e: Exception) {
Log.w(TAG, "Failed to build enabled state.", e)
BackupsSettingsState.EnabledState.Failed
}
}
private fun getEnabledStateForNoTier(): BackupsSettingsState.EnabledState {
Log.d(TAG, "Grabbing enabled state for no tier.")
return if (SignalStore.uiHints.hasEverEnabledRemoteBackups) {
BackupsSettingsState.EnabledState.Inactive
} else {
BackupsSettingsState.EnabledState.Never
}
}
} }

View File

@@ -105,6 +105,7 @@ import org.thoughtcrime.securesms.billing.launchManageBackupsSubscription
import org.thoughtcrime.securesms.components.compose.BetaHeader import org.thoughtcrime.securesms.components.compose.BetaHeader
import org.thoughtcrime.securesms.components.compose.TextWithBetaLabel import org.thoughtcrime.securesms.components.compose.TextWithBetaLabel
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.backups.BackupState
import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBackupsCheckoutLauncher.createBackupsCheckoutLauncher import org.thoughtcrime.securesms.components.settings.app.subscription.MessageBackupsCheckoutLauncher.createBackupsCheckoutLauncher
import org.thoughtcrime.securesms.compose.ComposeFragment import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.compose.StatusBarColorNestedScrollConnection import org.thoughtcrime.securesms.compose.StatusBarColorNestedScrollConnection
@@ -448,19 +449,19 @@ private fun RemoteBackupsSettingsContent(
item { item {
when (state.backupState) { when (state.backupState) {
is RemoteBackupsSettingsState.BackupState.Loading -> { is BackupState.Loading -> {
LoadingCard() LoadingCard()
} }
is RemoteBackupsSettingsState.BackupState.Error -> { is BackupState.Error -> {
ErrorCard() ErrorCard()
} }
is RemoteBackupsSettingsState.BackupState.Pending -> { is BackupState.Pending -> {
PendingCard(state.backupState.price) PendingCard(state.backupState.price)
} }
is RemoteBackupsSettingsState.BackupState.SubscriptionMismatchMissingGooglePlay -> { is BackupState.SubscriptionMismatchMissingGooglePlay -> {
SubscriptionMismatchMissingGooglePlayCard( SubscriptionMismatchMissingGooglePlayCard(
state = state.backupState, state = state.backupState,
onLearnMoreClick = contentCallbacks::onLearnMoreAboutLostSubscription, onLearnMoreClick = contentCallbacks::onLearnMoreAboutLostSubscription,
@@ -469,9 +470,9 @@ private fun RemoteBackupsSettingsContent(
) )
} }
RemoteBackupsSettingsState.BackupState.None -> Unit BackupState.None -> Unit
is RemoteBackupsSettingsState.BackupState.WithTypeAndRenewalTime -> { is BackupState.WithTypeAndRenewalTime -> {
BackupCard( BackupCard(
backupState = state.backupState, backupState = state.backupState,
onBackupTypeActionButtonClicked = contentCallbacks::onBackupTypeActionClick, onBackupTypeActionButtonClicked = contentCallbacks::onBackupTypeActionClick,
@@ -479,7 +480,7 @@ private fun RemoteBackupsSettingsContent(
) )
} }
RemoteBackupsSettingsState.BackupState.NotFound -> { BackupState.NotFound -> {
SubscriptionNotFoundCard( SubscriptionNotFoundCard(
title = stringResource(R.string.RemoteBackupsSettingsFragment__your_subscription_was_not_found), title = stringResource(R.string.RemoteBackupsSettingsFragment__your_subscription_was_not_found),
onRenewClick = contentCallbacks::onRenewLostSubscription, onRenewClick = contentCallbacks::onRenewLostSubscription,
@@ -487,6 +488,8 @@ private fun RemoteBackupsSettingsContent(
isRenewEnabled = backupDeleteState != DeletionState.DELETE_BACKUPS isRenewEnabled = backupDeleteState != DeletionState.DELETE_BACKUPS
) )
} }
BackupState.NotAvailable -> error("This shouldn't happen on this screen.")
} }
} }
@@ -582,7 +585,7 @@ private fun RemoteBackupsSettingsContent(
SkipDownloadDuringDeleteDialog() SkipDownloadDuringDeleteDialog()
} else { } else {
SkipDownloadDialog( SkipDownloadDialog(
renewalTime = if (state.backupState is RemoteBackupsSettingsState.BackupState.WithTypeAndRenewalTime) { renewalTime = if (state.backupState is BackupState.WithTypeAndRenewalTime) {
state.backupState.renewalTime state.backupState.renewalTime
} else { } else {
error("Unexpected dialog display without renewal time.") error("Unexpected dialog display without renewal time.")
@@ -800,7 +803,7 @@ private fun DescriptionText(
} }
private fun LazyListScope.appendBackupDetailsItems( private fun LazyListScope.appendBackupDetailsItems(
backupState: RemoteBackupsSettingsState.BackupState, backupState: BackupState,
canViewBackupKey: Boolean, canViewBackupKey: Boolean,
backupRestoreState: BackupRestoreState, backupRestoreState: BackupRestoreState,
backupProgress: ArchiveUploadProgressState?, backupProgress: ArchiveUploadProgressState?,
@@ -853,7 +856,7 @@ private fun LazyListScope.appendBackupDetailsItems(
} }
} }
if (backupState !is RemoteBackupsSettingsState.BackupState.ActiveFree) { if (backupState !is BackupState.ActiveFree) {
item { item {
Rows.TextRow(text = { Rows.TextRow(text = {
Column { Column {
@@ -923,7 +926,7 @@ private fun LazyListScope.appendBackupDetailsItems(
@Composable @Composable
private fun BackupCard( private fun BackupCard(
backupState: RemoteBackupsSettingsState.BackupState.WithTypeAndRenewalTime, backupState: BackupState.WithTypeAndRenewalTime,
buttonsEnabled: Boolean, buttonsEnabled: Boolean,
onBackupTypeActionButtonClicked: (MessageBackupTier) -> Unit = {} onBackupTypeActionButtonClicked: (MessageBackupTier) -> Unit = {}
) { ) {
@@ -950,21 +953,21 @@ private fun BackupCard(
) )
when (backupState) { when (backupState) {
is RemoteBackupsSettingsState.BackupState.ActivePaid -> { is BackupState.ActivePaid -> {
Text( Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__s_per_month, FiatMoneyUtil.format(LocalContext.current.resources, backupState.price)), text = stringResource(R.string.RemoteBackupsSettingsFragment__s_per_month, FiatMoneyUtil.format(LocalContext.current.resources, backupState.price)),
modifier = Modifier.padding(top = 12.dp) modifier = Modifier.padding(top = 12.dp)
) )
} }
is RemoteBackupsSettingsState.BackupState.ActiveFree -> { is BackupState.ActiveFree -> {
Text( Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__your_backup_plan_is_free), text = stringResource(R.string.RemoteBackupsSettingsFragment__your_backup_plan_is_free),
modifier = Modifier.padding(top = 12.dp) modifier = Modifier.padding(top = 12.dp)
) )
} }
is RemoteBackupsSettingsState.BackupState.Inactive -> { is BackupState.Inactive -> {
val text = when (messageBackupsType) { val text = when (messageBackupsType) {
is MessageBackupsType.Paid -> stringResource(R.string.RemoteBackupsSettingsFragment__subscription_inactive) is MessageBackupsType.Paid -> stringResource(R.string.RemoteBackupsSettingsFragment__subscription_inactive)
is MessageBackupsType.Free -> stringResource(R.string.RemoteBackupsSettingsFragment__you_turned_off_backups) is MessageBackupsType.Free -> stringResource(R.string.RemoteBackupsSettingsFragment__you_turned_off_backups)
@@ -978,7 +981,7 @@ private fun BackupCard(
) )
} }
is RemoteBackupsSettingsState.BackupState.Canceled -> { is BackupState.Canceled -> {
Text( Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__subscription_cancelled), text = stringResource(R.string.RemoteBackupsSettingsFragment__subscription_cancelled),
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
@@ -992,9 +995,9 @@ private fun BackupCard(
if (messageBackupsType is MessageBackupsType.Paid) { if (messageBackupsType is MessageBackupsType.Paid) {
val resource = when (backupState) { val resource = when (backupState) {
is RemoteBackupsSettingsState.BackupState.ActivePaid -> R.string.RemoteBackupsSettingsFragment__renews_s is BackupState.ActivePaid -> R.string.RemoteBackupsSettingsFragment__renews_s
is RemoteBackupsSettingsState.BackupState.Inactive -> R.string.RemoteBackupsSettingsFragment__expired_on_s is BackupState.Inactive -> R.string.RemoteBackupsSettingsFragment__expired_on_s
is RemoteBackupsSettingsState.BackupState.Canceled -> R.string.RemoteBackupsSettingsFragment__expires_on_s is BackupState.Canceled -> R.string.RemoteBackupsSettingsFragment__expires_on_s
else -> error("Not supported here.") else -> error("Not supported here.")
} }
@@ -1026,7 +1029,7 @@ private fun BackupCard(
enabled = buttonsEnabled, enabled = buttonsEnabled,
onClick = { onBackupTypeActionButtonClicked(messageBackupsType.tier) } onClick = { onBackupTypeActionButtonClicked(messageBackupsType.tier) }
) )
} else if (backupState is RemoteBackupsSettingsState.BackupState.Canceled) { } else if (backupState is BackupState.Canceled) {
CallToActionButton( CallToActionButton(
text = stringResource(R.string.RemoteBackupsSettingsFragment__resubscribe), text = stringResource(R.string.RemoteBackupsSettingsFragment__resubscribe),
enabled = buttonsEnabled, enabled = buttonsEnabled,
@@ -1288,7 +1291,7 @@ private fun SubscriptionNotFoundCard(
@Composable @Composable
private fun SubscriptionMismatchMissingGooglePlayCard( private fun SubscriptionMismatchMissingGooglePlayCard(
state: RemoteBackupsSettingsState.BackupState.SubscriptionMismatchMissingGooglePlay, state: BackupState.SubscriptionMismatchMissingGooglePlay,
isRenewEnabled: Boolean, isRenewEnabled: Boolean,
onRenewClick: () -> Unit = {}, onRenewClick: () -> Unit = {},
onLearnMoreClick: () -> Unit = {} onLearnMoreClick: () -> Unit = {}
@@ -1662,10 +1665,10 @@ private fun BackupFrequencyDialog(
@Composable @Composable
private fun BackupReadyToDownloadRow( private fun BackupReadyToDownloadRow(
ready: BackupRestoreState.Ready, ready: BackupRestoreState.Ready,
backupState: RemoteBackupsSettingsState.BackupState, backupState: BackupState,
onDownloadClick: () -> Unit = {} onDownloadClick: () -> Unit = {}
) { ) {
val string = if (backupState is RemoteBackupsSettingsState.BackupState.Canceled) { val string = if (backupState is BackupState.Canceled) {
stringResource(R.string.RemoteBackupsSettingsFragment__you_have_s_of_backup_data, ready.bytes) stringResource(R.string.RemoteBackupsSettingsFragment__you_have_s_of_backup_data, ready.bytes)
} else { } else {
stringResource(R.string.RemoteBackupsSettingsFragment__you_have_s_of_backup_data_not_on_device, ready.bytes) stringResource(R.string.RemoteBackupsSettingsFragment__you_have_s_of_backup_data_not_on_device, ready.bytes)
@@ -1719,7 +1722,7 @@ private fun RemoteBackupsSettingsContentPreview() {
dialog = RemoteBackupsSettingsState.Dialog.NONE, dialog = RemoteBackupsSettingsState.Dialog.NONE,
snackbar = RemoteBackupsSettingsState.Snackbar.NONE, snackbar = RemoteBackupsSettingsState.Snackbar.NONE,
backupMediaSize = 2300000, backupMediaSize = 2300000,
backupState = RemoteBackupsSettingsState.BackupState.ActiveFree( backupState = BackupState.ActiveFree(
messageBackupsType = MessageBackupsType.Free(mediaRetentionDays = 30) messageBackupsType = MessageBackupsType.Free(mediaRetentionDays = 30)
), ),
hasRedemptionError = true, hasRedemptionError = true,
@@ -1784,7 +1787,7 @@ private fun SubscriptionNotFoundCardPreview() {
private fun SubscriptionMismatchMissingGooglePlayCardPreview() { private fun SubscriptionMismatchMissingGooglePlayCardPreview() {
Previews.Preview { Previews.Preview {
SubscriptionMismatchMissingGooglePlayCard( SubscriptionMismatchMissingGooglePlayCard(
state = RemoteBackupsSettingsState.BackupState.SubscriptionMismatchMissingGooglePlay( state = BackupState.SubscriptionMismatchMissingGooglePlay(
messageBackupsType = MessageBackupsType.Paid( messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")), pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000, storageAllowanceBytes = 100_000_000,
@@ -1803,7 +1806,7 @@ private fun BackupCardPreview() {
Previews.Preview { Previews.Preview {
Column { Column {
BackupCard( BackupCard(
backupState = RemoteBackupsSettingsState.BackupState.ActivePaid( backupState = BackupState.ActivePaid(
messageBackupsType = MessageBackupsType.Paid( messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")), pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000, storageAllowanceBytes = 100_000_000,
@@ -1816,7 +1819,7 @@ private fun BackupCardPreview() {
) )
BackupCard( BackupCard(
backupState = RemoteBackupsSettingsState.BackupState.Canceled( backupState = BackupState.Canceled(
messageBackupsType = MessageBackupsType.Paid( messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")), pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000, storageAllowanceBytes = 100_000_000,
@@ -1828,7 +1831,7 @@ private fun BackupCardPreview() {
) )
BackupCard( BackupCard(
backupState = RemoteBackupsSettingsState.BackupState.Inactive( backupState = BackupState.Inactive(
messageBackupsType = MessageBackupsType.Paid( messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")), pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000, storageAllowanceBytes = 100_000_000,
@@ -1840,7 +1843,7 @@ private fun BackupCardPreview() {
) )
BackupCard( BackupCard(
backupState = RemoteBackupsSettingsState.BackupState.ActivePaid( backupState = BackupState.ActivePaid(
messageBackupsType = MessageBackupsType.Paid( messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")), pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000, storageAllowanceBytes = 100_000_000,
@@ -1853,7 +1856,7 @@ private fun BackupCardPreview() {
) )
BackupCard( BackupCard(
backupState = RemoteBackupsSettingsState.BackupState.ActiveFree( backupState = BackupState.ActiveFree(
messageBackupsType = MessageBackupsType.Free( messageBackupsType = MessageBackupsType.Free(
mediaRetentionDays = 30 mediaRetentionDays = 30
) )
@@ -1870,7 +1873,7 @@ private fun BackupReadyToDownloadPreview() {
Previews.Preview { Previews.Preview {
BackupReadyToDownloadRow( BackupReadyToDownloadRow(
ready = BackupRestoreState.Ready("12GB"), ready = BackupRestoreState.Ready("12GB"),
backupState = RemoteBackupsSettingsState.BackupState.None backupState = BackupState.None
) )
} }
} }
@@ -1881,7 +1884,7 @@ private fun BackupReadyToDownloadAfterCancelPreview() {
Previews.Preview { Previews.Preview {
BackupReadyToDownloadRow( BackupReadyToDownloadRow(
ready = BackupRestoreState.Ready("12GB"), ready = BackupRestoreState.Ready("12GB"),
backupState = RemoteBackupsSettingsState.BackupState.Canceled( backupState = BackupState.Canceled(
messageBackupsType = MessageBackupsType.Paid( messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD")), pricePerMonth = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD")),
storageAllowanceBytes = 10.gibiBytes.bytes, storageAllowanceBytes = 10.gibiBytes.bytes,

View File

@@ -5,11 +5,8 @@
package org.thoughtcrime.securesms.components.settings.app.backups.remote package org.thoughtcrime.securesms.components.settings.app.backups.remote
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.backup.v2.BackupFrequency import org.thoughtcrime.securesms.backup.v2.BackupFrequency
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType import org.thoughtcrime.securesms.components.settings.app.backups.BackupState
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
data class RemoteBackupsSettingsState( data class RemoteBackupsSettingsState(
val backupsEnabled: Boolean, val backupsEnabled: Boolean,
@@ -27,94 +24,6 @@ data class RemoteBackupsSettingsState(
val snackbar: Snackbar = Snackbar.NONE val snackbar: Snackbar = Snackbar.NONE
) { ) {
/**
* Describes the state of the user's selected backup tier.
*/
sealed interface BackupState {
/**
* User has no active backup tier, no tier history
*/
data object None : BackupState
/**
* The exact backup state is being loaded from the network.
*/
data object Loading : BackupState
/**
* User has a paid backup subscription pending redemption
*/
data class Pending(
val price: FiatMoney
) : BackupState
/**
* A backup state with a type and renewal time
*/
sealed interface WithTypeAndRenewalTime : BackupState {
val messageBackupsType: MessageBackupsType
val renewalTime: Duration
fun isActive(): Boolean = false
}
/**
* User has an active paid backup. Pricing comes from the subscription object.
*/
data class ActivePaid(
override val messageBackupsType: MessageBackupsType.Paid,
val price: FiatMoney,
override val renewalTime: Duration
) : WithTypeAndRenewalTime {
override fun isActive(): Boolean = true
}
/**
* User has an active free backup.
*/
data class ActiveFree(
override val messageBackupsType: MessageBackupsType.Free,
override val renewalTime: Duration = 0.seconds
) : WithTypeAndRenewalTime {
override fun isActive(): Boolean = true
}
/**
* User has an inactive backup
*/
data class Inactive(
override val messageBackupsType: MessageBackupsType,
override val renewalTime: Duration = 0.seconds
) : WithTypeAndRenewalTime
/**
* User has an active backup but no active subscription
*/
data object NotFound : BackupState
/**
* User has a canceled paid tier backup
*/
data class Canceled(
override val messageBackupsType: MessageBackupsType,
override val renewalTime: Duration
) : WithTypeAndRenewalTime
/**
* Subscription mismatch detected.
*/
data class SubscriptionMismatchMissingGooglePlay(
override val messageBackupsType: MessageBackupsType,
override val renewalTime: Duration
) : WithTypeAndRenewalTime
/**
* An error occurred retrieving the network state
*/
data object Error : BackupState
}
enum class Dialog { enum class Dialog {
NONE, NONE,
TURN_OFF_AND_DELETE_BACKUPS, TURN_OFF_AND_DELETE_BACKUPS,

View File

@@ -20,11 +20,8 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.reactive.asFlow import kotlinx.coroutines.reactive.asFlow
import kotlinx.coroutines.withContext
import org.signal.core.util.billing.BillingPurchaseResult
import org.signal.core.util.bytes import org.signal.core.util.bytes
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.core.util.throttleLatest import org.signal.core.util.throttleLatest
import org.signal.donations.InAppPaymentType import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.backup.ArchiveUploadProgress import org.thoughtcrime.securesms.backup.ArchiveUploadProgress
@@ -35,25 +32,17 @@ import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatusData import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatusData
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.banner.banners.MediaRestoreProgressBanner import org.thoughtcrime.securesms.banner.banners.MediaRestoreProgressBanner
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney import org.thoughtcrime.securesms.components.settings.app.backups.BackupStateRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.attachmentUpdates import org.thoughtcrime.securesms.database.attachmentUpdates
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.BackupMessagesJob import org.thoughtcrime.securesms.jobs.BackupMessagesJob
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.service.MessageBackupListener import org.thoughtcrime.securesms.service.MessageBackupListener
import org.thoughtcrime.securesms.util.TextSecurePreferences import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import java.math.BigDecimal
import java.util.Currency
import java.util.Locale
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
/** /**
@@ -233,8 +222,6 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
} }
private suspend fun performStateRefresh(lastPurchase: InAppPaymentTable.InAppPayment?) { private suspend fun performStateRefresh(lastPurchase: InAppPaymentTable.InAppPayment?) {
val tier = SignalStore.backup.latestBackupTier
_state.update { _state.update {
it.copy( it.copy(
backupsEnabled = SignalStore.backup.areBackupsEnabled, backupsEnabled = SignalStore.backup.areBackupsEnabled,
@@ -243,7 +230,8 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
backupsFrequency = SignalStore.backup.backupFrequency, backupsFrequency = SignalStore.backup.backupFrequency,
canBackUpUsingCellular = SignalStore.backup.backupWithCellular, canBackUpUsingCellular = SignalStore.backup.backupWithCellular,
canRestoreUsingCellular = SignalStore.backup.restoreWithCellular, canRestoreUsingCellular = SignalStore.backup.restoreWithCellular,
isOutOfStorageSpace = BackupRepository.shouldDisplayOutOfStorageSpaceUx() isOutOfStorageSpace = BackupRepository.shouldDisplayOutOfStorageSpaceUx(),
hasRedemptionError = lastPurchase?.data?.error?.data_ == "409"
) )
} }
@@ -258,264 +246,9 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
} }
} }
if (lastPurchase?.state == InAppPaymentTable.State.PENDING) { val state = BackupStateRepository.resolveBackupState(lastPurchase)
Log.d(TAG, "We have a pending subscription.") _state.update {
_state.update { it.copy(backupState = state)
it.copy(
backupState = RemoteBackupsSettingsState.BackupState.Pending(
price = lastPurchase.data.amount!!.toFiatMoney()
)
)
}
return
} }
if (SignalStore.backup.subscriptionStateMismatchDetected) {
Log.d(TAG, "[subscriptionStateMismatchDetected] A mismatch was detected.")
val hasActiveGooglePlayBillingSubscription = when (val purchaseResult = AppDependencies.billingApi.queryPurchases()) {
is BillingPurchaseResult.Success -> {
Log.d(TAG, "[subscriptionStateMismatchDetected] Found a purchase: $purchaseResult")
purchaseResult.isAcknowledged && purchaseResult.isAutoRenewing
}
else -> {
Log.d(TAG, "[subscriptionStateMismatchDetected] No purchase found in Google Play Billing: $purchaseResult")
false
}
} || SignalStore.backup.backupTierInternalOverride == MessageBackupTier.PAID
Log.d(TAG, "[subscriptionStateMismatchDetected] hasActiveGooglePlayBillingSubscription: $hasActiveGooglePlayBillingSubscription")
val activeSubscription = withContext(Dispatchers.IO) {
RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP).getOrNull()
}
val hasActiveSignalSubscription = activeSubscription?.isActive == true
Log.d(TAG, "[subscriptionStateMismatchDetected] hasActiveSignalSubscription: $hasActiveSignalSubscription")
when {
hasActiveSignalSubscription && !hasActiveGooglePlayBillingSubscription -> {
val type = buildPaidTypeFromSubscription(activeSubscription.activeSubscription)
if (type == null) {
Log.d(TAG, "[subscriptionMismatchDetected] failed to load backup configuration. Likely a network error.")
_state.update {
it.copy(
backupState = RemoteBackupsSettingsState.BackupState.Error
)
}
return
}
_state.update {
it.copy(
backupState = RemoteBackupsSettingsState.BackupState.SubscriptionMismatchMissingGooglePlay(
messageBackupsType = type,
renewalTime = activeSubscription.activeSubscription.endOfCurrentPeriod.seconds
)
)
}
return
}
hasActiveSignalSubscription && hasActiveGooglePlayBillingSubscription -> {
Log.d(TAG, "Found active signal subscription and active google play subscription. Clearing mismatch.")
SignalStore.backup.subscriptionStateMismatchDetected = false
}
!hasActiveSignalSubscription && !hasActiveGooglePlayBillingSubscription -> {
Log.d(TAG, "Found inactive signal subscription and inactive google play subscription. Clearing mismatch.")
SignalStore.backup.subscriptionStateMismatchDetected = false
}
else -> {
Log.w(TAG, "Hit unexpected subscription mismatch state: signal:false, google:true")
return
}
}
}
when (tier) {
MessageBackupTier.PAID -> {
Log.d(TAG, "Attempting to retrieve subscription details for active PAID backup.")
val type = withContext(Dispatchers.IO) {
BackupRepository.getBackupsType(tier) as? MessageBackupsType.Paid
}
Log.d(TAG, "Attempting to retrieve current subscription...")
val activeSubscription = withContext(Dispatchers.IO) {
RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP)
}
if (activeSubscription.isSuccess) {
Log.d(TAG, "Retrieved subscription details.")
val subscription = activeSubscription.getOrThrow().activeSubscription
if (subscription != null) {
Log.d(TAG, "Subscription found. Updating UI state with subscription details. Status: ${subscription.status}")
val subscriberType = type ?: buildPaidTypeFromSubscription(subscription)
if (subscriberType == null) {
Log.d(TAG, "Failed to create backup type. Possible network error.")
_state.update {
it.copy(backupState = RemoteBackupsSettingsState.BackupState.Error)
}
return
}
_state.update {
it.copy(
hasRedemptionError = lastPurchase?.data?.error?.data_ == "409",
backupState = when {
subscription.isCanceled && subscription.isActive -> RemoteBackupsSettingsState.BackupState.Canceled(
messageBackupsType = subscriberType,
renewalTime = subscription.endOfCurrentPeriod.seconds
)
subscription.isActive -> RemoteBackupsSettingsState.BackupState.ActivePaid(
messageBackupsType = subscriberType,
price = FiatMoney.fromSignalNetworkAmount(subscription.amount, Currency.getInstance(subscription.currency)),
renewalTime = subscription.endOfCurrentPeriod.seconds
)
else -> RemoteBackupsSettingsState.BackupState.Inactive(
messageBackupsType = subscriberType,
renewalTime = subscription.endOfCurrentPeriod.seconds
)
}
)
}
} else {
Log.d(TAG, "ActiveSubscription had null subscription object.")
if (SignalStore.backup.areBackupsEnabled) {
_state.update {
it.copy(
backupState = RemoteBackupsSettingsState.BackupState.NotFound
)
}
} else if (lastPurchase != null && lastPurchase.endOfPeriod > System.currentTimeMillis().milliseconds) {
val canceledType = type ?: buildPaidTypeFromInAppPayment(lastPurchase)
if (canceledType == null) {
Log.w(TAG, "Failed to load canceled type information. Possible network error.")
_state.update {
it.copy(
backupState = RemoteBackupsSettingsState.BackupState.Error
)
}
} else {
_state.update {
it.copy(
backupState = RemoteBackupsSettingsState.BackupState.Canceled(
messageBackupsType = canceledType,
renewalTime = lastPurchase.endOfPeriod
)
)
}
}
} else {
val inactiveType = type ?: buildPaidTypeWithoutPricing()
if (inactiveType == null) {
Log.w(TAG, "Failed to load inactive type information. Possible network error.")
_state.update {
it.copy(
backupState = RemoteBackupsSettingsState.BackupState.Error
)
}
} else {
_state.update {
it.copy(
backupState = RemoteBackupsSettingsState.BackupState.Inactive(
messageBackupsType = inactiveType,
renewalTime = lastPurchase?.endOfPeriod ?: 0.seconds
)
)
}
}
}
}
} else {
Log.d(TAG, "Failed to load ActiveSubscription data. Updating UI state with error.")
_state.update {
it.copy(
backupState = RemoteBackupsSettingsState.BackupState.Error
)
}
}
}
MessageBackupTier.FREE -> {
val type = withContext(Dispatchers.IO) {
BackupRepository.getBackupsType(tier) as MessageBackupsType.Free
}
val backupState = if (SignalStore.backup.areBackupsEnabled) {
RemoteBackupsSettingsState.BackupState.ActiveFree(type)
} else {
RemoteBackupsSettingsState.BackupState.Inactive(type)
}
Log.d(TAG, "Updating UI state with $backupState FREE tier.")
_state.update { it.copy(backupState = backupState) }
}
null -> {
Log.d(TAG, "Updating UI state with NONE null tier.")
_state.update { it.copy(backupState = RemoteBackupsSettingsState.BackupState.None) }
}
}
}
/**
* Builds out a Paid type utilizing pricing information stored in the user's active subscription object.
*
* @return A paid type, or null if we were unable to get the backup level configuration.
*/
private fun buildPaidTypeFromSubscription(subscription: ActiveSubscription.Subscription): MessageBackupsType.Paid? {
val config = BackupRepository.getBackupLevelConfiguration() ?: return null
val price = FiatMoney.fromSignalNetworkAmount(subscription.amount, Currency.getInstance(subscription.currency))
return MessageBackupsType.Paid(
pricePerMonth = price,
storageAllowanceBytes = config.storageAllowanceBytes,
mediaTtl = config.mediaTtlDays.days
)
}
/**
* Builds out a Paid type utilizing pricing information stored in the given in-app payment.
*
* @return A paid type, or null if we were unable to get the backup level configuration.
*/
private fun buildPaidTypeFromInAppPayment(inAppPayment: InAppPaymentTable.InAppPayment): MessageBackupsType.Paid? {
val config = BackupRepository.getBackupLevelConfiguration() ?: return null
val price = inAppPayment.data.amount!!.toFiatMoney()
return MessageBackupsType.Paid(
pricePerMonth = price,
storageAllowanceBytes = config.storageAllowanceBytes,
mediaTtl = config.mediaTtlDays.days
)
}
/**
* In the case of an Inactive subscription, we only care about the storage allowance and TTL, both of which we can
* grab from the backup level configuration.
*
* @return A paid type, or null if we were unable to get the backup level configuration.
*/
private fun buildPaidTypeWithoutPricing(): MessageBackupsType? {
val config = BackupRepository.getBackupLevelConfiguration() ?: return null
return MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.ZERO, Currency.getInstance(Locale.getDefault())),
storageAllowanceBytes = config.storageAllowanceBytes,
mediaTtl = config.mediaTtlDays.days
)
} }
} }

View File

@@ -8067,6 +8067,8 @@
<string name="BackupsSettingsFragment_last_backup_s">Last backup %1$s</string> <string name="BackupsSettingsFragment_last_backup_s">Last backup %1$s</string>
<!-- Subtitle for row for no backup ever created --> <!-- 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> <string name="BackupsSettingsFragment_automatic_backups_with_signals">Automatic backups with Signal\'s secure end-to-end encrypted storage service.</string>
<!-- Subtitle for row for backups that are active but subscription not found -->
<string name="BackupsSettingsFragment_subscription_not_found_on_this_device">"Subscription not found on this device."</string>
<!-- Action button label to set up backups --> <!-- Action button label to set up backups -->
<string name="BackupsSettingsFragment_set_up">Set up</string> <string name="BackupsSettingsFragment_set_up">Set up</string>