diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt index e655a34cb4..a6ebfc2ccb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -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? + } } } } 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 8e38476aa0..87c3ba1a08 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 @@ -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() { 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 d5fdfd281a..d170a9f80c 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 @@ -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. 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 e56eb3ae75..3692fb2ce7 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 @@ -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( 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 08852a5c2b..f980520022 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 @@ -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") diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepository.kt index 12eb672dcb..157dbbd2a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepository.kt @@ -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 { + 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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt index ab76452160..938eb91351 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt @@ -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) /**