diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupState.kt new file mode 100644 index 0000000000..6eebc46fa5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupState.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupStateRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupStateRepository.kt new file mode 100644 index 0000000000..9b70178064 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupStateRepository.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsFragment.kt index 8398a3ce8c..51b6393e28 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsFragment.kt @@ -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,20 +564,37 @@ 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, - lastBackupAt = 0.seconds - ) + renewalTime = 0.seconds, + price = FiatMoney(BigDecimal.valueOf(4), Currency.getInstance("CAD")) + ), + lastBackupAt = 0.seconds ) } } @@ -483,13 +604,13 @@ 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, - lastBackupAt = 0.seconds - ) + renewalTime = 0.seconds + ), + lastBackupAt = 0.seconds ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsState.kt index d170a9f80c..1d16066eae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsState.kt @@ -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 - } -} +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsViewModel.kt index 56c25583e9..c62391a57b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/BackupsSettingsViewModel.kt @@ -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 - } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt index 6f00ff1060..71327b5d59 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsFragment.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt index 32cc5a82e8..a6f5b6a6db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsState.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt index a4d46a9af7..b70496bc16 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/RemoteBackupsSettingsViewModel.kt @@ -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.") - _state.update { - it.copy( - backupState = RemoteBackupsSettingsState.BackupState.Pending( - price = lastPurchase.data.amount!!.toFiatMoney() - ) - ) - } - - return + val state = BackupStateRepository.resolveBackupState(lastPurchase) + _state.update { + it.copy(backupState = state) } - - 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 - ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8a1194368a..21b2dda84c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8067,6 +8067,8 @@ Last backup %1$s Automatic backups with Signal\'s secure end-to-end encrypted storage service. + + "Subscription not found on this device." Set up