From ddcb9564bbaf7413e1bc687ebea1f15e2789c0fb Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 30 Oct 2024 12:13:03 -0300 Subject: [PATCH] Add "Backups Subscription not found" states. --- .../settings/app/AppSettingsFragment.kt | 46 ++++- .../settings/app/AppSettingsState.kt | 3 +- .../settings/app/AppSettingsViewModel.kt | 17 +- .../settings/app/BackupFailureState.kt | 14 ++ .../remote/RemoteBackupsSettingsFragment.kt | 134 ++++++++++++++ .../remote/RemoteBackupsSettingsState.kt | 11 +- .../remote/RemoteBackupsSettingsViewModel.kt | 39 ++++ .../remote/SubscriptionNotFoundBottomSheet.kt | 175 ++++++++++++++++++ .../jobs/BackupSubscriptionCheckJob.kt | 65 ++++--- .../securesms/keyvalue/BackupValues.kt | 7 + .../securesms/keyvalue/InAppPaymentValues.kt | 2 + .../app_settings_with_change_number.xml | 7 + app/src/main/res/values/strings.xml | 32 ++++ 13 files changed, 513 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/BackupFailureState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/SubscriptionNotFoundBottomSheet.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt index 327c9d7255..a8990f1eab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt @@ -11,10 +11,12 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.absoluteOffset import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -26,6 +28,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.colorResource @@ -119,7 +122,7 @@ class AppSettingsFragment : ComposeFragment(), Callbacks { override fun onResume() { super.onResume() - viewModel.refreshExpiredGiftBadge() + viewModel.refresh() viewModel.refreshDeprecatedOrUnregistered() } @@ -184,6 +187,44 @@ private fun AppSettingsContent( ) } + if (state.backupFailureState != BackupFailureState.NONE) { + item { + Dividers.Default() + } + + item { + Rows.TextRow( + text = { + Text(text = stringResource(R.string.AppSettingsFragment__renew_your_signal_backups_subscription)) + }, + icon = { + Box { + Icon( + painter = painterResource(R.drawable.symbol_backup_24), + tint = MaterialTheme.colorScheme.onSurface, + contentDescription = null + ) + + Box( + modifier = Modifier + .absoluteOffset(3.dp, (-2).dp) + .background(color = Color(0xFFFFCC00), shape = CircleShape) + .size(12.dp) + .align(Alignment.TopEnd) + ) + } + }, + onClick = { + callbacks.navigate(R.id.action_appSettingsFragment_to_remoteBackupsSettingsFragment) + } + ) + } + + item { + Dividers.Default() + } + } + item { Rows.TextRow( text = stringResource(R.string.AccountSettingsFragment__account), @@ -555,7 +596,8 @@ private fun AppSettingsContentPreview() { showInternalPreferences = true, showPayments = true, showAppUpdates = true, - showBackups = true + showBackups = true, + backupFailureState = BackupFailureState.SUBSCRIPTION_STATE_MISMATCH ), bannerManager = BannerManager( banners = listOf(TestBanner()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsState.kt index 40bb8429d7..ec8db4dc78 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsState.kt @@ -15,7 +15,8 @@ data class AppSettingsState( val showInternalPreferences: Boolean = RemoteConfig.internalUser, val showPayments: Boolean = SignalStore.payments.paymentsAvailability.showPaymentsMenu(), val showAppUpdates: Boolean = Environment.IS_NIGHTLY, - val showBackups: Boolean = RemoteConfig.messageBackups + val showBackups: Boolean = RemoteConfig.messageBackups, + val backupFailureState: BackupFailureState = BackupFailureState.NONE ) { fun isRegisteredAndUpToDate(): Boolean { return !userUnregistered && !clientDeprecated diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt index ffcf4ebee5..54a5e5a3d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsViewModel.kt @@ -60,7 +60,20 @@ class AppSettingsViewModel : ViewModel() { } } - fun refreshExpiredGiftBadge() { - store.update { it.copy(hasExpiredGiftBadge = SignalStore.inAppPayments.getExpiredGiftBadge() != null) } + fun refresh() { + store.update { + it.copy( + hasExpiredGiftBadge = SignalStore.inAppPayments.getExpiredGiftBadge() != null, + backupFailureState = getBackupFailureState() + ) + } + } + + private fun getBackupFailureState(): BackupFailureState { + return if (SignalStore.backup.subscriptionStateMismatchDetected) { + BackupFailureState.SUBSCRIPTION_STATE_MISMATCH + } else { + BackupFailureState.NONE + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/BackupFailureState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/BackupFailureState.kt new file mode 100644 index 0000000000..66bcd278b0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/BackupFailureState.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app + +/** + * Describes the current backup failure state. + */ +enum class BackupFailureState { + NONE, + SUBSCRIPTION_STATE_MISMATCH +} 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 e9a9085c8a..ba2c3f4ff0 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 @@ -14,7 +14,9 @@ import androidx.biometric.BiometricPrompt import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -26,6 +28,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialogDefaults @@ -45,8 +48,10 @@ import androidx.compose.runtime.Stable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.dimensionResource @@ -94,6 +99,8 @@ import org.thoughtcrime.securesms.util.viewModel 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 /** @@ -199,6 +206,18 @@ class RemoteBackupsSettingsFragment : ComposeFragment() { override fun onSkipMediaRestore() { // TODO - [backups] Skip media restoration } + + override fun onLearnMoreAboutLostSubscription() { + viewModel.requestDialog(RemoteBackupsSettingsState.Dialog.SUBSCRIPTION_NOT_FOUND) + } + + override fun onRenewLostSubscription() { + // TODO - [backups] Need process here (cancel first?) + } + + override fun onContactSupport() { + // TODO - [backups] Need to contact support. + } } private fun displayBackupKey() { @@ -275,6 +294,9 @@ private interface ContentCallbacks { fun onViewBackupKeyClick() = Unit fun onSkipMediaRestore() = Unit fun onCancelMediaRestore() = Unit + fun onRenewLostSubscription() = Unit + fun onLearnMoreAboutLostSubscription() = Unit + fun onContactSupport() = Unit } @Composable @@ -313,13 +335,23 @@ private fun RemoteBackupsSettingsContent( is RemoteBackupsSettingsState.BackupState.Loading -> { LoadingCard() } + is RemoteBackupsSettingsState.BackupState.Error -> { ErrorCard() } + is RemoteBackupsSettingsState.BackupState.Pending -> { PendingCard(state.price) } + is RemoteBackupsSettingsState.BackupState.SubscriptionMismatchMissingGooglePlay -> { + SubscriptionMismatchMissingGooglePlayCard( + state = state, + onLearnMoreClick = contentCallbacks::onLearnMoreAboutLostSubscription, + onRenewClick = contentCallbacks::onRenewLostSubscription + ) + } + RemoteBackupsSettingsState.BackupState.None -> Unit is RemoteBackupsSettingsState.BackupState.WithTypeAndRenewalTime -> { @@ -404,6 +436,13 @@ private fun RemoteBackupsSettingsContent( RemoteBackupsSettingsState.Dialog.DOWNLOADING_YOUR_BACKUP -> { DownloadingYourBackupDialog(onDismiss = contentCallbacks::onDialogDismissed) } + + RemoteBackupsSettingsState.Dialog.SUBSCRIPTION_NOT_FOUND -> { + SubscriptionNotFoundBottomSheet( + onDismiss = contentCallbacks::onDialogDismissed, + onContactSupport = contentCallbacks::onContactSupport + ) + } } val snackbarMessageId = remember(requestedSnackbar) { @@ -727,6 +766,85 @@ private fun PendingCard( } } +@Composable +private fun SubscriptionMismatchMissingGooglePlayCard( + state: RemoteBackupsSettingsState.BackupState.SubscriptionMismatchMissingGooglePlay, + onRenewClick: () -> Unit = {}, + onLearnMoreClick: () -> Unit = {} +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(12.dp)) + .padding(24.dp) + ) { + val days by rememberUpdatedState((state.renewalTime - System.currentTimeMillis().milliseconds).inWholeDays) + + Row { + Text( + text = pluralStringResource(R.plurals.RemoteBackupsSettingsFragment__your_subscription_on_this_device_is_valid, days.toInt(), days), + modifier = Modifier + .weight(1f) + .padding(end = 13.dp) + ) + + Box { + Image( + painter = painterResource(R.drawable.image_signal_backups), + contentDescription = null, + modifier = Modifier.size(64.dp) + ) + + Box( + modifier = Modifier + .size(22.dp) + .background( + color = Color(0xFFFFCC00), + shape = CircleShape + ) + .border(5.dp, color = SignalTheme.colors.colorSurface2, shape = CircleShape) + .align(Alignment.TopEnd) + ) + } + } + + Row( + horizontalArrangement = spacedBy(16.dp) + ) { + Buttons.LargeTonal( + onClick = onRenewClick, + colors = ButtonDefaults.filledTonalButtonColors().copy( + containerColor = SignalTheme.colors.colorTransparent5, + contentColor = colorResource(R.color.signal_light_colorOnSurface) + ), + modifier = Modifier + .padding(top = 24.dp) + .weight(1f) + ) { + Text( + text = stringResource(R.string.RemoteBackupsSettingsFragment__renew) + ) + } + + Buttons.LargeTonal( + onClick = onLearnMoreClick, + colors = ButtonDefaults.filledTonalButtonColors().copy( + containerColor = SignalTheme.colors.colorTransparent5, + contentColor = colorResource(R.color.signal_light_colorOnSurface) + ), + modifier = Modifier + .padding(top = 24.dp) + .weight(1f) + ) { + Text( + text = stringResource(R.string.RemoteBackupsSettingsFragment__learn_more) + ) + } + } + } +} + @Composable private fun InProgressBackupRow( progress: Int?, @@ -995,6 +1113,22 @@ private fun PendingCardPreview() { } } +@SignalPreview +@Composable +private fun SubscriptionMismatchMissingGooglePlayCardPreview() { + Previews.Preview { + SubscriptionMismatchMissingGooglePlayCard( + state = RemoteBackupsSettingsState.BackupState.SubscriptionMismatchMissingGooglePlay( + messageBackupsType = MessageBackupsType.Paid( + pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("CAD")), + storageAllowanceBytes = 100_000_000 + ), + renewalTime = System.currentTimeMillis().milliseconds + 30.days + ) + ) + } +} + @SignalPreview @Composable private fun BackupCardPreview() { 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 c890f5072d..a208d19d09 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 @@ -91,6 +91,14 @@ data class RemoteBackupsSettingsState( 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 */ @@ -103,7 +111,8 @@ data class RemoteBackupsSettingsState( BACKUP_FREQUENCY, PROGRESS_SPINNER, DOWNLOADING_YOUR_BACKUP, - TURN_OFF_FAILED + TURN_OFF_FAILED, + SUBSCRIPTION_NOT_FOUND } enum class Snackbar { 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 17742eaa21..33308294be 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,6 +20,7 @@ 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.logging.Log import org.signal.core.util.money.FiatMoney import org.signal.donations.InAppPaymentType @@ -153,6 +154,44 @@ class RemoteBackupsSettingsViewModel : ViewModel() { return } + if (SignalStore.backup.subscriptionStateMismatchDetected) { + Log.d(TAG, "[subscriptionStateMismatchDetected] A mismatch was detected.") + + val hasActiveGooglePlayBillingSubscription = when (val purchaseResult = AppDependencies.billingApi.queryPurchases()) { + is BillingPurchaseResult.Success -> purchaseResult.isAcknowledged && purchaseResult.isWithinTheLastMonth() + else -> false + } + + 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") + + val type = withContext(Dispatchers.IO) { + BackupRepository.getBackupsType(MessageBackupTier.PAID) as MessageBackupsType.Paid + } + + if (hasActiveSignalSubscription && !hasActiveGooglePlayBillingSubscription) { + _state.update { + it.copy( + backupState = RemoteBackupsSettingsState.BackupState.SubscriptionMismatchMissingGooglePlay( + messageBackupsType = type, + renewalTime = activeSubscription!!.activeSubscription.endOfCurrentPeriod.seconds + ) + ) + } + } + + // TODO [backups] - handle other cases. + + return + } + when (tier) { MessageBackupTier.PAID -> { Log.d(TAG, "Attempting to retrieve subscription details for active PAID backup.") diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/SubscriptionNotFoundBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/SubscriptionNotFoundBottomSheet.kt new file mode 100644 index 0000000000..5e2e90db3d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/backups/remote/SubscriptionNotFoundBottomSheet.kt @@ -0,0 +1,175 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.backups.remote + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.signal.core.ui.BottomSheets +import org.signal.core.ui.Buttons +import org.signal.core.ui.Previews +import org.signal.core.ui.SignalPreview +import org.signal.core.ui.horizontalGutters +import org.signal.core.ui.theme.SignalTheme +import org.thoughtcrime.securesms.R + +/** + * Displayed after user taps "Learn more" when being notified that their subscription + * could not be found. This state is entered when a user has a Signal service backups + * subscription that is active but no on-device (Google Play Billing) subscription. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SubscriptionNotFoundBottomSheet( + onDismiss: () -> Unit, + onContactSupport: () -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = onDismiss, + dragHandle = { BottomSheets.Handle() } + ) { + SubscriptionNotFoundContent( + onDismiss = onDismiss, + onContactSupport = onContactSupport + ) + } +} + +@Composable +private fun ColumnScope.SubscriptionNotFoundContent( + onDismiss: () -> Unit = {}, + onContactSupport: () -> Unit = {} +) { + Text( + text = stringResource(R.string.SubscriptionNotFoundBottomSheet__subscription_not_found), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(top = 28.dp) + .horizontalGutters() + .align(Alignment.CenterHorizontally) + ) + + Text( + text = stringResource(R.string.SubscriptionNotFoundBottomSheet__your_subscription_couldnt_be_restored), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(top = 12.dp) + .horizontalGutters() + .align(Alignment.CenterHorizontally) + ) + + Text( + text = stringResource(R.string.SubscriptionNotFoundBottomSheet__this_could_happen_if), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .horizontalGutters() + .padding(bottom = 12.dp) + .align(Alignment.CenterHorizontally) + ) + + SubscriptionNotFoundReason(stringResource(R.string.SubscriptionNotFoundBottomSheet__youre_signed_into_the_play_store_with_a_different_google_account)) + + SubscriptionNotFoundReason(stringResource(R.string.SubscriptionNotFoundBottomSheet__you_transferred_from_an_iphone)) + + SubscriptionNotFoundReason(stringResource(R.string.SubscriptionNotFoundBottomSheet__your_subscription_recently_expired)) + + Text( + text = stringResource(R.string.SubscriptionNotFoundBottomSheet__if_you_have_an_active_subscription_on), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .padding(top = 24.dp) + .horizontalGutters() + .align(Alignment.CenterHorizontally) + ) + + Buttons.LargeTonal( + onClick = onDismiss, + modifier = Modifier + .defaultMinSize(minWidth = 220.dp) + .padding(top = 36.dp) + .align(Alignment.CenterHorizontally) + ) { + Text(text = stringResource(R.string.SubscriptionNotFoundBottomSheet__got_it)) + } + + TextButton( + onClick = onContactSupport, + modifier = Modifier + .defaultMinSize(minWidth = 220.dp) + .padding(top = 16.dp, bottom = 48.dp) + .align(Alignment.CenterHorizontally) + ) { + Text( + text = stringResource(R.string.SubscriptionNotFoundBottomSheet__contact_support) + ) + } +} + +@Composable +private fun SubscriptionNotFoundReason(text: String) { + Row( + modifier = Modifier + .height(IntrinsicSize.Min) + .padding(horizontal = 36.dp) + .padding(top = 12.dp) + ) { + Box( + modifier = Modifier + .padding(end = 12.dp) + .fillMaxHeight() + .padding(vertical = 2.dp) + .width(4.dp) + .background( + color = SignalTheme.colors.colorTransparentInverse2, + shape = RoundedCornerShape(2.dp) + ) + ) + + Text( + text = text, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + } +} + +@SignalPreview +@Composable +private fun SubscriptionNotFoundContentPreview() { + Previews.BottomSheetPreview { + Column { + SubscriptionNotFoundContent() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt index fc3679edd4..46aa4f8eb3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt @@ -9,6 +9,7 @@ import androidx.annotation.VisibleForTesting import org.signal.core.util.billing.BillingPurchaseResult import org.signal.core.util.logging.Log import org.signal.donations.InAppPaymentType +import org.thoughtcrime.securesms.backup.v2.BackupRepository import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository @@ -58,22 +59,26 @@ class BackupSubscriptionCheckJob private constructor(parameters: Parameters) : C override suspend fun doRun(): Result { if (!SignalStore.account.isRegistered) { - Log.i(TAG, "User is not registered. Exiting.") + Log.i(TAG, "User is not registered. Clearing mismatch value and exiting.") + SignalStore.backup.subscriptionStateMismatchDetected = false return Result.success() } if (!RemoteConfig.messageBackups) { - Log.i(TAG, "Message backups are not enabled. Exiting.") + Log.i(TAG, "Message backups are not enabled. Clearing mismatch value and exiting.") + SignalStore.backup.subscriptionStateMismatchDetected = false return Result.success() } if (!SignalStore.backup.areBackupsEnabled) { - Log.i(TAG, "Backups are not enabled on this device. Exiting.") + Log.i(TAG, "Backups are not enabled on this device. Clearing mismatch value and exiting.") + SignalStore.backup.subscriptionStateMismatchDetected = false return Result.success() } if (!AppDependencies.billingApi.isApiAvailable()) { - Log.i(TAG, "Google Play Billing API is not available on this device. Exiting.") + Log.i(TAG, "Google Play Billing API is not available on this device. Clearing mismatch value and exiting.") + SignalStore.backup.subscriptionStateMismatchDetected = false return Result.success() } @@ -81,41 +86,35 @@ class BackupSubscriptionCheckJob private constructor(parameters: Parameters) : C val hasActivePurchase = purchase is BillingPurchaseResult.Success && purchase.isAcknowledged && purchase.isWithinTheLastMonth() synchronized(InAppPaymentSubscriberRecord.Type.BACKUP) { - val subscriberId = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP) - if (subscriberId == null && hasActivePurchase) { - Log.w(TAG, "User has active Google Play Billing purchase but no subscriber id! User should cancel backup and resubscribe.") - // TODO [message-backups] Set UI flag hint here to launch sheet (designs pending) - return Result.success() - } - - val tier = SignalStore.backup.backupTier - if (subscriberId == null && tier == MessageBackupTier.PAID) { - Log.w(TAG, "User has no subscriber id but PAID backup tier. User will need to cancel and resubscribe.") - // TODO [message-backups] Set UI flag hint here to launch sheet (designs pending) - return Result.success() - } - val inAppPayment = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_BACKUP) + + if (inAppPayment?.state == InAppPaymentTable.State.PENDING) { + Log.i(TAG, "User has a pending in-app payment. Clearing mismatch value and re-checking later.") + SignalStore.backup.subscriptionStateMismatchDetected = false + return Result.success() + } + val activeSubscription = RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP).getOrNull() - if (activeSubscription?.isActive == true && tier != MessageBackupTier.PAID && inAppPayment?.state != InAppPaymentTable.State.PENDING) { - Log.w(TAG, "User has an active subscription but no backup tier and no pending redemption.") - // TODO [message-backups] Set UI flag hint here to launch error sheet? - return Result.success() + val hasActiveSignalSubscription = activeSubscription?.isActive == true + + Log.i(TAG, "Synchronizing backup tier with value from server.") + BackupRepository.getBackupTier().runIfSuccessful { + SignalStore.backup.backupTier = it } - if (activeSubscription?.isActive != true && tier == MessageBackupTier.PAID) { - Log.w(TAG, "User subscription is inactive or does not exist. User will need to cancel and resubscribe.") - // TODO [message-backups] Set UI hint? + val hasActivePaidBackupTier = SignalStore.backup.backupTier == MessageBackupTier.PAID + val hasValidActiveState = hasActivePaidBackupTier && hasActiveSignalSubscription && hasActivePurchase + val hasValidInactiveState = !hasActivePaidBackupTier && !hasActiveSignalSubscription && !hasActivePurchase + + if (hasValidActiveState || hasValidInactiveState) { + Log.i(TAG, "Valid state: (hasValidActiveState: $hasValidActiveState, hasValidInactiveState: $hasValidInactiveState). Clearing mismatch value and exiting.", true) + SignalStore.backup.subscriptionStateMismatchDetected = false + return Result.success() + } else { + Log.w(TAG, "State mismatch: (hasActivePaidBackupTier: $hasActivePaidBackupTier, hasActiveSignalSubscription: $hasActiveSignalSubscription, hasActivePurchase: $hasActivePurchase). Setting mismatch value and exiting.", true) + SignalStore.backup.subscriptionStateMismatchDetected = true return Result.success() } - - if (activeSubscription?.isActive != true && hasActivePurchase) { - Log.w(TAG, "User subscription is inactive but user has a recent purchase. User will need to cancel and resubscribe.") - // TODO [message-backups] Set UI hint? - return Result.success() - } - - return Result.success() } } 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 e30696492d..9e7081012a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt @@ -43,6 +43,7 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { private const val KEY_ARCHIVE_UPLOAD_STATE = "backup.archiveUploadState" private const val KEY_BACKUP_UPLOADED = "backup.backupUploaded" + private const val KEY_SUBSCRIPTION_STATE_MISMATCH = "backup.subscriptionStateMismatch" private val cachedCdnCredentialsExpiresIn: Duration = 12.hours } @@ -73,6 +74,12 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { */ val latestBackupTier: MessageBackupTier? by enumValue(KEY_LATEST_BACKUP_TIER, null, MessageBackupTier.Serializer) + /** + * 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) + /** * When seting 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InAppPaymentValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InAppPaymentValues.kt index 80f9a982fd..f74bfed2e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InAppPaymentValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InAppPaymentValues.kt @@ -469,6 +469,7 @@ class InAppPaymentValues internal constructor(store: KeyValueStore) : SignalStor } markDonationManuallyCancelled() } else { + SignalStore.backup.subscriptionStateMismatchDetected = false markBackupSubscriptionpManuallyCancelled() SignalStore.backup.disableBackups() @@ -513,6 +514,7 @@ class InAppPaymentValues internal constructor(store: KeyValueStore) : SignalStor } else { clearBackupSubscriptionManuallyCancelled() + SignalStore.backup.subscriptionStateMismatchDetected = false SignalStore.backup.backupTier = MessageBackupTier.PAID SignalStore.uiHints.markHasEverEnabledRemoteBackups() } diff --git a/app/src/main/res/navigation/app_settings_with_change_number.xml b/app/src/main/res/navigation/app_settings_with_change_number.xml index b301d80745..63227c442a 100644 --- a/app/src/main/res/navigation/app_settings_with_change_number.xml +++ b/app/src/main/res/navigation/app_settings_with_change_number.xml @@ -43,6 +43,13 @@ app:exitAnim="@anim/fragment_open_exit" app:popEnterAnim="@anim/fragment_close_enter" app:popExitAnim="@anim/fragment_close_exit" /> + Media quality + + Renew your Signal Backups subscription + Invite your friends Copied donor subscriber id to clipboard @@ -7682,6 +7685,35 @@ Downloading your backup Depending on the size of your backup, this could take a long time. You can use your phone as you normally do while the download takes place. + + + Your subscription on this device is valid for the next %1$d day. Renew to continue using Signal Backups + Your subscription on this device is valid for the next %1$d days. Renew to continue using Signal Backups + + + Renew + + Learn more + + + + Subscription not found + + Your subscription couldn\'t be restored. + + This could happen if: + + You\'re signed into the Play Store with a different Google account. + + You transferred from an iPhone. + + Your subscription recently expired. + + If you have an active subscription on your old phone consider canceling it before it renews. + + Got it + + Contact support