Improve missing play services handling for numberless payment.

This commit is contained in:
Greyson Parrelli
2026-09-10 17:10:13 -04:00
committed by Cody Henthorne
parent 7a474d0227
commit c985ab285a
15 changed files with 578 additions and 29 deletions
@@ -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].
*/
@@ -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<NavKey>.navigationEntries(
viewModel.onEvent(SignalLoginPaymentScreenEvents.PurchaseFlowCompleted(result))
}
}
SignalLoginPaymentScreenActions.MakeGooglePlayServicesAvailable -> {
if (activity != null) {
GoogleApiAvailability.getInstance()
.makeGooglePlayServicesAvailable(activity)
.addOnCompleteListener { viewModel.onEvent(SignalLoginPaymentScreenEvents.Foregrounded) }
}
}
SignalLoginPaymentScreenActions.OpenPlayStore -> openPlayStore(context)
}
}
@@ -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) {
@@ -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
}
}
}
}
}
@@ -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)
}
@@ -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
}
@@ -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()
}
@@ -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
)
}
@@ -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)))
}
}
}
@@ -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"
@@ -657,6 +657,34 @@
<string name="SignalLoginPaymentScreen__paste_a_receipt_credential" translatable="false">Paste a receipt credential (testing)</string>
<!-- Error shown when a manually-pasted receipt credential can\'t be parsed or is rejected. Internal testing only. -->
<string name="SignalLoginPaymentScreen__this_receipt_credential_is_invalid" translatable="false">This receipt credential is invalid.</string>
<!-- Title of the dialog shown when Google Play services is too old to take a payment. -->
<string name="SignalLoginPaymentScreen__update_google_play_services">Update Google Play services</string>
<!-- Body of the dialog shown when Google Play services is too old to take a payment. -->
<string name="SignalLoginPaymentScreen__to_purchase_a_signal_login_update_google_play_services">To purchase a Signal Login, update Google Play services on this device.</string>
<!-- Button that updates Google Play services. -->
<string name="SignalLoginPaymentScreen__update">Update</string>
<!-- Title of the dialog shown when Google Play services is not installed. -->
<string name="SignalLoginPaymentScreen__google_play_services_missing">Google Play services missing</string>
<!-- Body of the dialog shown when Google Play services is not installed. -->
<string name="SignalLoginPaymentScreen__to_purchase_a_signal_login_google_play_services_needs_to_be_installed">To purchase a Signal Login, Google Play services needs to be installed.</string>
<!-- Button that installs Google Play services. -->
<string name="SignalLoginPaymentScreen__install_play_services">Install Play Services</string>
<!-- Title of the dialog shown while Google Play services is updating itself. -->
<string name="SignalLoginPaymentScreen__google_play_services_are_updating">Google Play services are updating</string>
<!-- Body of the dialog shown while Google Play services is updating itself. -->
<string name="SignalLoginPaymentScreen__to_purchase_a_signal_login_wait_for_google_play_services">To purchase a Signal Login please wait for Google Play services to finish updating.</string>
<!-- Title of the dialog shown when Google Play cannot be used to take a payment. -->
<string name="SignalLoginPaymentScreen__google_play_is_required">Google Play is required</string>
<!-- Body of the dialog shown when Google Play services is installed but turned off. -->
<string name="SignalLoginPaymentScreen__to_purchase_a_signal_login_enable_google_play_services">To purchase a Signal Login, enable Google Play services on this device and sign into the Google Play store.</string>
<!-- Body of the dialog shown when the device cannot run Google Play services at all. -->
<string name="SignalLoginPaymentScreen__you_cant_purchase_a_signal_login_on_this_device">You can\'t purchase a Signal Login because this device does not support Google Play services. Contact the phone manufacturer for assistance.</string>
<!-- Body of the dialog shown when Google Play services works but nobody is signed into the Play Store. -->
<string name="SignalLoginPaymentScreen__to_purchase_a_signal_login_sign_into_the_google_play_store">To purchase a Signal Login, please sign into the Google Play store.</string>
<!-- Body of the dialog shown when this version of Signal cannot make Google Play purchases at all. -->
<string name="SignalLoginPaymentScreen__you_cant_purchase_a_signal_login_in_this_version">You can\'t purchase a Signal Login in this version of Signal. Install Signal from the Google Play store to buy one.</string>
<!-- Button that opens the Google Play store so the user can sign into it. -->
<string name="SignalLoginPaymentScreen__open_play_store">Open Play Store</string>
<!-- Signal Login details screen -->
<!-- Title of the screen that shows the user their newly purchased Signal Login. -->
@@ -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<Application>(),
networkController = networkController,
@@ -1969,7 +1973,8 @@ class RegistrationEndToEndTest {
isLinkAndSyncAvailable = false,
isPhoneNumberlessRegistrationAvailable = true,
isGooglePlayBillingAvailable = isGooglePlayBillingAvailable,
signalLoginPurchaseApi = purchaseApi
signalLoginPurchaseApi = purchaseApi,
googlePlayServicesStatus = { googlePlayServicesStatus }
)
}
@@ -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
@@ -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)
)
@@ -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