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