Add Billing API and Google API availability error dialogs.

This commit is contained in:
Alex Hart
2025-09-17 11:35:03 -03:00
committed by Greyson Parrelli
parent 0713a88ddb
commit 2a90809ba3
21 changed files with 450 additions and 76 deletions

View File

@@ -24,6 +24,7 @@ import org.junit.runner.RunWith
import org.signal.core.util.billing.BillingProduct import org.signal.core.util.billing.BillingProduct
import org.signal.core.util.billing.BillingPurchaseResult import org.signal.core.util.billing.BillingPurchaseResult
import org.signal.core.util.billing.BillingPurchaseState import org.signal.core.util.billing.BillingPurchaseState
import org.signal.core.util.billing.BillingResponseCode
import org.signal.core.util.money.FiatMoney import org.signal.core.util.money.FiatMoney
import org.signal.donations.InAppPaymentType import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.backup.DeletionState import org.thoughtcrime.securesms.backup.DeletionState
@@ -67,7 +68,7 @@ class BackupSubscriptionCheckJobTest {
every { RemoteConfig.messageBackups } returns true every { RemoteConfig.messageBackups } returns true
every { RemoteConfig.internalUser } 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.queryPurchases() } returns mockk()
coEvery { AppDependencies.billingApi.queryProduct() } returns null coEvery { AppDependencies.billingApi.queryProduct() } returns null
@@ -143,7 +144,7 @@ class BackupSubscriptionCheckJobTest {
@Test @Test
fun givenBillingApiNotAvailable_whenIRun_thenIExpectSuccessAndEarlyExit() { fun givenBillingApiNotAvailable_whenIRun_thenIExpectSuccessAndEarlyExit() {
coEvery { AppDependencies.billingApi.isApiAvailable() } returns false coEvery { AppDependencies.billingApi.getApiAvailability() } returns BillingResponseCode.BILLING_UNAVAILABLE
val job = BackupSubscriptionCheckJob.create() val job = BackupSubscriptionCheckJob.create()
val result = job.run() val result = job.run()

View File

@@ -12,6 +12,7 @@ import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.signal.core.util.billing.BillingPurchaseResult import org.signal.core.util.billing.BillingPurchaseResult
import org.signal.core.util.billing.BillingPurchaseState import org.signal.core.util.billing.BillingPurchaseState
import org.signal.core.util.billing.BillingResponseCode
import org.signal.core.util.deleteAll import org.signal.core.util.deleteAll
import org.thoughtcrime.securesms.database.InAppPaymentSubscriberTable import org.thoughtcrime.securesms.database.InAppPaymentSubscriberTable
import org.thoughtcrime.securesms.database.SignalDatabase 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() 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 coEvery { AppDependencies.billingApi.queryPurchases() } returns BillingPurchaseResult.None
val job = GooglePlayBillingPurchaseTokenMigrationJob() 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( coEvery { AppDependencies.billingApi.queryPurchases() } returns BillingPurchaseResult.Success(
purchaseState = BillingPurchaseState.PURCHASED, purchaseState = BillingPurchaseState.PURCHASED,
purchaseToken = "purchaseToken", purchaseToken = "purchaseToken",

View File

@@ -164,6 +164,7 @@ import java.io.File
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.math.BigDecimal
import java.time.ZonedDateTime import java.time.ZonedDateTime
import java.util.Currency import java.util.Currency
import java.util.Locale import java.util.Locale
@@ -1876,20 +1877,11 @@ object BackupRepository {
RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP).getOrNull()?.activeSubscription?.let { RecurringInAppPaymentRepository.getActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP).getOrNull()?.activeSubscription?.let {
FiatMoney.fromSignalNetworkAmount(it.amount, Currency.getInstance(it.currency)) 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.") Log.d(TAG, "Accessing price via billing api.")
AppDependencies.billingApi.queryProduct()?.price AppDependencies.billingApi.queryProduct()?.price
} else { } else {
Log.d(TAG, "Billing API is not available on this device. Accessing price via subscription configuration.") FiatMoney(BigDecimal.ZERO, Currency.getInstance(Locale.getDefault()))
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
}
} }
if (productPrice == null) { if (productPrice == null) {

View File

@@ -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({}, {})
}
}

View File

@@ -25,6 +25,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.google.android.gms.common.GoogleApiAvailability
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.asFlowable 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.database.InAppPaymentTable
import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.util.CommunicationActions import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.PlayStoreUtil
import org.thoughtcrime.securesms.util.Util import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository import org.thoughtcrime.securesms.util.storage.AndroidCredentialRepository
import org.thoughtcrime.securesms.util.viewModel import org.thoughtcrime.securesms.util.viewModel
@@ -63,7 +65,10 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
} }
private val viewModel: MessageBackupsFlowViewModel by viewModel { 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() private val errorHandler = InAppPaymentCheckoutDelegate.ErrorHandler()
@@ -97,6 +102,7 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
viewModel.refreshCurrentTier() viewModel.refreshCurrentTier()
viewModel.setGooglePlayApiAvailability(GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext()))
} }
@Composable @Composable
@@ -181,12 +187,21 @@ class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelega
) )
}, },
onNextClicked = viewModel::goToNextStage, onNextClicked = viewModel::goToNextStage,
isBillingApiAvailable = state.isBillingApiAvailable, googlePlayServicesAvailability = state.googlePlayApiAvailability,
googlePlayBillingAvailability = state.googlePlayBillingAvailability,
onLearnMoreAboutWhyUserCanNotUpgrade = { onLearnMoreAboutWhyUserCanNotUpgrade = {
CommunicationActions.openBrowserLink( CommunicationActions.openBrowserLink(
requireContext(), requireContext(),
getString(R.string.backup_support_url) getString(R.string.backup_support_url)
) )
},
onMakeGooglePlayServicesAvailable = {
GoogleApiAvailability.getInstance().makeGooglePlayServicesAvailable(requireActivity()).addOnSuccessListener {
viewModel.setGooglePlayApiAvailability(GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext()))
}
},
onOpenPlayStore = {
PlayStoreUtil.openPlayStoreHome(requireContext())
} }
) )
} }

