Add internal setting for forcing backup tier.

This commit is contained in:
Greyson Parrelli
2025-03-18 14:16:45 -04:00
committed by Cody Henthorne
parent ac4db23709
commit 3727a8e1df
7 changed files with 122 additions and 13 deletions

View File

@@ -132,10 +132,14 @@ object BackupRepository {
}
403 -> {
Log.w(TAG, "Received status 403. The user is not in the media tier. Updating local state.", error.exception)
SignalStore.backup.backupTier = MessageBackupTier.FREE
SignalStore.uiHints.markHasEverEnabledRemoteBackups()
// TODO [backup] If the user thought they were in media tier but aren't, feels like we should have a special UX flow for this?
if (SignalStore.backup.backupTierInternalOverride != null) {
Log.w(TAG, "Received status 403, but the internal override is set, so not doing anything.", error.exception)
} else {
Log.w(TAG, "Received status 403. The user is not in the media tier. Updating local state.", error.exception)
SignalStore.backup.backupTier = MessageBackupTier.FREE
SignalStore.uiHints.markHasEverEnabledRemoteBackups()
// TODO [backup] If the user thought they were in media tier but aren't, feels like we should have a special UX flow for this?
}
}
}
}

View File

@@ -11,6 +11,7 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -19,9 +20,11 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -95,7 +98,8 @@ class BackupsSettingsFragment : ComposeFragment() {
else -> Unit
}
},
onOnDeviceBackupsRowClick = { findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_backupsPreferenceFragment) }
onOnDeviceBackupsRowClick = { findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_backupsPreferenceFragment) },
onBackupTierInternalOverrideChanged = { viewModel.onBackupTierInternalOverrideChanged(it) }
)
}
}
@@ -105,7 +109,8 @@ private fun BackupsSettingsContent(
backupsSettingsState: BackupsSettingsState,
onNavigationClick: () -> Unit = {},
onBackupsRowClick: () -> Unit = {},
onOnDeviceBackupsRowClick: () -> Unit = {}
onOnDeviceBackupsRowClick: () -> Unit = {},
onBackupTierInternalOverrideChanged: (MessageBackupTier?) -> Unit = {}
) {
Scaffolds.Settings(
title = stringResource(R.string.preferences_chats__backups),
@@ -115,6 +120,23 @@ private fun BackupsSettingsContent(
LazyColumn(
modifier = Modifier.padding(paddingValues)
) {
if (backupsSettingsState.showBackupTierInternalOverride) {
item {
Column(modifier = Modifier.padding(horizontal = dimensionResource(id = org.signal.core.ui.R.dimen.gutter))) {
Text(
text = "INTERNAL ONLY",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "Use this to override the subscription state to one of your choosing.",
style = MaterialTheme.typography.bodyMedium
)
InternalBackupOverrideRow(backupsSettingsState, onBackupTierInternalOverrideChanged)
}
Dividers.Default()
}
}
item {
Text(
text = stringResource(R.string.RemoteBackupsSettingsFragment__back_up_your_message_history),
@@ -334,6 +356,30 @@ private fun LoadingBackupsRow() {
}
}
@Composable
private fun InternalBackupOverrideRow(
backupsSettingsState: BackupsSettingsState,
onBackupTierInternalOverrideChanged: (MessageBackupTier?) -> Unit = {}
) {
val options = remember {
mapOf(
"Unset" to null,
"Free" to MessageBackupTier.FREE,
"Paid" to MessageBackupTier.PAID
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
options.forEach { option ->
RadioButton(
selected = option.value == backupsSettingsState.backupTierInternalOverride,
onClick = { onBackupTierInternalOverrideChanged(option.value) }
)
Text(option.key)
}
}
}
@SignalPreview
@Composable
private fun BackupsSettingsContentPreview() {
@@ -366,6 +412,20 @@ private fun BackupsSettingsContentNotAvailablePreview() {
}
}
@SignalPreview
@Composable
private fun BackupsSettingsContentBackupTierInternalOverridePreview() {
Previews.Preview {
BackupsSettingsContent(
backupsSettingsState = BackupsSettingsState(
enabledState = BackupsSettingsState.EnabledState.Never,
showBackupTierInternalOverride = true,
backupTierInternalOverride = null
)
)
}
}
@SignalPreview
@Composable
private fun WaitingForNetworkRowPreview() {

View File

@@ -5,6 +5,7 @@
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 kotlin.time.Duration
@@ -12,7 +13,9 @@ import kotlin.time.Duration
* Screen state for top-level backups settings screen.
*/
data class BackupsSettingsState(
val enabledState: EnabledState = EnabledState.Loading
val enabledState: EnabledState = EnabledState.Loading,
val showBackupTierInternalOverride: Boolean = false,
val backupTierInternalOverride: MessageBackupTier? = null
) {
/**
* Describes the 'enabled' state of backups.

View File

@@ -61,7 +61,7 @@ class BackupsSettingsViewModel : ViewModel() {
private fun loadEnabledState() {
viewModelScope.launch(Dispatchers.IO) {
if (!RemoteConfig.messageBackups || !AppDependencies.billingApi.isApiAvailable()) {
internalStateFlow.update { it.copy(enabledState = BackupsSettingsState.EnabledState.NotAvailable) }
internalStateFlow.update { it.copy(enabledState = BackupsSettingsState.EnabledState.NotAvailable, showBackupTierInternalOverride = false) }
return@launch
}
@@ -71,10 +71,15 @@ class BackupsSettingsViewModel : ViewModel() {
null -> getEnabledStateForNoTier()
}
internalStateFlow.update { it.copy(enabledState = enabledState) }
internalStateFlow.update { it.copy(enabledState = enabledState, showBackupTierInternalOverride = RemoteConfig.internalUser, backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride) }
}
}
fun onBackupTierInternalOverrideChanged(tier: MessageBackupTier?) {
SignalStore.backup.backupTierInternalOverride = tier
refreshState()
}
private suspend fun getEnabledStateForFreeTier(): BackupsSettingsState.EnabledState {
return try {
BackupsSettingsState.EnabledState.Active(

View File

@@ -188,7 +188,7 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
val hasActiveGooglePlayBillingSubscription = when (val purchaseResult = AppDependencies.billingApi.queryPurchases()) {
is BillingPurchaseResult.Success -> purchaseResult.isAcknowledged && purchaseResult.isWithinTheLastMonth()
else -> false
}
} || SignalStore.backup.backupTierInternalOverride == MessageBackupTier.PAID
Log.d(TAG, "[subscriptionStateMismatchDetected] hasActiveGooglePlayBillingSubscription: $hasActiveGooglePlayBillingSubscription")

View File

@@ -7,6 +7,7 @@ import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.requireSubscriberType
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.toPaymentSourceType
@@ -30,6 +31,8 @@ import org.whispersystems.signalservice.api.subscriptions.IdempotencyKey
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import org.whispersystems.signalservice.internal.EmptyResponse
import org.whispersystems.signalservice.internal.ServiceResponse
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
import java.math.BigDecimal
import java.util.Locale
import kotlin.time.Duration.Companion.milliseconds
@@ -52,11 +55,33 @@ object RecurringInAppPaymentRepository {
}.subscribeOn(Schedulers.io())
}
/** A fake paid subscription to return when the backup tier override is set. */
private val MOCK_PAID_SUBSCRIPTION = ActiveSubscription(
ActiveSubscription.Subscription(
SubscriptionsConfiguration.BACKUPS_LEVEL,
"USD",
BigDecimal(42),
2147472000,
true,
2147472000,
false,
"active",
"USA",
"credit-card",
false
),
null
)
/**
* Gets the active subscription if it exists for the given [InAppPaymentSubscriberRecord.Type]
*/
@WorkerThread
fun getActiveSubscriptionSync(type: InAppPaymentSubscriberRecord.Type): Result<ActiveSubscription> {
if (SignalStore.backup.backupTierInternalOverride == MessageBackupTier.PAID) {
return Result.success(MOCK_PAID_SUBSCRIPTION)
}
val response = InAppPaymentsRepository.getSubscriber(type)?.let {
donationsService.getSubscription(it.subscriberId)
} ?: return Result.success(ActiveSubscription.EMPTY)

View File

@@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.backup.v2.BackupFrequency
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.jobmanager.impl.RestoreAttachmentConstraintObserver
import org.thoughtcrime.securesms.keyvalue.protos.ArchiveUploadProgressState
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.api.archive.ArchiveServiceCredential
import org.whispersystems.signalservice.api.archive.GetArchiveCdnCredentialsResponse
@@ -35,6 +36,7 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
private const val KEY_BACKUP_USED_MEDIA_SPACE = "backup.usedMediaSpace"
private const val KEY_BACKUP_LAST_PROTO_SIZE = "backup.lastProtoSize"
private const val KEY_BACKUP_TIER = "backup.backupTier"
private const val KEY_BACKUP_TIER_INTERNAL_OVERRIDE = "backup.backupTier.internalOverride"
private const val KEY_BACKUP_TIER_RESTORED = "backup.backupTierRestored"
private const val KEY_LATEST_BACKUP_TIER = "backup.latestBackupTier"
private const val KEY_LAST_CHECK_IN_MILLIS = "backup.lastCheckInMilliseconds"
@@ -164,13 +166,17 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
* be used to display backup tier information to the user in the settings fragments, not to check whether the user
* currently has backups enabled.
*/
val latestBackupTier: MessageBackupTier? by enumValue(KEY_LATEST_BACKUP_TIER, null, MessageBackupTier.Serializer)
val latestBackupTier: MessageBackupTier?
get() {
backupTierInternalOverride?.let { return it }
return MessageBackupTier.deserialize(getLong(KEY_LATEST_BACKUP_TIER, -1))
}
/**
* Denotes if there was a mismatch detected between the user's Signal subscription, on-device Google Play subscription,
* and what zk authorization we think we have.
*/
var subscriptionStateMismatchDetected: Boolean by booleanValue(KEY_SUBSCRIPTION_STATE_MISMATCH, false)
var subscriptionStateMismatchDetected: Boolean by booleanValue(KEY_SUBSCRIPTION_STATE_MISMATCH, false).withPrecondition { backupTierInternalOverride == null }
/**
* When setting the backup tier, we also want to write to the latestBackupTier, as long as
@@ -178,7 +184,10 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
* use in the UI
*/
var backupTier: MessageBackupTier?
get() = MessageBackupTier.deserialize(getLong(KEY_BACKUP_TIER, -1))
get() {
backupTierInternalOverride?.let { return it }
return MessageBackupTier.deserialize(getLong(KEY_BACKUP_TIER, -1))
}
set(value) {
Log.i(TAG, "Setting backup tier to $value", Throwable(), true)
val serializedValue = MessageBackupTier.serialize(value)
@@ -193,6 +202,9 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
}
}
/** An internal setting that can override the backup tier for a user. */
var backupTierInternalOverride: MessageBackupTier? by enumValue(KEY_BACKUP_TIER_INTERNAL_OVERRIDE, null, MessageBackupTier.Serializer).withPrecondition { RemoteConfig.internalUser }
var isBackupTierRestored: Boolean by booleanValue(KEY_BACKUP_TIER_RESTORED, false)
/**