From c985ab285a79ad91b5b067c708598c6a97b250b2 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 10 Sep 2026 00:02:23 -0400 Subject: [PATCH] Improve missing play services handling for numberless payment. --- .../core/util/billing/OneTimePurchaseApi.kt | 6 + .../registration/RegistrationNavigation.kt | 31 ++++ .../registration/RegistrationRepository.kt | 34 +++- .../signalloginpayment/PaymentAvailability.kt | 65 +++++++ .../SignalLoginPaymentScreen.kt | 173 ++++++++++++++++++ .../SignalLoginPaymentScreenActions.kt | 6 + .../SignalLoginPaymentScreenEvents.kt | 12 ++ .../SignalLoginPaymentState.kt | 17 +- .../SignalLoginPaymentViewModel.kt | 70 ++++++- .../org/signal/registration/test/TestTags.kt | 1 + .../src/main/res/values/strings.xml | 28 +++ .../registration/RegistrationEndToEndTest.kt | 9 +- .../fakes/FakeOneTimePurchaseApi.kt | 6 + .../SignalLoginPaymentViewModelTest.kt | 139 +++++++++++++- .../signal/billing/OneTimePurchaseApiImpl.kt | 10 + 15 files changed, 578 insertions(+), 29 deletions(-) create mode 100644 feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/PaymentAvailability.kt diff --git a/core/util/src/main/java/org/signal/core/util/billing/OneTimePurchaseApi.kt b/core/util/src/main/java/org/signal/core/util/billing/OneTimePurchaseApi.kt index 0ac9dd7c76..27c7cd80c0 100644 --- a/core/util/src/main/java/org/signal/core/util/billing/OneTimePurchaseApi.kt +++ b/core/util/src/main/java/org/signal/core/util/billing/OneTimePurchaseApi.kt @@ -10,6 +10,12 @@ package org.signal.core.util.billing */ interface OneTimePurchaseApi { + /** + * Whether Google Play billing can be reached right now. [BillingResponseCode.BILLING_UNAVAILABLE] most often means + * nobody is signed into the Play Store. + */ + suspend fun getApiAvailability(): BillingResponseCode = BillingResponseCode.FEATURE_NOT_SUPPORTED + /** * Localized pricing for [product]. */ diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt index 1305d10156..a4a5aaf1fa 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationNavigation.kt @@ -7,7 +7,10 @@ package org.signal.registration +import android.app.Activity +import android.content.ActivityNotFoundException import android.content.Context +import android.content.Intent import android.os.Parcelable import android.widget.Toast import androidx.activity.compose.LocalActivity @@ -26,6 +29,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.createSavedStateHandle import androidx.lifecycle.viewmodel.compose.viewModel @@ -40,6 +44,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.MultiplePermissionsState import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.accompanist.permissions.rememberPermissionState +import com.google.android.gms.common.GoogleApiAvailability import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import kotlinx.parcelize.TypeParceler @@ -417,6 +422,22 @@ private fun openUrl(context: Context, url: String) { } } +/** Opens the Play Store app so the user can sign into it, falling back to the web store when it is not installed. */ +private fun openPlayStore(context: Context) { + val intent = Intent(Intent.ACTION_VIEW, "market://details?id=com.android.vending".toUri()).apply { + setPackage("com.android.vending") + if (context !is Activity) { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + } + + try { + context.startActivity(intent) + } catch (_: ActivityNotFoundException) { + openUrl(context, "https://play.google.com/store/apps/") + } +} + /** * Sets up the navigation graph for the registration flow using Navigation 3. * @@ -733,6 +754,16 @@ private fun EntryProviderScope.navigationEntries( viewModel.onEvent(SignalLoginPaymentScreenEvents.PurchaseFlowCompleted(result)) } } + + SignalLoginPaymentScreenActions.MakeGooglePlayServicesAvailable -> { + if (activity != null) { + GoogleApiAvailability.getInstance() + .makeGooglePlayServicesAvailable(activity) + .addOnCompleteListener { viewModel.onEvent(SignalLoginPaymentScreenEvents.Foregrounded) } + } + } + + SignalLoginPaymentScreenActions.OpenPlayStore -> openPlayStore(context) } } diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt index bf878341f5..cafb39a3e3 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt @@ -13,6 +13,7 @@ import android.content.pm.PackageManager import android.net.Uri import androidx.core.content.ContextCompat import com.google.android.gms.auth.api.phone.SmsRetriever +import com.google.android.gms.common.GoogleApiAvailability import com.google.i18n.phonenumbers.PhoneNumberUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred @@ -41,6 +42,7 @@ import org.signal.core.util.Base64 import org.signal.core.util.Hex import org.signal.core.util.Util import org.signal.core.util.billing.BillingPurchaseState +import org.signal.core.util.billing.BillingResponseCode import org.signal.core.util.billing.OneTimeProductId import org.signal.core.util.billing.OneTimeProductResult import org.signal.core.util.billing.OneTimePurchase @@ -97,6 +99,7 @@ import org.signal.registration.screens.countrycode.CountryUtils import org.signal.registration.screens.localbackuprestore.LocalBackupInfo import org.signal.registration.screens.messagesync.LinkAndSyncProgress import org.signal.registration.screens.remotebackuprestore.RemoteBackupRestoreProgress +import org.signal.registration.screens.signalloginpayment.PaymentAvailability import org.signal.registration.util.SensitiveLog import java.nio.charset.StandardCharsets import java.security.SecureRandom @@ -113,8 +116,9 @@ class RegistrationRepository( val storageController: StorageController, val isLinkAndSyncAvailable: Boolean, val isPhoneNumberlessRegistrationAvailable: Boolean = false, - val isGooglePlayBillingAvailable: Boolean = false, - private val signalLoginPurchaseApi: OneTimePurchaseApi + private val isGooglePlayBillingAvailable: Boolean = false, + private val signalLoginPurchaseApi: OneTimePurchaseApi, + private val googlePlayServicesStatus: () -> PaymentAvailability = { PaymentAvailability.fromConnectionResult(GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context)) } ) { /** Gates debug-only affordances, like the manual receipt credential entry field on the Signal Login purchase screen. */ @@ -452,6 +456,32 @@ class RegistrationRepository( * [SignalLoginPriceResult.Unavailable], since the configuration fetch is not cached on failure and so a retry can * still succeed. */ + /** Whether Google Play can take a payment for a Signal Login right now, and if not, what is wrong with it. */ + suspend fun getPaymentAvailability(): PaymentAvailability = withContext(Dispatchers.IO) { + val services = googlePlayServicesStatus() + if (!services.isAvailable) { + Log.w(TAG, "[getPaymentAvailability] Google Play services cannot be used: $services") + return@withContext services + } + + if (!isGooglePlayBillingAvailable) { + Log.w(TAG, "[getPaymentAvailability] This build has no Google Play billing, so nothing can be bought here.") + return@withContext PaymentAvailability.PurchasesUnavailable + } + + when (val billing = signalLoginPurchaseApi.getApiAvailability()) { + BillingResponseCode.OK -> PaymentAvailability.Available + BillingResponseCode.BILLING_UNAVAILABLE -> { + Log.w(TAG, "[getPaymentAvailability] Google Play services works but billing does not, most likely because nobody is signed into the Play Store.") + PaymentAvailability.NotSignedIn + } + else -> { + Log.w(TAG, "[getPaymentAvailability] Unexpected billing availability: $billing. Letting the purchase attempt speak for itself.") + PaymentAvailability.Available + } + } + } + suspend fun getSignalLoginPrice(): SignalLoginPriceResult = withContext(Dispatchers.IO) { val product = fetchSignalLoginConfiguration()?.toProductId() if (product == null) { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/PaymentAvailability.kt b/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/PaymentAvailability.kt new file mode 100644 index 0000000000..a87c8dcc33 --- /dev/null +++ b/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/PaymentAvailability.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.registration.screens.signalloginpayment + +import com.google.android.gms.common.ConnectionResult +import org.signal.core.util.logging.Log + +/** + * Whether a Signal Login can be paid for on this device right now, and if not, what is wrong. + */ +enum class PaymentAvailability { + /** Google Play services and the Play Store are both ready to take a payment. */ + Available, + + /** Google Play services is installed but too old to be used, and can be updated in place. */ + ServiceUpdateRequired, + + /** Google Play services is installed but turned off. */ + ServiceDisabled, + + /** Google Play services is not installed, but this device could install it. */ + ServiceMissing, + + /** Google Play services is midway through updating itself and should work again shortly. */ + ServiceUpdating, + + /** This device cannot run Google Play services at all. There is nothing the user can do about it. */ + ServiceInvalid, + + /** Google Play services works, but there is no Play Store account to pay with. */ + NotSignedIn, + + /** Google Play services works, but this build has no Google Play billing to pay with. */ + PurchasesUnavailable; + + val isAvailable: Boolean + get() = this == Available + + /** Whether there is nothing the user could do about this, so the purchase option is not worth offering at all. */ + val isTerminal: Boolean + get() = this == ServiceInvalid || this == PurchasesUnavailable + + companion object { + private val TAG = Log.tag(PaymentAvailability::class) + + /** Maps a [ConnectionResult] code, as reported by `GoogleApiAvailability`, onto the state it describes. */ + fun fromConnectionResult(code: Int): PaymentAvailability { + return when (code) { + ConnectionResult.SUCCESS -> Available + ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED -> ServiceUpdateRequired + ConnectionResult.SERVICE_DISABLED -> ServiceDisabled + ConnectionResult.SERVICE_MISSING -> ServiceMissing + ConnectionResult.SERVICE_UPDATING -> ServiceUpdating + ConnectionResult.SERVICE_INVALID -> ServiceInvalid + else -> { + Log.w(TAG, "Unrecognized Google Play services connection result: $code. Treating it as missing.") + ServiceMissing + } + } + } + } +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentScreen.kt b/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentScreen.kt index 016ecd3b27..76c928e296 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentScreen.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentScreen.kt @@ -52,8 +52,11 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import org.signal.core.ui.compose.AllDevicePreviews import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.SignalIcons @@ -100,6 +103,14 @@ fun SignalLoginPaymentScreen( ) } + if (state.dialogs.paymentUnavailable) { + PaymentUnavailableDialog(availability = state.paymentAvailability, onEvent = onEvent) + } + + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + onEvent(SignalLoginPaymentScreenEvents.Foregrounded) + } + Surface( modifier = modifier .fillMaxSize() @@ -251,6 +262,111 @@ private fun Header( ) } +/** + * Explains why Google Play cannot take a payment for a Signal Login, and offers whatever fix matches the problem. + */ +@Composable +private fun PaymentUnavailableDialog( + availability: PaymentAvailability, + onEvent: (SignalLoginPaymentScreenEvents) -> Unit +) { + val onDismiss = { onEvent(SignalLoginPaymentScreenEvents.PaymentUnavailableDialogDismissed) } + val onMakeAvailable = { onEvent(SignalLoginPaymentScreenEvents.MakeGooglePlayServicesAvailableClicked) } + val onLearnMore = { onEvent(SignalLoginPaymentScreenEvents.LearnMoreClicked) } + + val dialogModifier = Modifier.testTag(TestTags.SIGNAL_LOGIN_PAYMENT_UNAVAILABLE_DIALOG) + + when (availability) { + PaymentAvailability.Available -> Unit + + PaymentAvailability.ServiceUpdateRequired -> { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.SignalLoginPaymentScreen__update_google_play_services), + body = stringResource(R.string.SignalLoginPaymentScreen__to_purchase_a_signal_login_update_google_play_services), + confirm = stringResource(R.string.SignalLoginPaymentScreen__update), + dismiss = stringResource(android.R.string.cancel), + onConfirm = onMakeAvailable, + onDismiss = onDismiss, + modifier = dialogModifier + ) + } + + PaymentAvailability.ServiceMissing -> { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.SignalLoginPaymentScreen__google_play_services_missing), + body = stringResource(R.string.SignalLoginPaymentScreen__to_purchase_a_signal_login_google_play_services_needs_to_be_installed), + confirm = stringResource(R.string.SignalLoginPaymentScreen__install_play_services), + dismiss = stringResource(android.R.string.cancel), + onConfirm = onMakeAvailable, + onDismiss = onDismiss, + modifier = dialogModifier + ) + } + + PaymentAvailability.ServiceUpdating -> { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.SignalLoginPaymentScreen__google_play_services_are_updating), + body = stringResource(R.string.SignalLoginPaymentScreen__to_purchase_a_signal_login_wait_for_google_play_services), + confirm = stringResource(android.R.string.ok), + onConfirm = {}, + onDismiss = onDismiss, + modifier = dialogModifier + ) + } + + PaymentAvailability.ServiceDisabled -> { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.SignalLoginPaymentScreen__google_play_is_required), + body = stringResource(R.string.SignalLoginPaymentScreen__to_purchase_a_signal_login_enable_google_play_services), + confirm = stringResource(android.R.string.ok), + dismiss = stringResource(R.string.SignalLoginPaymentScreen__learn_more), + onConfirm = {}, + onDeny = onLearnMore, + onDismiss = onDismiss, + modifier = dialogModifier + ) + } + + PaymentAvailability.ServiceInvalid -> { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.SignalLoginPaymentScreen__google_play_is_required), + body = stringResource(R.string.SignalLoginPaymentScreen__you_cant_purchase_a_signal_login_on_this_device), + confirm = stringResource(android.R.string.ok), + dismiss = stringResource(R.string.SignalLoginPaymentScreen__learn_more), + onConfirm = {}, + onDeny = onLearnMore, + onDismiss = onDismiss, + modifier = dialogModifier + ) + } + + PaymentAvailability.PurchasesUnavailable -> { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.SignalLoginPaymentScreen__google_play_is_required), + body = stringResource(R.string.SignalLoginPaymentScreen__you_cant_purchase_a_signal_login_in_this_version), + confirm = stringResource(android.R.string.ok), + dismiss = stringResource(R.string.SignalLoginPaymentScreen__learn_more), + onConfirm = {}, + onDeny = onLearnMore, + onDismiss = onDismiss, + modifier = dialogModifier + ) + } + + PaymentAvailability.NotSignedIn -> { + Dialogs.SimpleAlertDialog( + title = stringResource(R.string.SignalLoginPaymentScreen__google_play_is_required), + body = stringResource(R.string.SignalLoginPaymentScreen__to_purchase_a_signal_login_sign_into_the_google_play_store), + confirm = stringResource(R.string.SignalLoginPaymentScreen__open_play_store), + dismiss = stringResource(android.R.string.cancel), + onConfirm = { onEvent(SignalLoginPaymentScreenEvents.OpenPlayStoreClicked) }, + onDismiss = onDismiss, + modifier = dialogModifier + ) + } + } +} + @Composable private fun OptionCards( state: SignalLoginPaymentState, @@ -520,3 +636,60 @@ private fun SignalLoginPaymentScreenLoadingPricePreview() { ) } } + +@Composable +private fun PaymentUnavailablePreview(availability: PaymentAvailability) { + Previews.Preview { + SignalLoginPaymentScreen( + state = SignalLoginPaymentState( + price = if (availability.isTerminal) SignalLoginPaymentState.Price.Unavailable else SignalLoginPaymentState.Price.TransientError, + selectedOption = if (availability.isTerminal) Option.ExistingLogin else Option.Purchase, + paymentAvailability = availability, + dialogs = SignalLoginPaymentState.Dialogs(paymentUnavailable = true) + ), + onEvent = {} + ) + } +} + +@DayNightPreviews +@Composable +private fun ServiceUpdateRequiredPreview() { + PaymentUnavailablePreview(PaymentAvailability.ServiceUpdateRequired) +} + +@DayNightPreviews +@Composable +private fun ServiceMissingPreview() { + PaymentUnavailablePreview(PaymentAvailability.ServiceMissing) +} + +@DayNightPreviews +@Composable +private fun ServiceUpdatingPreview() { + PaymentUnavailablePreview(PaymentAvailability.ServiceUpdating) +} + +@DayNightPreviews +@Composable +private fun ServiceDisabledPreview() { + PaymentUnavailablePreview(PaymentAvailability.ServiceDisabled) +} + +@DayNightPreviews +@Composable +private fun ServiceInvalidPreview() { + PaymentUnavailablePreview(PaymentAvailability.ServiceInvalid) +} + +@DayNightPreviews +@Composable +private fun NotSignedInPreview() { + PaymentUnavailablePreview(PaymentAvailability.NotSignedIn) +} + +@DayNightPreviews +@Composable +private fun PurchasesUnavailablePreview() { + PaymentUnavailablePreview(PaymentAvailability.PurchasesUnavailable) +} diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentScreenActions.kt b/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentScreenActions.kt index b9b1dde427..ac7d92e5fd 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentScreenActions.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentScreenActions.kt @@ -16,4 +16,10 @@ sealed interface SignalLoginPaymentScreenActions { * outcome back as [SignalLoginPaymentScreenEvents.PurchaseFlowCompleted]. */ data class LaunchPurchaseFlow(val launcher: PurchaseLauncher) : SignalLoginPaymentScreenActions + + /** Ask Google Play services to make itself available, installing or updating itself as needed. */ + data object MakeGooglePlayServicesAvailable : SignalLoginPaymentScreenActions + + /** Open the Play Store, so the user can sign into it. */ + data object OpenPlayStore : SignalLoginPaymentScreenActions } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentScreenEvents.kt b/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentScreenEvents.kt index 4fdcca213c..5b2342c5ba 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentScreenEvents.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentScreenEvents.kt @@ -11,6 +11,9 @@ sealed class SignalLoginPaymentScreenEvents { /** Emitted once when the screen is created to load initial data (namely the purchase price) into the state. */ data object Initialize : SignalLoginPaymentScreenEvents() + /** The screen came back to the foreground, which is the cue to re-check anything the user went off to fix. */ + data object Foregrounded : SignalLoginPaymentScreenEvents() + /** The user tapped the back arrow. */ data object BackClicked : SignalLoginPaymentScreenEvents() @@ -52,4 +55,13 @@ sealed class SignalLoginPaymentScreenEvents { /** The user dismissed the invalid-receipt-credential dialog. */ data object InvalidReceiptCredentialDialogDismissed : SignalLoginPaymentScreenEvents() + + /** The user asked to install or update Google Play services from the dialog explaining that it is not usable. */ + data object MakeGooglePlayServicesAvailableClicked : SignalLoginPaymentScreenEvents() + + /** The user asked to open the Play Store so they can sign into it. */ + data object OpenPlayStoreClicked : SignalLoginPaymentScreenEvents() + + /** The user dismissed the dialog explaining that Google Play cannot take a payment. */ + data object PaymentUnavailableDialogDismissed : SignalLoginPaymentScreenEvents() } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentState.kt b/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentState.kt index defc497c49..1e7cc417ea 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentState.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentState.kt @@ -18,11 +18,8 @@ data class SignalLoginPaymentState( * payment last time. Continuing picks that purchase back up rather than charging again. */ val hasUnredeemedPurchase: Boolean = false, - /** - * Whether a Signal Login can be bought on this build at all, which requires Google Play billing. When false the - * purchase option is disabled, but the flow stays open so someone who already has a Signal Login can still log in. - */ - val isPurchaseSupported: Boolean = true, + /** Whether Google Play can take a payment for a Signal Login, and if not, what is wrong with it. */ + val paymentAvailability: PaymentAvailability = PaymentAvailability.Available, /** * A manually-pasted, base64-encoded receipt credential. Lets a debug build skip payment entirely and register with a * credential issued out-of-band. When non-blank, the continue button redeems it directly. @@ -32,9 +29,12 @@ data class SignalLoginPaymentState( val showSpinner: Boolean = false, val dialogs: Dialogs = Dialogs() ) { - /** Whether the purchase option can be picked. A purchase that was already paid for can still be continued. */ + /** + * Whether the purchase option can be picked. Anything the user could go and fix keeps it pickable so tapping it + * explains the problem, and a purchase that was already paid for can always be continued. + */ val isPurchaseOptionEnabled: Boolean - get() = isPurchaseSupported || hasUnredeemedPurchase + get() = !paymentAvailability.isTerminal || hasUnredeemedPurchase /** Whether we know enough to let the user act on the selected option. */ val isActionEnabled: Boolean @@ -69,6 +69,7 @@ data class SignalLoginPaymentState( val purchaseFailed: Boolean = false, val purchaseUnavailable: Boolean = false, val purchasePending: Boolean = false, - val invalidReceiptCredential: Boolean = false + val invalidReceiptCredential: Boolean = false, + val paymentUnavailable: Boolean = false ) } diff --git a/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentViewModel.kt index a858ae11ec..ed1f1b248e 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentViewModel.kt @@ -65,24 +65,31 @@ class SignalLoginPaymentViewModel( ) { when (event) { is SignalLoginPaymentScreenEvents.Initialize -> { - val isPurchaseSupported = repository.isGooglePlayBillingAvailable val hasUnredeemedPurchase = repository.hasUnredeemedSignalLoginPurchase() if (hasUnredeemedPurchase) { Log.i(TAG, "[Initialize] The user already has a Signal Login purchase that was never redeemed.") } - val price = if (isPurchaseSupported) { - loadPrice() - } else { - Log.i(TAG, "[Initialize] Google Play billing is unavailable, so a Signal Login cannot be bought here. Offering an existing login only.") - SignalLoginPaymentState.Price.Unavailable + val paymentAvailability = repository.getPaymentAvailability() + + val price = when { + paymentAvailability.isAvailable -> loadPrice() + paymentAvailability.isTerminal -> { + Log.w(TAG, "[Initialize] Google Play can never take a payment here ($paymentAvailability). Offering an existing login only.") + SignalLoginPaymentState.Price.Unavailable + } + else -> { + Log.w(TAG, "[Initialize] Google Play cannot take a payment ($paymentAvailability), so there is no price to show yet.") + SignalLoginPaymentState.Price.TransientError + } } val updated = state.copy( price = price, hasUnredeemedPurchase = hasUnredeemedPurchase, - isPurchaseSupported = isPurchaseSupported + paymentAvailability = paymentAvailability, + dialogs = state.dialogs.copy(paymentUnavailable = !paymentAvailability.isAvailable) ) stateEmitter( @@ -97,7 +104,37 @@ class SignalLoginPaymentViewModel( is SignalLoginPaymentScreenEvents.PriceRetryClicked -> { val localState = state.copy(price = SignalLoginPaymentState.Price.Loading) stateEmitter(localState) - stateEmitter(localState.copy(price = loadPrice())) + + val availability = repository.getPaymentAvailability() + if (availability.isAvailable) { + stateEmitter(localState.copy(paymentAvailability = availability, price = loadPrice())) + } else { + Log.w(TAG, "[PriceRetryClicked] Google Play still cannot take a payment: $availability") + stateEmitter( + localState.copy( + paymentAvailability = availability, + price = if (availability.isTerminal) SignalLoginPaymentState.Price.Unavailable else SignalLoginPaymentState.Price.TransientError, + dialogs = localState.dialogs.copy(paymentUnavailable = true) + ) + ) + } + } + + is SignalLoginPaymentScreenEvents.Foregrounded -> { + val availability = repository.getPaymentAvailability() + + if (availability != state.paymentAvailability) { + Log.i(TAG, "[Foregrounded] Google Play availability changed from ${state.paymentAvailability} to $availability.") + + val localState = state.copy(paymentAvailability = availability) + if (availability.isAvailable) { + stateEmitter(localState.copy(price = SignalLoginPaymentState.Price.Loading, dialogs = localState.dialogs.copy(paymentUnavailable = false))) + stateEmitter(localState.copy(price = loadPrice(), dialogs = localState.dialogs.copy(paymentUnavailable = false))) + } else { + val price = if (availability.isTerminal) SignalLoginPaymentState.Price.Unavailable else SignalLoginPaymentState.Price.TransientError + stateEmitter(localState.copy(price = price, dialogs = localState.dialogs.copy(paymentUnavailable = true))) + } + } } is SignalLoginPaymentScreenEvents.BackClicked -> { @@ -128,6 +165,9 @@ class SignalLoginPaymentViewModel( stateEmitter(localState.copy(showSpinner = false)) } else if (state.selectedOption == SignalLoginPaymentState.Option.ExistingLogin) { parentEventEmitter.navigateTo(RegistrationRoute.SignalLoginCredentialEntry()) + } else if (!state.paymentAvailability.isAvailable) { + Log.w(TAG, "[ContinueClicked] Google Play cannot take a payment: ${state.paymentAvailability}. Explaining rather than starting a purchase.") + stateEmitter(state.copy(dialogs = state.dialogs.copy(paymentUnavailable = true))) } else { val localState = state.copy(showSpinner = true) stateEmitter(localState) @@ -172,6 +212,20 @@ class SignalLoginPaymentViewModel( is SignalLoginPaymentScreenEvents.InvalidReceiptCredentialDialogDismissed -> { stateEmitter(state.copy(dialogs = state.dialogs.copy(invalidReceiptCredential = false))) } + + is SignalLoginPaymentScreenEvents.MakeGooglePlayServicesAvailableClicked -> { + stateEmitter(state.copy(dialogs = state.dialogs.copy(paymentUnavailable = false))) + _actions.trySend(SignalLoginPaymentScreenActions.MakeGooglePlayServicesAvailable) + } + + is SignalLoginPaymentScreenEvents.OpenPlayStoreClicked -> { + stateEmitter(state.copy(dialogs = state.dialogs.copy(paymentUnavailable = false))) + _actions.trySend(SignalLoginPaymentScreenActions.OpenPlayStore) + } + + is SignalLoginPaymentScreenEvents.PaymentUnavailableDialogDismissed -> { + stateEmitter(state.copy(dialogs = state.dialogs.copy(paymentUnavailable = false))) + } } } diff --git a/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt b/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt index ee3d52346a..bb6d96843f 100644 --- a/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt +++ b/feature/registration/src/main/java/org/signal/registration/test/TestTags.kt @@ -56,6 +56,7 @@ object TestTags { const val SIGNAL_LOGIN_PAYMENT_EXISTING_LOGIN_OPTION = "signal_login_payment_existing_login_option" const val SIGNAL_LOGIN_PAYMENT_CONTINUE_BUTTON = "signal_login_payment_continue_button" const val SIGNAL_LOGIN_PAYMENT_RECEIPT_CREDENTIAL_FIELD = "signal_login_payment_receipt_credential_field" + const val SIGNAL_LOGIN_PAYMENT_UNAVAILABLE_DIALOG = "signal_login_payment_unavailable_dialog" // Signal Login Credential Entry Screen const val SIGNAL_LOGIN_CREDENTIAL_ENTRY_SCREEN = "signal_login_credential_entry_screen" diff --git a/feature/registration/src/main/res/values/strings.xml b/feature/registration/src/main/res/values/strings.xml index 2bbc55dfa8..41f2a0af41 100644 --- a/feature/registration/src/main/res/values/strings.xml +++ b/feature/registration/src/main/res/values/strings.xml @@ -657,6 +657,34 @@ Paste a receipt credential (testing) This receipt credential is invalid. + + Update Google Play services + + To purchase a Signal Login, update Google Play services on this device. + + Update + + Google Play services missing + + To purchase a Signal Login, Google Play services needs to be installed. + + Install Play Services + + Google Play services are updating + + To purchase a Signal Login please wait for Google Play services to finish updating. + + Google Play is required + + To purchase a Signal Login, enable Google Play services on this device and sign into the Google Play store. + + You can\'t purchase a Signal Login because this device does not support Google Play services. Contact the phone manufacturer for assistance. + + To purchase a Signal Login, please sign into the Google Play store. + + You can\'t purchase a Signal Login in this version of Signal. Install Signal from the Google Play store to buy one. + + Open Play Store diff --git a/feature/registration/src/test/java/org/signal/registration/RegistrationEndToEndTest.kt b/feature/registration/src/test/java/org/signal/registration/RegistrationEndToEndTest.kt index c3bd46119a..25725595b9 100644 --- a/feature/registration/src/test/java/org/signal/registration/RegistrationEndToEndTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/RegistrationEndToEndTest.kt @@ -84,6 +84,7 @@ import org.signal.registration.fakes.FakeStorageController import org.signal.registration.fakes.SystemOutLogger import org.signal.registration.proto.SvrCredential import org.signal.registration.screens.remotebackuprestore.RemoteBackupRestoreProgress +import org.signal.registration.screens.signalloginpayment.PaymentAvailability import org.signal.registration.screens.util.MockMultiplePermissionsState import org.signal.registration.screens.util.MockPermissionsState import org.signal.registration.test.TestTags @@ -1961,7 +1962,10 @@ class RegistrationEndToEndTest { * Rebuilds the repository with phone-numberless registration turned on, which is what puts the Signal Login screens * in front of the user at all. */ - private fun enableSignalLoginRegistration(isGooglePlayBillingAvailable: Boolean = true) { + private fun enableSignalLoginRegistration( + isGooglePlayBillingAvailable: Boolean = true, + googlePlayServicesStatus: PaymentAvailability = PaymentAvailability.Available + ) { repository = RegistrationRepository( context = ApplicationProvider.getApplicationContext(), networkController = networkController, @@ -1969,7 +1973,8 @@ class RegistrationEndToEndTest { isLinkAndSyncAvailable = false, isPhoneNumberlessRegistrationAvailable = true, isGooglePlayBillingAvailable = isGooglePlayBillingAvailable, - signalLoginPurchaseApi = purchaseApi + signalLoginPurchaseApi = purchaseApi, + googlePlayServicesStatus = { googlePlayServicesStatus } ) } diff --git a/feature/registration/src/test/java/org/signal/registration/fakes/FakeOneTimePurchaseApi.kt b/feature/registration/src/test/java/org/signal/registration/fakes/FakeOneTimePurchaseApi.kt index d6c9ca4060..69ed4a8ac5 100644 --- a/feature/registration/src/test/java/org/signal/registration/fakes/FakeOneTimePurchaseApi.kt +++ b/feature/registration/src/test/java/org/signal/registration/fakes/FakeOneTimePurchaseApi.kt @@ -6,6 +6,7 @@ package org.signal.registration.fakes import org.signal.core.util.billing.BillingPurchaseState +import org.signal.core.util.billing.BillingResponseCode import org.signal.core.util.billing.OneTimeProduct import org.signal.core.util.billing.OneTimeProductId import org.signal.core.util.billing.OneTimeProductResult @@ -36,6 +37,11 @@ class FakeOneTimePurchaseApi( var productResultWhenUnpriced: OneTimeProductResult = OneTimeProductResult.Unavailable var launchCount: Int = 0 + /** What [getApiAvailability] reports. Anything but OK means Google Play cannot be reached. */ + var apiAvailability: BillingResponseCode = BillingResponseCode.OK + + override suspend fun getApiAvailability(): BillingResponseCode = apiAvailability + override suspend fun queryProduct(product: OneTimeProductId): OneTimeProductResult { requestedProducts += product return formattedPrice?.let { OneTimeProductResult.Success(OneTimeProduct(formattedPrice = it)) } ?: productResultWhenUnpriced diff --git a/feature/registration/src/test/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentViewModelTest.kt index bbb7b6c41b..5be390e4ce 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/signalloginpayment/SignalLoginPaymentViewModelTest.kt @@ -11,9 +11,9 @@ import assertk.assertions.isEmpty import assertk.assertions.isEqualTo import assertk.assertions.isFalse import assertk.assertions.isTrue +import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -52,7 +52,7 @@ class SignalLoginPaymentViewModelTest { fun setup() { Dispatchers.setMain(testDispatcher) mockRepository = mockk(relaxed = true) - every { mockRepository.isGooglePlayBillingAvailable } returns true + coEvery { mockRepository.getPaymentAvailability() } returns PaymentAvailability.Available parentEventEmitter = {} viewModel = SignalLoginPaymentViewModel( repository = mockRepository, @@ -113,35 +113,156 @@ class SignalLoginPaymentViewModelTest { } @Test - fun `Initialize disables the purchase option and skips the price lookup when Play billing is unavailable`() = runTest(testDispatcher) { - every { mockRepository.isGooglePlayBillingAvailable } returns false + fun `Initialize disables the purchase option and skips the price lookup when purchases can never happen here`() = runTest(testDispatcher) { + coEvery { mockRepository.getPaymentAvailability() } returns PaymentAvailability.PurchasesUnavailable coEvery { mockRepository.hasUnredeemedSignalLoginPurchase() } returns false - coEvery { mockRepository.getSignalLoginPrice() } returns SignalLoginPriceResult.TransientError + coEvery { mockRepository.getSignalLoginPrice() } returns SignalLoginPriceResult.Available("$1.99") + clearMocks(mockRepository, answers = false) val state = applyEvent(SignalLoginPaymentState(), SignalLoginPaymentScreenEvents.Initialize) - assertThat(state.isPurchaseSupported).isFalse() assertThat(state.isPurchaseOptionEnabled).isFalse() assertThat(state.selectedOption).isEqualTo(SignalLoginPaymentState.Option.ExistingLogin) assertThat(state.price).isEqualTo(SignalLoginPaymentState.Price.Unavailable) + assertThat(state.dialogs.paymentUnavailable).isTrue() + coVerify(exactly = 0) { mockRepository.getSignalLoginPrice() } } @Test fun `Initialize keeps the purchase option enabled without Play billing when a purchase is already paid for`() = runTest(testDispatcher) { - every { mockRepository.isGooglePlayBillingAvailable } returns false + coEvery { mockRepository.getPaymentAvailability() } returns PaymentAvailability.PurchasesUnavailable coEvery { mockRepository.hasUnredeemedSignalLoginPurchase() } returns true val state = applyEvent(SignalLoginPaymentState(), SignalLoginPaymentScreenEvents.Initialize) - assertThat(state.isPurchaseSupported).isFalse() assertThat(state.isPurchaseOptionEnabled).isTrue() assertThat(state.selectedOption).isEqualTo(SignalLoginPaymentState.Option.Purchase) } + @Test + fun `Initialize explains the problem and skips the price lookup when Google Play cannot take a payment`() = runTest(testDispatcher) { + coEvery { mockRepository.getPaymentAvailability() } returns PaymentAvailability.ServiceMissing + coEvery { mockRepository.hasUnredeemedSignalLoginPurchase() } returns false + clearMocks(mockRepository, answers = false) + + val state = applyEvent(SignalLoginPaymentState(), SignalLoginPaymentScreenEvents.Initialize) + + assertThat(state.paymentAvailability).isEqualTo(PaymentAvailability.ServiceMissing) + assertThat(state.dialogs.paymentUnavailable).isTrue() + assertThat(state.price).isEqualTo(SignalLoginPaymentState.Price.TransientError) + assertThat(state.isPurchaseOptionEnabled).isTrue() + coVerify(exactly = 0) { mockRepository.getSignalLoginPrice() } + } + + @Test + fun `ContinueClicked explains the problem instead of starting a purchase Google Play cannot take`() = runTest(testDispatcher) { + val actions = collectActions() + + val state = applyEvent( + SignalLoginPaymentState( + price = SignalLoginPaymentState.Price.TransientError, + paymentAvailability = PaymentAvailability.NotSignedIn + ), + SignalLoginPaymentScreenEvents.ContinueClicked + ) + + assertThat(state.dialogs.paymentUnavailable).isTrue() + assertThat(state.showSpinner).isFalse() + assertThat(actions).isEmpty() + coVerify(exactly = 0) { mockRepository.startOrCompleteSignalLoginPurchase() } + } + + @Test + fun `PriceRetryClicked explains the problem again when Google Play still cannot take a payment`() = runTest(testDispatcher) { + coEvery { mockRepository.getPaymentAvailability() } returns PaymentAvailability.ServiceUpdating + clearMocks(mockRepository, answers = false) + + val state = applyEvent( + SignalLoginPaymentState(price = SignalLoginPaymentState.Price.TransientError), + SignalLoginPaymentScreenEvents.PriceRetryClicked + ) + + assertThat(state.paymentAvailability).isEqualTo(PaymentAvailability.ServiceUpdating) + assertThat(state.dialogs.paymentUnavailable).isTrue() + assertThat(state.price).isEqualTo(SignalLoginPaymentState.Price.TransientError) + coVerify(exactly = 0) { mockRepository.getSignalLoginPrice() } + } + + @Test + fun `Foregrounded loads the price once the user has fixed Google Play`() = runTest(testDispatcher) { + coEvery { mockRepository.getPaymentAvailability() } returns PaymentAvailability.Available + coEvery { mockRepository.getSignalLoginPrice() } returns SignalLoginPriceResult.Available("$1.99") + + val state = applyEvent( + SignalLoginPaymentState( + price = SignalLoginPaymentState.Price.TransientError, + paymentAvailability = PaymentAvailability.ServiceUpdating, + dialogs = SignalLoginPaymentState.Dialogs(paymentUnavailable = true) + ), + SignalLoginPaymentScreenEvents.Foregrounded + ) + + assertThat(state.paymentAvailability).isEqualTo(PaymentAvailability.Available) + assertThat(state.dialogs.paymentUnavailable).isFalse() + assertThat(state.price).isEqualTo(SignalLoginPaymentState.Price.Available("$1.99")) + } + + @Test + fun `Foregrounded leaves a dismissed dialog dismissed when nothing changed`() = runTest(testDispatcher) { + coEvery { mockRepository.getPaymentAvailability() } returns PaymentAvailability.ServiceInvalid + clearMocks(mockRepository, answers = false) + + val state = applyEvent( + SignalLoginPaymentState( + price = SignalLoginPaymentState.Price.TransientError, + paymentAvailability = PaymentAvailability.ServiceInvalid + ), + SignalLoginPaymentScreenEvents.Foregrounded + ) + + assertThat(state.dialogs.paymentUnavailable).isFalse() + coVerify(exactly = 0) { mockRepository.getSignalLoginPrice() } + } + + @Test + fun `MakeGooglePlayServicesAvailableClicked asks the UI layer to fix Google Play services`() = runTest(testDispatcher) { + val actions = collectActions() + + val state = applyEvent( + SignalLoginPaymentState( + paymentAvailability = PaymentAvailability.ServiceMissing, + dialogs = SignalLoginPaymentState.Dialogs(paymentUnavailable = true) + ), + SignalLoginPaymentScreenEvents.MakeGooglePlayServicesAvailableClicked + ) + + assertThat(actions).containsExactly(SignalLoginPaymentScreenActions.MakeGooglePlayServicesAvailable) + assertThat(state.dialogs.paymentUnavailable).isFalse() + } + + @Test + fun `OpenPlayStoreClicked asks the UI layer to open the Play Store`() = runTest(testDispatcher) { + val actions = collectActions() + + val state = applyEvent( + SignalLoginPaymentState( + paymentAvailability = PaymentAvailability.NotSignedIn, + dialogs = SignalLoginPaymentState.Dialogs(paymentUnavailable = true) + ), + SignalLoginPaymentScreenEvents.OpenPlayStoreClicked + ) + + assertThat(actions).containsExactly(SignalLoginPaymentScreenActions.OpenPlayStore) + assertThat(state.dialogs.paymentUnavailable).isFalse() + } + @Test fun `OptionSelected ignores the purchase option when it cannot be acted on`() = runTest(testDispatcher) { val state = applyEvent( - SignalLoginPaymentState(isPurchaseSupported = false, selectedOption = SignalLoginPaymentState.Option.ExistingLogin), + SignalLoginPaymentState( + paymentAvailability = PaymentAvailability.ServiceInvalid, + selectedOption = SignalLoginPaymentState.Option.ExistingLogin + ), SignalLoginPaymentScreenEvents.OptionSelected(SignalLoginPaymentState.Option.Purchase) ) diff --git a/lib/billing/src/main/java/org/signal/billing/OneTimePurchaseApiImpl.kt b/lib/billing/src/main/java/org/signal/billing/OneTimePurchaseApiImpl.kt index e55f2ed57b..946ff6bea8 100644 --- a/lib/billing/src/main/java/org/signal/billing/OneTimePurchaseApiImpl.kt +++ b/lib/billing/src/main/java/org/signal/billing/OneTimePurchaseApiImpl.kt @@ -36,6 +36,7 @@ import org.signal.core.util.billing.OneTimePurchaseResult import org.signal.core.util.billing.PurchaseLauncher import org.signal.core.util.logging.Log import java.util.concurrent.atomic.AtomicReference +import org.signal.core.util.billing.BillingResponseCode as CoreBillingResponseCode /** * Google Play Billing implementation of [OneTimePurchaseApi] for consumable products. @@ -63,6 +64,15 @@ internal class OneTimePurchaseApiImpl( private val connectionLazy = lazy { BillingClientConnection(context, purchasesUpdatedListener) } private val connection: BillingClientConnection get() = connectionLazy.value + override suspend fun getApiAvailability(): CoreBillingResponseCode = withContext(Dispatchers.IO) { + try { + connection.withConnection("getApiAvailability") { CoreBillingResponseCode.OK } + } catch (e: BillingError) { + Log.w(TAG, "[getApiAvailability] Could not reach Google Play billing. Error code: ${e.billingResponseCode}", true) + CoreBillingResponseCode.fromBillingLibraryResponseCode(e.billingResponseCode) + } + } + override suspend fun queryProduct(product: OneTimeProductId): OneTimeProductResult = withContext(Dispatchers.IO) { val offer = when (val result = queryOfferDetails(product)) { is QueryResult.Success -> result.value