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