Skip to content
Merged
2 changes: 1 addition & 1 deletion .github/badges/jacoco.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -387,11 +387,11 @@ class GoogleBillingWrapperTest {
}

// ========================================================================
// Region: Products cache — transient errors are not cached
// Region: Billing unavailable — later requests fail fast
// ========================================================================

@Test
fun test_billing_not_available_is_cached() =
fun test_billing_not_available_fails_later_requests_fast() =
runTest {
Given("a wrapper where billing is unavailable") {
val wrapper = createWrapper(clientReady = false)
Expand All @@ -416,11 +416,11 @@ class GoogleBillingWrapperTest {
outcome1.exceptionOrNull() is BillingError.BillingNotAvailable,
)

Then("a second call should fail immediately from cache without hitting billing") {
Then("a second call should fail immediately without reconnecting") {
val outcome2 = runCatching { wrapper.awaitGetProducts(setOf("p1:base:sw-auto")) }
assertTrue("Second call should also fail", outcome2.isFailure)
assertTrue(
"Should be BillingNotAvailable from cache",
"Should be BillingNotAvailable",
outcome2.exceptionOrNull() is BillingError.BillingNotAvailable,
)
}
Expand All @@ -429,7 +429,7 @@ class GoogleBillingWrapperTest {
}

@Test
fun test_multiple_products_cached_on_billing_not_available() =
fun test_billing_not_available_fails_later_requests_for_any_product() =
runTest {
Given("multiple products that fail due to billing unavailable") {
val wrapper = createWrapper(clientReady = false)
Expand All @@ -451,11 +451,11 @@ class GoogleBillingWrapperTest {

assertTrue(result1.await().isFailure)

Then("retrying any single product should fail from cache immediately") {
Then("retrying any single product should fail immediately") {
val outcome = runCatching { wrapper.awaitGetProducts(setOf("p1:base:sw-auto")) }
assertTrue(outcome.isFailure)
assertTrue(
"Should be a cached BillingNotAvailable error",
"Should be BillingNotAvailable",
outcome.exceptionOrNull() is BillingError.BillingNotAvailable,
)
}
Expand Down Expand Up @@ -495,11 +495,11 @@ class GoogleBillingWrapperTest {
outcome1.exceptionOrNull() is BillingError.BillingNotAvailable,
)

Then("product is cached as BillingNotAvailable, second call fails from cache") {
Then("billing is known to be unavailable, so a second call fails immediately") {
val outcome2 = runCatching { wrapper.awaitGetProducts(setOf("p1:base:sw-auto")) }
assertTrue("Second call should also fail", outcome2.isFailure)
assertTrue(
"Should be BillingNotAvailable from cache",
"Should be BillingNotAvailable",
outcome2.exceptionOrNull() is BillingError.BillingNotAvailable,
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.superwall.sdk.billing

/**
* Whether Google Play Billing can be used on this device, as learned from the billing
* client's connection attempts.
*/
internal sealed interface BillingAvailability {
/** No connection attempt has resolved yet. */
object Unknown : BillingAvailability

object Available : BillingAvailability

/**
* The device can't use Play Billing (no Play Store, no signed in account, ...).
* Requests fail straight away with [error] instead of reconnecting.
*/
data class Unavailable(
val error: BillingError.BillingNotAvailable,
) : BillingAvailability
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
Expand All @@ -50,6 +51,11 @@ import kotlin.math.min
internal const val RECONNECT_TIMER_START_MILLISECONDS = 1L * 1000L
internal const val RECONNECT_TIMER_MAX_TIME_MILLISECONDS = 16L * 1000L

// Consecutive transient setup failures after which billing is treated as unavailable, so
// requests stop waiting on a connection that may never come (e.g. a broken Play Store).
// Reconnecting carries on in the background and a later successful setup makes it available.
internal const val MAX_TRANSIENT_SETUP_FAILURES = 3

class GoogleBillingWrapper(
val context: Context,
val ioScope: IOScope,
Expand Down Expand Up @@ -101,6 +107,17 @@ class GoogleBillingWrapper(
@set:Synchronized
private var reconnectionAlreadyScheduled = false

private val transientSetupFailures = AtomicInteger(0)

private val _availability = MutableStateFlow<BillingAvailability>(BillingAvailability.Unknown)

/**
* Whether Play Billing can be used on this device. Once [BillingAvailability.Unavailable],
* requests fail straight away instead of reconnecting, until the app next returns to
* the foreground and billing is probed again.
*/
internal val availability = _availability.asStateFlow()

// Setup mutable state flow for purchase results
override val purchaseResults = MutableStateFlow<InternalPurchaseResult?>(null)

Expand All @@ -109,6 +126,20 @@ class GoogleBillingWrapper(

init {
startConnectionOnMainThread()
// Unavailable isn't always permanent - the user may sign in to the Play Store
// while the app is in the background - so probe again on every foregrounding.
ioScope.launch {
appLifecycleObserver.isInBackground
.drop(1)
.filter { inBackground -> !inBackground }
.collect {
if (availability.value is BillingAvailability.Unavailable) {
_availability.value = BillingAvailability.Unknown
transientSetupFailures.set(0)
startConnection()
}
}
}
}

internal class Handler(
Expand Down Expand Up @@ -148,14 +179,16 @@ class GoogleBillingWrapper(
}
}

override suspend fun queryAllPurchases(): List<Purchase> =
coroutineScope {
override suspend fun queryAllPurchases(): List<Purchase> {
if (availability.value is BillingAvailability.Unavailable) return emptyList()
return coroutineScope {
val apps =
async { retryOrNull(QUERY_PURCHASES_MAX_RETRIES) { queryType(ProductType.INAPP).getOrThrow() } }
val subs =
async { retryOrNull(QUERY_PURCHASES_MAX_RETRIES) { queryType(ProductType.SUBS).getOrThrow() } }
(apps.await() ?: emptyList()) + (subs.await() ?: emptyList())
}
}

override suspend fun consume(purchaseToken: String): Result<String> =
suspendCoroutine { cont ->
Expand Down Expand Up @@ -187,7 +220,18 @@ class GoogleBillingWrapper(
fun startConnection() {
synchronized(this@GoogleBillingWrapper) {
if (billingClient == null) {
billingClient = createBillingClient(this)
billingClient =
try {
createBillingClient(this)
} catch (e: Throwable) {
markUnavailable(
BillingError.BillingNotAvailable(
"Billing is not available in this device. " +
"The billing client could not be created: ${e.message}",
),
)
return
}
}

reconnectionAlreadyScheduled = false
Expand Down Expand Up @@ -275,15 +319,9 @@ class GoogleBillingWrapper(
}

override fun onError(error: BillingError) {
// Cache BillingNotAvailable — it's a permanent device state
// that won't resolve, so retrying is wasteful.
// Other billing errors (service unavailable, disconnected, network)
// are transient and should NOT be cached to allow retry.
if (error is BillingError.BillingNotAvailable) {
missingFullProductIds.forEach { fullProductId ->
productsCache[fullProductId] = Either.Failure(error)
}
}
// Billing errors aren't cached so a later request can retry. Setup-time
// unavailability is remembered in [availability]; a BillingNotAvailable
// returned by a query isn't, so the next load queries billing again.
continuation.resumeWithException(error)
}
},
Expand Down Expand Up @@ -411,6 +449,10 @@ class GoogleBillingWrapper(
delayMilliseconds: Long? = null,
request: (BillingError?) -> Unit,
) {
(availability.value as? BillingAvailability.Unavailable)?.let { unavailable ->
threadHandler.post { request(unavailable.error) }
return
}
serviceRequests.add(request to delayMilliseconds)
if (billingClient?.isReady == false) {
startConnectionOnMainThread()
Expand Down Expand Up @@ -508,8 +550,10 @@ class GoogleBillingWrapper(
LogScope.productsManager,
"Billing client connected",
)
_availability.value = BillingAvailability.Available
executePendingRequests()
reconnectMilliseconds = RECONNECT_TIMER_START_MILLISECONDS
transientSetupFailures.set(0)
trackProductDetailsNotSupportedIfNeeded()
}

Expand Down Expand Up @@ -554,7 +598,7 @@ class GoogleBillingWrapper(
)
// The calls will fail with an error that will be surfaced. We want to surface these errors
// Can't call executePendingRequests because it will not do anything since it checks for isReady()
sendErrorsToAllPendingRequests(error)
markUnavailable(error)
}

BillingClient.BillingResponseCode.ERROR,
Expand All @@ -568,6 +612,18 @@ class GoogleBillingWrapper(
LogScope.productsManager,
"Billing client error, retrying: ${billingResult.responseCode}",
)
val failures = transientSetupFailures.incrementAndGet()
if (failures >= MAX_TRANSIENT_SETUP_FAILURES &&
availability.value !is BillingAvailability.Unavailable
) {
markUnavailable(
BillingError.BillingNotAvailable(
"Billing is not available in this device. Setup failed $failures " +
"times in a row. Last error: ${billingResult.debugMessage} " +
"ErrorCode: ${billingResult.responseCode}.",
),
)
}
retryBillingServiceConnectionWithExponentialBackoff()
}

Expand Down Expand Up @@ -682,6 +738,11 @@ class GoogleBillingWrapper(
}
}

private fun markUnavailable(error: BillingError.BillingNotAvailable) {
_availability.value = BillingAvailability.Unavailable(error)
sendErrorsToAllPendingRequests(error)
}

@Synchronized
private fun sendErrorsToAllPendingRequests(error: BillingError) {
while (true) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ private val BILLING_INSANTIATION_ERROR =
- User not being signed in into the play store
- Mismatching Google Play Billing versions"""

private const val BILLING_UNAVAILABLE_ERROR = "Google Play Billing is not available on this device."

class AutomaticPurchaseController(
var context: Context,
val scope: IOScope,
Expand Down Expand Up @@ -81,7 +83,9 @@ class AutomaticPurchaseController(
private const val MAX_RETRIES = 3
}

private var billingClient: BillingClient = getBilling(context, this)
// Null when the client can't be created (e.g. no Play Store on the device). Creating
// it must never throw, as that would take down the whole SDK configuration.
private val billingClient: BillingClient? = runCatching { getBilling(context, this) }.getOrNull()

// Tri-state so waiters can short-circuit when the connection is known to
// have failed instead of blocking until the timeout
Expand All @@ -102,6 +106,12 @@ class AutomaticPurchaseController(
}

private fun startConnection() {
val billingClient =
billingClient ?: run {
connectionState.value = ConnectionState.Failed
syncSubscriptionStatus()
return
}
try {
billingClient.startConnection(
object : BillingClientStateListener {
Expand Down Expand Up @@ -150,6 +160,24 @@ class AutomaticPurchaseController(
}
}

/**
* Waits for the billing client to be connected, giving a failed connection one more
* attempt - billing may have become available since (e.g. the user signed in to the
* Play Store).
*
* @return The connected client, or null if billing can't be used.
*/
private suspend fun awaitConnectedClient(): BillingClient? {
val billingClient = billingClient ?: return null
if (connectionState.value == ConnectionState.Failed) {
connectionState.value = ConnectionState.Connecting
startConnection()
}
val state =
withTimeoutOrNull(CONNECTION_TIMEOUT_MS) { connectionState.first { it != ConnectionState.Connecting } }
return billingClient.takeIf { state == ConnectionState.Connected }
}

//endregion

//region Public
Expand Down Expand Up @@ -189,6 +217,24 @@ class AutomaticPurchaseController(
basePlanId: String?,
offerId: String?,
): PurchaseResult {
Logger.debug(
logLevel = LogLevel.info,
scope = LogScope.nativePurchaseController,
message = "Waiting for billing client to be connected",
)

// Without a connected billing client the purchase can never complete, so fail
// instead of waiting forever
val billingClient =
awaitConnectedClient()
?: return PurchaseResult.Failed(BILLING_UNAVAILABLE_ERROR)

Logger.debug(
logLevel = LogLevel.info,
scope = LogScope.nativePurchaseController,
message = "Billing client is connected",
)

// Clear previous purchase results to avoid emitting old results
purchaseResults.value = null

Expand Down Expand Up @@ -269,21 +315,6 @@ class AutomaticPurchaseController(
}.setProductDetailsParamsList(listOf(productDetailsParams))
.build()

Logger.debug(
logLevel = LogLevel.info,
scope = LogScope.nativePurchaseController,
message = "Waiting for billing client to be connected",
)

// Wait until the billing client becomes connected
connectionState.first { it == ConnectionState.Connected }

Logger.debug(
logLevel = LogLevel.info,
scope = LogScope.nativePurchaseController,
message = "Billing client is connected",
)

billingClient.launchBillingFlow(activity, flowParams)

// Wait until a purchase result is emitted before returning the result
Expand Down Expand Up @@ -434,9 +465,9 @@ class AutomaticPurchaseController(

val params = QueryPurchasesParams.newBuilder().setProductType(productType).build()

if (!billingClient.isReady) {
return Result.failure(IllegalStateException("Billing client not ready"))
}
val billingClient =
billingClient?.takeIf { it.isReady }
?: return Result.failure(IllegalStateException("Billing client not ready"))

billingClient.queryPurchasesAsync(params) { billingResult, purchasesList ->
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
Expand Down Expand Up @@ -466,7 +497,7 @@ class AutomaticPurchaseController(
.setPurchaseToken(purchase.purchaseToken)
.build()

billingClient.acknowledgePurchase(acknowledgePurchaseParams) { billingResult ->
billingClient?.acknowledgePurchase(acknowledgePurchaseParams) { billingResult ->
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
Logger.debug(
logLevel = LogLevel.error,
Expand Down
Loading