From 2a90809ba3a6e5a0a9325f700d88dcf4c4e4a72a Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 17 Sep 2025 11:35:03 -0300 Subject: [PATCH] Add Billing API and Google API availability error dialogs. --- .../jobs/BackupSubscriptionCheckJobTest.kt | 5 +- ...layBillingPurchaseTokenMigrationJobTest.kt | 7 +- .../securesms/backup/v2/BackupRepository.kt | 14 +- .../GooglePlayServicesAvailability.kt | 231 ++++++++++++++++++ .../MessageBackupsFlowFragment.kt | 19 +- .../subscription/MessageBackupsFlowState.kt | 4 +- .../MessageBackupsFlowViewModel.kt | 9 +- .../MessageBackupsTypeSelectionScreen.kt | 125 ++++++---- .../upgrade/UpgradeToPaidTierBottomSheet.kt | 8 + .../remote/RemoteBackupsSettingsViewModel.kt | 2 +- .../storage/ManageStorageSettingsViewModel.kt | 2 +- .../jobs/BackupSubscriptionCheckJob.kt | 2 +- .../jobs/InAppPaymentPurchaseTokenJob.kt | 2 +- .../PostRegistrationBackupRedemptionJob.kt | 2 +- .../logsubmit/LogSectionRemoteBackups.kt | 2 +- ...glePlayBillingPurchaseTokenMigrationJob.kt | 2 +- .../securesms/util/PlayStoreUtil.java | 10 + app/src/main/res/values/strings.xml | 30 +++ .../java/org/signal/billing/BillingApiImpl.kt | 6 +- .../signal/core/util/billing/BillingApi.kt | 2 +- .../core/util/billing/BillingResponseCode.kt | 42 ++++ 21 files changed, 450 insertions(+), 76 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/GooglePlayServicesAvailability.kt create mode 100644 core-util/src/main/java/org/signal/core/util/billing/BillingResponseCode.kt diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJobTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJobTest.kt index 5fbaf360a7..6bdbd57b97 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJobTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJobTest.kt @@ -24,6 +24,7 @@ import org.junit.runner.RunWith import org.signal.core.util.billing.BillingProduct import org.signal.core.util.billing.BillingPurchaseResult import org.signal.core.util.billing.BillingPurchaseState +import org.signal.core.util.billing.BillingResponseCode import org.signal.core.util.money.FiatMoney import org.signal.donations.InAppPaymentType import org.thoughtcrime.securesms.backup.DeletionState @@ -67,7 +68,7 @@ class BackupSubscriptionCheckJobTest { every { RemoteConfig.messageBackups } returns true every { RemoteConfig.internalUser } returns true - coEvery { AppDependencies.billingApi.isApiAvailable() } returns true + coEvery { AppDependencies.billingApi.getApiAvailability() } returns BillingResponseCode.OK coEvery { AppDependencies.billingApi.queryPurchases() } returns mockk() coEvery { AppDependencies.billingApi.queryProduct() } returns null @@ -143,7 +144,7 @@ class BackupSubscriptionCheckJobTest { @Test fun givenBillingApiNotAvailable_whenIRun_thenIExpectSuccessAndEarlyExit() { - coEvery { AppDependencies.billingApi.isApiAvailable() } returns false + coEvery { AppDependencies.billingApi.getApiAvailability() } returns BillingResponseCode.BILLING_UNAVAILABLE val job = BackupSubscriptionCheckJob.create() val result = job.run() diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/migrations/GooglePlayBillingPurchaseTokenMigrationJobTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/migrations/GooglePlayBillingPurchaseTokenMigrationJobTest.kt index b3843325c2..daee48a68b 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/migrations/GooglePlayBillingPurchaseTokenMigrationJobTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/migrations/GooglePlayBillingPurchaseTokenMigrationJobTest.kt @@ -12,6 +12,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.signal.core.util.billing.BillingPurchaseResult import org.signal.core.util.billing.BillingPurchaseState +import org.signal.core.util.billing.BillingResponseCode import org.signal.core.util.deleteAll import org.thoughtcrime.securesms.database.InAppPaymentSubscriberTable import org.thoughtcrime.securesms.database.SignalDatabase @@ -94,7 +95,7 @@ class GooglePlayBillingPurchaseTokenMigrationJobTest { ) ) - coEvery { AppDependencies.billingApi.isApiAvailable() } returns false + coEvery { AppDependencies.billingApi.getApiAvailability() } returns BillingResponseCode.BILLING_UNAVAILABLE val job = GooglePlayBillingPurchaseTokenMigrationJob() @@ -118,7 +119,7 @@ class GooglePlayBillingPurchaseTokenMigrationJobTest { ) ) - coEvery { AppDependencies.billingApi.isApiAvailable() } returns true + coEvery { AppDependencies.billingApi.getApiAvailability() } returns BillingResponseCode.OK coEvery { AppDependencies.billingApi.queryPurchases() } returns BillingPurchaseResult.None val job = GooglePlayBillingPurchaseTokenMigrationJob() @@ -143,7 +144,7 @@ class GooglePlayBillingPurchaseTokenMigrationJobTest { ) ) - coEvery { AppDependencies.billingApi.isApiAvailable() } returns true + coEvery { AppDependencies.billingApi.getApiAvailability() } returns BillingResponseCode.OK coEvery { AppDependencies.billingApi.queryPurchases() } returns BillingPurchaseResult.Success( purchaseState = BillingPurchaseState.PURCHASED, purchaseToken = "purchaseToken", diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt index fbb900261c..f14d1710a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -164,6 +164,7 @@ import java.io.File import java.io.IOException import java.io.InputStream import java.io.OutputStream +import java.math.BigDecimal import java.time.ZonedDateTime import java.util.Currency import java.util.Locale @@ -1876,20 +1877,11 @@ object BackupRepository { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP).getOrNull()?.activeSubscription?.let { FiatMoney.fromSignalNetworkAmount(it.amount, Currency.getInstance(it.currency)) } - } else if (AppDependencies.billingApi.isApiAvailable()) { + } else if (AppDependencies.billingApi.getApiAvailability().isSuccess) { Log.d(TAG, "Accessing price via billing api.") AppDependencies.billingApi.queryProduct()?.price } else { - Log.d(TAG, "Billing API is not available on this device. Accessing price via subscription configuration.") - val configurationResult = AppDependencies.donationsService.getDonationsConfiguration(Locale.getDefault()).toNetworkResult() - val currency = Currency.getInstance(Locale.getDefault()) - - when (configurationResult) { - is NetworkResult.Success -> configurationResult.result.currencies[currency.currencyCode.lowercase()]?.backupSubscription[SubscriptionsConfiguration.BACKUPS_LEVEL]?.let { - FiatMoney(it, currency) - } - else -> null - } + FiatMoney(BigDecimal.ZERO, Currency.getInstance(Locale.getDefault())) } if (productPrice == null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/GooglePlayServicesAvailability.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/GooglePlayServicesAvailability.kt new file mode 100644 index 0000000000..c295eceb24 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/GooglePlayServicesAvailability.kt @@ -0,0 +1,231 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.ui.subscription + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.res.stringResource +import com.google.android.gms.common.ConnectionResult +import org.signal.core.ui.compose.Dialogs +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.SignalPreview +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R + +/** + * Represents the availability status of Google Play Services on the device. + * + * Maps Google Play Services ConnectionResult codes to enum values for easier handling + * in the application. Each enum value corresponds to a specific state that determines + * what dialog or action should be presented to the user. + * + * @param code The corresponding ConnectionResult code from Google Play Services + */ +enum class GooglePlayServicesAvailability(val code: Int) { + /** An unknown code. Possibly due to an update on Google's end */ + UNKNOWN(code = Int.MIN_VALUE), + + /** Google Play Services is available and ready to use */ + SUCCESS(code = ConnectionResult.SUCCESS), + + /** Google Play Services is not installed on the device */ + SERVICE_MISSING(code = ConnectionResult.SERVICE_MISSING), + + /** Google Play Services is currently being updated */ + SERVICE_UPDATING(code = ConnectionResult.SERVICE_UPDATING), + + /** Google Play Services requires an update to a newer version */ + SERVICE_VERSION_UPDATE_REQUIRED(code = ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED), + + /** Google Play Services is installed but disabled by the user */ + SERVICE_DISABLED(code = ConnectionResult.SERVICE_DISABLED), + + /** Google Play Services installation is invalid or corrupted */ + SERVICE_INVALID(code = ConnectionResult.SERVICE_INVALID); + + companion object { + + private val TAG = Log.tag(GooglePlayServicesAvailability::class) + + /** + * Converts a Google Play Services ConnectionResult code to the corresponding enum value. + * + * @param code The ConnectionResult code from Google Play Services + * @return The matching GooglePlayServicesAvailability enum value + */ + fun fromCode(code: Int): GooglePlayServicesAvailability { + val availability = entries.firstOrNull { it.code == code } ?: UNKNOWN + if (availability == UNKNOWN) { + Log.w(TAG, "Unknown availability code: $code") + } + + return availability + } + } +} + +/** + * Displays a dialog based on the Google Play Services availability status. + * + * Shows different dialogs with appropriate messages and actions depending on whether + * Google Play Services is missing, updating, requires an update, is disabled, or invalid. + * When availability is SUCCESS, automatically calls onDismissRequest to dismiss any dialog. + * + * @param onDismissRequest Callback invoked when the dialog is dismissed or when SUCCESS status is received + * @param onLearnMoreClick Callback invoked when the "Learn More" action is selected + * @param onMakeServicesAvailableClick Callback invoked when an action to make services + * available is selected (e.g., install or update) + * @param googlePlayServicesAvailability The current availability status of Google Play Services + */ +@Composable +fun GooglePlayServicesAvailabilityDialog( + onDismissRequest: () -> Unit, + onLearnMoreClick: () -> Unit, + onMakeServicesAvailableClick: () -> Unit, + googlePlayServicesAvailability: GooglePlayServicesAvailability +) { + when (googlePlayServicesAvailability) { + GooglePlayServicesAvailability.SUCCESS -> { + LaunchedEffect(Unit) { + onDismissRequest() + } + } + GooglePlayServicesAvailability.SERVICE_MISSING, GooglePlayServicesAvailability.UNKNOWN -> { + ServiceMissingDialog( + onDismissRequest = onDismissRequest, + onInstallPlayServicesClick = onMakeServicesAvailableClick + ) + } + GooglePlayServicesAvailability.SERVICE_UPDATING -> { + ServiceUpdatingDialog(onDismissRequest = onDismissRequest) + } + GooglePlayServicesAvailability.SERVICE_VERSION_UPDATE_REQUIRED -> { + ServiceVersionUpdateRequiredDialog( + onDismissRequest = onDismissRequest, + onUpdateClick = onMakeServicesAvailableClick + ) + } + GooglePlayServicesAvailability.SERVICE_DISABLED -> { + ServiceDisabledDialog( + onDismissRequest = onDismissRequest, + onLearnMoreClick = onLearnMoreClick + ) + } + GooglePlayServicesAvailability.SERVICE_INVALID -> { + ServiceInvalidDialog( + onDismissRequest = onDismissRequest, + onLearnMoreClick = onLearnMoreClick + ) + } + } +} + +@Composable +private fun ServiceMissingDialog(onDismissRequest: () -> Unit, onInstallPlayServicesClick: () -> Unit) { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.GooglePlayServicesAvailability__service_missing_title), + body = stringResource(R.string.GooglePlayServicesAvailability__service_missing_message), + confirm = stringResource(R.string.GooglePlayServicesAvailability__install_play_services), + dismiss = stringResource(android.R.string.cancel), + onConfirm = {}, + onDeny = onInstallPlayServicesClick, + onDismiss = onDismissRequest, + onDismissRequest = onDismissRequest + ) +} + +@Composable +private fun ServiceUpdatingDialog(onDismissRequest: () -> Unit) { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.GooglePlayServicesAvailability__service_updating_title), + body = stringResource(R.string.GooglePlayServicesAvailability__service_updating_message), + confirm = stringResource(android.R.string.ok), + onConfirm = {}, + onDismiss = onDismissRequest, + onDismissRequest = onDismissRequest + ) +} + +@Composable +private fun ServiceVersionUpdateRequiredDialog(onDismissRequest: () -> Unit, onUpdateClick: () -> Unit) { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.GooglePlayServicesAvailability__service_update_required_title), + body = stringResource(R.string.GooglePlayServicesAvailability__service_update_required_message), + confirm = stringResource(R.string.GooglePlayServicesAvailability__update), + dismiss = stringResource(android.R.string.cancel), + onConfirm = onUpdateClick, + onDismiss = onDismissRequest, + onDismissRequest = onDismissRequest + ) +} + +@Composable +private fun ServiceDisabledDialog(onDismissRequest: () -> Unit, onLearnMoreClick: () -> Unit) { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.GooglePlayServicesAvailability__service_disabled_title), + body = stringResource(R.string.GooglePlayServicesAvailability__service_disabled_message), + confirm = stringResource(android.R.string.ok), + dismiss = stringResource(R.string.GooglePlayServicesAvailability__learn_more), + onConfirm = onDismissRequest, + onDeny = onLearnMoreClick, + onDismiss = onDismissRequest, + onDismissRequest = onDismissRequest + ) +} + +@Composable +private fun ServiceInvalidDialog(onDismissRequest: () -> Unit, onLearnMoreClick: () -> Unit) { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.GooglePlayServicesAvailability__service_disabled_title), + body = stringResource(R.string.GooglePlayServicesAvailability__service_invalid_message), + confirm = stringResource(android.R.string.ok), + dismiss = stringResource(R.string.GooglePlayServicesAvailability__learn_more), + onConfirm = {}, + onDeny = onLearnMoreClick, + onDismiss = onDismissRequest, + onDismissRequest = onDismissRequest + ) +} + +@SignalPreview +@Composable +private fun ServiceMissingDialogPreview() { + Previews.Preview { + ServiceMissingDialog({}, {}) + } +} + +@SignalPreview +@Composable +private fun ServiceUpdatingDialogPreview() { + Previews.Preview { + ServiceUpdatingDialog({}) + } +} + +@SignalPreview +@Composable +private fun ServiceVersionUpdateRequiredDialogPreview() { + Previews.Preview { + ServiceVersionUpdateRequiredDialog({}, {}) + } +} + +@SignalPreview +@Composable +private fun ServiceDisabledDialogPreview() { + Previews.Preview { + ServiceDisabledDialog({}, {}) + } +} + +@SignalPreview +@Composable +private fun ServiceInvalidDialogPreview() { + Previews.Preview { + ServiceInvalidDialog({}, {}) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt index 95bc5c10f0..04b6ad10e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowFragment.kt @@ -25,6 +25,7 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import com.google.android.gms.common.GoogleApiAvailability import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.rx3.asFlowable @@ -40,6 +41,7 @@ import org.thoughtcrime.securesms.compose.Nav import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.util.CommunicationActions +import org.thoughtcrime.securesms.util.PlayStoreUtil import org.thoughtcrime.securesms.util.Util import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository import org.thoughtcrime.securesms.util.viewModel @@ -63,7 +65,10 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega } private val viewModel: MessageBackupsFlowViewModel by viewModel { - MessageBackupsFlowViewModel(requireArguments().getSerializableCompat(TIER, MessageBackupTier::class.java)) + MessageBackupsFlowViewModel( + initialTierSelection = requireArguments().getSerializableCompat(TIER, MessageBackupTier::class.java), + googlePlayApiAvailability = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext()) + ) } private val errorHandler = InAppPaymentCheckoutDelegate.ErrorHandler() @@ -97,6 +102,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega override fun onResume() { super.onResume() viewModel.refreshCurrentTier() + viewModel.setGooglePlayApiAvailability(GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext())) } @Composable @@ -181,12 +187,21 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega ) }, onNextClicked = viewModel::goToNextStage, - isBillingApiAvailable = state.isBillingApiAvailable, + googlePlayServicesAvailability = state.googlePlayApiAvailability, + googlePlayBillingAvailability = state.googlePlayBillingAvailability, onLearnMoreAboutWhyUserCanNotUpgrade = { CommunicationActions.openBrowserLink( requireContext(), getString(R.string.backup_support_url) ) + }, + onMakeGooglePlayServicesAvailable = { + GoogleApiAvailability.getInstance().makeGooglePlayServicesAvailable(requireActivity()).addOnSuccessListener { + viewModel.setGooglePlayApiAvailability(GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext())) + } + }, + onOpenPlayStore = { + PlayStoreUtil.openPlayStoreHome(requireContext()) } ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt index 6b86fafce4..d2f969dfb8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowState.kt @@ -6,6 +6,7 @@ package org.thoughtcrime.securesms.backup.v2.ui.subscription import androidx.compose.runtime.Immutable +import org.signal.core.util.billing.BillingResponseCode import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState import org.thoughtcrime.securesms.database.InAppPaymentTable @@ -17,7 +18,8 @@ data class MessageBackupsFlowState( val selectedMessageBackupTier: MessageBackupTier? = SignalStore.backup.backupTier, val currentMessageBackupTier: MessageBackupTier? = null, val allBackupTypes: List = emptyList(), - val isBillingApiAvailable: Boolean = false, + val googlePlayApiAvailability: GooglePlayServicesAvailability = GooglePlayServicesAvailability.SUCCESS, + val googlePlayBillingAvailability: BillingResponseCode = BillingResponseCode.FEATURE_NOT_SUPPORTED, val inAppPayment: InAppPaymentTable.InAppPayment? = null, val startScreen: MessageBackupsStage, val stage: MessageBackupsStage = startScreen, diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt index e84c79b8fc..3b89d276d1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsFlowViewModel.kt @@ -53,6 +53,7 @@ import kotlin.time.Duration.Companion.seconds class MessageBackupsFlowViewModel( private val initialTierSelection: MessageBackupTier?, + googlePlayApiAvailability: Int, startScreen: MessageBackupsStage = if (SignalStore.backup.backupTier == null) MessageBackupsStage.EDUCATION else MessageBackupsStage.TYPE_SELECTION ) : ViewModel(), BackupKeyCredentialManagerHandler { @@ -64,6 +65,7 @@ class MessageBackupsFlowViewModel( private val internalStateFlow = MutableStateFlow( MessageBackupsFlowState( allBackupTypes = emptyList(), + googlePlayApiAvailability = GooglePlayServicesAvailability.fromCode(googlePlayApiAvailability), currentMessageBackupTier = SignalStore.backup.backupTier, selectedMessageBackupTier = resolveSelectedTier(initialTierSelection, SignalStore.backup.backupTier), startScreen = startScreen @@ -105,7 +107,6 @@ class MessageBackupsFlowViewModel( internalStateFlow.update { state -> state.copy( allBackupTypes = allBackupTypes, - isBillingApiAvailable = AppDependencies.billingApi.isApiAvailable(), selectedMessageBackupTier = if (state.selectedMessageBackupTier in allBackupTypes.map { it.tier }) state.selectedMessageBackupTier else allBackupTypes.firstOrNull()?.tier ) } @@ -158,6 +159,12 @@ class MessageBackupsFlowViewModel( } } + fun setGooglePlayApiAvailability(googlePlayApiAvailability: Int) { + internalStateFlow.update { + it.copy(googlePlayApiAvailability = GooglePlayServicesAvailability.fromCode(googlePlayApiAvailability)) + } + } + fun refreshCurrentTier() { val tier = SignalStore.backup.backupTier if (tier == MessageBackupTier.PAID) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt index 4ec80179ad..95f0211485 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/MessageBackupsTypeSelectionScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -52,6 +53,7 @@ import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Scaffolds import org.signal.core.ui.compose.SignalPreview import org.signal.core.ui.compose.theme.SignalTheme +import org.signal.core.util.billing.BillingResponseCode import org.signal.core.util.bytes import org.signal.core.util.money.FiatMoney import org.thoughtcrime.securesms.R @@ -75,13 +77,16 @@ fun MessageBackupsTypeSelectionScreen( currentBackupTier: MessageBackupTier?, selectedBackupTier: MessageBackupTier?, allBackupTypes: List, - isBillingApiAvailable: Boolean, + googlePlayServicesAvailability: GooglePlayServicesAvailability, + googlePlayBillingAvailability: BillingResponseCode, isNextEnabled: Boolean, onMessageBackupsTierSelected: (MessageBackupTier) -> Unit, onNavigationClick: () -> Unit, onReadMoreClicked: () -> Unit, onNextClicked: () -> Unit, - onLearnMoreAboutWhyUserCanNotUpgrade: () -> Unit + onLearnMoreAboutWhyUserCanNotUpgrade: () -> Unit, + onMakeGooglePlayServicesAvailable: () -> Unit, + onOpenPlayStore: () -> Unit ) { Scaffolds.Settings( title = "", @@ -161,28 +166,26 @@ fun MessageBackupsTypeSelectionScreen( } val hasCurrentBackupTier = currentBackupTier != null - var displayNotAvailableDialog by remember { mutableStateOf(false) } - val onSubscribeButtonClick = remember(isBillingApiAvailable, selectedBackupTier) { + val paidTierNotAvailableDialogState = remember { PaidTierNotAvailableDialogState() } + val onSubscribeButtonClick = remember(googlePlayServicesAvailability, googlePlayBillingAvailability, selectedBackupTier) { { - if (selectedBackupTier == MessageBackupTier.PAID && !isBillingApiAvailable) { - displayNotAvailableDialog = true + if (selectedBackupTier == MessageBackupTier.PAID && googlePlayServicesAvailability != GooglePlayServicesAvailability.SUCCESS) { + paidTierNotAvailableDialogState.displayGooglePlayApiErrorDialog = true + } else if (selectedBackupTier == MessageBackupTier.PAID && !googlePlayBillingAvailability.isSuccess) { + paidTierNotAvailableDialogState.displayGooglePlayBillingErrorDialog = true } else { onNextClicked() } } } - if (displayNotAvailableDialog) { - UpgradeNotAvailableDialog( - onConfirm = { - displayNotAvailableDialog = false - }, - onDismiss = onLearnMoreAboutWhyUserCanNotUpgrade, - onDismissRequest = { - displayNotAvailableDialog = false - } - ) - } + PaidTierNotAvailableDialogs( + state = paidTierNotAvailableDialogState, + onOpenPlayStore = onOpenPlayStore, + onLearnMoreAboutWhyUserCanNotUpgrade = onLearnMoreAboutWhyUserCanNotUpgrade, + onMakeGooglePlayServicesAvailable = onMakeGooglePlayServicesAvailable, + googlePlayServicesAvailability = googlePlayServicesAvailability + ) Buttons.LargeTonal( onClick = onSubscribeButtonClick, @@ -193,7 +196,9 @@ fun MessageBackupsTypeSelectionScreen( .padding(vertical = if (hasCurrentBackupTier) 10.dp else 16.dp) ) { val text: String = if (currentBackupTier == null) { - if (selectedBackupTier == MessageBackupTier.PAID && allBackupTypes.map { it.tier }.contains(selectedBackupTier)) { + if (selectedBackupTier == MessageBackupTier.PAID && (googlePlayServicesAvailability != GooglePlayServicesAvailability.SUCCESS || !googlePlayBillingAvailability.isSuccess)) { + stringResource(R.string.MessageBackupsTypeSelectionScreen__more_about_this_plan) + } else if (selectedBackupTier == MessageBackupTier.PAID && allBackupTypes.map { it.tier }.contains(selectedBackupTier)) { val paidTier = allBackupTypes.first { it.tier == MessageBackupTier.PAID } as MessageBackupsType.Paid val context = LocalContext.current @@ -207,6 +212,8 @@ fun MessageBackupsTypeSelectionScreen( } else { stringResource(R.string.MessageBackupsTypeSelectionScreen__subscribe) } + } else if (selectedBackupTier == MessageBackupTier.PAID && (googlePlayServicesAvailability != GooglePlayServicesAvailability.SUCCESS || !googlePlayBillingAvailability.isSuccess)) { + stringResource(R.string.MessageBackupsTypeSelectionScreen__more_about_this_plan) } else { stringResource(R.string.MessageBackupsTypeSelectionScreen__change_backup_type) } @@ -224,20 +231,50 @@ fun MessageBackupsTypeSelectionScreen( } } +@Stable +class PaidTierNotAvailableDialogState { + var displayGooglePlayBillingErrorDialog: Boolean by mutableStateOf(false) + var displayGooglePlayApiErrorDialog: Boolean by mutableStateOf(false) +} + @Composable -private fun UpgradeNotAvailableDialog( - onConfirm: () -> Unit, - onDismiss: () -> Unit, - onDismissRequest: () -> Unit +fun PaidTierNotAvailableDialogs( + state: PaidTierNotAvailableDialogState, + googlePlayServicesAvailability: GooglePlayServicesAvailability, + onLearnMoreAboutWhyUserCanNotUpgrade: () -> Unit, + onMakeGooglePlayServicesAvailable: () -> Unit, + onOpenPlayStore: () -> Unit +) { + if (state.displayGooglePlayApiErrorDialog) { + GooglePlayServicesAvailabilityDialog( + onDismissRequest = { state.displayGooglePlayApiErrorDialog = false }, + googlePlayServicesAvailability = googlePlayServicesAvailability, + onLearnMoreClick = onLearnMoreAboutWhyUserCanNotUpgrade, + onMakeServicesAvailableClick = onMakeGooglePlayServicesAvailable + ) + } + + if (state.displayGooglePlayBillingErrorDialog) { + UserNotSignedInDialog( + onDismissRequest = { state.displayGooglePlayBillingErrorDialog = false }, + onOpenPlayStore = onOpenPlayStore + ) + } +} + +@Composable +private fun UserNotSignedInDialog( + onDismissRequest: () -> Unit, + onOpenPlayStore: () -> Unit ) { Dialogs.SimpleAlertDialog( - title = stringResource(R.string.MessageBackupsTypeSelectionScreen__cant_upgrade_plan), - body = stringResource(R.string.MessageBackupsTypeSelectionScreen__to_subscribe_to_signal_secure_backups), - confirm = stringResource(android.R.string.ok), - dismiss = stringResource(R.string.MessageBackupsTypeSelectionScreen__learn_more), - onConfirm = onConfirm, - onDismiss = onDismiss, - onDismissRequest = onDismissRequest + title = stringResource(R.string.GooglePlayServicesAvailability__service_disabled_title), + body = "To subscribe to Signal Secure Backups, please sign into the Google Play store.", + onConfirm = onOpenPlayStore, + onDismiss = onDismissRequest, + onDismissRequest = onDismissRequest, + confirm = "Open Play Store", + dismiss = stringResource(android.R.string.cancel) ) } @@ -256,8 +293,11 @@ private fun MessageBackupsTypeSelectionScreenPreview() { onReadMoreClicked = {}, onNextClicked = {}, onLearnMoreAboutWhyUserCanNotUpgrade = {}, + onMakeGooglePlayServicesAvailable = {}, + onOpenPlayStore = {}, currentBackupTier = null, - isBillingApiAvailable = true, + googlePlayServicesAvailability = GooglePlayServicesAvailability.SUCCESS, + googlePlayBillingAvailability = BillingResponseCode.OK, isNextEnabled = true ) } @@ -278,25 +318,16 @@ private fun MessageBackupsTypeSelectionScreenWithCurrentTierPreview() { onReadMoreClicked = {}, onNextClicked = {}, onLearnMoreAboutWhyUserCanNotUpgrade = {}, + onMakeGooglePlayServicesAvailable = {}, + onOpenPlayStore = {}, currentBackupTier = MessageBackupTier.PAID, - isBillingApiAvailable = true, + googlePlayServicesAvailability = GooglePlayServicesAvailability.SUCCESS, + googlePlayBillingAvailability = BillingResponseCode.OK, isNextEnabled = true ) } } -@SignalPreview -@Composable -private fun UpgradeNotAvailableDialogPreview() { - Previews.Preview { - UpgradeNotAvailableDialog( - onConfirm = {}, - onDismiss = {}, - onDismissRequest = {} - ) - } -} - @Composable fun MessageBackupsTypeBlock( messageBackupsType: MessageBackupsType, @@ -382,8 +413,12 @@ private fun getFormattedPricePerMonth(messageBackupsType: MessageBackupsType): S return when (messageBackupsType) { is MessageBackupsType.Free -> stringResource(id = R.string.MessageBackupsTypeSelectionScreen__free) is MessageBackupsType.Paid -> { - val formattedAmount = FiatMoneyUtil.format(LocalContext.current.resources, messageBackupsType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal()) - stringResource(id = R.string.MessageBackupsTypeSelectionScreen__s_month, formattedAmount) + if (messageBackupsType.pricePerMonth.amount == BigDecimal.ZERO) { + stringResource(R.string.MessageBackupsTypeSelectionScreen__paid) + } else { + val formattedAmount = FiatMoneyUtil.format(LocalContext.current.resources, messageBackupsType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal()) + stringResource(id = R.string.MessageBackupsTypeSelectionScreen__s_month, formattedAmount) + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/billing/upgrade/UpgradeToPaidTierBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/billing/upgrade/UpgradeToPaidTierBottomSheet.kt index 35f583e3f1..ac19632fc2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/billing/upgrade/UpgradeToPaidTierBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/billing/upgrade/UpgradeToPaidTierBottomSheet.kt @@ -19,6 +19,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import com.google.android.gms.common.GoogleApiAvailability import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.rx3.asFlowable @@ -59,6 +60,7 @@ abstract class UpgradeToPaidTierBottomSheet : ComposeBottomSheetDialogFragment() private val viewModel: MessageBackupsFlowViewModel by viewModel { MessageBackupsFlowViewModel( initialTierSelection = MessageBackupTier.PAID, + googlePlayApiAvailability = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext()), startScreen = MessageBackupsStage.TYPE_SELECTION ) } @@ -93,6 +95,12 @@ abstract class UpgradeToPaidTierBottomSheet : ComposeBottomSheetDialogFragment() } } + override fun onResume() { + super.onResume() + viewModel.refreshCurrentTier() + viewModel.setGooglePlayApiAvailability(GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext())) + } + @Composable override fun SheetContent() { val state by viewModel.stateFlow.collectAsStateWithLifecycle() 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 e9fe4843ca..c9a8f663ef 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 @@ -83,7 +83,7 @@ class RemoteBackupsSettingsViewModel : ViewModel() { init { viewModelScope.launch(Dispatchers.IO) { - val isBillingApiAvailable = AppDependencies.billingApi.isApiAvailable() + val isBillingApiAvailable = AppDependencies.billingApi.getApiAvailability().isSuccess if (isBillingApiAvailable) { _state.update { it.copy(isPaidTierPricingAvailable = true) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/storage/ManageStorageSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/storage/ManageStorageSettingsViewModel.kt index d22f6235f5..738dca54a8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/storage/ManageStorageSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/storage/ManageStorageSettingsViewModel.kt @@ -135,7 +135,7 @@ class ManageStorageSettingsViewModel : ViewModel() { private suspend fun getOnDeviceStorageOptimizationState(): OnDeviceStorageOptimizationState { return when { - !RemoteConfig.messageBackups || !SignalStore.backup.areBackupsEnabled || !AppDependencies.billingApi.isApiAvailable() || (!RemoteConfig.internalUser && !Environment.IS_STAGING) -> OnDeviceStorageOptimizationState.FEATURE_NOT_AVAILABLE + !RemoteConfig.messageBackups || !SignalStore.backup.areBackupsEnabled || !AppDependencies.billingApi.getApiAvailability().isSuccess || (!RemoteConfig.internalUser && !Environment.IS_STAGING) -> OnDeviceStorageOptimizationState.FEATURE_NOT_AVAILABLE SignalStore.backup.backupTier != MessageBackupTier.PAID -> OnDeviceStorageOptimizationState.REQUIRES_PAID_TIER SignalStore.backup.optimizeStorage -> OnDeviceStorageOptimizationState.ENABLED else -> OnDeviceStorageOptimizationState.DISABLED 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 5792de127b..963c2e889e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt @@ -90,7 +90,7 @@ class BackupSubscriptionCheckJob private constructor(parameters: Parameters) : C return Result.success() } - if (!AppDependencies.billingApi.isApiAvailable()) { + if (!AppDependencies.billingApi.getApiAvailability().isSuccess) { Log.i(TAG, "Google Play Billing API is not available on this device. Clearing mismatch value and exiting.", true) SignalStore.backup.subscriptionStateMismatchDetected = false return Result.success() diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentPurchaseTokenJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentPurchaseTokenJob.kt index ff0d06bf87..fd47c0b10b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentPurchaseTokenJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentPurchaseTokenJob.kt @@ -87,7 +87,7 @@ class InAppPaymentPurchaseTokenJob private constructor( } private suspend fun linkPurchaseToken(): Result { - if (!AppDependencies.billingApi.isApiAvailable()) { + if (!AppDependencies.billingApi.getApiAvailability().isSuccess) { warning("Billing API is not available on this device. Exiting.") return Result.failure() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PostRegistrationBackupRedemptionJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/PostRegistrationBackupRedemptionJob.kt index 8037d2408b..c5e0f3d9c0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PostRegistrationBackupRedemptionJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PostRegistrationBackupRedemptionJob.kt @@ -103,7 +103,7 @@ class PostRegistrationBackupRedemptionJob : CoroutineJob { val emptyPrice = FiatMoney(BigDecimal.ZERO, Currency.getInstance(Locale.getDefault())) val price: FiatMoney = if (subscription != null) { FiatMoney.fromSignalNetworkAmount(subscription.amount, Currency.getInstance(subscription.currency)) - } else if (AppDependencies.billingApi.isApiAvailable()) { + } else if (AppDependencies.billingApi.getApiAvailability().isSuccess) { AppDependencies.billingApi.queryProduct()?.price ?: emptyPrice } else { emptyPrice diff --git a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionRemoteBackups.kt b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionRemoteBackups.kt index eaac4f2f81..2723f85f41 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionRemoteBackups.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionRemoteBackups.kt @@ -44,7 +44,7 @@ class LogSectionRemoteBackups : LogSection { output.append("\n -- Subscription State\n") val backupSubscriptionId = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP) - val hasGooglePlayBilling = runBlocking { AppDependencies.billingApi.isApiAvailable() } + val hasGooglePlayBilling = runBlocking { AppDependencies.billingApi.getApiAvailability().isSuccess } val inAppPayment = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_BACKUP) output.append("Has backup subscription id: ${backupSubscriptionId != null}\n") diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/GooglePlayBillingPurchaseTokenMigrationJob.kt b/app/src/main/java/org/thoughtcrime/securesms/migrations/GooglePlayBillingPurchaseTokenMigrationJob.kt index 6dac2e59b2..7dfc840c53 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/GooglePlayBillingPurchaseTokenMigrationJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/GooglePlayBillingPurchaseTokenMigrationJob.kt @@ -54,7 +54,7 @@ internal class GooglePlayBillingPurchaseTokenMigrationJob private constructor( if (backupSubscriber.iapSubscriptionId?.purchaseToken == "-") { val purchaseResult: BillingPurchaseResult.Success? = runBlocking { - if (AppDependencies.billingApi.isApiAvailable()) { + if (AppDependencies.billingApi.getApiAvailability().isSuccess) { val purchase = AppDependencies.billingApi.queryPurchases() if (purchase is BillingPurchaseResult.Success) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/PlayStoreUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/PlayStoreUtil.java index 745e6a1ac1..998cecd42f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/PlayStoreUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/PlayStoreUtil.java @@ -21,6 +21,16 @@ public final class PlayStoreUtil { } } + public static void openPlayStoreHome(@NonNull Context context) { + try { + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=com.android.vending")); + intent.setPackage("com.android.vending"); + context.startActivity(intent); + } catch (android.content.ActivityNotFoundException e) { + CommunicationActions.openBrowserLink(context, "https://play.google.com/store/apps/"); + } + } + private static void openPlayStore(@NonNull Context context) { String packageName = context.getPackageName(); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5e679ade70..b85b8d6d05 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8041,6 +8041,32 @@ Change or cancel subscription + + + Google Play services missing + + To subscribe to Signal Secure backups, you need to install Google Play services on your phone. + + Install Play Services + + Google Play services are updating + + To subscribe to Signal Secure backups please wait for Google Play services to finish updating. + + Update Google Play services + + To subscribe to Signal Secure Backups, update Google Play service on your phone. + + Update + + Paid subscriptions require Google Play + + To subscribe to Signal Secure Backups, enable Google Play services on your phone and sign into the Google Play store. + + Learn more + + You can\'t subscribe to Signal Secure backups because your phone does not support Google Play services. Contact the phone manufacturer for assistance. + Chat limits @@ -8481,10 +8507,14 @@ Subscribe for %1$s/month Change backup type + + More about this plan Cancel subscription Free + + Paid %1$s/month diff --git a/billing/src/main/java/org/signal/billing/BillingApiImpl.kt b/billing/src/main/java/org/signal/billing/BillingApiImpl.kt index 19e3a2ed88..d1402a36b5 100644 --- a/billing/src/main/java/org/signal/billing/BillingApiImpl.kt +++ b/billing/src/main/java/org/signal/billing/BillingApiImpl.kt @@ -268,14 +268,14 @@ internal class BillingApiImpl( * Returns whether or not subscriptions are supported by a user's device. Lack of subscription support is generally due * to out-of-date Google Play API */ - override suspend fun isApiAvailable(): Boolean { + override suspend fun getApiAvailability(): org.signal.core.util.billing.BillingResponseCode { return try { doOnConnectionReady("isApiAvailable") { - billingClient.isFeatureSupported(BillingClient.FeatureType.SUBSCRIPTIONS).responseCode == BillingResponseCode.OK + org.signal.core.util.billing.BillingResponseCode.fromBillingLibraryResponseCode(billingClient.isFeatureSupported(BillingClient.FeatureType.SUBSCRIPTIONS).responseCode) } } catch (e: BillingError) { Log.e(TAG, "Failed to connect to Google Play Billing", e) - false + org.signal.core.util.billing.BillingResponseCode.fromBillingLibraryResponseCode(e.billingResponseCode) } } diff --git a/core-util/src/main/java/org/signal/core/util/billing/BillingApi.kt b/core-util/src/main/java/org/signal/core/util/billing/BillingApi.kt index 12d39c7fed..8d7d8e835c 100644 --- a/core-util/src/main/java/org/signal/core/util/billing/BillingApi.kt +++ b/core-util/src/main/java/org/signal/core/util/billing/BillingApi.kt @@ -19,7 +19,7 @@ interface BillingApi { */ fun getBillingPurchaseResults(): Flow = emptyFlow() - suspend fun isApiAvailable(): Boolean = false + suspend fun getApiAvailability(): BillingResponseCode = BillingResponseCode.FEATURE_NOT_SUPPORTED /** * Queries the Billing API for product pricing. This value should be cached by diff --git a/core-util/src/main/java/org/signal/core/util/billing/BillingResponseCode.kt b/core-util/src/main/java/org/signal/core/util/billing/BillingResponseCode.kt new file mode 100644 index 0000000000..7cb14e3681 --- /dev/null +++ b/core-util/src/main/java/org/signal/core/util/billing/BillingResponseCode.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.util.billing + +import org.signal.core.util.logging.Log + +enum class BillingResponseCode(val code: Int) { + UNKNOWN(code = Int.MIN_VALUE), + SERVICE_TIMEOUT(code = -3), + FEATURE_NOT_SUPPORTED(code = -2), + SERVICE_DISCONNECTED(code = -1), + OK(code = 0), + USER_CANCELED(code = 1), + SERVICE_UNAVAILABLE(code = 2), + BILLING_UNAVAILABLE(code = 3), + ITEM_UNAVAILABLE(code = 4), + DEVELOPER_ERROR(code = 5), + ERROR(code = 6), + ITEM_ALREADY_OWNED(code = 7), + ITEM_NOT_OWNED(code = 8), + NETWORK_ERROR(code = 12); + + val isSuccess: Boolean get() = this == OK + + companion object { + + private val TAG = Log.tag(BillingResponseCode::class) + + fun fromBillingLibraryResponseCode(responseCode: Int): BillingResponseCode { + val code = BillingResponseCode.entries.firstOrNull { responseCode == it.code } ?: UNKNOWN + + if (code == UNKNOWN) { + Log.w(TAG, "Unknown response code: $code") + } + + return code + } + } +}