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.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()

View File

@@ -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",

View File

@@ -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) {

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.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())
}
)
}

View File

@@ -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<MessageBackupsType> = 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,

View File

@@ -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) {

View File

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

View File

@@ -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()

View File

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

View File

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

View File

@@ -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()

View File

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

View File

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

View File

@@ -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")

View File

@@ -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) {

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) {
String packageName = context.getPackageName();

View File

@@ -8041,6 +8041,32 @@
<!-- Row title to change or cancel subscription -->
<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 -->
<!-- Settings section title header show above manage settings options for automatic chat history management (deleting) -->
<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>
<!-- Primary action button label when selecting a backup tier with a current selection -->
<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 -->
<string name="MessageBackupsTypeSelectionScreen__cancel_subscription">Cancel subscription</string>
<!-- MessageBackupsType block amount for free tier -->
<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. -->
<string name="MessageBackupsTypeSelectionScreen__s_month">%1$s/month</string>
<!-- 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
* 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)
}
}

View File

@@ -19,7 +19,7 @@ interface BillingApi {
*/
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

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