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

View File

@@ -6,49 +6,16 @@
package org.thoughtcrime.securesms.components.settings.app.backups
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.Companion.milliseconds
/**
* Screen state for top-level backups settings screen.
*/
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 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 org.signal.core.util.concurrent.SignalDispatchers
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.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.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.InternetConnectionObserver
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.seconds
class BackupsSettingsViewModel : ViewModel() {
@@ -56,7 +51,7 @@ class BackupsSettingsViewModel : ViewModel() {
.drop(1)
.collect {
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.")
}
}
viewModelScope.launch(SignalDispatchers.Default) {
InAppPaymentsRepository.observeLatestBackupPayment().collect {
Log.d(TAG, "Triggering refresh from payment state change.")
loadRequests.emit(Unit)
}
}
}
override fun onCleared() {
@@ -75,7 +77,9 @@ class BackupsSettingsViewModel : ViewModel() {
fun refreshState() {
Log.d(TAG, "Refreshing state from manual call.")
loadRequests.tryEmit(Unit)
viewModelScope.launch(SignalDispatchers.Default) {
loadRequests.emit(Unit)
}
}
@WorkerThread
@@ -83,18 +87,16 @@ class BackupsSettingsViewModel : ViewModel() {
return viewModelScope.launch(SignalDispatchers.IO) {
if (!RemoteConfig.messageBackups) {
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 {
val enabledState = when (SignalStore.backup.backupTier) {
MessageBackupTier.FREE -> getEnabledStateForFreeTier()
MessageBackupTier.PAID -> getEnabledStateForPaidTier()
null -> getEnabledStateForNoTier()
}
val latestPurchase = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_BACKUP)
val enabledState = BackupStateRepository.resolveBackupState(latestPurchase)
Log.d(TAG, "Found enabled state $enabledState. Updating UI state.")
internalStateFlow.update {
it.copy(
enabledState = enabledState,
backupState = enabledState,
lastBackupAt = SignalStore.backup.lastBackupTime.milliseconds,
showBackupTierInternalOverride = RemoteConfig.internalUser || Environment.IS_STAGING,
backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride
)
@@ -108,62 +110,4 @@ class BackupsSettingsViewModel : ViewModel() {
SignalStore.backup.deletionState = DeletionState.NONE
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.TextWithBetaLabel
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.compose.ComposeFragment
import org.thoughtcrime.securesms.compose.StatusBarColorNestedScrollConnection
@@ -448,19 +449,19 @@ private fun RemoteBackupsSettingsContent(
item {
when (state.backupState) {
is RemoteBackupsSettingsState.BackupState.Loading -> {
is BackupState.Loading -> {
LoadingCard()
}
is RemoteBackupsSettingsState.BackupState.Error -> {
is BackupState.Error -> {
ErrorCard()
}
is RemoteBackupsSettingsState.BackupState.Pending -> {
is BackupState.Pending -> {
PendingCard(state.backupState.price)
}
is RemoteBackupsSettingsState.BackupState.SubscriptionMismatchMissingGooglePlay -> {
is BackupState.SubscriptionMismatchMissingGooglePlay -> {
SubscriptionMismatchMissingGooglePlayCard(
state = state.backupState,
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(
backupState = state.backupState,
onBackupTypeActionButtonClicked = contentCallbacks::onBackupTypeActionClick,
@@ -479,7 +480,7 @@ private fun RemoteBackupsSettingsContent(
)
}
RemoteBackupsSettingsState.BackupState.NotFound -> {
BackupState.NotFound -> {
SubscriptionNotFoundCard(
title = stringResource(R.string.RemoteBackupsSettingsFragment__your_subscription_was_not_found),
onRenewClick = contentCallbacks::onRenewLostSubscription,
@@ -487,6 +488,8 @@ private fun RemoteBackupsSettingsContent(
isRenewEnabled = backupDeleteState != DeletionState.DELETE_BACKUPS
)
}
BackupState.NotAvailable -> error("This shouldn't happen on this screen.")
}
}
@@ -582,7 +585,7 @@ private fun RemoteBackupsSettingsContent(
SkipDownloadDuringDeleteDialog()
} else {
SkipDownloadDialog(
renewalTime = if (state.backupState is RemoteBackupsSettingsState.BackupState.WithTypeAndRenewalTime) {
renewalTime = if (state.backupState is BackupState.WithTypeAndRenewalTime) {
state.backupState.renewalTime
} else {
error("Unexpected dialog display without renewal time.")
@@ -800,7 +803,7 @@ private fun DescriptionText(
}
private fun LazyListScope.appendBackupDetailsItems(
backupState: RemoteBackupsSettingsState.BackupState,
backupState: BackupState,
canViewBackupKey: Boolean,
backupRestoreState: BackupRestoreState,
backupProgress: ArchiveUploadProgressState?,
@@ -853,7 +856,7 @@ private fun LazyListScope.appendBackupDetailsItems(
}
}
if (backupState !is RemoteBackupsSettingsState.BackupState.ActiveFree) {
if (backupState !is BackupState.ActiveFree) {
item {
Rows.TextRow(text = {
Column {
@@ -923,7 +926,7 @@ private fun LazyListScope.appendBackupDetailsItems(
@Composable
private fun BackupCard(
backupState: RemoteBackupsSettingsState.BackupState.WithTypeAndRenewalTime,
backupState: BackupState.WithTypeAndRenewalTime,
buttonsEnabled: Boolean,
onBackupTypeActionButtonClicked: (MessageBackupTier) -> Unit = {}
) {
@@ -950,21 +953,21 @@ private fun BackupCard(
)
when (backupState) {
is RemoteBackupsSettingsState.BackupState.ActivePaid -> {
is BackupState.ActivePaid -> {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__s_per_month, FiatMoneyUtil.format(LocalContext.current.resources, backupState.price)),
modifier = Modifier.padding(top = 12.dp)
)
}
is RemoteBackupsSettingsState.BackupState.ActiveFree -> {
is BackupState.ActiveFree -> {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__your_backup_plan_is_free),
modifier = Modifier.padding(top = 12.dp)
)
}
is RemoteBackupsSettingsState.BackupState.Inactive -> {
is BackupState.Inactive -> {
val text = when (messageBackupsType) {
is MessageBackupsType.Paid -> stringResource(R.string.RemoteBackupsSettingsFragment__subscription_inactive)
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 = stringResource(R.string.RemoteBackupsSettingsFragment__subscription_cancelled),
color = MaterialTheme.colorScheme.error,
@@ -992,9 +995,9 @@ private fun BackupCard(
if (messageBackupsType is MessageBackupsType.Paid) {
val resource = when (backupState) {
is RemoteBackupsSettingsState.BackupState.ActivePaid -> R.string.RemoteBackupsSettingsFragment__renews_s
is RemoteBackupsSettingsState.BackupState.Inactive -> R.string.RemoteBackupsSettingsFragment__expired_on_s
is RemoteBackupsSettingsState.BackupState.Canceled -> R.string.RemoteBackupsSettingsFragment__expires_on_s
is BackupState.ActivePaid -> R.string.RemoteBackupsSettingsFragment__renews_s
is BackupState.Inactive -> R.string.RemoteBackupsSettingsFragment__expired_on_s
is BackupState.Canceled -> R.string.RemoteBackupsSettingsFragment__expires_on_s
else -> error("Not supported here.")
}
@@ -1026,7 +1029,7 @@ private fun BackupCard(
enabled = buttonsEnabled,
onClick = { onBackupTypeActionButtonClicked(messageBackupsType.tier) }
)
} else if (backupState is RemoteBackupsSettingsState.BackupState.Canceled) {
} else if (backupState is BackupState.Canceled) {
CallToActionButton(
text = stringResource(R.string.RemoteBackupsSettingsFragment__resubscribe),
enabled = buttonsEnabled,
@@ -1288,7 +1291,7 @@ private fun SubscriptionNotFoundCard(
@Composable
private fun SubscriptionMismatchMissingGooglePlayCard(
state: RemoteBackupsSettingsState.BackupState.SubscriptionMismatchMissingGooglePlay,
state: BackupState.SubscriptionMismatchMissingGooglePlay,
isRenewEnabled: Boolean,
onRenewClick: () -> Unit = {},
onLearnMoreClick: () -> Unit = {}
@@ -1662,10 +1665,10 @@ private fun BackupFrequencyDialog(
@Composable
private fun BackupReadyToDownloadRow(
ready: BackupRestoreState.Ready,
backupState: RemoteBackupsSettingsState.BackupState,
backupState: BackupState,
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)
} else {
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,
snackbar = RemoteBackupsSettingsState.Snackbar.NONE,
backupMediaSize = 2300000,
backupState = RemoteBackupsSettingsState.BackupState.ActiveFree(
backupState = BackupState.ActiveFree(
messageBackupsType = MessageBackupsType.Free(mediaRetentionDays = 30)
),
hasRedemptionError = true,
@@ -1784,7 +1787,7 @@ private fun SubscriptionNotFoundCardPreview() {
private fun SubscriptionMismatchMissingGooglePlayCardPreview() {
Previews.Preview {
SubscriptionMismatchMissingGooglePlayCard(
state = RemoteBackupsSettingsState.BackupState.SubscriptionMismatchMissingGooglePlay(
state = BackupState.SubscriptionMismatchMissingGooglePlay(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000,
@@ -1803,7 +1806,7 @@ private fun BackupCardPreview() {
Previews.Preview {
Column {
BackupCard(
backupState = RemoteBackupsSettingsState.BackupState.ActivePaid(
backupState = BackupState.ActivePaid(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000,
@@ -1816,7 +1819,7 @@ private fun BackupCardPreview() {
)
BackupCard(
backupState = RemoteBackupsSettingsState.BackupState.Canceled(
backupState = BackupState.Canceled(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000,
@@ -1828,7 +1831,7 @@ private fun BackupCardPreview() {
)
BackupCard(
backupState = RemoteBackupsSettingsState.BackupState.Inactive(
backupState = BackupState.Inactive(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000,
@@ -1840,7 +1843,7 @@ private fun BackupCardPreview() {
)
BackupCard(
backupState = RemoteBackupsSettingsState.BackupState.ActivePaid(
backupState = BackupState.ActivePaid(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")),
storageAllowanceBytes = 100_000_000,
@@ -1853,7 +1856,7 @@ private fun BackupCardPreview() {
)
BackupCard(
backupState = RemoteBackupsSettingsState.BackupState.ActiveFree(
backupState = BackupState.ActiveFree(
messageBackupsType = MessageBackupsType.Free(
mediaRetentionDays = 30
)
@@ -1870,7 +1873,7 @@ private fun BackupReadyToDownloadPreview() {
Previews.Preview {
BackupReadyToDownloadRow(
ready = BackupRestoreState.Ready("12GB"),
backupState = RemoteBackupsSettingsState.BackupState.None
backupState = BackupState.None
)
}
}
@@ -1881,7 +1884,7 @@ private fun BackupReadyToDownloadAfterCancelPreview() {
Previews.Preview {
BackupReadyToDownloadRow(
ready = BackupRestoreState.Ready("12GB"),
backupState = RemoteBackupsSettingsState.BackupState.Canceled(
backupState = BackupState.Canceled(
messageBackupsType = MessageBackupsType.Paid(
pricePerMonth = FiatMoney(BigDecimal.ONE, Currency.getInstance("USD")),
storageAllowanceBytes = 10.gibiBytes.bytes,

View File

@@ -5,11 +5,8 @@
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.ui.subscription.MessageBackupsType
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import org.thoughtcrime.securesms.components.settings.app.backups.BackupState
data class RemoteBackupsSettingsState(
val backupsEnabled: Boolean,
@@ -27,94 +24,6 @@ data class RemoteBackupsSettingsState(
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 {
NONE,
TURN_OFF_AND_DELETE_BACKUPS,

View File

@@ -20,11 +20,8 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
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.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.core.util.throttleLatest
import org.signal.donations.InAppPaymentType
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.subscription.MessageBackupsType
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.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.attachmentUpdates
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.keyvalue.protos.ArchiveUploadProgressState
import org.thoughtcrime.securesms.service.MessageBackupListener
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
/**
@@ -233,8 +222,6 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
}
private suspend fun performStateRefresh(lastPurchase: InAppPaymentTable.InAppPayment?) {
val tier = SignalStore.backup.latestBackupTier
_state.update {
it.copy(
backupsEnabled = SignalStore.backup.areBackupsEnabled,
@@ -243,7 +230,8 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
backupsFrequency = SignalStore.backup.backupFrequency,
canBackUpUsingCellular = SignalStore.backup.backupWithCellular,
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) {
Log.d(TAG, "We have a pending subscription.")
val state = BackupStateRepository.resolveBackupState(lastPurchase)
_state.update {
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
it.copy(backupState = state)
}
}
}
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>
<!-- 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>
<!-- 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 -->
<string name="BackupsSettingsFragment_set_up">Set up</string>