mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-26 20:55:10 +00:00
Implement io-free state update and fallback mechanism for backups state.
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.backups
|
||||
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -25,9 +26,9 @@ sealed interface BackupState {
|
||||
data object None : BackupState
|
||||
|
||||
/**
|
||||
* The exact backup state is being loaded from the network.
|
||||
* Temporary state object that just denotes what the local store thinks we are.
|
||||
*/
|
||||
data object Loading : BackupState
|
||||
data class LocalStore(val tier: MessageBackupTier) : BackupState
|
||||
|
||||
/**
|
||||
* User has a paid backup subscription pending redemption
|
||||
|
||||
@@ -5,20 +5,35 @@
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.backups
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.asFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.core.util.billing.BillingPurchaseResult
|
||||
import org.signal.core.util.concurrent.SignalDispatchers
|
||||
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.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.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.model.InAppPaymentSubscriberRecord
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.InternetConnectionObserver
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import java.math.BigDecimal
|
||||
@@ -30,12 +45,211 @@ import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Manages BackupState information gathering for the UI.
|
||||
*
|
||||
* This class utilizes a stream of requests which are throttled to one per 100ms, such that we don't flood
|
||||
* ourselves with network and database activity.
|
||||
*
|
||||
* @param scope A coroutine scope, generally expected to be a viewModelScope
|
||||
* @param useDatabaseFallbackOnNetworkError Whether we will display network errors or fall back to database information. Defaults to false.
|
||||
*/
|
||||
object BackupStateRepository {
|
||||
class BackupStateObserver(
|
||||
scope: CoroutineScope,
|
||||
private val useDatabaseFallbackOnNetworkError: Boolean = false
|
||||
) {
|
||||
companion object {
|
||||
private val TAG = Log.tag(BackupStateObserver::class)
|
||||
|
||||
private val TAG = Log.tag(BackupStateRepository::class)
|
||||
private val staticScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val backupTierChangedNotifier = MutableSharedFlow<Unit>()
|
||||
|
||||
suspend fun resolveBackupState(lastPurchase: InAppPaymentTable.InAppPayment?): BackupState {
|
||||
/**
|
||||
* Called when the value returned by [SignalStore.backup.backupTier] changes.
|
||||
*/
|
||||
fun notifyBackupTierChanged(scope: CoroutineScope = staticScope) {
|
||||
Log.d(TAG, "Notifier got a change")
|
||||
scope.launch {
|
||||
backupTierChangedNotifier.emit(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a BackupState without touching the database or network. At most what this
|
||||
* can tell you is whether the tier is set or if backups are available at all.
|
||||
*
|
||||
* This method is meant to be lightweight and instantaneous, and is a good candidate for
|
||||
* setting initial ViewModel state values.
|
||||
*/
|
||||
fun getNonIOBackupState(): BackupState {
|
||||
return if (RemoteConfig.messageBackups) {
|
||||
val tier = SignalStore.backup.backupTier
|
||||
|
||||
if (tier != null) {
|
||||
BackupState.LocalStore(tier)
|
||||
} else {
|
||||
BackupState.None
|
||||
}
|
||||
} else {
|
||||
BackupState.NotAvailable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val internalBackupState = MutableStateFlow(getNonIOBackupState())
|
||||
private val backupStateRefreshRequest = MutableSharedFlow<Unit>()
|
||||
|
||||
val backupState: StateFlow<BackupState> = internalBackupState
|
||||
|
||||
init {
|
||||
scope.launch(SignalDispatchers.IO) {
|
||||
performDatabaseBackupStateRefresh()
|
||||
}
|
||||
|
||||
scope.launch(SignalDispatchers.IO) {
|
||||
backupStateRefreshRequest
|
||||
.throttleLatest(100.milliseconds)
|
||||
.collect {
|
||||
performFullBackupStateRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(SignalDispatchers.IO) {
|
||||
backupTierChangedNotifier.collect {
|
||||
requestBackupStateRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(SignalDispatchers.IO) {
|
||||
InternetConnectionObserver.observe().asFlow()
|
||||
.collect {
|
||||
if (backupState.value == BackupState.Error) {
|
||||
requestBackupStateRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(SignalDispatchers.IO) {
|
||||
InAppPaymentsRepository.observeLatestBackupPayment().collect {
|
||||
requestBackupStateRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(SignalDispatchers.IO) {
|
||||
SignalStore.backup.subscriptionStateMismatchDetectedFlow.collect {
|
||||
requestBackupStateRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(SignalDispatchers.IO) {
|
||||
SignalStore.backup.deletionStateFlow.collect {
|
||||
requestBackupStateRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a refresh behind a throttler.
|
||||
*/
|
||||
private suspend fun requestBackupStateRefresh() {
|
||||
Log.d(TAG, "Requesting refresh.")
|
||||
backupStateRefreshRequest.emit(Unit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces state based off what we have locally in the database. Does not hit the network.
|
||||
*/
|
||||
@WorkerThread
|
||||
private fun getDatabaseBackupState(): BackupState {
|
||||
if (SignalStore.backup.backupTier != MessageBackupTier.PAID) {
|
||||
Log.d(TAG, "No additional information available without accessing the network.")
|
||||
return getNonIOBackupState()
|
||||
}
|
||||
|
||||
val latestPayment = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_BACKUP)
|
||||
if (latestPayment == null) {
|
||||
Log.d(TAG, "No additional information is available in the local database.")
|
||||
return getNonIOBackupState()
|
||||
}
|
||||
|
||||
val price = latestPayment.data.amount!!.toFiatMoney()
|
||||
val isPending = SignalDatabase.inAppPayments.hasPendingBackupRedemption()
|
||||
if (isPending) {
|
||||
return BackupState.Pending(price = price)
|
||||
}
|
||||
|
||||
val paidBackupType = MessageBackupsType.Paid(
|
||||
pricePerMonth = price,
|
||||
storageAllowanceBytes = -1L,
|
||||
mediaTtl = 0.days
|
||||
)
|
||||
|
||||
val isCanceled = latestPayment.data.cancellation != null
|
||||
if (isCanceled) {
|
||||
return BackupState.Canceled(
|
||||
messageBackupsType = paidBackupType,
|
||||
renewalTime = latestPayment.endOfPeriod
|
||||
)
|
||||
}
|
||||
|
||||
if (SignalStore.backup.subscriptionStateMismatchDetected) {
|
||||
return BackupState.SubscriptionMismatchMissingGooglePlay(
|
||||
messageBackupsType = paidBackupType,
|
||||
renewalTime = latestPayment.endOfPeriod
|
||||
)
|
||||
}
|
||||
|
||||
if (latestPayment.endOfPeriod < System.currentTimeMillis().milliseconds) {
|
||||
return BackupState.Inactive(
|
||||
messageBackupsType = paidBackupType,
|
||||
renewalTime = latestPayment.endOfPeriod
|
||||
)
|
||||
}
|
||||
|
||||
return BackupState.ActivePaid(
|
||||
messageBackupsType = paidBackupType,
|
||||
price = price,
|
||||
renewalTime = latestPayment.endOfPeriod
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun performDatabaseBackupStateRefresh() {
|
||||
if (!RemoteConfig.messageBackups) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!SignalStore.account.isRegistered) {
|
||||
Log.d(TAG, "Dropping refresh for unregistered user.")
|
||||
return
|
||||
}
|
||||
|
||||
if (backupState.value !is BackupState.LocalStore) {
|
||||
Log.d(TAG, "Dropping database refresh for non-local store state.")
|
||||
return
|
||||
}
|
||||
|
||||
internalBackupState.emit(getDatabaseBackupState())
|
||||
}
|
||||
|
||||
private suspend fun performFullBackupStateRefresh() {
|
||||
if (!RemoteConfig.messageBackups) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!SignalStore.account.isRegistered) {
|
||||
Log.d(TAG, "Dropping refresh for unregistered user.")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Performing refresh.")
|
||||
withContext(SignalDispatchers.IO) {
|
||||
val latestInAppPayment = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_BACKUP)
|
||||
internalBackupState.emit(getNetworkBackupState(latestInAppPayment))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utilizes everything we can to resolve the most accurate backup state available, including database and network.
|
||||
*/
|
||||
private suspend fun getNetworkBackupState(lastPurchase: InAppPaymentTable.InAppPayment?): BackupState {
|
||||
if (lastPurchase?.state == InAppPaymentTable.State.PENDING) {
|
||||
Log.d(TAG, "We have a pending subscription.")
|
||||
return BackupState.Pending(
|
||||
@@ -74,7 +288,7 @@ object BackupStateRepository {
|
||||
|
||||
if (type == null) {
|
||||
Log.d(TAG, "[subscriptionMismatchDetected] failed to load backup configuration. Likely a network error.")
|
||||
return BackupState.Error
|
||||
return getStateOnError()
|
||||
}
|
||||
|
||||
return BackupState.SubscriptionMismatchMissingGooglePlay(
|
||||
@@ -116,6 +330,17 @@ object BackupStateRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to fall back to database state if [useDatabaseFallbackOnNetworkError] is set to true.
|
||||
*/
|
||||
private fun getStateOnError(): BackupState {
|
||||
return if (useDatabaseFallbackOnNetworkError) {
|
||||
getDatabaseBackupState()
|
||||
} else {
|
||||
BackupState.Error
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getPaidBackupState(lastPurchase: InAppPaymentTable.InAppPayment?): BackupState {
|
||||
Log.d(TAG, "Attempting to retrieve subscription details for active PAID backup.")
|
||||
|
||||
@@ -141,7 +366,7 @@ object BackupStateRepository {
|
||||
if (subscriberType == null) {
|
||||
Log.d(TAG, "Failed to create backup type. Possible network error.")
|
||||
|
||||
BackupState.Error
|
||||
getStateOnError()
|
||||
} else {
|
||||
when {
|
||||
subscription.isCanceled && subscription.isActive -> BackupState.Canceled(
|
||||
@@ -169,7 +394,7 @@ object BackupStateRepository {
|
||||
val canceledType = type ?: buildPaidTypeFromInAppPayment(lastPurchase)
|
||||
if (canceledType == null) {
|
||||
Log.w(TAG, "Failed to load canceled type information. Possible network error.")
|
||||
BackupState.Error
|
||||
getStateOnError()
|
||||
} else {
|
||||
BackupState.Canceled(
|
||||
messageBackupsType = canceledType,
|
||||
@@ -180,7 +405,7 @@ object BackupStateRepository {
|
||||
val inactiveType = type ?: buildPaidTypeWithoutPricing()
|
||||
if (inactiveType == null) {
|
||||
Log.w(TAG, "Failed to load inactive type information. Possible network error.")
|
||||
BackupState.Error
|
||||
getStateOnError()
|
||||
} else {
|
||||
BackupState.Inactive(
|
||||
messageBackupsType = inactiveType,
|
||||
@@ -191,7 +416,7 @@ object BackupStateRepository {
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Failed to load ActiveSubscription data. Updating UI state with error.")
|
||||
BackupState.Error
|
||||
getStateOnError()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +427,7 @@ object BackupStateRepository {
|
||||
|
||||
if (type !is NetworkResult.Success) {
|
||||
Log.w(TAG, "Failed to load FREE type.", type.getCause())
|
||||
return BackupState.Error
|
||||
return getStateOnError()
|
||||
}
|
||||
|
||||
val backupState = if (SignalStore.backup.areBackupsEnabled) {
|
||||
@@ -13,7 +13,6 @@ 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
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -80,11 +79,6 @@ class BackupsSettingsFragment : ComposeFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.refreshState()
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
@@ -94,7 +88,7 @@ class BackupsSettingsFragment : ComposeFragment() {
|
||||
onNavigationClick = { requireActivity().onNavigateUp() },
|
||||
onBackupsRowClick = {
|
||||
when (state.backupState) {
|
||||
BackupState.Loading, BackupState.Error, BackupState.NotAvailable -> Unit
|
||||
BackupState.Error, BackupState.NotAvailable -> Unit
|
||||
|
||||
BackupState.None -> {
|
||||
checkoutLauncher.launch(null)
|
||||
@@ -155,8 +149,12 @@ private fun BackupsSettingsContent(
|
||||
|
||||
item {
|
||||
when (backupsSettingsState.backupState) {
|
||||
BackupState.Loading -> {
|
||||
LoadingBackupsRow()
|
||||
is BackupState.LocalStore -> {
|
||||
LocalStoreBackupRow(
|
||||
backupState = backupsSettingsState.backupState,
|
||||
lastBackupAt = backupsSettingsState.lastBackupAt,
|
||||
onBackupsRowClick = onBackupsRowClick
|
||||
)
|
||||
|
||||
OtherWaysToBackUpHeading()
|
||||
}
|
||||
@@ -419,6 +417,52 @@ private fun ViewSettingsButton(onClick: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LocalStoreBackupRow(
|
||||
backupState: BackupState.LocalStore,
|
||||
lastBackupAt: Duration,
|
||||
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),
|
||||
textStyle = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
|
||||
val tierText = when (backupState.tier) {
|
||||
MessageBackupTier.FREE -> stringResource(R.string.RemoteBackupsSettingsFragment__your_backup_plan_is_free)
|
||||
MessageBackupTier.PAID -> stringResource(R.string.MessageBackupsTypeSelectionScreen__text_plus_all_your_media)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = tierText,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
LastBackedUpText(lastBackupAt)
|
||||
ViewSettingsButton(onBackupsRowClick)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveBackupsRow(
|
||||
backupState: BackupState.WithTypeAndRenewalTime,
|
||||
@@ -483,24 +527,7 @@ private fun ActiveBackupsRow(
|
||||
}
|
||||
}
|
||||
|
||||
val lastBackupString = if (lastBackupAt.inWholeMilliseconds > 0) {
|
||||
DateUtils.getDatelessRelativeTimeSpanFormattedDate(
|
||||
LocalContext.current,
|
||||
Locale.getDefault(),
|
||||
lastBackupAt.inWholeMilliseconds
|
||||
).value
|
||||
} else {
|
||||
stringResource(R.string.RemoteBackupsSettingsFragment__never)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.BackupsSettingsFragment_last_backup_s,
|
||||
lastBackupString
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
LastBackedUpText(lastBackupAt)
|
||||
|
||||
ViewSettingsButton(onBackupsRowClick)
|
||||
}
|
||||
@@ -509,16 +536,25 @@ private fun ActiveBackupsRow(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingBackupsRow() {
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter))
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
private fun LastBackedUpText(lastBackupAt: Duration) {
|
||||
val lastBackupString = if (lastBackupAt.inWholeMilliseconds > 0) {
|
||||
DateUtils.getDatelessRelativeTimeSpanFormattedDate(
|
||||
LocalContext.current,
|
||||
Locale.getDefault(),
|
||||
lastBackupAt.inWholeMilliseconds
|
||||
).value
|
||||
} else {
|
||||
stringResource(R.string.RemoteBackupsSettingsFragment__never)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.BackupsSettingsFragment_last_backup_s,
|
||||
lastBackupString
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -664,14 +700,6 @@ private fun ActiveFreeBackupsRowPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun LoadingBackupsRowPreview() {
|
||||
Previews.Preview {
|
||||
LoadingBackupsRow()
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun NeverEnabledBackupsRowPreview() {
|
||||
|
||||
@@ -14,7 +14,7 @@ import kotlin.time.Duration.Companion.milliseconds
|
||||
* Screen state for top-level backups settings screen.
|
||||
*/
|
||||
data class BackupsSettingsState(
|
||||
val backupState: BackupState = BackupState.Loading,
|
||||
val backupState: BackupState,
|
||||
val lastBackupAt: Duration = SignalStore.backup.lastBackupTime.milliseconds,
|
||||
val showBackupTierInternalOverride: Boolean = false,
|
||||
val backupTierInternalOverride: MessageBackupTier? = null
|
||||
|
||||
@@ -5,31 +5,21 @@
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.backups
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.update
|
||||
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.donations.InAppPaymentType
|
||||
import org.thoughtcrime.securesms.backup.DeletionState
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
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.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.thoughtcrime.securesms.util.InternetConnectionObserver
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@@ -39,61 +29,16 @@ class BackupsSettingsViewModel : ViewModel() {
|
||||
private val TAG = Log.tag(BackupsSettingsViewModel::class)
|
||||
}
|
||||
|
||||
private val internalStateFlow = MutableStateFlow(BackupsSettingsState())
|
||||
private val internalStateFlow: MutableStateFlow<BackupsSettingsState>
|
||||
|
||||
val stateFlow: StateFlow<BackupsSettingsState> = internalStateFlow
|
||||
|
||||
private val loadRequests = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val stateFlow: StateFlow<BackupsSettingsState> by lazy { internalStateFlow }
|
||||
|
||||
init {
|
||||
viewModelScope.launch(SignalDispatchers.Default) {
|
||||
InternetConnectionObserver.observe().asFlow()
|
||||
.distinctUntilChanged()
|
||||
.filter { it }
|
||||
.drop(1)
|
||||
.collect {
|
||||
Log.d(TAG, "Triggering refresh from internet reconnect.")
|
||||
loadRequests.emit(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch(SignalDispatchers.Default) {
|
||||
loadRequests.collect {
|
||||
Log.d(TAG, "-- Dispatching state load.")
|
||||
loadEnabledState().join()
|
||||
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() {
|
||||
Log.d(TAG, "ViewModel has been cleared.")
|
||||
}
|
||||
|
||||
fun refreshState() {
|
||||
Log.d(TAG, "Refreshing state from manual call.")
|
||||
viewModelScope.launch(SignalDispatchers.Default) {
|
||||
loadRequests.emit(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun loadEnabledState(): Job {
|
||||
return viewModelScope.launch(SignalDispatchers.IO) {
|
||||
if (!RemoteConfig.messageBackups) {
|
||||
Log.w(TAG, "Remote backups are not available on this device.")
|
||||
internalStateFlow.update { it.copy(backupState = BackupState.NotAvailable, showBackupTierInternalOverride = false) }
|
||||
} else {
|
||||
val latestPurchase = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_BACKUP)
|
||||
val enabledState = BackupStateRepository.resolveBackupState(latestPurchase)
|
||||
val repo = BackupStateObserver(viewModelScope, useDatabaseFallbackOnNetworkError = true)
|
||||
internalStateFlow = MutableStateFlow(BackupsSettingsState(backupState = repo.backupState.value))
|
||||
|
||||
viewModelScope.launch {
|
||||
repo.backupState.collect { enabledState ->
|
||||
Log.d(TAG, "Found enabled state $enabledState. Updating UI state.")
|
||||
internalStateFlow.update {
|
||||
it.copy(
|
||||
@@ -114,6 +59,7 @@ class BackupsSettingsViewModel : ViewModel() {
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}
|
||||
refreshState()
|
||||
|
||||
BackupStateObserver.notifyBackupTierChanged(scope = viewModelScope)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,7 +468,7 @@ private fun RemoteBackupsSettingsContent(
|
||||
|
||||
item {
|
||||
when (state.backupState) {
|
||||
is BackupState.Loading -> {
|
||||
is BackupState.LocalStore -> {
|
||||
LoadingCard()
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ data class RemoteBackupsSettingsState(
|
||||
val hasRedemptionError: Boolean = false,
|
||||
val isOutOfStorageSpace: Boolean = false,
|
||||
val totalAllowedStorageSpace: String = "",
|
||||
val backupState: BackupState = BackupState.Loading,
|
||||
val backupState: BackupState,
|
||||
val backupMediaSize: Long = 0,
|
||||
val backupsFrequency: BackupFrequency = BackupFrequency.DAILY,
|
||||
val lastBackupTimestamp: Long = 0,
|
||||
|
||||
@@ -35,7 +35,7 @@ import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupStatusData
|
||||
import org.thoughtcrime.securesms.banner.banners.MediaRestoreProgressBanner
|
||||
import org.thoughtcrime.securesms.components.settings.app.backups.BackupStateRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.backups.BackupStateObserver
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
@@ -65,6 +65,7 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
|
||||
private val _state = MutableStateFlow(
|
||||
RemoteBackupsSettingsState(
|
||||
tier = SignalStore.backup.backupTier,
|
||||
backupState = BackupStateObserver.getNonIOBackupState(),
|
||||
backupsEnabled = SignalStore.backup.areBackupsEnabled,
|
||||
canBackupMessagesJobRun = BackupMessagesConstraint.isMet(AppDependencies.application),
|
||||
canViewBackupKey = !TextSecurePreferences.isUnauthorizedReceived(AppDependencies.application),
|
||||
@@ -155,6 +156,14 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
|
||||
previous = current.state
|
||||
}
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
BackupStateObserver(viewModelScope).backupState.collect { state ->
|
||||
_state.update {
|
||||
it.copy(backupState = state)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setCanBackUpUsingCellular(canBackUpUsingCellular: Boolean) {
|
||||
@@ -296,11 +305,6 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
|
||||
hasRedemptionError = lastPurchase?.data?.error?.data_ == "409"
|
||||
)
|
||||
}
|
||||
|
||||
val state = BackupStateRepository.resolveBackupState(lastPurchase)
|
||||
_state.update {
|
||||
it.copy(backupState = state)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getBackupMediaSize(): Long {
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.backup.RestoreState
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupFrequency
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
|
||||
import org.thoughtcrime.securesms.components.settings.app.backups.BackupStateObserver
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.BackupMessagesConstraintObserver
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.DeletionNotAwaitingMediaDownloadConstraint
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NoRemoteArchiveGarbageCollectionPendingConstraint
|
||||
@@ -212,12 +213,6 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
|
||||
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).withPrecondition { backupTierInternalOverride == null }
|
||||
|
||||
/**
|
||||
* When setting the backup tier, we also want to write to the latestBackupTier, as long as
|
||||
* the value is non-null. This gives us a 1-deep history of the selected backup tier for
|
||||
@@ -247,11 +242,21 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
|
||||
} else {
|
||||
putLong(KEY_BACKUP_TIER, serializedValue)
|
||||
}
|
||||
|
||||
BackupStateObserver.notifyBackupTierChanged()
|
||||
}
|
||||
|
||||
/** 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 }
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
val subscriptionStateMismatchDetectedValue = booleanValue(KEY_SUBSCRIPTION_STATE_MISMATCH, false).withPrecondition { backupTierInternalOverride == null }
|
||||
var subscriptionStateMismatchDetected: Boolean by subscriptionStateMismatchDetectedValue
|
||||
val subscriptionStateMismatchDetectedFlow: Flow<Boolean> by lazy { subscriptionStateMismatchDetectedValue.toFlow() }
|
||||
|
||||
/** Set to true if we successfully restored a backup file timestamp or didn't find a file at all so a "no timestamp" value is restored. */
|
||||
var isBackupTimestampRestored: Boolean by booleanValue(KEY_BACKUP_TIMESTAMP_RESTORED, false)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user