mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 08:45:50 +01:00
Improve missing play services handling for numberless payment.
This commit is contained in:
committed by
Cody Henthorne
parent
7a474d0227
commit
c985ab285a
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+32
-2
@@ -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) {
|
||||
|
||||
+65
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+173
@@ -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)
|
||||
}
|
||||
|
||||
+6
@@ -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
|
||||
}
|
||||
|
||||
+12
@@ -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()
|
||||
}
|
||||
|
||||
+9
-8
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+62
-8
@@ -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. -->
|
||||
|
||||
+7
-2
@@ -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 +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
|
||||
|
||||
+130
-9
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user