View File

@@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.backup.v2.ui.subscription package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import org.signal.core.util.billing.BillingResponseCode
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState import org.thoughtcrime.securesms.components.settings.app.backups.remote.BackupKeySaveState
import org.thoughtcrime.securesms.database.InAppPaymentTable import org.thoughtcrime.securesms.database.InAppPaymentTable
@@ -17,7 +18,8 @@ data class MessageBackupsFlowState(
val selectedMessageBackupTier: MessageBackupTier? = SignalStore.backup.backupTier, val selectedMessageBackupTier: MessageBackupTier? = SignalStore.backup.backupTier,
val currentMessageBackupTier: MessageBackupTier? = null, val currentMessageBackupTier: MessageBackupTier? = null,
val allBackupTypes: List<MessageBackupsType> = emptyList(), val allBackupTypes: List<MessageBackupsType> = emptyList(),
val isBillingApiAvailable: Boolean = false, val googlePlayApiAvailability: GooglePlayServicesAvailability = GooglePlayServicesAvailability.SUCCESS,
val googlePlayBillingAvailability: BillingResponseCode = BillingResponseCode.FEATURE_NOT_SUPPORTED,
val inAppPayment: InAppPaymentTable.InAppPayment? = null, val inAppPayment: InAppPaymentTable.InAppPayment? = null,
val startScreen: MessageBackupsStage, val startScreen: MessageBackupsStage,
val stage: MessageBackupsStage = startScreen, val stage: MessageBackupsStage = startScreen,

View File

@@ -53,6 +53,7 @@ import kotlin.time.Duration.Companion.seconds
class MessageBackupsFlowViewModel( class MessageBackupsFlowViewModel(
private val initialTierSelection: MessageBackupTier?, private val initialTierSelection: MessageBackupTier?,
googlePlayApiAvailability: Int,
startScreen: MessageBackupsStage = if (SignalStore.backup.backupTier == null) MessageBackupsStage.EDUCATION else MessageBackupsStage.TYPE_SELECTION startScreen: MessageBackupsStage = if (SignalStore.backup.backupTier == null) MessageBackupsStage.EDUCATION else MessageBackupsStage.TYPE_SELECTION
) : ViewModel(), BackupKeyCredentialManagerHandler { ) : ViewModel(), BackupKeyCredentialManagerHandler {
@@ -64,6 +65,7 @@ class MessageBackupsFlowViewModel(
private val internalStateFlow = MutableStateFlow( private val internalStateFlow = MutableStateFlow(
MessageBackupsFlowState( MessageBackupsFlowState(
allBackupTypes = emptyList(), allBackupTypes = emptyList(),
googlePlayApiAvailability = GooglePlayServicesAvailability.fromCode(googlePlayApiAvailability),
currentMessageBackupTier = SignalStore.backup.backupTier, currentMessageBackupTier = SignalStore.backup.backupTier,
selectedMessageBackupTier = resolveSelectedTier(initialTierSelection, SignalStore.backup.backupTier), selectedMessageBackupTier = resolveSelectedTier(initialTierSelection, SignalStore.backup.backupTier),
startScreen = startScreen startScreen = startScreen
@@ -105,7 +107,6 @@ class MessageBackupsFlowViewModel(
internalStateFlow.update { state -> internalStateFlow.update { state ->
state.copy( state.copy(
allBackupTypes = allBackupTypes, allBackupTypes = allBackupTypes,
isBillingApiAvailable = AppDependencies.billingApi.isApiAvailable(),
selectedMessageBackupTier = if (state.selectedMessageBackupTier in allBackupTypes.map { it.tier }) state.selectedMessageBackupTier else allBackupTypes.firstOrNull()?.tier 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() { fun refreshCurrentTier() {
val tier = SignalStore.backup.backupTier val tier = SignalStore.backup.backupTier
if (tier == MessageBackupTier.PAID) { if (tier == MessageBackupTier.PAID) {

View File

@@ -21,6 +21,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.Scaffolds
import org.signal.core.ui.compose.SignalPreview import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.theme.SignalTheme 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.bytes
import org.signal.core.util.money.FiatMoney import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
@@ -75,13 +77,16 @@ fun MessageBackupsTypeSelectionScreen(
currentBackupTier: MessageBackupTier?, currentBackupTier: MessageBackupTier?,
selectedBackupTier: MessageBackupTier?, selectedBackupTier: MessageBackupTier?,
allBackupTypes: List<MessageBackupsType>, allBackupTypes: List<MessageBackupsType>,
isBillingApiAvailable: Boolean, googlePlayServicesAvailability: GooglePlayServicesAvailability,
googlePlayBillingAvailability: BillingResponseCode,
isNextEnabled: Boolean, isNextEnabled: Boolean,
onMessageBackupsTierSelected: (MessageBackupTier) -> Unit, onMessageBackupsTierSelected: (MessageBackupTier) -> Unit,
onNavigationClick: () -> Unit, onNavigationClick: () -> Unit,
onReadMoreClicked: () -> Unit, onReadMoreClicked: () -> Unit,
onNextClicked: () -> Unit, onNextClicked: () -> Unit,
onLearnMoreAboutWhyUserCanNotUpgrade: () -> Unit onLearnMoreAboutWhyUserCanNotUpgrade: () -> Unit,
onMakeGooglePlayServicesAvailable: () -> Unit,
onOpenPlayStore: () -> Unit
) { ) {
Scaffolds.Settings( Scaffolds.Settings(
title = "", title = "",
@@ -161,28 +166,26 @@ fun MessageBackupsTypeSelectionScreen(
} }
val hasCurrentBackupTier = currentBackupTier != null val hasCurrentBackupTier = currentBackupTier != null
var displayNotAvailableDialog by remember { mutableStateOf(false) } val paidTierNotAvailableDialogState = remember { PaidTierNotAvailableDialogState() }
val onSubscribeButtonClick = remember(isBillingApiAvailable, selectedBackupTier) { val onSubscribeButtonClick = remember(googlePlayServicesAvailability, googlePlayBillingAvailability, selectedBackupTier) {
{ {
if (selectedBackupTier == MessageBackupTier.PAID && !isBillingApiAvailable) { if (selectedBackupTier == MessageBackupTier.PAID && googlePlayServicesAvailability != GooglePlayServicesAvailability.SUCCESS) {
displayNotAvailableDialog = true paidTierNotAvailableDialogState.displayGooglePlayApiErrorDialog = true
} else if (selectedBackupTier == MessageBackupTier.PAID && !googlePlayBillingAvailability.isSuccess) {
paidTierNotAvailableDialogState.displayGooglePlayBillingErrorDialog = true
} else { } else {
onNextClicked() onNextClicked()
} }
} }
} }
if (displayNotAvailableDialog) { PaidTierNotAvailableDialogs(
UpgradeNotAvailableDialog( state = paidTierNotAvailableDialogState,
onConfirm = { onOpenPlayStore = onOpenPlayStore,
displayNotAvailableDialog = false onLearnMoreAboutWhyUserCanNotUpgrade = onLearnMoreAboutWhyUserCanNotUpgrade,
}, onMakeGooglePlayServicesAvailable = onMakeGooglePlayServicesAvailable,
onDismiss = onLearnMoreAboutWhyUserCanNotUpgrade, googlePlayServicesAvailability = googlePlayServicesAvailability
onDismissRequest = { )
displayNotAvailableDialog = false
}
)
}
Buttons.LargeTonal( Buttons.LargeTonal(
onClick = onSubscribeButtonClick, onClick = onSubscribeButtonClick,
@@ -193,7 +196,9 @@ fun MessageBackupsTypeSelectionScreen(
.padding(vertical = if (hasCurrentBackupTier) 10.dp else 16.dp) .padding(vertical = if (hasCurrentBackupTier) 10.dp else 16.dp)
) { ) {
val text: String = if (currentBackupTier == null) { 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 paidTier = allBackupTypes.first { it.tier == MessageBackupTier.PAID } as MessageBackupsType.Paid
val context = LocalContext.current val context = LocalContext.current
@@ -207,6 +212,8 @@ fun MessageBackupsTypeSelectionScreen(
} else { } else {
stringResource(R.string.MessageBackupsTypeSelectionScreen__subscribe) stringResource(R.string.MessageBackupsTypeSelectionScreen__subscribe)
} }
} else if (selectedBackupTier == MessageBackupTier.PAID && (googlePlayServicesAvailability != GooglePlayServicesAvailability.SUCCESS || !googlePlayBillingAvailability.isSuccess)) {
stringResource(R.string.MessageBackupsTypeSelectionScreen__more_about_this_plan)
} else { } else {
stringResource(R.string.MessageBackupsTypeSelectionScreen__change_backup_type) 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 @Composable
private fun UpgradeNotAvailableDialog( fun PaidTierNotAvailableDialogs(
onConfirm: () -> Unit, state: PaidTierNotAvailableDialogState,
onDismiss: () -> Unit, googlePlayServicesAvailability: GooglePlayServicesAvailability,
onDismissRequest: () -> Unit 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( Dialogs.SimpleAlertDialog(
title = stringResource(R.string.MessageBackupsTypeSelectionScreen__cant_upgrade_plan), title = stringResource(R.string.GooglePlayServicesAvailability__service_disabled_title),
body = stringResource(R.string.MessageBackupsTypeSelectionScreen__to_subscribe_to_signal_secure_backups), body = "To subscribe to Signal Secure Backups, please sign into the Google Play store.",
confirm = stringResource(android.R.string.ok), onConfirm = onOpenPlayStore,
dismiss = stringResource(R.string.MessageBackupsTypeSelectionScreen__learn_more), onDismiss = onDismissRequest,
onConfirm = onConfirm, onDismissRequest = onDismissRequest,
onDismiss = onDismiss, confirm = "Open Play Store",
onDismissRequest = onDismissRequest dismiss = stringResource(android.R.string.cancel)
) )
} }
@@ -256,8 +293,11 @@ private fun MessageBackupsTypeSelectionScreenPreview() {
onReadMoreClicked = {}, onReadMoreClicked = {},
onNextClicked = {}, onNextClicked = {},
onLearnMoreAboutWhyUserCanNotUpgrade = {}, onLearnMoreAboutWhyUserCanNotUpgrade = {},
onMakeGooglePlayServicesAvailable = {},
onOpenPlayStore = {},
currentBackupTier = null, currentBackupTier = null,
isBillingApiAvailable = true, googlePlayServicesAvailability = GooglePlayServicesAvailability.SUCCESS,
googlePlayBillingAvailability = BillingResponseCode.OK,
isNextEnabled = true isNextEnabled = true
) )
} }
@@ -278,25 +318,16 @@ private fun MessageBackupsTypeSelectionScreenWithCurrentTierPreview() {
onReadMoreClicked = {}, onReadMoreClicked = {},
onNextClicked = {}, onNextClicked = {},
onLearnMoreAboutWhyUserCanNotUpgrade = {}, onLearnMoreAboutWhyUserCanNotUpgrade = {},
onMakeGooglePlayServicesAvailable = {},
onOpenPlayStore = {},
currentBackupTier = MessageBackupTier.PAID, currentBackupTier = MessageBackupTier.PAID,
isBillingApiAvailable = true, googlePlayServicesAvailability = GooglePlayServicesAvailability.SUCCESS,
googlePlayBillingAvailability = BillingResponseCode.OK,
isNextEnabled = true isNextEnabled = true
) )
} }
} }
@SignalPreview
@Composable
private fun UpgradeNotAvailableDialogPreview() {
Previews.Preview {
UpgradeNotAvailableDialog(
onConfirm = {},
onDismiss = {},
onDismissRequest = {}
)
}
}
@Composable @Composable
fun MessageBackupsTypeBlock( fun MessageBackupsTypeBlock(
messageBackupsType: MessageBackupsType, messageBackupsType: MessageBackupsType,
@@ -382,8 +413,12 @@ private fun getFormattedPricePerMonth(messageBackupsType: MessageBackupsType): S
return when (messageBackupsType) { return when (messageBackupsType) {
is MessageBackupsType.Free -> stringResource(id = R.string.MessageBackupsTypeSelectionScreen__free) is MessageBackupsType.Free -> stringResource(id = R.string.MessageBackupsTypeSelectionScreen__free)
is MessageBackupsType.Paid -> { is MessageBackupsType.Paid -> {
val formattedAmount = FiatMoneyUtil.format(LocalContext.current.resources, messageBackupsType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal()) if (messageBackupsType.pricePerMonth.amount == BigDecimal.ZERO) {
stringResource(id = R.string.MessageBackupsTypeSelectionScreen__s_month, formattedAmount) 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)
}
} }
} }
} }

View File

@@ -19,6 +19,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import com.google.android.gms.common.GoogleApiAvailability
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.asFlowable import kotlinx.coroutines.rx3.asFlowable
@@ -59,6 +60,7 @@ abstract class UpgradeToPaidTierBottomSheet : ComposeBottomSheetDialogFragment()
private val viewModel: MessageBackupsFlowViewModel by viewModel { private val viewModel: MessageBackupsFlowViewModel by viewModel {
MessageBackupsFlowViewModel( MessageBackupsFlowViewModel(
initialTierSelection = MessageBackupTier.PAID, initialTierSelection = MessageBackupTier.PAID,
googlePlayApiAvailability = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(requireContext()),
startScreen = MessageBackupsStage.TYPE_SELECTION 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 @Composable
override fun SheetContent() { override fun SheetContent() {
val state by viewModel.stateFlow.collectAsStateWithLifecycle() val state by viewModel.stateFlow.collectAsStateWithLifecycle()

View File

@@ -83,7 +83,7 @@ class RemoteBackupsSettingsViewModel : ViewModel() {
init { init {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val isBillingApiAvailable = AppDependencies.billingApi.isApiAvailable() val isBillingApiAvailable = AppDependencies.billingApi.getApiAvailability().isSuccess
if (isBillingApiAvailable) { if (isBillingApiAvailable) {
_state.update { _state.update {
it.copy(isPaidTierPricingAvailable = true) it.copy(isPaidTierPricingAvailable = true)

View File

@@ -135,7 +135,7 @@ class ManageStorageSettingsViewModel : ViewModel() {
private suspend fun getOnDeviceStorageOptimizationState(): OnDeviceStorageOptimizationState { private suspend fun getOnDeviceStorageOptimizationState(): OnDeviceStorageOptimizationState {
return when { 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.backupTier != MessageBackupTier.PAID -> OnDeviceStorageOptimizationState.REQUIRES_PAID_TIER
SignalStore.backup.optimizeStorage -> OnDeviceStorageOptimizationState.ENABLED SignalStore.backup.optimizeStorage -> OnDeviceStorageOptimizationState.ENABLED
else -> OnDeviceStorageOptimizationState.DISABLED else -> OnDeviceStorageOptimizationState.DISABLED

View File

@@ -90,7 +90,7 @@ class BackupSubscriptionCheckJob private constructor(parameters: Parameters) : C
return Result.success() 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) Log.i(TAG, "Google Play Billing API is not available on this device. Clearing mismatch value and exiting.", true)
SignalStore.backup.subscriptionStateMismatchDetected = false SignalStore.backup.subscriptionStateMismatchDetected = false
return Result.success() return Result.success()

View File

@@ -87,7 +87,7 @@ class InAppPaymentPurchaseTokenJob private constructor(
} }
private suspend fun linkPurchaseToken(): Result { private suspend fun linkPurchaseToken(): Result {
if (!AppDependencies.billingApi.isApiAvailable()) { if (!AppDependencies.billingApi.getApiAvailability().isSuccess) {
warning("Billing API is not available on this device. Exiting.") warning("Billing API is not available on this device. Exiting.")
return Result.failure() return Result.failure()
} }

View File

@@ -103,7 +103,7 @@ class PostRegistrationBackupRedemptionJob : CoroutineJob {
val emptyPrice = FiatMoney(BigDecimal.ZERO, Currency.getInstance(Locale.getDefault())) val emptyPrice = FiatMoney(BigDecimal.ZERO, Currency.getInstance(Locale.getDefault()))
val price: FiatMoney = if (subscription != null) { val price: FiatMoney = if (subscription != null) {
FiatMoney.fromSignalNetworkAmount(subscription.amount, Currency.getInstance(subscription.currency)) FiatMoney.fromSignalNetworkAmount(subscription.amount, Currency.getInstance(subscription.currency))
} else if (AppDependencies.billingApi.isApiAvailable()) { } else if (AppDependencies.billingApi.getApiAvailability().isSuccess) {
AppDependencies.billingApi.queryProduct()?.price ?: emptyPrice AppDependencies.billingApi.queryProduct()?.price ?: emptyPrice
} else { } else {
emptyPrice emptyPrice

View File

@@ -44,7 +44,7 @@ class LogSectionRemoteBackups : LogSection {
output.append("\n -- Subscription State\n") output.append("\n -- Subscription State\n")
val backupSubscriptionId = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP) 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) val inAppPayment = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_BACKUP)
output.append("Has backup subscription id: ${backupSubscriptionId != null}\n") output.append("Has backup subscription id: ${backupSubscriptionId != null}\n")

View File

@@ -54,7 +54,7 @@ internal class GooglePlayBillingPurchaseTokenMigrationJob private constructor(
if (backupSubscriber.iapSubscriptionId?.purchaseToken == "-") { if (backupSubscriber.iapSubscriptionId?.purchaseToken == "-") {
val purchaseResult: BillingPurchaseResult.Success? = runBlocking { val purchaseResult: BillingPurchaseResult.Success? = runBlocking {
if (AppDependencies.billingApi.isApiAvailable()) { if (AppDependencies.billingApi.getApiAvailability().isSuccess) {
val purchase = AppDependencies.billingApi.queryPurchases() val purchase = AppDependencies.billingApi.queryPurchases()
if (purchase is BillingPurchaseResult.Success) { if (purchase is BillingPurchaseResult.Success) {

View File

@@ -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) { private static void openPlayStore(@NonNull Context context) {
String packageName = context.getPackageName(); String packageName = context.getPackageName();

View File

@@ -8041,6 +8041,32 @@
<!-- Row title to change or cancel subscription --> <!-- Row title to change or cancel subscription -->
<string name="BackupsTypeSettingsFragment__change_or_cancel_subscription">Change or cancel subscription</string> <string name="BackupsTypeSettingsFragment__change_or_cancel_subscription">Change or cancel subscription</string>
<!-- GooglePlayServicesAvailability -->
<!-- Dialog title when Google Play services are missing from the device -->
<string name="GooglePlayServicesAvailability__service_missing_title">Google Play services missing</string>
<!-- Dialog message explaining that Google Play services are required for Signal Secure backups subscription -->
<string name="GooglePlayServicesAvailability__service_missing_message">To subscribe to Signal Secure backups, you need to install Google Play services on your phone.</string>
<!-- Dialog button text to install Google Play services -->
<string name="GooglePlayServicesAvailability__install_play_services">Install Play Services</string>
<!-- Dialog title when Google Play services are currently updating -->
<string name="GooglePlayServicesAvailability__service_updating_title">Google Play services are updating</string>
<!-- Dialog message asking user to wait while Google Play services finish updating -->
<string name="GooglePlayServicesAvailability__service_updating_message">To subscribe to Signal Secure backups please wait for Google Play services to finish updating.</string>
<!-- Dialog title when Google Play services need to be updated -->
<string name="GooglePlayServicesAvailability__service_update_required_title">Update Google Play services</string>
<!-- Dialog message explaining that Google Play services need to be updated for Signal Secure backups -->
<string name="GooglePlayServicesAvailability__service_update_required_message">To subscribe to Signal Secure Backups, update Google Play service on your phone.</string>
<!-- Dialog button text to update Google Play services -->
<string name="GooglePlayServicesAvailability__update">Update</string>
<!-- Dialog title when Google Play services are disabled -->
<string name="GooglePlayServicesAvailability__service_disabled_title">Paid subscriptions require Google Play</string>
<!-- Dialog message explaining that Google Play services must be enabled for subscriptions -->
<string name="GooglePlayServicesAvailability__service_disabled_message">To subscribe to Signal Secure Backups, enable Google Play services on your phone and sign into the Google Play store. </string>
<!-- Dialog button text to learn more about enabling Google Play services -->
<string name="GooglePlayServicesAvailability__learn_more">Learn more</string>
<!-- Dialog message shown when Google Play services is invalid and subscriptions are not available -->
<string name="GooglePlayServicesAvailability__service_invalid_message">You can\'t subscribe to Signal Secure backups because your phone does not support Google Play services. Contact the phone manufacturer for assistance.</string>
<!-- ManageStorageSettingsFragment --> <!-- ManageStorageSettingsFragment -->
<!-- Settings section title header show above manage settings options for automatic chat history management (deleting) --> <!-- Settings section title header show above manage settings options for automatic chat history management (deleting) -->
<string name="ManageStorageSettingsFragment_chat_limit">Chat limits</string> <string name="ManageStorageSettingsFragment_chat_limit">Chat limits</string>
@@ -8481,10 +8507,14 @@
<string name="MessageBackupsTypeSelectionScreen__subscribe_for_x_month">Subscribe for %1$s/month</string> <string name="MessageBackupsTypeSelectionScreen__subscribe_for_x_month">Subscribe for %1$s/month</string>
<!-- Primary action button label when selecting a backup tier with a current selection --> <!-- Primary action button label when selecting a backup tier with a current selection -->
<string name="MessageBackupsTypeSelectionScreen__change_backup_type">Change backup type</string> <string name="MessageBackupsTypeSelectionScreen__change_backup_type">Change backup type</string>
<!-- Primary action button label when selecting paid tier when Google Play API isn't available -->
<string name="MessageBackupsTypeSelectionScreen__more_about_this_plan">More about this plan</string>
<!-- Secondary action button label when selecting a backup tier with a current selection --> <!-- Secondary action button label when selecting a backup tier with a current selection -->
<string name="MessageBackupsTypeSelectionScreen__cancel_subscription">Cancel subscription</string> <string name="MessageBackupsTypeSelectionScreen__cancel_subscription">Cancel subscription</string>
<!-- MessageBackupsType block amount for free tier --> <!-- MessageBackupsType block amount for free tier -->
<string name="MessageBackupsTypeSelectionScreen__free">Free</string> <string name="MessageBackupsTypeSelectionScreen__free">Free</string>
<!-- MessageBackupsType block amount for paid tier when no pricing is available -->
<string name="MessageBackupsTypeSelectionScreen__paid">Paid</string>
<!-- MessageBackupsType block amount for paid tier. Placeholder is formatted currency amount. --> <!-- MessageBackupsType block amount for paid tier. Placeholder is formatted currency amount. -->
<string name="MessageBackupsTypeSelectionScreen__s_month">%1$s/month</string> <string name="MessageBackupsTypeSelectionScreen__s_month">%1$s/month</string>
<!-- Title for free tier --> <!-- Title for free tier -->

View File

@@ -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 * 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 * to out-of-date Google Play API
*/ */
override suspend fun isApiAvailable(): Boolean { override suspend fun getApiAvailability(): org.signal.core.util.billing.BillingResponseCode {
return try { return try {
doOnConnectionReady("isApiAvailable") { 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) { } catch (e: BillingError) {
Log.e(TAG, "Failed to connect to Google Play Billing", e) Log.e(TAG, "Failed to connect to Google Play Billing", e)
false org.signal.core.util.billing.BillingResponseCode.fromBillingLibraryResponseCode(e.billingResponseCode)
} }
} }

View File

@@ -19,7 +19,7 @@ interface BillingApi {
*/ */
fun getBillingPurchaseResults(): Flow<BillingPurchaseResult> = emptyFlow() fun getBillingPurchaseResults(): Flow<BillingPurchaseResult> = 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 * Queries the Billing API for product pricing. This value should be cached by

View File

@@ -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
}
}
}